diff --git a/.github/workflows/contracts.yaml b/.github/workflows/contracts.yaml index 6deb1be3b2..6f749119e3 100644 --- a/.github/workflows/contracts.yaml +++ b/.github/workflows/contracts.yaml @@ -49,4 +49,4 @@ jobs: - name: Run Forge tests run: | cd packages/nouns-contracts - forge test -vvv --ffi + forge test -vvv --ffi --nmc 'ForkMainnetTest' diff --git a/packages/nouns-contracts/.gas-snapshot b/packages/nouns-contracts/.gas-snapshot index 71e08ca5e4..74f2b93661 100644 --- a/packages/nouns-contracts/.gas-snapshot +++ b/packages/nouns-contracts/.gas-snapshot @@ -1,4 +1,11 @@ -NounsDAOLogic_GasSnapshot_V2:test_propose_longDescription() (gas: 528689) -NounsDAOLogic_GasSnapshot_V2:test_propose_shortDescription() (gas: 398310) -NounsDAOLogic_GasSnapshot_V3:test_propose_longDescription() (gas: 556248) -NounsDAOLogic_GasSnapshot_V3:test_propose_shortDescription() (gas: 425860) \ No newline at end of file +NounsDAOLogic_GasSnapshot_V2_propose:test_propose_longDescription() (gas: 528733) +NounsDAOLogic_GasSnapshot_V2_propose:test_propose_shortDescription() (gas: 398388) +NounsDAOLogic_GasSnapshot_V2_vote:test_castVoteWithReason() (gas: 83474) +NounsDAOLogic_GasSnapshot_V2_vote:test_castVote_against() (gas: 82886) +NounsDAOLogic_GasSnapshot_V2_vote:test_castVote_lastMinuteFor() (gas: 83459) +NounsDAOLogic_GasSnapshot_V3_propose:test_propose_longDescription() (gas: 538195) +NounsDAOLogic_GasSnapshot_V3_propose:test_propose_shortDescription() (gas: 404029) +NounsDAOLogic_GasSnapshot_V3_vote:test_castVoteWithReason() (gas: 89809) +NounsDAOLogic_GasSnapshot_V3_vote:test_castVote_against() (gas: 88733) +NounsDAOLogic_GasSnapshot_V3_vote:test_castVote_lastMinuteFor() (gas: 112249) +NounsDAOLogic_GasSnapshot_V3_voteDuringObjectionPeriod:test_castVote_duringObjectionPeriod_against() (gas: 88656) \ No newline at end of file diff --git a/packages/nouns-contracts/abi/contracts/governance/NounsDAOExecutorV2.sol/NounsDAOExecutorV2.json b/packages/nouns-contracts/abi/contracts/governance/NounsDAOExecutorV2.sol/NounsDAOExecutorV2.json new file mode 100644 index 0000000000..46bda353ea --- /dev/null +++ b/packages/nouns-contracts/abi/contracts/governance/NounsDAOExecutorV2.sol/NounsDAOExecutorV2.json @@ -0,0 +1,622 @@ +[ + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "previousAdmin", + "type": "address" + }, + { + "indexed": false, + "internalType": "address", + "name": "newAdmin", + "type": "address" + } + ], + "name": "AdminChanged", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "beacon", + "type": "address" + } + ], + "name": "BeaconUpgraded", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "txHash", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "target", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "string", + "name": "signature", + "type": "string" + }, + { + "indexed": false, + "internalType": "bytes", + "name": "data", + "type": "bytes" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "eta", + "type": "uint256" + } + ], + "name": "CancelTransaction", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "erc20Token", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "bool", + "name": "success", + "type": "bool" + } + ], + "name": "ERC20Sent", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "bool", + "name": "success", + "type": "bool" + } + ], + "name": "ETHSent", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "txHash", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "target", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "string", + "name": "signature", + "type": "string" + }, + { + "indexed": false, + "internalType": "bytes", + "name": "data", + "type": "bytes" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "eta", + "type": "uint256" + } + ], + "name": "ExecuteTransaction", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "newAdmin", + "type": "address" + } + ], + "name": "NewAdmin", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "newDelay", + "type": "uint256" + } + ], + "name": "NewDelay", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "newPendingAdmin", + "type": "address" + } + ], + "name": "NewPendingAdmin", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "txHash", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "target", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "string", + "name": "signature", + "type": "string" + }, + { + "indexed": false, + "internalType": "bytes", + "name": "data", + "type": "bytes" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "eta", + "type": "uint256" + } + ], + "name": "QueueTransaction", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "implementation", + "type": "address" + } + ], + "name": "Upgraded", + "type": "event" + }, + { + "stateMutability": "payable", + "type": "fallback" + }, + { + "inputs": [], + "name": "GRACE_PERIOD", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MAXIMUM_DELAY", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MINIMUM_DELAY", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "acceptAdmin", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "admin", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "target", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + }, + { + "internalType": "string", + "name": "signature", + "type": "string" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + }, + { + "internalType": "uint256", + "name": "eta", + "type": "uint256" + } + ], + "name": "cancelTransaction", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "delay", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "target", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + }, + { + "internalType": "string", + "name": "signature", + "type": "string" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + }, + { + "internalType": "uint256", + "name": "eta", + "type": "uint256" + } + ], + "name": "executeTransaction", + "outputs": [ + { + "internalType": "bytes", + "name": "", + "type": "bytes" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "admin_", + "type": "address" + }, + { + "internalType": "uint256", + "name": "delay_", + "type": "uint256" + } + ], + "name": "initialize", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "pendingAdmin", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "target", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + }, + { + "internalType": "string", + "name": "signature", + "type": "string" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + }, + { + "internalType": "uint256", + "name": "eta", + "type": "uint256" + } + ], + "name": "queueTransaction", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "name": "queuedTransactions", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newDAOTreasury", + "type": "address" + }, + { + "internalType": "address", + "name": "erc20Token", + "type": "address" + }, + { + "internalType": "uint256", + "name": "tokensToSend", + "type": "uint256" + } + ], + "name": "sendERC20ToNewDAO", + "outputs": [ + { + "internalType": "bool", + "name": "success", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newDAOTreasury", + "type": "address" + }, + { + "internalType": "uint256", + "name": "ethToSend", + "type": "uint256" + } + ], + "name": "sendETHToNewDAO", + "outputs": [ + { + "internalType": "bool", + "name": "success", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "delay_", + "type": "uint256" + } + ], + "name": "setDelay", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "pendingAdmin_", + "type": "address" + } + ], + "name": "setPendingAdmin", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newImplementation", + "type": "address" + } + ], + "name": "upgradeTo", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newImplementation", + "type": "address" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "name": "upgradeToAndCall", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "stateMutability": "payable", + "type": "receive" + } +] diff --git a/packages/nouns-contracts/abi/contracts/governance/NounsDAOLogicV3.sol/NounsDAOLogicV3.json b/packages/nouns-contracts/abi/contracts/governance/NounsDAOLogicV3.sol/NounsDAOLogicV3.json index 5cd07cd903..d38c979b32 100644 --- a/packages/nouns-contracts/abi/contracts/governance/NounsDAOLogicV3.sol/NounsDAOLogicV3.json +++ b/packages/nouns-contracts/abi/contracts/governance/NounsDAOLogicV3.sol/NounsDAOLogicV3.json @@ -44,6 +44,212 @@ "name": "UnsafeUint16Cast", "type": "error" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256[]", + "name": "tokenIds", + "type": "uint256[]" + }, + { + "indexed": false, + "internalType": "address", + "name": "to", + "type": "address" + } + ], + "name": "DAOWithdrawNounsFromEscrow", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address[]", + "name": "oldErc20Tokens", + "type": "address[]" + }, + { + "indexed": false, + "internalType": "address[]", + "name": "newErc20tokens", + "type": "address[]" + } + ], + "name": "ERC20TokensToIncludeInForkSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint32", + "name": "forkId", + "type": "uint32" + }, + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256[]", + "name": "tokenIds", + "type": "uint256[]" + }, + { + "indexed": false, + "internalType": "uint256[]", + "name": "proposalIds", + "type": "uint256[]" + }, + { + "indexed": false, + "internalType": "string", + "name": "reason", + "type": "string" + } + ], + "name": "EscrowedToFork", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint32", + "name": "forkId", + "type": "uint32" + }, + { + "indexed": false, + "internalType": "address", + "name": "forkTreasury", + "type": "address" + }, + { + "indexed": false, + "internalType": "address", + "name": "forkToken", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "forkEndTimestamp", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "tokensInEscrow", + "type": "uint256" + } + ], + "name": "ExecuteFork", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "oldForkDAODeployer", + "type": "address" + }, + { + "indexed": false, + "internalType": "address", + "name": "newForkDAODeployer", + "type": "address" + } + ], + "name": "ForkDAODeployerSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "oldForkPeriod", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "newForkPeriod", + "type": "uint256" + } + ], + "name": "ForkPeriodSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "oldForkThreshold", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "newForkThreshold", + "type": "uint256" + } + ], + "name": "ForkThresholdSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint32", + "name": "forkId", + "type": "uint32" + }, + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256[]", + "name": "tokenIds", + "type": "uint256[]" + }, + { + "indexed": false, + "internalType": "uint256[]", + "name": "proposalIds", + "type": "uint256[]" + }, + { + "indexed": false, + "internalType": "string", + "name": "reason", + "type": "string" + } + ], + "name": "JoinFork", + "type": "event" + }, { "anonymous": false, "inputs": [ @@ -289,6 +495,19 @@ "name": "ProposalCreated", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "id", + "type": "uint256" + } + ], + "name": "ProposalCreatedOnTimelockV1", + "type": "event" + }, { "anonymous": false, "inputs": [ @@ -766,6 +985,31 @@ "name": "SignatureCancelled", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "timelock", + "type": "address" + }, + { + "indexed": false, + "internalType": "address", + "name": "timelockV1", + "type": "address" + }, + { + "indexed": false, + "internalType": "address", + "name": "admin", + "type": "address" + } + ], + "name": "TimelocksAndAdminSet", + "type": "event" + }, { "anonymous": false, "inputs": [ @@ -879,6 +1123,31 @@ "name": "Withdraw", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint32", + "name": "forkId", + "type": "uint32" + }, + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256[]", + "name": "tokenIds", + "type": "uint256[]" + } + ], + "name": "WithdrawFromForkEscrow", + "type": "event" + }, { "inputs": [], "name": "MAX_PROPOSAL_THRESHOLD_BPS", @@ -1001,6 +1270,104 @@ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "address[]", + "name": "erc20tokens", + "type": "address[]" + } + ], + "name": "_setErc20TokensToIncludeInFork", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newForkDAODeployer", + "type": "address" + } + ], + "name": "_setForkDAODeployer", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newForkEscrow", + "type": "address" + } + ], + "name": "_setForkEscrow", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "forkEscrow_", + "type": "address" + }, + { + "internalType": "address", + "name": "forkDAODeployer_", + "type": "address" + }, + { + "internalType": "address[]", + "name": "erc20TokensToIncludeInFork_", + "type": "address[]" + }, + { + "internalType": "uint256", + "name": "forkPeriod_", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "forkThresholdBPS_", + "type": "uint256" + } + ], + "name": "_setForkParams", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "newForkPeriod", + "type": "uint256" + } + ], + "name": "_setForkPeriod", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "newForkThresholdBPS", + "type": "uint256" + } + ], + "name": "_setForkThresholdBPS", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { @@ -1121,11 +1488,28 @@ { "inputs": [ { - "internalType": "uint256", - "name": "newVoteSnapshotBlockSwitchProposalId", - "type": "uint256" + "internalType": "address", + "name": "newTimelock", + "type": "address" + }, + { + "internalType": "address", + "name": "newTimelockV1", + "type": "address" + }, + { + "internalType": "address", + "name": "newAdmin", + "type": "address" } ], + "name": "_setTimelocksAndAdmin", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], "name": "_setVoteSnapshotBlockSwitchProposalId", "outputs": [], "stateMutability": "nonpayable", @@ -1175,6 +1559,19 @@ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [], + "name": "adjustedTotalSupply", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { @@ -1299,59 +1696,204 @@ "internalType": "uint256", "name": "proposalId", "type": "uint256" - }, - { - "internalType": "uint8", - "name": "support", - "type": "uint8" - }, - { - "internalType": "string", - "name": "reason", - "type": "string" + }, + { + "internalType": "uint8", + "name": "support", + "type": "uint8" + }, + { + "internalType": "string", + "name": "reason", + "type": "string" + } + ], + "name": "castVoteWithReason", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "againstVotes", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "totalSupply", + "type": "uint256" + }, + { + "components": [ + { + "internalType": "uint16", + "name": "minQuorumVotesBPS", + "type": "uint16" + }, + { + "internalType": "uint16", + "name": "maxQuorumVotesBPS", + "type": "uint16" + }, + { + "internalType": "uint32", + "name": "quorumCoefficient", + "type": "uint32" + } + ], + "internalType": "struct NounsDAOStorageV3.DynamicQuorumParams", + "name": "params", + "type": "tuple" + } + ], + "name": "dynamicQuorumVotes", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [], + "name": "erc20TokensToIncludeInFork", + "outputs": [ + { + "internalType": "address[]", + "name": "", + "type": "address[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256[]", + "name": "tokenIds", + "type": "uint256[]" + }, + { + "internalType": "uint256[]", + "name": "proposalIds", + "type": "uint256[]" + }, + { + "internalType": "string", + "name": "reason", + "type": "string" + } + ], + "name": "escrowToFork", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "proposalId", + "type": "uint256" + } + ], + "name": "execute", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "executeFork", + "outputs": [ + { + "internalType": "address", + "name": "forkTreasury", + "type": "address" + }, + { + "internalType": "address", + "name": "forkToken", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "proposalId", + "type": "uint256" } ], - "name": "castVoteWithReason", + "name": "executeOnTimelockV1", "outputs": [], "stateMutability": "nonpayable", "type": "function" }, { - "inputs": [ + "inputs": [], + "name": "forkDAODeployer", + "outputs": [ + { + "internalType": "contract IForkDAODeployer", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "forkEndTimestamp", + "outputs": [ { "internalType": "uint256", - "name": "againstVotes", + "name": "", "type": "uint256" - }, + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "forkEscrow", + "outputs": [ + { + "internalType": "contract INounsDAOForkEscrow", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "forkPeriod", + "outputs": [ { "internalType": "uint256", - "name": "totalSupply", + "name": "", "type": "uint256" - }, - { - "components": [ - { - "internalType": "uint16", - "name": "minQuorumVotesBPS", - "type": "uint16" - }, - { - "internalType": "uint16", - "name": "maxQuorumVotesBPS", - "type": "uint16" - }, - { - "internalType": "uint32", - "name": "quorumCoefficient", - "type": "uint32" - } - ], - "internalType": "struct NounsDAOStorageV3.DynamicQuorumParams", - "name": "params", - "type": "tuple" } ], - "name": "dynamicQuorumVotes", + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "forkThreshold", "outputs": [ { "internalType": "uint256", @@ -1359,20 +1901,20 @@ "type": "uint256" } ], - "stateMutability": "pure", + "stateMutability": "view", "type": "function" }, { - "inputs": [ + "inputs": [], + "name": "forkThresholdBPS", + "outputs": [ { "internalType": "uint256", - "name": "proposalId", + "name": "", "type": "uint256" } ], - "name": "execute", - "outputs": [], - "stateMutability": "nonpayable", + "stateMutability": "view", "type": "function" }, { @@ -1500,23 +2042,55 @@ }, { "internalType": "address", - "name": "vetoer_", + "name": "forkEscrow_", "type": "address" }, { - "internalType": "uint256", - "name": "votingPeriod_", - "type": "uint256" + "internalType": "address", + "name": "forkDAODeployer_", + "type": "address" }, { - "internalType": "uint256", - "name": "votingDelay_", - "type": "uint256" + "internalType": "address", + "name": "vetoer_", + "type": "address" }, { - "internalType": "uint256", - "name": "proposalThresholdBPS_", - "type": "uint256" + "components": [ + { + "internalType": "uint256", + "name": "votingPeriod", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "votingDelay", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "proposalThresholdBPS", + "type": "uint256" + }, + { + "internalType": "uint32", + "name": "lastMinuteWindowInBlocks", + "type": "uint32" + }, + { + "internalType": "uint32", + "name": "objectionPeriodDurationInBlocks", + "type": "uint32" + }, + { + "internalType": "uint32", + "name": "proposalUpdatablePeriodInBlocks", + "type": "uint32" + } + ], + "internalType": "struct NounsDAOStorageV3.NounsDAOParams", + "name": "daoParams_", + "type": "tuple" }, { "components": [ @@ -1539,24 +2113,32 @@ "internalType": "struct NounsDAOStorageV3.DynamicQuorumParams", "name": "dynamicQuorumParams_", "type": "tuple" - }, + } + ], + "name": "initialize", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ { - "internalType": "uint32", - "name": "lastMinuteWindowInBlocks_", - "type": "uint32" + "internalType": "uint256[]", + "name": "tokenIds", + "type": "uint256[]" }, { - "internalType": "uint32", - "name": "objectionPeriodDurationInBlocks_", - "type": "uint32" + "internalType": "uint256[]", + "name": "proposalIds", + "type": "uint256[]" }, { - "internalType": "uint32", - "name": "proposalUpdatablePeriodInBlocks_", - "type": "uint32" + "internalType": "string", + "name": "reason", + "type": "string" } ], - "name": "initialize", + "name": "joinFork", "outputs": [], "stateMutability": "nonpayable", "type": "function" @@ -1632,6 +2214,19 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [], + "name": "numTokensInForkEscrow", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [], "name": "objectionPeriodDurationInBlocks", @@ -1920,6 +2515,11 @@ "internalType": "uint256", "name": "objectionPeriodEndBlock", "type": "uint256" + }, + { + "internalType": "bool", + "name": "executeOnTimelockV1", + "type": "bool" } ], "internalType": "struct NounsDAOStorageV3.ProposalCondensed", @@ -2030,6 +2630,45 @@ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "address[]", + "name": "targets", + "type": "address[]" + }, + { + "internalType": "uint256[]", + "name": "values", + "type": "uint256[]" + }, + { + "internalType": "string[]", + "name": "signatures", + "type": "string[]" + }, + { + "internalType": "bytes[]", + "name": "calldatas", + "type": "bytes[]" + }, + { + "internalType": "string", + "name": "description", + "type": "string" + } + ], + "name": "proposeOnTimelockV1", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { @@ -2197,6 +2836,19 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [], + "name": "timelockV1", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { @@ -2392,6 +3044,19 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [], + "name": "voteSnapshotBlockSwitchProposalId", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [], "name": "votingDelay", @@ -2418,6 +3083,37 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "uint256[]", + "name": "tokenIds", + "type": "uint256[]" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + } + ], + "name": "withdrawDAONounsFromEscrow", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256[]", + "name": "tokenIds", + "type": "uint256[]" + } + ], + "name": "withdrawFromForkEscrow", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "stateMutability": "payable", "type": "receive" diff --git a/packages/nouns-contracts/broadcast/DeployDAOV3DataContractsGoerli.s.sol/5/run-latest.json b/packages/nouns-contracts/broadcast/DeployDAOV3DataContractsGoerli.s.sol/5/run-latest.json new file mode 100644 index 0000000000..c7e832f51f --- /dev/null +++ b/packages/nouns-contracts/broadcast/DeployDAOV3DataContractsGoerli.s.sol/5/run-latest.json @@ -0,0 +1,118 @@ +{ + "transactions": [ + { + "hash": "0x75b1b384fcd85a39eb7e48239e2153e7acb689629c3558691e2ba5ba07a5da9a", + "transactionType": "CREATE", + "contractName": "NounsDAOData", + "contractAddress": "0x442961F79C3968f908ed295a5DEbfcD9aC1712B6", + "function": null, + "arguments": [ + "0x30656985039923EAa1eBb968fe84A1277581f602", + "0x34b74B5c1996b37e5e3EDB756731A5812FF43F67" + ], + "transaction": { + "type": "0x02", + "from": "0xd70ce993b8f90146ca4eaf33fb31528fac7adb78", + "gas": "0x29c75d", + "value": "0x0", + "data": "0x60e0604052306080523480156200001557600080fd5b506040516200261b3803806200261b83398101604081905262000038916200006d565b6001600160a01b0391821660a0521660c052620000a5565b80516001600160a01b03811681146200006857600080fd5b919050565b600080604083850312156200008157600080fd5b6200008c8362000050565b91506200009c6020840162000050565b90509250929050565b60805160a05160c05161251c620000ff600039600081816102d20152610a3101526000818161033c01528181610cac0152610fb701526000818161054001528181610580015281816107140152610754015261251c6000f3fe60806040526004361061011f5760003560e01c8063715018a6116100a0578063ced9481f11610064578063ced9481f1461032a578063cf14ed561461035e578063d1b4a3351461037e578063f2fde38b14610391578063ff4ca184146103b157600080fd5b8063715018a6146102785780637a1ac61e1461028d578063832c6ed5146102ad578063841de947146102c05780638da5cb5b1461030c57600080fd5b80634782f779116100e75780634782f779146101f65780634f1ef28614610216578063628ff474146102295780636549df9f1461024d5780637031ba591461026357600080fd5b806306b88d68146101245780631fdefaab146101465780632a03c079146101665780633659cfe61461018657806338cbe0ca146101a6575b600080fd5b34801561013057600080fd5b5061014461013f3660046118bd565b6103d1565b005b34801561015257600080fd5b506101446101613660046118bd565b61044a565b34801561017257600080fd5b5061014461018136600461198b565b6104b2565b34801561019257600080fd5b506101446101a13660046119e3565b610536565b3480156101b257600080fd5b506101e16101c13660046119fe565b606760209081526000928352604080842090915290825290205460ff1681565b60405190151581526020015b60405180910390f35b34801561020257600080fd5b506101446102113660046119fe565b6105fe565b610144610224366004611a28565b61070a565b34801561023557600080fd5b5061023f60655481565b6040519081526020016101ed565b34801561025957600080fd5b5061023f60665481565b34801561026f57600080fd5b5061023f600181565b34801561028457600080fd5b506101446107c3565b34801561029957600080fd5b506101446102a8366004611a75565b6107f9565b6101446102bb366004611c96565b6108c2565b3480156102cc57600080fd5b506102f47f000000000000000000000000000000000000000000000000000000000000000081565b6040516001600160a01b0390911681526020016101ed565b34801561031857600080fd5b506033546001600160a01b03166102f4565b34801561033657600080fd5b506102f47f000000000000000000000000000000000000000000000000000000000000000081565b34801561036a57600080fd5b50610144610379366004611db0565b6109be565b61014461038c366004611e75565b610ad4565b34801561039d57600080fd5b506101446103ac3660046119e3565b610be3565b3480156103bd57600080fd5b506101446103cc366004611f17565b610c7b565b6033546001600160a01b031633146104045760405162461bcd60e51b81526004016103fb90611f75565b60405180910390fd5b606680549082905560408051828152602081018490527fa5ff6097ec99d1e07b3a63580670fae7f549bde33491de1bd1877250d26cc34b91015b60405180910390a15050565b6033546001600160a01b031633146104745760405162461bcd60e51b81526004016103fb90611f75565b606580549082905560408051828152602081018490527f3e09bd31378b9c85b14184aa5a8cdafd32b41315a2adf5579140ccfff0aefa47910161043e565b336000908152606760209081526040808320845185840120845290915290205460ff166104f257604051636a76632960e11b815260040160405180910390fd5b336001600160a01b03167f535f3f195da71b37d09a686916267ef7ad39d15bef95c61e85224d35f8b8fbad8260405161052b9190611ffa565b60405180910390a250565b6001600160a01b037f000000000000000000000000000000000000000000000000000000000000000016300361057e5760405162461bcd60e51b81526004016103fb9061200d565b7f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03166105b0610db5565b6001600160a01b0316146105d65760405162461bcd60e51b81526004016103fb90612059565b6105df81610de3565b604080516000808252602082019092526105fb91839190610e0d565b50565b6033546001600160a01b031633146106285760405162461bcd60e51b81526004016103fb90611f75565b47811115610649576040516312d5633960e31b815260040160405180910390fd5b600080836001600160a01b03168360405160006040518083038185875af1925050503d8060008114610697576040519150601f19603f3d011682016040523d82523d6000602084013e61069c565b606091505b5091509150816106c15780604051636a30b5a560e01b81526004016103fb9190611ffa565b836001600160a01b03167f94b2de810873337ed265c5f8cf98c9cffefa06b8607f9a2f1fbaebdfbcfbef1c846040516106fc91815260200190565b60405180910390a250505050565b6001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001630036107525760405162461bcd60e51b81526004016103fb9061200d565b7f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316610784610db5565b6001600160a01b0316146107aa5760405162461bcd60e51b81526004016103fb90612059565b6107b382610de3565b6107bf82826001610e0d565b5050565b6033546001600160a01b031633146107ed5760405162461bcd60e51b81526004016103fb90611f75565b6107f76000610f58565b565b600054610100900460ff1680610812575060005460ff16155b6108755760405162461bcd60e51b815260206004820152602e60248201527f496e697469616c697a61626c653a20636f6e747261637420697320616c72656160448201526d191e481a5b9a5d1a585b1a5e995960921b60648201526084016103fb565b600054610100900460ff16158015610897576000805461ffff19166101011790555b6108a084610f58565b6065839055606682905580156108bc576000805461ff00191690555b50505050565b6108cb33610faa565b1580156108d9575060665434105b156108f7576040516339b4a7f160e11b815260040160405180910390fd5b336000908152606760209081526040808320855186840120845290915290205460ff1661093757604051636a76632960e11b815260040160405180910390fd5b60006109643360405180608001604052808b81526020018a81526020018981526020018881525086611065565b8051602082012060405191925033917f41bbb6fef9e9c2fa6584a5aabcbd8bdb321d8c8c40bcf25571deea936c8632b9916109ac918c918c918c918c918c918c918c9061216e565b60405180910390a25050505050505050565b6001600160a01b0384166000908152606760209081526040808320865187840120845290915290205460ff16610a0757604051636a76632960e11b815260040160405180910390fd5b6000610a557fd4eafb6bc770edecb5da52765bc3f50af948bfe1b8344cf115f8ee7fefe55ec784887f00000000000000000000000000000000000000000000000000000000000000006112c1565b9050610a623382896113c7565b610a7f57604051638baa579f60e01b815260040160405180910390fd5b8251602084012060405133917fc5b395e432400c48b910b3dca153a3cf877dd97c3c43db6146d7cc0cf9261ffa91610ac3918b918b918b918b919089908b9061220f565b60405180910390a250505050505050565b610add33610faa565b158015610aeb575060655434105b15610b09576040516339b4a7f160e11b815260040160405180910390fd5b336000908152606760209081526040808320845185840120845290915290205460ff1615610b4a576040516323a5379b60e21b815260040160405180910390fd5b33600081815260676020908152604080832085518684012084528252808320805460ff1916600117905580516080810182528a81529182018990528101879052606081018690529091610b9d9185611065565b8051602082012060405191925033917ff4d0e9be3bf9d9f89a01a2d113ddbdff4f2772f8d554ff1f6b0a7269ff64b29591610ac3918b918b918b918b918b918b91612278565b6033546001600160a01b03163314610c0d5760405162461bcd60e51b81526004016103fb90611f75565b6001600160a01b038116610c725760405162461bcd60e51b815260206004820152602660248201527f4f776e61626c653a206e6577206f776e657220697320746865207a65726f206160448201526564647265737360d01b60648201526084016103fb565b6105fb81610f58565b60028260ff161115610ca057604051634f81348960e11b815260040160405180910390fd5b60006001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663782d6fe133610cdd600143612318565b6040516001600160e01b031960e085901b1681526001600160a01b0390921660048301526024820152604401602060405180830381865afa158015610d26573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190610d4a919061232b565b9050806001600160601b0316600003610d76576040516323c4033960e21b815260040160405180910390fd5b336001600160a01b03167fe23b22131826bd9019b95527be0a94cc1db14559566354f0144786792cc41397858386866040516106fc9493929190612354565b7f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc546001600160a01b031690565b6033546001600160a01b031633146105fb5760405162461bcd60e51b81526004016103fb90611f75565b6000610e17610db5565b9050610e2284611513565b600083511180610e2f5750815b15610e4057610e3e84846115b8565b505b7f4910fdfa16fed3260ed0e7147f7cc6da11a60208b5b9406d12a635614ffd9143805460ff16610f5157805460ff191660011781556040516001600160a01b0383166024820152610ebf90869060440160408051601f198184030181529190526020810180516001600160e01b0316631b2ce7f360e11b1790526115b8565b50805460ff19168155610ed0610db5565b6001600160a01b0316826001600160a01b031614610f485760405162461bcd60e51b815260206004820152602f60248201527f45524331393637557067726164653a207570677261646520627265616b73206660448201526e75727468657220757067726164657360881b60648201526084016103fb565b610f51856115e6565b5050505050565b603380546001600160a01b038381166001600160a01b0319831681179093556040519116919082907f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e090600090a35050565b6000806001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001663782d6fe184610fe8600143612318565b6040516001600160e01b031960e085901b1681526001600160a01b0390921660048301526024820152604401602060405180830381865afa158015611031573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190611055919061232b565b6001600160601b03161192915050565b606060008360400151516001600160401b03811115611086576110866118d6565b6040519080825280602002602001820160405280156110af578160200160208202803683370190505b50905060005b84604001515181101561111457846040015181815181106110d8576110d8612385565b6020026020010151805190602001208282815181106110f9576110f9612385565b602090810291909101015261110d8161239b565b90506110b5565b5060008460600151516001600160401b03811115611134576111346118d6565b60405190808252806020026020018201604052801561115d578160200160208202803683370190505b50905060005b8560600151518110156111c2578560600151818151811061118657611186612385565b6020026020010151805190602001208282815181106111a7576111a7612385565b60209081029190910101526111bb8161239b565b9050611163565b50845160405187916111d6916020016123b4565b60405160208183030381529060405280519060200120866020015160405160200161120191906123f3565b604051602081830303815290604052805190602001208460405160200161122891906123f3565b604051602081830303815290604052805190602001208460405160200161124f91906123f3565b60408051601f1981840301815282825280516020918201208b518c8301206001600160a01b0390981691840191909152908201949094526060810192909252608082015260a081019190915260c081019190915260e001604051602081830303815290604052925050505b9392505050565b6000808585856040516020016112d99392919061241d565b60408051601f198184030181528282528051602091820120838301835260098452684e6f756e732044414f60b81b9382019390935281517f8cad95687ba82c2ce50e74f7b754645e5117c3a5bec8151c0726d5857980a866818301527fe1dd93b3612547b4bb7c3d429f3df8508d84f5a4f63b5e2e44340b94698e6b3b818401524660608201526001600160a01b03969096166080808801919091528251808803909101815260a08701835280519082012061190160f01b60c088015260c287015260e280870193909352815180870390930183526101029095019052805193019290922095945050505050565b60008060006113d68585611626565b909250905060008160048111156113ef576113ef61244a565b14801561140d5750856001600160a01b0316826001600160a01b0316145b1561141d576001925050506112ba565b600080876001600160a01b0316631626ba7e60e01b8888604051602401611445929190612460565b60408051601f198184030181529181526020820180516001600160e01b03166001600160e01b03199094169390931790925290516114839190612479565b600060405180830381855afa9150503d80600081146114be576040519150601f19603f3d011682016040523d82523d6000602084013e6114c3565b606091505b50915091508180156114d6575080516020145b801561150757508051630b135d3f60e11b906114fb9083016020908101908401612495565b6001600160e01b031916145b98975050505050505050565b803b6115775760405162461bcd60e51b815260206004820152602d60248201527f455243313936373a206e657720696d706c656d656e746174696f6e206973206e60448201526c1bdd08184818dbdb9d1c9858dd609a1b60648201526084016103fb565b7f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc80546001600160a01b0319166001600160a01b0392909216919091179055565b60606115dd83836040518060600160405280602781526020016124c060279139611694565b90505b92915050565b6115ef81611513565b6040516001600160a01b038216907fbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b90600090a250565b600080825160410361165c5760208301516040840151606085015160001a61165087828585611768565b9450945050505061168d565b8251604003611685576020830151604084015161167a868383611855565b93509350505061168d565b506000905060025b9250929050565b6060833b6116f35760405162461bcd60e51b815260206004820152602660248201527f416464726573733a2064656c65676174652063616c6c20746f206e6f6e2d636f6044820152651b9d1c9858dd60d21b60648201526084016103fb565b600080856001600160a01b03168560405161170e9190612479565b600060405180830381855af49150503d8060008114611749576040519150601f19603f3d011682016040523d82523d6000602084013e61174e565b606091505b509150915061175e828286611884565b9695505050505050565b6000807f7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a083111561179f575060009050600361184c565b8460ff16601b141580156117b757508460ff16601c14155b156117c8575060009050600461184c565b6040805160008082526020820180845289905260ff881692820192909252606081018690526080810185905260019060a0016020604051602081039080840390855afa15801561181c573d6000803e3d6000fd5b5050604051601f1901519150506001600160a01b0381166118455760006001925092505061184c565b9150600090505b94509492505050565b6000806001600160ff1b03831660ff84901c601b0161187687828885611768565b935093505050935093915050565b606083156118935750816112ba565b8251156118a35782518084602001fd5b8160405162461bcd60e51b81526004016103fb9190611ffa565b6000602082840312156118cf57600080fd5b5035919050565b634e487b7160e01b600052604160045260246000fd5b604051601f8201601f191681016001600160401b0381118282101715611914576119146118d6565b604052919050565b600082601f83011261192d57600080fd5b81356001600160401b03811115611946576119466118d6565b611959601f8201601f19166020016118ec565b81815284602083860101111561196e57600080fd5b816020850160208301376000918101602001919091529392505050565b60006020828403121561199d57600080fd5b81356001600160401b038111156119b357600080fd5b6119bf8482850161191c565b949350505050565b80356001600160a01b03811681146119de57600080fd5b919050565b6000602082840312156119f557600080fd5b6115dd826119c7565b60008060408385031215611a1157600080fd5b611a1a836119c7565b946020939093013593505050565b60008060408385031215611a3b57600080fd5b611a44836119c7565b915060208301356001600160401b03811115611a5f57600080fd5b611a6b8582860161191c565b9150509250929050565b600080600060608486031215611a8a57600080fd5b611a93846119c7565b95602085013595506040909401359392505050565b60006001600160401b03821115611ac157611ac16118d6565b5060051b60200190565b600082601f830112611adc57600080fd5b81356020611af1611aec83611aa8565b6118ec565b82815260059290921b84018101918181019086841115611b1057600080fd5b8286015b84811015611b3257611b25816119c7565b8352918301918301611b14565b509695505050505050565b600082601f830112611b4e57600080fd5b81356020611b5e611aec83611aa8565b82815260059290921b84018101918181019086841115611b7d57600080fd5b8286015b84811015611b325780358352918301918301611b81565b600082601f830112611ba957600080fd5b81356020611bb9611aec83611aa8565b82815260059290921b84018101918181019086841115611bd857600080fd5b8286015b84811015611b325780356001600160401b03811115611bfb5760008081fd5b611c098986838b010161191c565b845250918301918301611bdc565b600082601f830112611c2857600080fd5b81356020611c38611aec83611aa8565b82815260059290921b84018101918181019086841115611c5757600080fd5b8286015b84811015611b325780356001600160401b03811115611c7a5760008081fd5b611c888986838b010161191c565b845250918301918301611c5b565b600080600080600080600060e0888a031215611cb157600080fd5b87356001600160401b0380821115611cc857600080fd5b611cd48b838c01611acb565b985060208a0135915080821115611cea57600080fd5b611cf68b838c01611b3d565b975060408a0135915080821115611d0c57600080fd5b611d188b838c01611b98565b965060608a0135915080821115611d2e57600080fd5b611d3a8b838c01611c17565b955060808a0135915080821115611d5057600080fd5b611d5c8b838c0161191c565b945060a08a0135915080821115611d7257600080fd5b611d7e8b838c0161191c565b935060c08a0135915080821115611d9457600080fd5b50611da18a828b0161191c565b91505092959891949750929550565b60008060008060008060c08789031215611dc957600080fd5b86356001600160401b0380821115611de057600080fd5b611dec8a838b0161191c565b975060208901359650611e0160408a016119c7565b95506060890135915080821115611e1757600080fd5b611e238a838b0161191c565b94506080890135915080821115611e3957600080fd5b611e458a838b0161191c565b935060a0890135915080821115611e5b57600080fd5b50611e6889828a0161191c565b9150509295509295509295565b60008060008060008060c08789031215611e8e57600080fd5b86356001600160401b0380821115611ea557600080fd5b611eb18a838b01611acb565b97506020890135915080821115611ec757600080fd5b611ed38a838b01611b3d565b96506040890135915080821115611ee957600080fd5b611ef58a838b01611b98565b95506060890135915080821115611f0b57600080fd5b611e238a838b01611c17565b600080600060608486031215611f2c57600080fd5b83359250602084013560ff81168114611f4457600080fd5b915060408401356001600160401b03811115611f5f57600080fd5b611f6b8682870161191c565b9150509250925092565b6020808252818101527f4f776e61626c653a2063616c6c6572206973206e6f7420746865206f776e6572604082015260600190565b60005b83811015611fc5578181015183820152602001611fad565b50506000910152565b60008151808452611fe6816020860160208601611faa565b601f01601f19169290920160200192915050565b6020815260006115dd6020830184611fce565b6020808252602c908201527f46756e6374696f6e206d7573742062652063616c6c6564207468726f7567682060408201526b19195b1959d85d1958d85b1b60a21b606082015260800190565b6020808252602c908201527f46756e6374696f6e206d7573742062652063616c6c6564207468726f7567682060408201526b6163746976652070726f787960a01b606082015260800190565b600081518084526020808501945080840160005b838110156120de5781516001600160a01b0316875295820195908201906001016120b9565b509495945050505050565b600081518084526020808501945080840160005b838110156120de578151875295820195908201906001016120fd565b600081518084526020808501808196508360051b8101915082860160005b8581101561216157828403895261214f848351611fce565b98850198935090840190600101612137565b5091979650505050505050565b60006101008083526121828184018c6120a5565b90508281036020840152612196818b6120e9565b905082810360408401526121aa818a612119565b905082810360608401526121be8189612119565b905082810360808401526121d28188611fce565b905082810360a08401526121e68187611fce565b90508460c084015282810360e08401526122008185611fce565b9b9a5050505050505050505050565b60e08152600061222260e083018a611fce565b602083018990526001600160a01b0388166040840152828103606084015261224a8188611fce565b90508560808401528460a084015282810360c084015261226a8185611fce565b9a9950505050505050505050565b60e08152600061228b60e083018a6120a5565b828103602084015261229d818a6120e9565b905082810360408401526122b18189612119565b905082810360608401526122c58188612119565b905082810360808401526122d98187611fce565b905082810360a08401526122ed8186611fce565b9150508260c083015298975050505050505050565b634e487b7160e01b600052601160045260246000fd5b818103818111156115e0576115e0612302565b60006020828403121561233d57600080fd5b81516001600160601b03811681146112ba57600080fd5b8481526001600160601b038416602082015260ff8316604082015260806060820152600061175e6080830184611fce565b634e487b7160e01b600052603260045260246000fd5b6000600182016123ad576123ad612302565b5060010190565b815160009082906020808601845b838110156123e75781516001600160a01b0316855293820193908201906001016123c2565b50929695505050505050565b815160009082906020808601845b838110156123e757815185529382019390820190600101612401565b83815260008351612435816020850160208801611faa565b60209201918201929092526040019392505050565b634e487b7160e01b600052602160045260246000fd5b8281526040602082015260006119bf6040830184611fce565b6000825161248b818460208701611faa565b9190910192915050565b6000602082840312156124a757600080fd5b81516001600160e01b0319811681146112ba57600080fdfe416464726573733a206c6f772d6c6576656c2064656c65676174652063616c6c206661696c6564a26469706673582212205a5b07daf6135a03c0720c163bc16d3178f6fd3ecc79f0faa6853805cb618dfe64736f6c6343000811003300000000000000000000000030656985039923eaa1ebb968fe84a1277581f60200000000000000000000000034b74b5c1996b37e5e3edb756731a5812ff43f67", + "nonce": "0x193", + "accessList": [] + }, + "additionalContracts": [] + }, + { + "hash": "0x6233eed2214dd417ccb42c757ebf8259df1b4d8cf562df670d37fe9a7c5f135d", + "transactionType": "CREATE", + "contractName": "NounsDAOExecutorProxy", + "contractAddress": "0x3F2e938318f5a9438853D25083DbE7CFB9A0652d", + "function": null, + "arguments": null, + "transaction": { + "type": "0x02", + "from": "0xd70ce993b8f90146ca4eaf33fb31528fac7adb78", + "gas": "0x64c5b", + "value": "0x0", + "data": "0x608060405260405161077d38038061077d83398101604081905261002291610337565b818161004f60017f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbd610405565b6000805160206107368339815191521461006b5761006b610426565b61007782826000610080565b5050505061048b565b610089836100b6565b6000825111806100965750805b156100b1576100af83836100f660201b6100291760201c565b505b505050565b6100bf81610124565b6040516001600160a01b038216907fbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b90600090a250565b606061011b8383604051806060016040528060278152602001610756602791396101e4565b90505b92915050565b610137816102bb60201b6100551760201c565b61019e5760405162461bcd60e51b815260206004820152602d60248201527f455243313936373a206e657720696d706c656d656e746174696f6e206973206e60448201526c1bdd08184818dbdb9d1c9858dd609a1b60648201526084015b60405180910390fd5b806101c360008051602061073683398151915260001b6102c160201b61005b1760201c565b80546001600160a01b0319166001600160a01b039290921691909117905550565b6060833b6102435760405162461bcd60e51b815260206004820152602660248201527f416464726573733a2064656c65676174652063616c6c20746f206e6f6e2d636f6044820152651b9d1c9858dd60d21b6064820152608401610195565b600080856001600160a01b03168560405161025e919061043c565b600060405180830381855af49150503d8060008114610299576040519150601f19603f3d011682016040523d82523d6000602084013e61029e565b606091505b5090925090506102af8282866102c4565b925050505b9392505050565b3b151590565b90565b606083156102d35750816102b4565b8251156102e35782518084602001fd5b8160405162461bcd60e51b81526004016101959190610458565b634e487b7160e01b600052604160045260246000fd5b60005b8381101561032e578181015183820152602001610316565b50506000910152565b6000806040838503121561034a57600080fd5b82516001600160a01b038116811461036157600080fd5b60208401519092506001600160401b038082111561037e57600080fd5b818501915085601f83011261039257600080fd5b8151818111156103a4576103a46102fd565b604051601f8201601f19908116603f011681019083821181831017156103cc576103cc6102fd565b816040528281528860208487010111156103e557600080fd5b6103f6836020830160208801610313565b80955050505050509250929050565b8181038181111561011e57634e487b7160e01b600052601160045260246000fd5b634e487b7160e01b600052600160045260246000fd5b6000825161044e818460208701610313565b9190910192915050565b6020815260008251806020840152610477816040850160208701610313565b601f01601f19169190910160400192915050565b61029c8061049a6000396000f3fe60806040523661001357610011610017565b005b6100115b61002761002261005e565b610096565b565b606061004e8383604051806060016040528060278152602001610240602791396100ba565b9392505050565b3b151590565b90565b60006100917f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc546001600160a01b031690565b905090565b3660008037600080366000845af43d6000803e8080156100b5573d6000f35b3d6000fd5b6060833b61011e5760405162461bcd60e51b815260206004820152602660248201527f416464726573733a2064656c65676174652063616c6c20746f206e6f6e2d636f6044820152651b9d1c9858dd60d21b60648201526084015b60405180910390fd5b600080856001600160a01b03168560405161013991906101f0565b600060405180830381855af49150503d8060008114610174576040519150601f19603f3d011682016040523d82523d6000602084013e610179565b606091505b5091509150610189828286610193565b9695505050505050565b606083156101a257508161004e565b8251156101b25782518084602001fd5b8160405162461bcd60e51b8152600401610115919061020c565b60005b838110156101e75781810151838201526020016101cf565b50506000910152565b600082516102028184602087016101cc565b9190910192915050565b602081526000825180602084015261022b8160408501602087016101cc565b601f01601f1916919091016040019291505056fe416464726573733a206c6f772d6c6576656c2064656c65676174652063616c6c206661696c6564a264697066735822122092c76cd4be71d9c59256a80f44fd2a3b77b6881fcac81575f67ae0852b4b1cdb64736f6c63430008110033360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc416464726573733a206c6f772d6c6576656c2064656c65676174652063616c6c206661696c6564000000000000000000000000442961f79c3968f908ed295a5debfcd9ac1712b6000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000647a1ac61e000000000000000000000000c1a82a952d48e015bea401ac982aa3d019aaa91e000000000000000000000000000000000000000000000000002386f26fc10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "nonce": "0x194", + "accessList": [] + }, + "additionalContracts": [] + } + ], + "receipts": [ + { + "transactionHash": "0x75b1b384fcd85a39eb7e48239e2153e7acb689629c3558691e2ba5ba07a5da9a", + "transactionIndex": "0x2a", + "blockHash": "0x678d34d97607662ddcf0777ea6872169d57c9d4b953c1506fd497a80caa8dc45", + "blockNumber": "0x8aa565", + "from": "0xD70CE993b8F90146Ca4eAf33Fb31528FAC7AdB78", + "to": null, + "cumulativeGasUsed": "0x862f00", + "gasUsed": "0x20259a", + "contractAddress": "0x442961F79C3968f908ed295a5DEbfcD9aC1712B6", + "logs": [], + "status": "0x1", + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "type": "0x2", + "effectiveGasPrice": "0xf1e1bfd9" + }, + { + "transactionHash": "0x6233eed2214dd417ccb42c757ebf8259df1b4d8cf562df670d37fe9a7c5f135d", + "transactionIndex": "0x2b", + "blockHash": "0x678d34d97607662ddcf0777ea6872169d57c9d4b953c1506fd497a80caa8dc45", + "blockNumber": "0x8aa565", + "from": "0xD70CE993b8F90146Ca4eAf33Fb31528FAC7AdB78", + "to": null, + "cumulativeGasUsed": "0x8b07cc", + "gasUsed": "0x4d8cc", + "contractAddress": "0x3F2e938318f5a9438853D25083DbE7CFB9A0652d", + "logs": [ + { + "address": "0x3F2e938318f5a9438853D25083DbE7CFB9A0652d", + "topics": [ + "0xbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b", + "0x000000000000000000000000442961f79c3968f908ed295a5debfcd9ac1712b6" + ], + "data": "0x", + "blockHash": "0x678d34d97607662ddcf0777ea6872169d57c9d4b953c1506fd497a80caa8dc45", + "blockNumber": "0x8aa565", + "transactionHash": "0x6233eed2214dd417ccb42c757ebf8259df1b4d8cf562df670d37fe9a7c5f135d", + "transactionIndex": "0x2b", + "logIndex": "0x52", + "removed": false + }, + { + "address": "0x3F2e938318f5a9438853D25083DbE7CFB9A0652d", + "topics": [ + "0x8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e0", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x000000000000000000000000c1a82a952d48e015bea401ac982aa3d019aaa91e" + ], + "data": "0x", + "blockHash": "0x678d34d97607662ddcf0777ea6872169d57c9d4b953c1506fd497a80caa8dc45", + "blockNumber": "0x8aa565", + "transactionHash": "0x6233eed2214dd417ccb42c757ebf8259df1b4d8cf562df670d37fe9a7c5f135d", + "transactionIndex": "0x2b", + "logIndex": "0x53", + "removed": false + } + ], + "status": "0x1", + "logsBloom": "0x00000000000000000000000000000000400000000000000000800000000000800000000000001000000000000000000000000000000000000004000000002000000000000040000000000000000002000001000002000000000000000000000000000000020000000000000000000800000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100080020000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000020000000", + "type": "0x2", + "effectiveGasPrice": "0xf1e1bfd9" + } + ], + "libraries": [], + "pending": [], + "path": "/Users/elad/code/nounsDAO/nouns-monorepo/packages/nouns-contracts/broadcast/DeployDAOV3DataContractsGoerli.s.sol/5/run-latest.json", + "returns": { + "dataProxy": { + "internal_type": "contract NounsDAODataProxy", + "value": "0x3F2e938318f5a9438853D25083DbE7CFB9A0652d" + } + }, + "timestamp": 1685378966, + "commit": "9232207a" +} \ No newline at end of file diff --git a/packages/nouns-contracts/contracts/NounsToken.sol b/packages/nouns-contracts/contracts/NounsToken.sol index db569d54e3..c4c41f42c1 100644 --- a/packages/nouns-contracts/contracts/NounsToken.sol +++ b/packages/nouns-contracts/contracts/NounsToken.sol @@ -183,7 +183,7 @@ contract NounsToken is INounsToken, Ownable, ERC721Checkpointable { * @notice Set the nounders DAO. * @dev Only callable by the nounders DAO when not locked. */ - function setNoundersDAO(address _noundersDAO) external override onlyNoundersDAO { + function setNoundersDAO(address _noundersDAO) external onlyNoundersDAO { noundersDAO = _noundersDAO; emit NoundersDAOUpdated(_noundersDAO); diff --git a/packages/nouns-contracts/contracts/governance/data/NounsDAODataProxyAdmin.sol b/packages/nouns-contracts/contracts/governance/NounsDAOExecutorProxy.sol similarity index 70% rename from packages/nouns-contracts/contracts/governance/data/NounsDAODataProxyAdmin.sol rename to packages/nouns-contracts/contracts/governance/NounsDAOExecutorProxy.sol index bbce7b4bbe..4eb391294b 100644 --- a/packages/nouns-contracts/contracts/governance/data/NounsDAODataProxyAdmin.sol +++ b/packages/nouns-contracts/contracts/governance/NounsDAOExecutorProxy.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-3.0 -/// @title The Nouns DAO Data proxy admin +/// @title The Nouns DAO Data proxy /********************************* * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * @@ -17,7 +17,12 @@ pragma solidity ^0.8.6; -import { ProxyAdmin } from '@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol'; +import { ERC1967Proxy } from '@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol'; -// prettier-ignore -contract NounsDAODataProxyAdmin is ProxyAdmin {} +/** + * @notice This is a proxy contract meant to be used as the Nouns DAO timelock. + * The first imlemenation is meant to be NounsDAOExecutorV2 which makes this an upgradable proxy. + */ +contract NounsDAOExecutorProxy is ERC1967Proxy { + constructor(address logic, bytes memory data) ERC1967Proxy(logic, data) {} +} diff --git a/packages/nouns-contracts/contracts/governance/NounsDAOExecutorV2.sol b/packages/nouns-contracts/contracts/governance/NounsDAOExecutorV2.sol new file mode 100644 index 0000000000..01584a6458 --- /dev/null +++ b/packages/nouns-contracts/contracts/governance/NounsDAOExecutorV2.sol @@ -0,0 +1,244 @@ +// SPDX-License-Identifier: BSD-3-Clause + +/// @title The Nouns DAO executor and treasury, supporting DAO fork + +/********************************* + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + * ░░░░░░█████████░░█████████░░░ * + * ░░░░░░██░░░████░░██░░░████░░░ * + * ░░██████░░░████████░░░████░░░ * + * ░░██░░██░░░████░░██░░░████░░░ * + * ░░██░░██░░░████░░██░░░████░░░ * + * ░░░░░░█████████░░█████████░░░ * + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + *********************************/ + +// LICENSE +// NounsDAOExecutor2.sol is a modified version of Compound Lab's Timelock.sol: +// https://github.com/compound-finance/compound-protocol/blob/20abad28055a2f91df48a90f8bb6009279a4cb35/contracts/Timelock.sol +// +// Timelock.sol source code Copyright 2020 Compound Labs, Inc. licensed under the BSD-3-Clause license. +// With modifications by Nounders DAO. +// +// Additional conditions of BSD-3-Clause can be found here: https://opensource.org/licenses/BSD-3-Clause +// +// MODIFICATIONS +// NounsDAOExecutor2.sol is a modified version of NounsDAOExecutor.sol +// +// NounsDAOExecutor.sol modifications: +// NounsDAOExecutor.sol modifies Timelock to use Solidity 0.8.x receive(), fallback(), and built-in over/underflow protection +// This contract acts as executor of Nouns DAO governance and its treasury, so it has been modified to accept ETH. +// +// +// NounsDAOExecutor2.sol modifications: +// - `sendETH` and `sendERC20` functions used for DAO forks +// - is upgradable via UUPSUpgradeable. uses intializer instead of constructor. +// - `GRACE_PERIOD` has been increased from 14 days to 21 days to allow more time in case of a forking period + +pragma solidity ^0.8.6; + +import { IERC20 } from '@openzeppelin/contracts/token/ERC20/IERC20.sol'; +import { Initializable } from '@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol'; +import { UUPSUpgradeable } from '@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol'; + +contract NounsDAOExecutorV2 is UUPSUpgradeable, Initializable { + event NewAdmin(address indexed newAdmin); + event NewPendingAdmin(address indexed newPendingAdmin); + event NewDelay(uint256 indexed newDelay); + event CancelTransaction( + bytes32 indexed txHash, + address indexed target, + uint256 value, + string signature, + bytes data, + uint256 eta + ); + event ExecuteTransaction( + bytes32 indexed txHash, + address indexed target, + uint256 value, + string signature, + bytes data, + uint256 eta + ); + event QueueTransaction( + bytes32 indexed txHash, + address indexed target, + uint256 value, + string signature, + bytes data, + uint256 eta + ); + event ETHSent(address indexed to, uint256 amount, bool success); + event ERC20Sent(address indexed to, address indexed erc20Token, uint256 amount, bool success); + + string public constant NAME = 'NounsDAOExecutorV2'; + + /// @dev increased grace period from 14 days to 21 days to allow more time in case of a forking period + uint256 public constant GRACE_PERIOD = 21 days; + uint256 public constant MINIMUM_DELAY = 2 days; + uint256 public constant MAXIMUM_DELAY = 30 days; + + address public admin; + address public pendingAdmin; + uint256 public delay; + + mapping(bytes32 => bool) public queuedTransactions; + + function initialize(address admin_, uint256 delay_) public virtual initializer { + require(delay_ >= MINIMUM_DELAY, 'NounsDAOExecutor::constructor: Delay must exceed minimum delay.'); + require(delay_ <= MAXIMUM_DELAY, 'NounsDAOExecutor::setDelay: Delay must not exceed maximum delay.'); + + admin = admin_; + delay = delay_; + } + + function setDelay(uint256 delay_) public { + require(msg.sender == address(this), 'NounsDAOExecutor::setDelay: Call must come from NounsDAOExecutor.'); + require(delay_ >= MINIMUM_DELAY, 'NounsDAOExecutor::setDelay: Delay must exceed minimum delay.'); + require(delay_ <= MAXIMUM_DELAY, 'NounsDAOExecutor::setDelay: Delay must not exceed maximum delay.'); + delay = delay_; + + emit NewDelay(delay); + } + + function acceptAdmin() public { + require(msg.sender == pendingAdmin, 'NounsDAOExecutor::acceptAdmin: Call must come from pendingAdmin.'); + admin = msg.sender; + pendingAdmin = address(0); + + emit NewAdmin(admin); + } + + function setPendingAdmin(address pendingAdmin_) public { + require( + msg.sender == address(this), + 'NounsDAOExecutor::setPendingAdmin: Call must come from NounsDAOExecutor.' + ); + pendingAdmin = pendingAdmin_; + + emit NewPendingAdmin(pendingAdmin); + } + + function queueTransaction( + address target, + uint256 value, + string memory signature, + bytes memory data, + uint256 eta + ) public returns (bytes32) { + require(msg.sender == admin, 'NounsDAOExecutor::queueTransaction: Call must come from admin.'); + require( + eta >= getBlockTimestamp() + delay, + 'NounsDAOExecutor::queueTransaction: Estimated execution block must satisfy delay.' + ); + + bytes32 txHash = keccak256(abi.encode(target, value, signature, data, eta)); + queuedTransactions[txHash] = true; + + emit QueueTransaction(txHash, target, value, signature, data, eta); + return txHash; + } + + function cancelTransaction( + address target, + uint256 value, + string memory signature, + bytes memory data, + uint256 eta + ) public { + require(msg.sender == admin, 'NounsDAOExecutor::cancelTransaction: Call must come from admin.'); + + bytes32 txHash = keccak256(abi.encode(target, value, signature, data, eta)); + queuedTransactions[txHash] = false; + + emit CancelTransaction(txHash, target, value, signature, data, eta); + } + + function executeTransaction( + address target, + uint256 value, + string memory signature, + bytes memory data, + uint256 eta + ) public returns (bytes memory) { + require(msg.sender == admin, 'NounsDAOExecutor::executeTransaction: Call must come from admin.'); + + bytes32 txHash = keccak256(abi.encode(target, value, signature, data, eta)); + require(queuedTransactions[txHash], "NounsDAOExecutor::executeTransaction: Transaction hasn't been queued."); + require( + getBlockTimestamp() >= eta, + "NounsDAOExecutor::executeTransaction: Transaction hasn't surpassed time lock." + ); + require( + getBlockTimestamp() <= eta + GRACE_PERIOD, + 'NounsDAOExecutor::executeTransaction: Transaction is stale.' + ); + + queuedTransactions[txHash] = false; + + bytes memory callData; + + if (bytes(signature).length == 0) { + callData = data; + } else { + callData = abi.encodePacked(bytes4(keccak256(bytes(signature))), data); + } + + // solium-disable-next-line security/no-call-value + (bool success, bytes memory returnData) = target.call{ value: value }(callData); + require(success, 'NounsDAOExecutor::executeTransaction: Transaction execution reverted.'); + + emit ExecuteTransaction(txHash, target, value, signature, data, eta); + + return returnData; + } + + function getBlockTimestamp() internal view returns (uint256) { + // solium-disable-next-line security/no-block-members + return block.timestamp; + } + + receive() external payable {} + + fallback() external payable {} + + function sendETH(address newDAOTreasury, uint256 ethToSend) external returns (bool success) { + require(msg.sender == admin, 'NounsDAOExecutor::executeTransaction: Call must come from admin.'); + + (success, ) = newDAOTreasury.call{ value: ethToSend }(''); + + emit ETHSent(newDAOTreasury, ethToSend, success); + } + + function sendERC20( + address newDAOTreasury, + address erc20Token, + uint256 tokensToSend + ) external returns (bool success) { + require(msg.sender == admin, 'NounsDAOExecutor::executeTransaction: Call must come from admin.'); + + success = IERC20(erc20Token).transfer(newDAOTreasury, tokensToSend); + + emit ERC20Sent(newDAOTreasury, erc20Token, tokensToSend, success); + } + + /** + * @dev Function that should revert when `msg.sender` is not authorized to upgrade the contract. Called by + * {upgradeTo} and {upgradeToAndCall}. + * + * Normally, this function will use an xref:access.adoc[access control] modifier such as {Ownable-onlyOwner}. + * + * ```solidity + * function _authorizeUpgrade(address) internal override onlyOwner {} + * ``` + */ + function _authorizeUpgrade(address) internal view override { + require( + msg.sender == address(this), + 'NounsDAOExecutor::_authorizeUpgrade: Call must come from NounsDAOExecutor.' + ); + } +} diff --git a/packages/nouns-contracts/contracts/governance/NounsDAOInterfaces.sol b/packages/nouns-contracts/contracts/governance/NounsDAOInterfaces.sol index 496824c7d5..483aaeead1 100644 --- a/packages/nouns-contracts/contracts/governance/NounsDAOInterfaces.sol +++ b/packages/nouns-contracts/contracts/governance/NounsDAOInterfaces.sol @@ -29,6 +29,8 @@ // See NounsDAOLogicV1.sol for more details. // NounsDAOStorageV1Adjusted and NounsDAOStorageV2 add support for a dynamic vote quorum. // See NounsDAOLogicV2.sol for more details. +// NounsDAOStorageV3 +// See NounsDAOLogicV3.sol for more details. pragma solidity ^0.8.6; @@ -145,6 +147,9 @@ contract NounsDAOEventsV3 is NounsDAOEventsV2 { string description ); + /// @notice Emitted when a proposal is created to be executed on timelockV1 + event ProposalCreatedOnTimelockV1(uint256 id); + /// @notice Emitted when a proposal is updated event ProposalUpdated( uint256 indexed id, @@ -157,6 +162,7 @@ contract NounsDAOEventsV3 is NounsDAOEventsV2 { string updateMessage ); + /// @notice Emitted when a proposal's transactions are updated event ProposalTransactionsUpdated( uint256 indexed id, address indexed proposer, @@ -167,7 +173,13 @@ contract NounsDAOEventsV3 is NounsDAOEventsV2 { string updateMessage ); - event ProposalDescriptionUpdated(uint256 indexed id, address indexed proposer, string description, string updateMessage); + /// @notice Emitted when a proposal's description is updated + event ProposalDescriptionUpdated( + uint256 indexed id, + address indexed proposer, + string description, + string updateMessage + ); /// @notice Emitted when a proposal is set to have an objection period event ProposalObjectionPeriodSet(uint256 indexed id, uint256 objectionPeriodEndBlock); @@ -195,6 +207,54 @@ contract NounsDAOEventsV3 is NounsDAOEventsV2 { uint256 oldVoteSnapshotBlockSwitchProposalId, uint256 newVoteSnapshotBlockSwitchProposalId ); + + /// @notice Emitted when the erc20 tokens to include in a fork are set + event ERC20TokensToIncludeInForkSet(address[] oldErc20Tokens, address[] newErc20tokens); + + /// @notice Emitted when the fork DAO deployer is set + event ForkDAODeployerSet(address oldForkDAODeployer, address newForkDAODeployer); + + /// @notice Emitted when the during of the forking period is set + event ForkPeriodSet(uint256 oldForkPeriod, uint256 newForkPeriod); + + /// @notice Emitted when the threhsold for forking is set + event ForkThresholdSet(uint256 oldForkThreshold, uint256 newForkThreshold); + + /// @notice Emitted when the main timelock, timelockV1 and admin are set + event TimelocksAndAdminSet(address timelock, address timelockV1, address admin); + + /// @notice Emitted when someones adds nouns to the fork escrow + event EscrowedToFork( + uint32 indexed forkId, + address indexed owner, + uint256[] tokenIds, + uint256[] proposalIds, + string reason + ); + + /// @notice Emitted when the owner withdraws their nouns from the fork escrow + event WithdrawFromForkEscrow(uint32 indexed forkId, address indexed owner, uint256[] tokenIds); + + /// @notice Emitted when the fork is executed and the forking period begins + event ExecuteFork( + uint32 indexed forkId, + address forkTreasury, + address forkToken, + uint256 forkEndTimestamp, + uint256 tokensInEscrow + ); + + /// @notice Emitted when someone joins a fork during the forking period + event JoinFork( + uint32 indexed forkId, + address indexed owner, + uint256[] tokenIds, + uint256[] proposalIds, + string reason + ); + + /// @notice Emitted when the DAO withdraws nouns from the fork escrow after a fork has been executed + event DAOWithdrawNounsFromEscrow(uint256[] tokenIds, address to); } contract NounsDAOProxyStorage { @@ -408,8 +468,7 @@ contract NounsDAOStorageV1Adjusted is NounsDAOProxyStorage { Queued, Expired, Executed, - Vetoed, - ObjectionPeriod + Vetoed } } @@ -425,9 +484,6 @@ contract NounsDAOStorageV2 is NounsDAOStorageV1Adjusted { /// @notice Pending new vetoer address public pendingVetoer; - uint256 public lastMinuteWindowInBlocks; - uint256 public objectionPeriodDurationInBlocks; - struct DynamicQuorumParams { /// @notice The minimum basis point number of votes in support of a proposal required in order for a quorum to be reached and for a vote to succeed. uint16 minQuorumVotesBPS; @@ -518,6 +574,74 @@ interface NounsTokenLike { function getPriorVotes(address account, uint256 blockNumber) external view returns (uint96); function totalSupply() external view returns (uint256); + + function transferFrom( + address from, + address to, + uint256 tokenId + ) external; + + function safeTransferFrom( + address from, + address to, + uint256 tokenId + ) external; + + function balanceOf(address owner) external view returns (uint256 balance); + + function ownerOf(uint256 tokenId) external view returns (address owner); + + function minter() external view returns (address); + + function mint() external returns (uint256); + + function setApprovalForAll(address operator, bool approved) external; +} + +interface IForkDAODeployer { + function deployForkDAO(uint256 forkingPeriodEndTimestamp, INounsDAOForkEscrow forkEscrowAddress) + external + returns (address treasury, address token); + + function tokenImpl() external view returns (address); + + function auctionImpl() external view returns (address); + + function governorImpl() external view returns (address); + + function treasuryImpl() external view returns (address); +} + +interface INounsDAOExecutorV2 is INounsDAOExecutor { + function sendETH(address newDAOTreasury, uint256 ethToSend) external returns (bool success); + + function sendERC20( + address newDAOTreasury, + address erc20Token, + uint256 tokensToSend + ) external returns (bool success); +} + +interface INounsDAOForkEscrow { + function markOwner(address owner, uint256[] calldata tokenIds) external; + + function returnTokensToOwner(address owner, uint256[] calldata tokenIds) external; + + function closeEscrow() external returns (uint32); + + function numTokensInEscrow() external view returns (uint256); + + function numTokensOwnedByDAO() external view returns (uint256); + + function withdrawTokensToDAO(uint256[] calldata tokenIds, address to) external; + + function forkId() external view returns (uint32); + + function nounsToken() external view returns (NounsTokenLike); + + function dao() external view returns (address); + + function ownerOfEscrowedToken(uint32 forkId_, uint256 tokenId) external view returns (address); } contract NounsDAOStorageV3 { @@ -545,7 +669,7 @@ contract NounsDAOStorageV3 { /// @notice The total number of proposals uint256 proposalCount; /// @notice The address of the Nouns DAO Executor NounsDAOExecutor - INounsDAOExecutor timelock; + INounsDAOExecutorV2 timelock; /// @notice The address of the Nouns tokens NounsTokenLike nouns; /// @notice The official record of all proposals ever proposed @@ -559,10 +683,32 @@ contract NounsDAOStorageV3 { // ================ V3 ================ // /// @notice user => sig => isCancelled: signatures that have been cancelled by the signer and are no longer valid mapping(address => mapping(bytes32 => bool)) cancelledSigs; + /// @notice The number of blocks before voting ends during which the objection period can be initiated uint32 lastMinuteWindowInBlocks; + /// @notice Length of the objection period in blocks uint32 objectionPeriodDurationInBlocks; + /// @notice Length of proposal updatable period in block uint32 proposalUpdatablePeriodInBlocks; + /// @notice address of the DAO's fork escrow contract + INounsDAOForkEscrow forkEscrow; + /// @notice address of the DAO's fork deployer contract + IForkDAODeployer forkDAODeployer; + /// @notice ERC20 tokens to include when sending funds to a deployed fork + address[] erc20TokensToIncludeInFork; + /// @notice The treasury contract of the last deployed fork + address forkDAOTreasury; + /// @notice The token contract of the last deployed fork + address forkDAOToken; + /// @notice Timestamp at which the last fork period ends + uint256 forkEndTimestamp; + /// @notice Fork period in seconds + uint256 forkPeriod; + /// @notice Threshold defined in basis points (10,000 = 100%) required for forking + uint256 forkThresholdBPS; + /// @notice Address of the original timelock + INounsDAOExecutor timelockV1; /// @notice The proposal at which to start using `startBlock` instead of `creationBlock` for vote snapshots + /// @dev Make sure this stays the last variable in this struct, so we can delete it in the next version /// @dev To be zeroed-out and removed in a V3.1 fix version once the switch takes place uint256 voteSnapshotBlockSwitchProposalId; } @@ -607,10 +753,17 @@ contract NounsDAOStorageV3 { /// @notice The total supply at the time of proposal creation uint256 totalSupply; /// @notice The block at which this proposal was created - uint256 creationBlock; + uint64 creationBlock; + /// @notice The last block which allows updating a proposal's description and transactions + uint64 updatePeriodEndBlock; + /// @notice Starts at 0 and is set to the block at which the objection period ends when the objection period is initiated + uint64 objectionPeriodEndBlock; + /// @dev unused for now + uint64 placeholder; + /// @notice The signers of a proposal, when using proposeBySigs address[] signers; - uint256 updatePeriodEndBlock; - uint256 objectionPeriodEndBlock; + /// @notice When true, a proposal would be executed on timelockV1 instead of the current timelock + bool executeOnTimelockV1; } /// @notice Ballot receipt record for a voter @@ -624,8 +777,11 @@ contract NounsDAOStorageV3 { } struct ProposerSignature { + /// @notice Signature of a proposal bytes sig; + /// @notice The address of the signer address signer; + /// @notice The timestamp until which the signature is valid uint256 expirationTimestamp; } @@ -660,9 +816,14 @@ contract NounsDAOStorageV3 { uint256 totalSupply; /// @notice The block at which this proposal was created uint256 creationBlock; + /// @notice The signers of a proposal, when using proposeBySigs address[] signers; + /// @notice The last block which allows updating a proposal's description and transactions uint256 updatePeriodEndBlock; + /// @notice Starts at 0 and is set to the block at which the objection period ends when the objection period is initiated uint256 objectionPeriodEndBlock; + /// @notice When true, a proposal would be executed on timelockV1 instead of the current timelock + bool executeOnTimelockV1; } struct DynamicQuorumParams { @@ -675,6 +836,15 @@ contract NounsDAOStorageV3 { uint32 quorumCoefficient; } + struct NounsDAOParams { + uint256 votingPeriod; + uint256 votingDelay; + uint256 proposalThresholdBPS; + uint32 lastMinuteWindowInBlocks; + uint32 objectionPeriodDurationInBlocks; + uint32 proposalUpdatablePeriodInBlocks; + } + /// @notice A checkpoint for storing dynamic quorum params from a given block struct DynamicQuorumParamsCheckpoint { /// @notice The block at which the new values were set diff --git a/packages/nouns-contracts/contracts/governance/NounsDAOLogicV3.sol b/packages/nouns-contracts/contracts/governance/NounsDAOLogicV3.sol index 59e8fb0cdc..852f2585be 100644 --- a/packages/nouns-contracts/contracts/governance/NounsDAOLogicV3.sol +++ b/packages/nouns-contracts/contracts/governance/NounsDAOLogicV3.sol @@ -26,29 +26,32 @@ // // MODIFICATIONS // See NounsDAOLogicV1 for initial GovernorBravoDelegate modifications. - -// NounsDAOLogicV2 adds: -// - `quorumParamsCheckpoints`, which store dynamic quorum parameters checkpoints -// to be used when calculating the dynamic quorum. -// - `_setDynamicQuorumParams(DynamicQuorumParams memory params)`, which allows the -// DAO to update the dynamic quorum parameters' values. -// - `getDynamicQuorumParamsAt(uint256 blockNumber_)` -// - Individual setters of the DynamicQuorumParams members: -// - `_setMinQuorumVotesBPS(uint16 newMinQuorumVotesBPS)` -// - `_setMaxQuorumVotesBPS(uint16 newMaxQuorumVotesBPS)` -// - `_setQuorumCoefficient(uint32 newQuorumCoefficient)` -// - `minQuorumVotes` and `maxQuorumVotes`, which returns the current min and -// max quorum votes using the current Noun supply. -// - New `Proposal` struct member: -// - `totalSupply` used in dynamic quorum calculation. -// - `creationBlock` used for retrieving checkpoints of votes and dynamic quorum params. This now -// allows changing `votingDelay` without affecting the checkpoints lookup. -// - `quorumVotes(uint256 proposalId)`, which calculates and returns the dynamic -// quorum for a specific proposal. -// - `proposals(uint256 proposalId)` instead of the implicit getter, to avoid stack-too-deep error +// See NounsDAOLogicV2 for additional modifications +// +// NounsDAOLogicV3 adds: +// - Contract has been broken down to use libraries because of contract size limitations +// - Proposal editing: allowing proposers to update their proposal’s transactions and text description, +// during the Updatable period only, which is the state upon proposal creation. Editing also works with signatures, +// assuming the proposer is able to accumulate signatures from the same signers. +// - Propose by signature: allowing Nouners and delegates to pool their voting power towards submitting a proposal, +// by submitting their signature, instead of the current approach where sponsors must delegate their votes to help +// a proposer achieve threshold. +// - Objection-only Period: a conditional voting period that gets activated upon a last-minute proposal swing +// from defeated to successful, affording against voters more reaction time. +// Only against votes are possible during the objection period. +// - Votes snapshot after voting delay: moving votes snapshot up, to provide Nouners with reaction time per proposal, +// to get their votes ready (e.g. some might want to move their delegations around). +// In NounsDAOLogicV2 the vote snapshot block is the proposal creation block. +// - Nouns fork: any token holder can signal to fork (exit) in response to a governance proposal. +// If a quorum of a configured threshold amount of tokens signals to exit, the fork will succeed. +// This will deploy a new DAO and send part of the treasury to the new DAO. +// +// 2 new states have been added to the proposal state machine: Updatable, ObjectionPeriod +// +// Updated state machine: +// Updatable -> Pending -> Active -> ObjectionPeriod (conditional) -> Succeeded -> Queued -> Executed +// ┖> Defeated // -// NounsDAOLogicV2 removes: -// - `quorumVotes()` has been replaced by `quorumVotes(uint256 proposalId)`. pragma solidity ^0.8.6; @@ -57,12 +60,14 @@ import { NounsDAOV3Admin } from './NounsDAOV3Admin.sol'; import { NounsDAOV3DynamicQuorum } from './NounsDAOV3DynamicQuorum.sol'; import { NounsDAOV3Votes } from './NounsDAOV3Votes.sol'; import { NounsDAOV3Proposals } from './NounsDAOV3Proposals.sol'; +import { NounsDAOV3Fork } from './fork/NounsDAOV3Fork.sol'; contract NounsDAOLogicV3 is NounsDAOStorageV3, NounsDAOEventsV3 { using NounsDAOV3Admin for StorageV3; using NounsDAOV3DynamicQuorum for StorageV3; using NounsDAOV3Votes for StorageV3; using NounsDAOV3Proposals for StorageV3; + using NounsDAOV3Fork for StorageV3; /** * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ @@ -124,36 +129,34 @@ contract NounsDAOLogicV3 is NounsDAOStorageV3, NounsDAOEventsV3 { /** * @notice Used to initialize the contract during delegator contructor + * @dev This will only be called for a newly deployed DAO, not as part of an upgrade from V2 to V3 * @param timelock_ The address of the NounsDAOExecutor * @param nouns_ The address of the NOUN tokens * @param vetoer_ The address allowed to unilaterally veto proposals - * @param votingPeriod_ The initial voting period - * @param votingDelay_ The initial voting delay - * @param proposalThresholdBPS_ The initial proposal threshold in basis points + * @param daoParams_ Initial DAO parameters * @param dynamicQuorumParams_ The initial dynamic quorum parameters */ function initialize( address timelock_, address nouns_, + address forkEscrow_, + address forkDAODeployer_, address vetoer_, - uint256 votingPeriod_, - uint256 votingDelay_, - uint256 proposalThresholdBPS_, - DynamicQuorumParams calldata dynamicQuorumParams_, - uint32 lastMinuteWindowInBlocks_, - uint32 objectionPeriodDurationInBlocks_, - uint32 proposalUpdatablePeriodInBlocks_ + NounsDAOParams calldata daoParams_, + DynamicQuorumParams calldata dynamicQuorumParams_ ) public virtual { if (address(ds.timelock) != address(0)) revert CanOnlyInitializeOnce(); if (msg.sender != ds.admin) revert AdminOnly(); if (timelock_ == address(0)) revert InvalidTimelockAddress(); if (nouns_ == address(0)) revert InvalidNounsAddress(); - ds._setVotingPeriod(votingPeriod_); - ds._setVotingDelay(votingDelay_); - ds._setProposalThresholdBPS(proposalThresholdBPS_); - ds.timelock = INounsDAOExecutor(timelock_); + ds._setVotingPeriod(daoParams_.votingPeriod); + ds._setVotingDelay(daoParams_.votingDelay); + ds._setProposalThresholdBPS(daoParams_.proposalThresholdBPS); + ds.timelock = INounsDAOExecutorV2(timelock_); ds.nouns = NounsTokenLike(nouns_); + ds.forkEscrow = INounsDAOForkEscrow(forkEscrow_); + ds.forkDAODeployer = IForkDAODeployer(forkDAODeployer_); ds.vetoer = vetoer_; _setDynamicQuorumParams( dynamicQuorumParams_.minQuorumVotesBPS, @@ -161,9 +164,9 @@ contract NounsDAOLogicV3 is NounsDAOStorageV3, NounsDAOEventsV3 { dynamicQuorumParams_.quorumCoefficient ); - ds._setLastMinuteWindowInBlocks(lastMinuteWindowInBlocks_); - ds._setObjectionPeriodDurationInBlocks(objectionPeriodDurationInBlocks_); - ds._setProposalUpdatablePeriodInBlocks(proposalUpdatablePeriodInBlocks_); + ds._setLastMinuteWindowInBlocks(daoParams_.lastMinuteWindowInBlocks); + ds._setObjectionPeriodDurationInBlocks(daoParams_.objectionPeriodDurationInBlocks); + ds._setProposalUpdatablePeriodInBlocks(daoParams_.proposalUpdatablePeriodInBlocks); } /** @@ -191,6 +194,42 @@ contract NounsDAOLogicV3 is NounsDAOStorageV3, NounsDAOEventsV3 { return ds.propose(NounsDAOV3Proposals.ProposalTxs(targets, values, signatures, calldatas), description); } + /** + * @notice Function used to propose a new proposal. Sender must have delegates above the proposal threshold. + * This proposal would be executed via the timelockV1 contract. This is meant to be used in case timelockV1 + * is still holding funds or has special permissions to execute on certain contracts. + * @param targets Target addresses for proposal calls + * @param values Eth values for proposal calls + * @param signatures Function signatures for proposal calls + * @param calldatas Calldatas for proposal calls + * @param description String description of the proposal + * @return uint256 Proposal id of new proposal + */ + function proposeOnTimelockV1( + address[] memory targets, + uint256[] memory values, + string[] memory signatures, + bytes[] memory calldatas, + string memory description + ) public returns (uint256) { + return + ds.proposeOnTimelockV1( + NounsDAOV3Proposals.ProposalTxs(targets, values, signatures, calldatas), + description + ); + } + + /** + * @notice Function used to propose a new proposal. Sender and signers must have delegates above the proposal threshold + * @param proposerSignatures Array of signers who have signed the proposal and their signatures. + * @dev The signatures follow EIP-712. See `PROPOSAL_TYPEHASH` in NounsDAOV3Proposals.sol + * @param targets Target addresses for proposal calls + * @param values Eth values for proposal calls + * @param signatures Function signatures for proposal calls + * @param calldatas Calldatas for proposal calls + * @param description String description of the proposal + * @return uint256 Proposal id of new proposal + */ function proposeBySigs( ProposerSignature[] memory proposerSignatures, address[] memory targets, @@ -207,10 +246,30 @@ contract NounsDAOLogicV3 is NounsDAOStorageV3, NounsDAOEventsV3 { ); } + /** + * @notice Invalidates a signature that may be used for signing a proposal. + * Once a signature is canceled, the sender can no longer use it again. + * If the sender changes their mind and want to sign the proposal, they can change the expiry timestamp + * in order to produce a new signature. + * The signature will only be invalidated when used by the sender. If used by a different account, it will + * not be invalidated. + * @param sig The signature to cancel + */ function cancelSig(bytes calldata sig) external { ds.cancelSig(sig); } + /** + * @notice Update a proposal transactions and description. + * Only the proposer can update it, and only during the updateable period. + * @param proposalId Proposal's id + * @param targets Updated target addresses for proposal calls + * @param values Updated eth values for proposal calls + * @param signatures Updated function signatures for proposal calls + * @param calldatas Updated calldatas for proposal calls + * @param description Updated description of the proposal + * @param updateMessage Short message to explain the update + */ function updateProposal( uint256 proposalId, address[] memory targets, @@ -224,17 +283,27 @@ contract NounsDAOLogicV3 is NounsDAOStorageV3, NounsDAOEventsV3 { } /** - * Updates the proposal's description. Only the proposer can update it, and only during the updateable period. - * @param proposalId proposal's id - * @param description the updated description - * @param updateMessage short message to explain the update + * @notice Updates the proposal's description. Only the proposer can update it, and only during the updateable period. + * @param proposalId Proposal's id + * @param description Updated description of the proposal + * @param updateMessage Short message to explain the update */ - function updateProposalDescription(uint256 proposalId, string calldata description, string calldata updateMessage) external { + function updateProposalDescription( + uint256 proposalId, + string calldata description, + string calldata updateMessage + ) external { ds.updateProposalDescription(proposalId, description, updateMessage); } /** - * Updates the proposal's transactions. Only the proposer can update it, and only during the updateable period. + * @notice Updates the proposal's transactions. Only the proposer can update it, and only during the updateable period. + * @param proposalId Proposal's id + * @param targets Updated target addresses for proposal calls + * @param values Updated eth values for proposal calls + * @param signatures Updated function signatures for proposal calls + * @param calldatas Updated calldatas for proposal calls + * @param updateMessage Short message to explain the update */ function updateProposalTransactions( uint256 proposalId, @@ -244,10 +313,23 @@ contract NounsDAOLogicV3 is NounsDAOStorageV3, NounsDAOEventsV3 { bytes[] memory calldatas, string memory updateMessage ) external { - // TODO: gas: should these be calldata instead of memory? ds.updateProposalTransactions(proposalId, targets, values, signatures, calldatas, updateMessage); } + /** + * @notice Update a proposal's transactions and description that was created with proposeBySigs. + * Only the proposer can update it, during the updateable period. + * Requires the original signers to sign the update. + * @param proposalId Proposal's id + * @param proposerSignatures Array of signers who have signed the proposal and their signatures. + * @dev The signatures follow EIP-712. See `UPDATE_PROPOSAL_TYPEHASH` in NounsDAOV3Proposals.sol + * @param targets Updated target addresses for proposal calls + * @param values Updated eth values for proposal calls + * @param signatures Updated function signatures for proposal calls + * @param calldatas Updated calldatas for proposal calls + * @param description Updated description of the proposal + * @param updateMessage Short message to explain the update + */ function updateProposalBySigs( uint256 proposalId, ProposerSignature[] memory proposerSignatures, @@ -283,6 +365,15 @@ contract NounsDAOLogicV3 is NounsDAOStorageV3, NounsDAOEventsV3 { ds.execute(proposalId); } + /** + * @notice Executes a queued proposal on timelockV1 if eta has passed + * This is only required for proposal that were queued on timelockV1, but before the upgrade to DAO V3. + * These proposals will not have the `executeOnTimelockV1` bool turned on. + */ + function executeOnTimelockV1(uint256 proposalId) external { + ds.executeOnTimelockV1(proposalId); + } + /** * @notice Cancels a proposal only if sender is the proposer, or proposer delegates dropped below proposal threshold * @param proposalId The id of the proposal to cancel @@ -357,7 +448,95 @@ contract NounsDAOLogicV3 is NounsDAOStorageV3, NounsDAOEventsV3 { * Differs from `GovernerBravo` which uses fixed amount */ function proposalThreshold() public view returns (uint256) { - return ds.proposalThreshold(); + return ds.proposalThreshold(ds.adjustedTotalSupply()); + } + + /** + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + * DAO FORK + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + */ + + /** + * @notice Escrow Nouns to contribute to the fork threshold + * @dev Requires approving the tokenIds or the entire noun token to the DAO contract + * @param tokenIds the tokenIds to escrow. They will be sent to the DAO once the fork threshold is reached and the escrow is closed. + * @param proposalIds array of proposal ids which are the reason for wanting to fork. This will only be used to emit event. + * @param reason the reason for want to fork. This will only be used to emit event. + */ + function escrowToFork( + uint256[] calldata tokenIds, + uint256[] calldata proposalIds, + string calldata reason + ) external { + ds.escrowToFork(tokenIds, proposalIds, reason); + } + + /** + * @notice Withdraw Nouns from the fork escrow. Only possible if the fork has not been executed. + * Only allowed to withdraw tokens that the sender has escrowed. + * @param tokenIds the tokenIds to withdraw + */ + function withdrawFromForkEscrow(uint256[] calldata tokenIds) external { + ds.withdrawFromForkEscrow(tokenIds); + } + + /** + * @notice Execute the fork. Only possible if the fork threshold has been met. + * This will deploy a new DAO and send part of the treasury to the new DAO's treasury. + * This will also close the active escrow and all nouns in the escrow belong to the original DAO. + * @return forkTreasury The address of the new DAO's treasury + * @return forkToken The address of the new DAO's token + */ + function executeFork() external returns (address forkTreasury, address forkToken) { + return ds.executeFork(); + } + + /** + * @notice Joins a fork while a fork is active + * @param tokenIds the tokenIds to send to the DAO in exchange for joining the fork + * @param proposalIds array of proposal ids which are the reason for wanting to fork. This will only be used to emit event. + * @param reason the reason for want to fork. This will only be used to emit event. + */ + function joinFork( + uint256[] calldata tokenIds, + uint256[] calldata proposalIds, + string calldata reason + ) external { + ds.joinFork(tokenIds, proposalIds, reason); + } + + /** + * @notice Withdraws nouns from the fork escrow after the fork has been executed + * @dev Only the DAO can call this function + * @param tokenIds the tokenIds to withdraw + * @param to the address to send the nouns to + */ + function withdrawDAONounsFromEscrow(uint256[] calldata tokenIds, address to) external { + ds.withdrawDAONounsFromEscrow(tokenIds, to); + } + + /** + * @notice Returns the number of nouns in supply minus nouns owned by the DAO, i.e. held in the treasury or in an + * escrow after it has closed. + * This is used when calculating proposal threshold, quorum, fork threshold & treasury split. + */ + function adjustedTotalSupply() external view returns (uint256) { + return ds.adjustedTotalSupply(); + } + + /** + * @notice returns the required number of tokens to escrow to trigger a fork + */ + function forkThreshold() external view returns (uint256) { + return ds.forkThreshold(); + } + + /** + * @notice Returns the number of tokens currently in escrow, contributing to the fork threshold + */ + function numTokensInForkEscrow() external view returns (uint256) { + return ds.numTokensInForkEscrow(); } /** @@ -524,6 +703,9 @@ contract NounsDAOLogicV3 is NounsDAOStorageV3, NounsDAOEventsV3 { ds._setPendingVetoer(newPendingVetoer); } + /** + * @notice Called by the pendingVetoer to accept role and update vetoer + */ function _acceptVetoer() external { ds._acceptVetoer(); } @@ -582,17 +764,93 @@ contract NounsDAOLogicV3 is NounsDAOStorageV3, NounsDAOEventsV3 { ds._setDynamicQuorumParams(newMinQuorumVotesBPS, newMaxQuorumVotesBPS, newQuorumCoefficient); } + /** + * @notice Withdraws all the ETH in the contract. This is callable only by the admin (timelock). + */ function _withdraw() external returns (uint256, bool) { return ds._withdraw(); } + /** + * @notice Admin function for setting the fork period + * @param newForkPeriod the new fork proposal period, in seconds + */ + function _setForkPeriod(uint256 newForkPeriod) external { + ds._setForkPeriod(newForkPeriod); + } + + /** + * @notice Admin function for setting the fork threshold + * @param newForkThresholdBPS the new fork proposal threshold, in basis points + */ + function _setForkThresholdBPS(uint256 newForkThresholdBPS) external { + ds._setForkThresholdBPS(newForkThresholdBPS); + } + /** * @notice Admin function for setting the proposal id at which vote snapshots start using the voting start block * instead of the proposal creation block. - * @param newVoteSnapshotBlockSwitchProposalId the new proposal id at which to flip the switch + * Sets it to the next proposal id. + */ + function _setVoteSnapshotBlockSwitchProposalId() external { + ds._setVoteSnapshotBlockSwitchProposalId(); + } + + /** + * @notice Admin function for setting the fork DAO deployer contract */ - function _setVoteSnapshotBlockSwitchProposalId(uint256 newVoteSnapshotBlockSwitchProposalId) external { - ds._setVoteSnapshotBlockSwitchProposalId(newVoteSnapshotBlockSwitchProposalId); + function _setForkDAODeployer(address newForkDAODeployer) external { + ds._setForkDAODeployer(newForkDAODeployer); + } + + /** + * @notice Admin function for setting the ERC20 tokens that are used when splitting funds to a fork + */ + function _setErc20TokensToIncludeInFork(address[] calldata erc20tokens) external { + ds._setErc20TokensToIncludeInFork(erc20tokens); + } + + /** + * @notice Admin function for setting the fork escrow contract + */ + function _setForkEscrow(address newForkEscrow) external { + ds._setForkEscrow(newForkEscrow); + } + + /** + * @notice Admin function for setting the fork related parameters + * @param forkEscrow_ the fork escrow contract + * @param forkDAODeployer_ the fork dao deployer contract + * @param erc20TokensToIncludeInFork_ the ERC20 tokens used when splitting funds to a fork + * @param forkPeriod_ the period during which it's possible to join a fork after exeuction + * @param forkThresholdBPS_ the threshold required of escrowed nouns in order to execute a fork + */ + function _setForkParams( + address forkEscrow_, + address forkDAODeployer_, + address[] calldata erc20TokensToIncludeInFork_, + uint256 forkPeriod_, + uint256 forkThresholdBPS_ + ) external { + ds._setForkEscrow(forkEscrow_); + ds._setForkDAODeployer(forkDAODeployer_); + ds._setErc20TokensToIncludeInFork(erc20TokensToIncludeInFork_); + ds._setForkPeriod(forkPeriod_); + ds._setForkThresholdBPS(forkThresholdBPS_); + } + + /** + * @notice Admin function for setting the timelocks and admin + * @param newTimelock the new timelock contract + * @param newTimelockV1 the new timelockV1 contract + * @param newAdmin the new admin address + */ + function _setTimelocksAndAdmin( + address newTimelock, + address newTimelockV1, + address newAdmin + ) external { + ds._setTimelocksAndAdmin(newTimelock, newTimelockV1, newAdmin); } /** @@ -644,14 +902,14 @@ contract NounsDAOLogicV3 is NounsDAOStorageV3, NounsDAOEventsV3 { * @notice Current min quorum votes using Noun total supply */ function minQuorumVotes() public view returns (uint256) { - return ds.minQuorumVotes(); + return ds.minQuorumVotes(ds.adjustedTotalSupply()); } /** * @notice Current max quorum votes using Noun total supply */ function maxQuorumVotes() public view returns (uint256) { - return ds.maxQuorumVotes(); + return ds.maxQuorumVotes(ds.adjustedTotalSupply()); } /** @@ -722,9 +980,41 @@ contract NounsDAOLogicV3 is NounsDAOStorageV3, NounsDAOEventsV3 { return ds.objectionPeriodDurationInBlocks; } + function erc20TokensToIncludeInFork() public view returns (address[] memory) { + return ds.erc20TokensToIncludeInFork; + } + + function forkEscrow() public view returns (INounsDAOForkEscrow) { + return ds.forkEscrow; + } + + function forkDAODeployer() public view returns (IForkDAODeployer) { + return ds.forkDAODeployer; + } + + function forkEndTimestamp() public view returns (uint256) { + return ds.forkEndTimestamp; + } + + function forkPeriod() public view returns (uint256) { + return ds.forkPeriod; + } + + function forkThresholdBPS() public view returns (uint256) { + return ds.forkThresholdBPS; + } + function proposalUpdatablePeriodInBlocks() public view returns (uint256) { return ds.proposalUpdatablePeriodInBlocks; } + function timelockV1() public view returns (address) { + return address(ds.timelockV1); + } + + function voteSnapshotBlockSwitchProposalId() public view returns (uint256) { + return ds.voteSnapshotBlockSwitchProposalId; + } + receive() external payable {} } diff --git a/packages/nouns-contracts/contracts/governance/NounsDAOProxyV3.sol b/packages/nouns-contracts/contracts/governance/NounsDAOProxyV3.sol index 1154691655..9489c9511b 100644 --- a/packages/nouns-contracts/contracts/governance/NounsDAOProxyV3.sol +++ b/packages/nouns-contracts/contracts/governance/NounsDAOProxyV3.sol @@ -41,20 +41,16 @@ pragma solidity ^0.8.6; import './NounsDAOInterfaces.sol'; contract NounsDAOProxyV3 is NounsDAOProxyStorage, NounsDAOEvents { - constructor( address timelock_, address nouns_, + address forkEscrow_, + address forkDAODeployer_, address vetoer_, address admin_, address implementation_, - uint256 votingPeriod_, - uint256 votingDelay_, - uint256 proposalThresholdBPS_, - NounsDAOStorageV3.DynamicQuorumParams memory dynamicQuorumParams_, - uint32 lastMinuteWindowInBlocks_, - uint32 objectionPeriodDurationInBlocks_, - uint32 proposalUpdatablePeriodInBlocks_ + NounsDAOStorageV3.NounsDAOParams memory daoParams_, + NounsDAOStorageV3.DynamicQuorumParams memory dynamicQuorumParams_ ) { // Admin set to msg.sender for initialization admin = msg.sender; @@ -62,17 +58,14 @@ contract NounsDAOProxyV3 is NounsDAOProxyStorage, NounsDAOEvents { delegateTo( implementation_, abi.encodeWithSignature( - 'initialize(address,address,address,uint256,uint256,uint256,(uint16,uint16,uint32),uint32,uint32,uint32)', + 'initialize(address,address,address,address,address,(uint256,uint256,uint256,uint32,uint32,uint32),(uint16,uint16,uint32))', timelock_, nouns_, + forkEscrow_, + forkDAODeployer_, vetoer_, - votingPeriod_, - votingDelay_, - proposalThresholdBPS_, - dynamicQuorumParams_, - lastMinuteWindowInBlocks_, - objectionPeriodDurationInBlocks_, - proposalUpdatablePeriodInBlocks_ + daoParams_, + dynamicQuorumParams_ ) ); diff --git a/packages/nouns-contracts/contracts/governance/NounsDAOV3Admin.sol b/packages/nouns-contracts/contracts/governance/NounsDAOV3Admin.sol index 70b712c79a..e4ff9393cd 100644 --- a/packages/nouns-contracts/contracts/governance/NounsDAOV3Admin.sol +++ b/packages/nouns-contracts/contracts/governance/NounsDAOV3Admin.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-3.0 -/// @title +/// @title Library for NounsDAOLogicV3 contract containing admin related functions /********************************* * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * @@ -26,10 +26,12 @@ library NounsDAOV3Admin { error AdminOnly(); error VetoerOnly(); error PendingVetoerOnly(); - error InvalidMinQuorumVotesBPS(); error InvalidMaxQuorumVotesBPS(); error MinQuorumBPSGreaterThanMaxQuorumBPS(); + error ForkPeriodTooLong(); + error InvalidObjectionPeriodDurationInBlocks(); + error InvalidProposalUpdatablePeriodInBlocks(); /// @notice Emitted when proposal threshold basis points is set event ProposalThresholdBPSSet(uint256 oldProposalThresholdBPS, uint256 newProposalThresholdBPS); @@ -85,6 +87,21 @@ library NounsDAOV3Admin { uint256 newVoteSnapshotBlockSwitchProposalId ); + /// @notice Emitted when the fork DAO deployer is set + event ForkDAODeployerSet(address oldForkDAODeployer, address newForkDAODeployer); + + /// @notice Emitted when the erc20 tokens to include in a fork are set + event ERC20TokensToIncludeInForkSet(address[] oldErc20Tokens, address[] newErc20tokens); + + /// @notice Emitted when the during of the forking period is set + event ForkPeriodSet(uint256 oldForkPeriod, uint256 newForkPeriod); + + /// @notice Emitted when the threhsold for forking is set + event ForkThresholdSet(uint256 oldForkThreshold, uint256 newForkThreshold); + + /// @notice Emitted when the main timelock, timelockV1 and admin are set + event TimelocksAndAdminSet(address timelock, address timelockV1, address admin); + /// @notice The minimum setable proposal threshold uint256 public constant MIN_PROPOSAL_THRESHOLD_BPS = 1; // 1 basis point or 0.01% @@ -112,14 +129,27 @@ library NounsDAOV3Admin { /// @notice The upper bound of maximum quorum votes basis points uint256 public constant MAX_QUORUM_VOTES_BPS_UPPER_BOUND = 6_000; // 4,000 basis points or 60% + /// @notice Upper bound for forking period. If forking period is too high it can block proposals for too long. + uint256 public constant MAX_FORK_PERIOD = 14 days; + + /// @notice Upper bound for objection period duration in blocks. + uint256 public constant MAX_OBJECTION_PERIOD_BLOCKS = 7 days / 12; + + /// @notice Upper bound for proposal updatable period duration in blocks. + uint256 public constant MAX_UPDATABLE_PERIOD_BLOCKS = 7 days / 12; + + modifier onlyAdmin(NounsDAOStorageV3.StorageV3 storage ds) { + if (msg.sender != ds.admin) { + revert AdminOnly(); + } + _; + } + /** * @notice Admin function for setting the voting delay * @param newVotingDelay new voting delay, in blocks */ - function _setVotingDelay(NounsDAOStorageV3.StorageV3 storage ds, uint256 newVotingDelay) external { - if (msg.sender != ds.admin) { - revert AdminOnly(); - } + function _setVotingDelay(NounsDAOStorageV3.StorageV3 storage ds, uint256 newVotingDelay) external onlyAdmin(ds) { require( newVotingDelay >= MIN_VOTING_DELAY && newVotingDelay <= MAX_VOTING_DELAY, 'NounsDAO::_setVotingDelay: invalid voting delay' @@ -134,10 +164,7 @@ library NounsDAOV3Admin { * @notice Admin function for setting the voting period * @param newVotingPeriod new voting period, in blocks */ - function _setVotingPeriod(NounsDAOStorageV3.StorageV3 storage ds, uint256 newVotingPeriod) external { - if (msg.sender != ds.admin) { - revert AdminOnly(); - } + function _setVotingPeriod(NounsDAOStorageV3.StorageV3 storage ds, uint256 newVotingPeriod) external onlyAdmin(ds) { require( newVotingPeriod >= MIN_VOTING_PERIOD && newVotingPeriod <= MAX_VOTING_PERIOD, 'NounsDAO::_setVotingPeriod: invalid voting period' @@ -155,10 +182,8 @@ library NounsDAOV3Admin { */ function _setProposalThresholdBPS(NounsDAOStorageV3.StorageV3 storage ds, uint256 newProposalThresholdBPS) external + onlyAdmin(ds) { - if (msg.sender != ds.admin) { - revert AdminOnly(); - } require( newProposalThresholdBPS >= MIN_PROPOSAL_THRESHOLD_BPS && newProposalThresholdBPS <= MAX_PROPOSAL_THRESHOLD_BPS, @@ -170,13 +195,16 @@ library NounsDAOV3Admin { emit ProposalThresholdBPSSet(oldProposalThresholdBPS, newProposalThresholdBPS); } + /** + * @notice Admin function for setting the objection period duration + * @param newObjectionPeriodDurationInBlocks new objection period duration, in blocks + */ function _setObjectionPeriodDurationInBlocks( NounsDAOStorageV3.StorageV3 storage ds, uint32 newObjectionPeriodDurationInBlocks - ) external { - if (msg.sender != ds.admin) { - revert AdminOnly(); - } + ) external onlyAdmin(ds) { + if (newObjectionPeriodDurationInBlocks > MAX_OBJECTION_PERIOD_BLOCKS) + revert InvalidObjectionPeriodDurationInBlocks(); uint32 oldObjectionPeriodDurationInBlocks = ds.objectionPeriodDurationInBlocks; ds.objectionPeriodDurationInBlocks = newObjectionPeriodDurationInBlocks; @@ -184,26 +212,30 @@ library NounsDAOV3Admin { emit ObjectionPeriodDurationSet(oldObjectionPeriodDurationInBlocks, newObjectionPeriodDurationInBlocks); } + /** + * @notice Admin function for setting the objection period last minute window + * @param newLastMinuteWindowInBlocks new objection period last minute window, in blocks + */ function _setLastMinuteWindowInBlocks(NounsDAOStorageV3.StorageV3 storage ds, uint32 newLastMinuteWindowInBlocks) external + onlyAdmin(ds) { - if (msg.sender != ds.admin) { - revert AdminOnly(); - } - uint32 oldLastMinuteWindowInBlocks = ds.lastMinuteWindowInBlocks; ds.lastMinuteWindowInBlocks = newLastMinuteWindowInBlocks; emit LastMinuteWindowSet(oldLastMinuteWindowInBlocks, newLastMinuteWindowInBlocks); } + /** + * @notice Admin function for setting the proposal updatable period + * @param newProposalUpdatablePeriodInBlocks the new proposal updatable period, in blocks + */ function _setProposalUpdatablePeriodInBlocks( NounsDAOStorageV3.StorageV3 storage ds, uint32 newProposalUpdatablePeriodInBlocks - ) external { - if (msg.sender != ds.admin) { - revert AdminOnly(); - } + ) external onlyAdmin(ds) { + if (newProposalUpdatablePeriodInBlocks > MAX_UPDATABLE_PERIOD_BLOCKS) + revert InvalidProposalUpdatablePeriodInBlocks(); uint32 oldProposalUpdatablePeriodInBlocks = ds.proposalUpdatablePeriodInBlocks; ds.proposalUpdatablePeriodInBlocks = newProposalUpdatablePeriodInBlocks; @@ -216,10 +248,7 @@ library NounsDAOV3Admin { * @dev Admin function to begin change of admin. The newPendingAdmin must call `_acceptAdmin` to finalize the transfer. * @param newPendingAdmin New pending admin. */ - function _setPendingAdmin(NounsDAOStorageV3.StorageV3 storage ds, address newPendingAdmin) external { - // Check caller = admin - require(msg.sender == ds.admin, 'NounsDAO::_setPendingAdmin: admin only'); - + function _setPendingAdmin(NounsDAOStorageV3.StorageV3 storage ds, address newPendingAdmin) external onlyAdmin(ds) { // Save current value, if any, for inclusion in log address oldPendingAdmin = ds.pendingAdmin; @@ -269,6 +298,9 @@ library NounsDAOV3Admin { ds.pendingVetoer = newPendingVetoer; } + /** + * @notice Called by the pendingVetoer to accept role and update vetoer + */ function _acceptVetoer(NounsDAOStorageV3.StorageV3 storage ds) external { if (msg.sender != ds.pendingVetoer) { revert PendingVetoerOnly(); @@ -306,10 +338,10 @@ library NounsDAOV3Admin { * Must be between `MIN_QUORUM_VOTES_BPS_LOWER_BOUND` and `MIN_QUORUM_VOTES_BPS_UPPER_BOUND` * Must be lower than or equal to maxQuorumVotesBPS */ - function _setMinQuorumVotesBPS(NounsDAOStorageV3.StorageV3 storage ds, uint16 newMinQuorumVotesBPS) external { - if (msg.sender != ds.admin) { - revert AdminOnly(); - } + function _setMinQuorumVotesBPS(NounsDAOStorageV3.StorageV3 storage ds, uint16 newMinQuorumVotesBPS) + external + onlyAdmin(ds) + { NounsDAOStorageV3.DynamicQuorumParams memory params = ds.getDynamicQuorumParamsAt(block.number); require( @@ -336,10 +368,10 @@ library NounsDAOV3Admin { * Must be lower than `MAX_QUORUM_VOTES_BPS_UPPER_BOUND` * Must be higher than or equal to minQuorumVotesBPS */ - function _setMaxQuorumVotesBPS(NounsDAOStorageV3.StorageV3 storage ds, uint16 newMaxQuorumVotesBPS) external { - if (msg.sender != ds.admin) { - revert AdminOnly(); - } + function _setMaxQuorumVotesBPS(NounsDAOStorageV3.StorageV3 storage ds, uint16 newMaxQuorumVotesBPS) + external + onlyAdmin(ds) + { NounsDAOStorageV3.DynamicQuorumParams memory params = ds.getDynamicQuorumParamsAt(block.number); require( @@ -363,10 +395,10 @@ library NounsDAOV3Admin { * @notice Admin function for setting the dynamic quorum coefficient * @param newQuorumCoefficient the new coefficient, as a fixed point integer with 6 decimals */ - function _setQuorumCoefficient(NounsDAOStorageV3.StorageV3 storage ds, uint32 newQuorumCoefficient) external { - if (msg.sender != ds.admin) { - revert AdminOnly(); - } + function _setQuorumCoefficient(NounsDAOStorageV3.StorageV3 storage ds, uint32 newQuorumCoefficient) + external + onlyAdmin(ds) + { NounsDAOStorageV3.DynamicQuorumParams memory params = ds.getDynamicQuorumParamsAt(block.number); uint32 oldQuorumCoefficient = params.quorumCoefficient; @@ -392,10 +424,7 @@ library NounsDAOV3Admin { uint16 newMinQuorumVotesBPS, uint16 newMaxQuorumVotesBPS, uint32 newQuorumCoefficient - ) public { - if (msg.sender != ds.admin) { - revert AdminOnly(); - } + ) public onlyAdmin(ds) { if ( newMinQuorumVotesBPS < MIN_QUORUM_VOTES_BPS_LOWER_BOUND || newMinQuorumVotesBPS > MIN_QUORUM_VOTES_BPS_UPPER_BOUND @@ -423,11 +452,10 @@ library NounsDAOV3Admin { emit QuorumCoefficientSet(oldParams.quorumCoefficient, params.quorumCoefficient); } - function _withdraw(NounsDAOStorageV3.StorageV3 storage ds) external returns (uint256, bool) { - if (msg.sender != ds.admin) { - revert AdminOnly(); - } - + /** + * @notice Withdraws all the ETH in the contract. This is callable only by the admin (timelock). + */ + function _withdraw(NounsDAOStorageV3.StorageV3 storage ds) external onlyAdmin(ds) returns (uint256, bool) { uint256 amount = address(this).balance; (bool sent, ) = msg.sender.call{ value: amount }(''); @@ -439,17 +467,12 @@ library NounsDAOV3Admin { /** * @notice Admin function for setting the proposal id at which vote snapshots start using the voting start block * instead of the proposal creation block. - * @param newVoteSnapshotBlockSwitchProposalId the new proposal id at which to flip the switch + * Sets it to the next proposal id. */ - function _setVoteSnapshotBlockSwitchProposalId( - NounsDAOStorageV3.StorageV3 storage ds, - uint256 newVoteSnapshotBlockSwitchProposalId - ) external { - if (msg.sender != ds.admin) { - revert AdminOnly(); - } - + function _setVoteSnapshotBlockSwitchProposalId(NounsDAOStorageV3.StorageV3 storage ds) external onlyAdmin(ds) { + uint256 newVoteSnapshotBlockSwitchProposalId = ds.proposalCount + 1; uint256 oldVoteSnapshotBlockSwitchProposalId = ds.voteSnapshotBlockSwitchProposalId; + ds.voteSnapshotBlockSwitchProposalId = newVoteSnapshotBlockSwitchProposalId; emit VoteSnapshotBlockSwitchProposalIdSet( @@ -458,6 +481,80 @@ library NounsDAOV3Admin { ); } + /** + * @notice Admin function for setting the fork DAO deployer contract + */ + function _setForkDAODeployer(NounsDAOStorageV3.StorageV3 storage ds, address newForkDAODeployer) + external + onlyAdmin(ds) + { + address oldForkDAODeployer = address(ds.forkDAODeployer); + ds.forkDAODeployer = IForkDAODeployer(newForkDAODeployer); + + emit ForkDAODeployerSet(oldForkDAODeployer, newForkDAODeployer); + } + + /** + * @notice Admin function for setting the ERC20 tokens that are used when splitting funds to a fork + */ + function _setErc20TokensToIncludeInFork(NounsDAOStorageV3.StorageV3 storage ds, address[] calldata erc20tokens) + external + onlyAdmin(ds) + { + emit ERC20TokensToIncludeInForkSet(ds.erc20TokensToIncludeInFork, erc20tokens); + + ds.erc20TokensToIncludeInFork = erc20tokens; + } + + /** + * @notice Admin function for setting the fork escrow contract + */ + function _setForkEscrow(NounsDAOStorageV3.StorageV3 storage ds, address newForkEscrow) external onlyAdmin(ds) { + ds.forkEscrow = INounsDAOForkEscrow(newForkEscrow); + } + + function _setForkPeriod(NounsDAOStorageV3.StorageV3 storage ds, uint256 newForkPeriod) external onlyAdmin(ds) { + if (newForkPeriod > MAX_FORK_PERIOD) { + revert ForkPeriodTooLong(); + } + + emit ForkPeriodSet(ds.forkPeriod, newForkPeriod); + + ds.forkPeriod = newForkPeriod; + } + + /** + * @notice Admin function for setting the fork threshold + * @param newForkThresholdBPS the new fork proposal threshold, in basis points + */ + function _setForkThresholdBPS(NounsDAOStorageV3.StorageV3 storage ds, uint256 newForkThresholdBPS) + external + onlyAdmin(ds) + { + emit ForkThresholdSet(ds.forkThresholdBPS, newForkThresholdBPS); + + ds.forkThresholdBPS = newForkThresholdBPS; + } + + /** + * @notice Admin function for setting the timelocks and admin + * @param timelock the new timelock contract + * @param timelockV1 the new timelockV1 contract + * @param admin the new admin address + */ + function _setTimelocksAndAdmin( + NounsDAOStorageV3.StorageV3 storage ds, + address timelock, + address timelockV1, + address admin + ) external onlyAdmin(ds) { + ds.timelock = INounsDAOExecutorV2(timelock); + ds.timelockV1 = INounsDAOExecutor(timelockV1); + ds.admin = admin; + + emit TimelocksAndAdminSet(timelock, timelockV1, admin); + } + function _writeQuorumParamsCheckpoint( NounsDAOStorageV3.StorageV3 storage ds, NounsDAOStorageV3.DynamicQuorumParams memory params diff --git a/packages/nouns-contracts/contracts/governance/NounsDAOV3DynamicQuorum.sol b/packages/nouns-contracts/contracts/governance/NounsDAOV3DynamicQuorum.sol index 9e3646b25c..e65dae3740 100644 --- a/packages/nouns-contracts/contracts/governance/NounsDAOV3DynamicQuorum.sol +++ b/packages/nouns-contracts/contracts/governance/NounsDAOV3DynamicQuorum.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-3.0 -/// @title +/// @title Library for NounsDAOLogicV3 contract containing functions related to quorum calculations /********************************* * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * @@ -18,8 +18,11 @@ pragma solidity ^0.8.6; import './NounsDAOInterfaces.sol'; +import { NounsDAOV3Fork } from './fork/NounsDAOV3Fork.sol'; library NounsDAOV3DynamicQuorum { + using NounsDAOV3Fork for NounsDAOStorageV3.StorageV3; + error UnsafeUint16Cast(); /** @@ -72,7 +75,7 @@ library NounsDAOV3DynamicQuorum { * @return The dynamic quorum parameters that were set at the given block number */ function getDynamicQuorumParamsAt(NounsDAOStorageV3.StorageV3 storage ds, uint256 blockNumber_) - public + internal view returns (NounsDAOStorageV3.DynamicQuorumParams memory) { @@ -120,15 +123,23 @@ library NounsDAOV3DynamicQuorum { /** * @notice Current min quorum votes using Noun total supply */ - function minQuorumVotes(NounsDAOStorageV3.StorageV3 storage ds) internal view returns (uint256) { - return bps2Uint(getDynamicQuorumParamsAt(ds, block.number).minQuorumVotesBPS, ds.nouns.totalSupply()); + function minQuorumVotes(NounsDAOStorageV3.StorageV3 storage ds, uint256 adjustedTotalSupply) + internal + view + returns (uint256) + { + return bps2Uint(getDynamicQuorumParamsAt(ds, block.number).minQuorumVotesBPS, adjustedTotalSupply); } /** * @notice Current max quorum votes using Noun total supply */ - function maxQuorumVotes(NounsDAOStorageV3.StorageV3 storage ds) internal view returns (uint256) { - return bps2Uint(getDynamicQuorumParamsAt(ds, block.number).maxQuorumVotesBPS, ds.nouns.totalSupply()); + function maxQuorumVotes(NounsDAOStorageV3.StorageV3 storage ds, uint256 adjustedTotalSupply) + internal + view + returns (uint256) + { + return bps2Uint(getDynamicQuorumParamsAt(ds, block.number).maxQuorumVotesBPS, adjustedTotalSupply); } function safe32(uint256 n, string memory errorMessage) internal pure returns (uint32) { diff --git a/packages/nouns-contracts/contracts/governance/NounsDAOV3Proposals.sol b/packages/nouns-contracts/contracts/governance/NounsDAOV3Proposals.sol index 6bfad4686a..16781c9188 100644 --- a/packages/nouns-contracts/contracts/governance/NounsDAOV3Proposals.sol +++ b/packages/nouns-contracts/contracts/governance/NounsDAOV3Proposals.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-3.0 -/// @title +/// @title Library for NounsDAOLogicV3 contract containing the proposal lifecycle code /********************************* * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * @@ -19,11 +19,13 @@ pragma solidity ^0.8.6; import './NounsDAOInterfaces.sol'; import { NounsDAOV3DynamicQuorum } from './NounsDAOV3DynamicQuorum.sol'; +import { NounsDAOV3Fork } from './fork/NounsDAOV3Fork.sol'; import { SignatureChecker } from '@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol'; import { ECDSA } from '@openzeppelin/contracts/utils/cryptography/ECDSA.sol'; library NounsDAOV3Proposals { using NounsDAOV3DynamicQuorum for NounsDAOStorageV3.StorageV3; + using NounsDAOV3Fork for NounsDAOStorageV3.StorageV3; error CantCancelProposalAtFinalState(); error ProposalInfoArityMismatch(); @@ -38,6 +40,13 @@ library NounsDAOV3Proposals { error ProposerCannotUpdateProposalWithSigners(); error MustProvideSignatures(); error SignatureIsCancelled(); + error CannotExecuteDuringForkingPeriod(); + error VetoerBurned(); + error VetoerOnly(); + error CantVetoExecutedProposal(); + + /// @notice An event emitted when a proposal has been vetoed by vetoAddress + event ProposalVetoed(uint256 id); /// @notice An event emitted when a new proposal is created event ProposalCreated( @@ -70,6 +79,10 @@ library NounsDAOV3Proposals { string description ); + /// @notice Emitted when a proposal is created to be executed on timelockV1 + event ProposalCreatedOnTimelockV1(uint256 id); + + /// @notice Emitted when a proposal is updated event ProposalUpdated( uint256 indexed id, address indexed proposer, @@ -81,6 +94,7 @@ library NounsDAOV3Proposals { string updateMessage ); + /// @notice Emitted when a proposal's transactions are updated event ProposalTransactionsUpdated( uint256 indexed id, address indexed proposer, @@ -91,7 +105,13 @@ library NounsDAOV3Proposals { string updateMessage ); - event ProposalDescriptionUpdated(uint256 indexed id, address indexed proposer, string description, string updateMessage); + /// @notice Emitted when a proposal's description is updated + event ProposalDescriptionUpdated( + uint256 indexed id, + address indexed proposer, + string description, + string updateMessage + ); /// @notice An event emitted when a proposal has been queued in the NounsDAOExecutor event ProposalQueued(uint256 id, uint256 eta); @@ -105,12 +125,6 @@ library NounsDAOV3Proposals { /// @notice Emitted when someone cancels a signature event SignatureCancelled(address indexed signer, bytes sig); - struct ProposalTemp { - uint256 totalSupply; - uint256 proposalThreshold; - uint256 latestProposalId; - } - // Created to solve stack-too-deep errors struct ProposalTxs { address[] targets; @@ -146,26 +160,61 @@ library NounsDAOV3Proposals { ProposalTxs memory txs, string memory description ) internal returns (uint256) { - ProposalTemp memory temp; - temp.totalSupply = ds.nouns.totalSupply(); - temp.proposalThreshold = checkPropThreshold(ds, ds.nouns.getPriorVotes(msg.sender, block.number - 1)); + uint256 adjustedTotalSupply = ds.adjustedTotalSupply(); + uint256 proposalThreshold_ = checkPropThreshold( + ds, + ds.nouns.getPriorVotes(msg.sender, block.number - 1), + adjustedTotalSupply + ); checkProposalTxs(txs); checkNoActiveProp(ds, msg.sender); - ds.proposalCount++; + uint256 proposalId = ds.proposalCount = ds.proposalCount + 1; NounsDAOStorageV3.Proposal storage newProposal = createNewProposal( ds, - ds.proposalCount, - temp.proposalThreshold, + proposalId, + proposalThreshold_, + adjustedTotalSupply, txs ); - ds.latestProposalIds[newProposal.proposer] = newProposal.id; + ds.latestProposalIds[msg.sender] = proposalId; + + emitNewPropEvents(newProposal, new address[](0), ds.minQuorumVotes(adjustedTotalSupply), txs, description); + + return proposalId; + } + + /** + * @notice Function used to propose a new proposal. Sender must have delegates above the proposal threshold. + * This proposal would be executed via the timelockV1 contract. This is meant to be used in case timelockV1 + * is still holding funds or has special permissions to execute on certain contracts. + * @param txs Target addresses, eth values, function signatures and calldatas for proposal calls + * @param description String description of the proposal + * @return uint256 Proposal id of new proposal + */ + function proposeOnTimelockV1( + NounsDAOStorageV3.StorageV3 storage ds, + ProposalTxs memory txs, + string memory description + ) internal returns (uint256) { + uint256 newProposalId = propose(ds, txs, description); - emitNewPropEvents(newProposal, new address[](0), ds.minQuorumVotes(), txs, description); + NounsDAOStorageV3.Proposal storage newProposal = ds._proposals[newProposalId]; + newProposal.executeOnTimelockV1 = true; - return newProposal.id; + emit ProposalCreatedOnTimelockV1(newProposalId); + + return newProposalId; } + /** + * @notice Function used to propose a new proposal. Sender and signers must have delegates above the proposal threshold + * @param proposerSignatures Array of signers who have signed the proposal and their signatures. + * @dev The signatures follow EIP-712. See `PROPOSAL_TYPEHASH` in NounsDAOV3Proposals.sol + * @param txs Target addresses, eth values, function signatures and calldatas for proposal calls + * @param description String description of the proposal + * @return uint256 Proposal id of new proposal + */ function proposeBySigs( NounsDAOStorageV3.StorageV3 storage ds, NounsDAOStorageV3.ProposerSignature[] memory proposerSignatures, @@ -176,33 +225,39 @@ library NounsDAOV3Proposals { checkProposalTxs(txs); uint256 proposalId = ds.proposalCount = ds.proposalCount + 1; - bytes memory proposalEncodeData = calcProposalEncodeData(msg.sender, txs, description); - - uint256 votes; - address[] memory signers = new address[](proposerSignatures.length); - for (uint256 i = 0; i < proposerSignatures.length; ++i) { - verifyProposalSignature(ds, proposalEncodeData, proposerSignatures[i], PROPOSAL_TYPEHASH); - address signer = signers[i] = proposerSignatures[i].signer; - - checkNoActiveProp(ds, signer); - ds.latestProposalIds[signer] = proposalId; - votes += ds.nouns.getPriorVotes(signer, block.number - 1); - } - - checkNoActiveProp(ds, msg.sender); - ds.latestProposalIds[msg.sender] = proposalId; - votes += ds.nouns.getPriorVotes(msg.sender, block.number - 1); - - uint256 propThreshold = checkPropThreshold(ds, votes); + (uint256 votes, address[] memory signers) = verifySignersCanBackThisProposalAndCountTheirVotes( + ds, + proposerSignatures, + txs, + description, + proposalId + ); - NounsDAOStorageV3.Proposal storage newProposal = createNewProposal(ds, proposalId, propThreshold, txs); + uint256 adjustedTotalSupply = ds.adjustedTotalSupply(); + uint256 propThreshold = checkPropThreshold(ds, votes, adjustedTotalSupply); + NounsDAOStorageV3.Proposal storage newProposal = createNewProposal( + ds, + proposalId, + propThreshold, + adjustedTotalSupply, + txs + ); newProposal.signers = signers; - emitNewPropEvents(newProposal, signers, ds.minQuorumVotes(), txs, description); + emitNewPropEvents(newProposal, signers, ds.minQuorumVotes(adjustedTotalSupply), txs, description); return proposalId; } + /** + * @notice Invalidates a signature that may be used for signing a proposal. + * Once a signature is canceled, the sender can no longer use it again. + * If the sender changes their mind and want to sign the proposal, they can change the expiry timestamp + * in order to produce a new signature. + * The signature will only be invalidated when used by the sender. If used by a different account, it will + * not be invalidated. + * @param sig The signature to cancel + */ function cancelSig(NounsDAOStorageV3.StorageV3 storage ds, bytes calldata sig) external { bytes32 sigHash = keccak256(sig); ds.cancelledSigs[msg.sender][sigHash] = true; @@ -210,32 +265,17 @@ library NounsDAOV3Proposals { emit SignatureCancelled(msg.sender, sig); } - function calcProposalEncodeData( - address proposer, - ProposalTxs memory txs, - string memory description - ) internal pure returns (bytes memory) { - bytes32[] memory signatureHashes = new bytes32[](txs.signatures.length); - for (uint256 i = 0; i < txs.signatures.length; ++i) { - signatureHashes[i] = keccak256(bytes(txs.signatures[i])); - } - - bytes32[] memory calldatasHashes = new bytes32[](txs.calldatas.length); - for (uint256 i = 0; i < txs.calldatas.length; ++i) { - calldatasHashes[i] = keccak256(txs.calldatas[i]); - } - - return - abi.encode( - proposer, - keccak256(abi.encodePacked(txs.targets)), - keccak256(abi.encodePacked(txs.values)), - keccak256(abi.encodePacked(signatureHashes)), - keccak256(abi.encodePacked(calldatasHashes)), - keccak256(bytes(description)) - ); - } - + /** + * @notice Update a proposal transactions and description. + * Only the proposer can update it, and only during the updateable period. + * @param proposalId Proposal's id + * @param targets Updated target addresses for proposal calls + * @param values Updated eth values for proposal calls + * @param signatures Updated function signatures for proposal calls + * @param calldatas Updated calldatas for proposal calls + * @param description Updated description of the proposal + * @param updateMessage Short message to explain the update + */ function updateProposal( NounsDAOStorageV3.StorageV3 storage ds, uint256 proposalId, @@ -259,7 +299,16 @@ library NounsDAOV3Proposals { updateMessage ); } - + + /** + * @notice Updates the proposal's transactions. Only the proposer can update it, and only during the updateable period. + * @param proposalId Proposal's id + * @param targets Updated target addresses for proposal calls + * @param values Updated eth values for proposal calls + * @param signatures Updated function signatures for proposal calls + * @param calldatas Updated calldatas for proposal calls + * @param updateMessage Short message to explain the update + */ function updateProposalTransactions( NounsDAOStorageV3.StorageV3 storage ds, uint256 proposalId, @@ -271,15 +320,7 @@ library NounsDAOV3Proposals { ) external { updateProposalTransactionsInternal(ds, proposalId, targets, values, signatures, calldatas); - emit ProposalTransactionsUpdated( - proposalId, - msg.sender, - targets, - values, - signatures, - calldatas, - updateMessage - ); + emit ProposalTransactionsUpdated(proposalId, msg.sender, targets, values, signatures, calldatas, updateMessage); } function updateProposalTransactionsInternal( @@ -301,6 +342,12 @@ library NounsDAOV3Proposals { proposal.calldatas = calldatas; } + /** + * @notice Updates the proposal's description. Only the proposer can update it, and only during the updateable period. + * @param proposalId Proposal's id + * @param description Updated description of the proposal + * @param updateMessage Short message to explain the update + */ function updateProposalDescription( NounsDAOStorageV3.StorageV3 storage ds, uint256 proposalId, @@ -313,17 +360,17 @@ library NounsDAOV3Proposals { emit ProposalDescriptionUpdated(proposalId, msg.sender, description, updateMessage); } - function checkProposalUpdatable( - NounsDAOStorageV3.StorageV3 storage ds, - uint256 proposalId, - NounsDAOStorageV3.Proposal storage proposal - ) internal view { - // TODO: gas: does reading the proposal once save gas? - if (state(ds, proposalId) != NounsDAOStorageV3.ProposalState.Updatable) revert CanOnlyEditUpdatableProposals(); - if (msg.sender != proposal.proposer) revert OnlyProposerCanEdit(); - if (proposal.signers.length > 0) revert ProposerCannotUpdateProposalWithSigners(); - } - + /** + * @notice Update a proposal's transactions and description that was created with proposeBySigs. + * Only the proposer can update it, during the updateable period. + * Requires the original signers to sign the update. + * @param proposalId Proposal's id + * @param proposerSignatures Array of signers who have signed the proposal and their signatures. + * @dev The signatures follow EIP-712. See `UPDATE_PROPOSAL_TYPEHASH` in NounsDAOV3Proposals.sol + * @param txs Updated transactions for the proposal + * @param description Updated description of the proposal + * @param updateMessage Short message to explain the update + */ function updateProposalBySigs( NounsDAOStorageV3.StorageV3 storage ds, uint256 proposalId, @@ -384,10 +431,11 @@ library NounsDAOV3Proposals { 'NounsDAO::queue: proposal can only be queued if it is succeeded' ); NounsDAOStorageV3.Proposal storage proposal = ds._proposals[proposalId]; - uint256 eta = block.timestamp + ds.timelock.delay(); + INounsDAOExecutor timelock = getProposalTimelock(ds, proposal); + uint256 eta = block.timestamp + timelock.delay(); for (uint256 i = 0; i < proposal.targets.length; i++) { queueOrRevertInternal( - ds, + timelock, proposal.targets[i], proposal.values[i], proposal.signatures[i], @@ -400,7 +448,7 @@ library NounsDAOV3Proposals { } function queueOrRevertInternal( - NounsDAOStorageV3.StorageV3 storage ds, + INounsDAOExecutor timelock, address target, uint256 value, string memory signature, @@ -408,10 +456,10 @@ library NounsDAOV3Proposals { uint256 eta ) internal { require( - !ds.timelock.queuedTransactions(keccak256(abi.encode(target, value, signature, data, eta))), + !timelock.queuedTransactions(keccak256(abi.encode(target, value, signature, data, eta))), 'NounsDAO::queueOrRevertInternal: identical proposal action already queued at eta' ); - ds.timelock.queueTransaction(target, value, signature, data, eta); + timelock.queueTransaction(target, value, signature, data, eta); } /** @@ -419,14 +467,80 @@ library NounsDAOV3Proposals { * @param proposalId The id of the proposal to execute */ function execute(NounsDAOStorageV3.StorageV3 storage ds, uint256 proposalId) external { + NounsDAOStorageV3.Proposal storage proposal = ds._proposals[proposalId]; + INounsDAOExecutor timelock = getProposalTimelock(ds, proposal); + executeInternal(ds, proposal, timelock); + } + + /** + * @notice Executes a queued proposal on timelockV1 if eta has passed + * This is only required for proposal that were queued on timelockV1, but before the upgrade to DAO V3. + * These proposals will not have the `executeOnTimelockV1` bool turned on. + */ + function executeOnTimelockV1(NounsDAOStorageV3.StorageV3 storage ds, uint256 proposalId) external { + NounsDAOStorageV3.Proposal storage proposal = ds._proposals[proposalId]; + executeInternal(ds, proposal, ds.timelockV1); + } + + function executeInternal( + NounsDAOStorageV3.StorageV3 storage ds, + NounsDAOStorageV3.Proposal storage proposal, + INounsDAOExecutor timelock + ) internal { require( - state(ds, proposalId) == NounsDAOStorageV3.ProposalState.Queued, + state(ds, proposal.id) == NounsDAOStorageV3.ProposalState.Queued, 'NounsDAO::execute: proposal can only be executed if it is queued' ); - NounsDAOStorageV3.Proposal storage proposal = ds._proposals[proposalId]; + if (ds.isForkPeriodActive()) revert CannotExecuteDuringForkingPeriod(); + proposal.executed = true; + + for (uint256 i = 0; i < proposal.targets.length; i++) { + timelock.executeTransaction( + proposal.targets[i], + proposal.values[i], + proposal.signatures[i], + proposal.calldatas[i], + proposal.eta + ); + } + emit ProposalExecuted(proposal.id); + } + + function getProposalTimelock(NounsDAOStorageV3.StorageV3 storage ds, NounsDAOStorageV3.Proposal storage proposal) + internal + view + returns (INounsDAOExecutor) + { + if (proposal.executeOnTimelockV1) { + return ds.timelockV1; + } else { + return ds.timelock; + } + } + + /** + * @notice Vetoes a proposal only if sender is the vetoer and the proposal has not been executed. + * @param proposalId The id of the proposal to veto + */ + function veto(NounsDAOStorageV3.StorageV3 storage ds, uint256 proposalId) external { + if (ds.vetoer == address(0)) { + revert VetoerBurned(); + } + + if (msg.sender != ds.vetoer) { + revert VetoerOnly(); + } + + if (stateInternal(ds, proposalId) == NounsDAOStorageV3.ProposalState.Executed) { + revert CantVetoExecutedProposal(); + } + + NounsDAOStorageV3.Proposal storage proposal = ds._proposals[proposalId]; + + proposal.vetoed = true; for (uint256 i = 0; i < proposal.targets.length; i++) { - ds.timelock.executeTransaction( + ds.timelock.cancelTransaction( proposal.targets[i], proposal.values[i], proposal.signatures[i], @@ -434,7 +548,8 @@ library NounsDAOV3Proposals { proposal.eta ); } - emit ProposalExecuted(proposalId); + + emit ProposalVetoed(proposalId); } /** @@ -455,13 +570,14 @@ library NounsDAOV3Proposals { NounsDAOStorageV3.Proposal storage proposal = ds._proposals[proposalId]; address proposer = proposal.proposer; + NounsTokenLike nouns = ds.nouns; - uint256 votes = ds.nouns.getPriorVotes(proposer, block.number - 1); + uint256 votes = nouns.getPriorVotes(proposer, block.number - 1); bool msgSenderIsProposer = proposer == msg.sender; address[] memory signers = proposal.signers; for (uint256 i = 0; i < signers.length; ++i) { msgSenderIsProposer = msgSenderIsProposer || msg.sender == signers[i]; - votes += ds.nouns.getPriorVotes(signers[i], block.number - 1); + votes += nouns.getPriorVotes(signers[i], block.number - 1); } require( @@ -470,8 +586,9 @@ library NounsDAOV3Proposals { ); proposal.canceled = true; + INounsDAOExecutor timelock = getProposalTimelock(ds, proposal); for (uint256 i = 0; i < proposal.targets.length; i++) { - ds.timelock.cancelTransaction( + timelock.cancelTransaction( proposal.targets[i], proposal.values[i], proposal.signatures[i], @@ -511,6 +628,7 @@ library NounsDAOV3Proposals { { require(ds.proposalCount >= proposalId, 'NounsDAO::state: invalid proposal id'); NounsDAOStorageV3.Proposal storage proposal = ds._proposals[proposalId]; + if (proposal.vetoed) { return NounsDAOStorageV3.ProposalState.Vetoed; } else if (proposal.canceled) { @@ -529,7 +647,7 @@ library NounsDAOV3Proposals { return NounsDAOStorageV3.ProposalState.Succeeded; } else if (proposal.executed) { return NounsDAOStorageV3.ProposalState.Executed; - } else if (block.timestamp >= proposal.eta + ds.timelock.GRACE_PERIOD()) { + } else if (block.timestamp >= proposal.eta + getProposalTimelock(ds, proposal).GRACE_PERIOD()) { return NounsDAOStorageV3.ProposalState.Expired; } else { return NounsDAOStorageV3.ProposalState.Queued; @@ -635,7 +753,8 @@ library NounsDAOV3Proposals { creationBlock: proposal.creationBlock, signers: proposal.signers, updatePeriodEndBlock: proposal.updatePeriodEndBlock, - objectionPeriodEndBlock: proposal.objectionPeriodEndBlock + objectionPeriodEndBlock: proposal.objectionPeriodEndBlock, + executeOnTimelockV1: proposal.executeOnTimelockV1 }); } @@ -643,8 +762,12 @@ library NounsDAOV3Proposals { * @notice Current proposal threshold using Noun Total Supply * Differs from `GovernerBravo` which uses fixed amount */ - function proposalThreshold(NounsDAOStorageV3.StorageV3 storage ds) internal view returns (uint256) { - return bps2Uint(ds.proposalThresholdBPS, ds.nouns.totalSupply()); + function proposalThreshold(NounsDAOStorageV3.StorageV3 storage ds, uint256 adjustedTotalSupply) + internal + view + returns (uint256) + { + return bps2Uint(ds.proposalThresholdBPS, adjustedTotalSupply); } function isDefeated(NounsDAOStorageV3.StorageV3 storage ds, NounsDAOStorageV3.Proposal storage proposal) @@ -652,9 +775,14 @@ library NounsDAOV3Proposals { view returns (bool) { - return proposal.forVotes <= proposal.againstVotes || proposal.forVotes < ds.quorumVotes(proposal.id); + uint256 forVotes = proposal.forVotes; + return forVotes <= proposal.againstVotes || forVotes < ds.quorumVotes(proposal.id); } + /** + * @notice reverts if `proposer` is the proposer or signer of an active proposal. + * This is a spam protection mechanism to limit the number of proposals each noun can back. + */ function checkNoActiveProp(NounsDAOStorageV3.StorageV3 storage ds, address proposer) internal view { uint256 latestProposalId = ds.latestProposalIds[proposer]; if (latestProposalId != 0) { @@ -668,13 +796,78 @@ library NounsDAOV3Proposals { } } + /** + * @dev Extracted this function to fix the `Stack too deep` error `proposeBySigs` hit. + */ + function verifySignersCanBackThisProposalAndCountTheirVotes( + NounsDAOStorageV3.StorageV3 storage ds, + NounsDAOStorageV3.ProposerSignature[] memory proposerSignatures, + ProposalTxs memory txs, + string memory description, + uint256 proposalId + ) internal returns (uint256 votes, address[] memory signers) { + NounsTokenLike nouns = ds.nouns; + bytes memory proposalEncodeData = calcProposalEncodeData(msg.sender, txs, description); + + signers = new address[](proposerSignatures.length); + for (uint256 i = 0; i < proposerSignatures.length; ++i) { + verifyProposalSignature(ds, proposalEncodeData, proposerSignatures[i], PROPOSAL_TYPEHASH); + address signer = signers[i] = proposerSignatures[i].signer; + + checkNoActiveProp(ds, signer); + ds.latestProposalIds[signer] = proposalId; + votes += nouns.getPriorVotes(signer, block.number - 1); + } + + checkNoActiveProp(ds, msg.sender); + ds.latestProposalIds[msg.sender] = proposalId; + votes += nouns.getPriorVotes(msg.sender, block.number - 1); + } + + function calcProposalEncodeData( + address proposer, + ProposalTxs memory txs, + string memory description + ) internal pure returns (bytes memory) { + bytes32[] memory signatureHashes = new bytes32[](txs.signatures.length); + for (uint256 i = 0; i < txs.signatures.length; ++i) { + signatureHashes[i] = keccak256(bytes(txs.signatures[i])); + } + + bytes32[] memory calldatasHashes = new bytes32[](txs.calldatas.length); + for (uint256 i = 0; i < txs.calldatas.length; ++i) { + calldatasHashes[i] = keccak256(txs.calldatas[i]); + } + + return + abi.encode( + proposer, + keccak256(abi.encodePacked(txs.targets)), + keccak256(abi.encodePacked(txs.values)), + keccak256(abi.encodePacked(signatureHashes)), + keccak256(abi.encodePacked(calldatasHashes)), + keccak256(bytes(description)) + ); + } + + function checkProposalUpdatable( + NounsDAOStorageV3.StorageV3 storage ds, + uint256 proposalId, + NounsDAOStorageV3.Proposal storage proposal + ) internal view { + if (state(ds, proposalId) != NounsDAOStorageV3.ProposalState.Updatable) revert CanOnlyEditUpdatableProposals(); + if (msg.sender != proposal.proposer) revert OnlyProposerCanEdit(); + if (proposal.signers.length > 0) revert ProposerCannotUpdateProposalWithSigners(); + } + function createNewProposal( NounsDAOStorageV3.StorageV3 storage ds, uint256 proposalId, uint256 proposalThreshold_, + uint256 adjustedTotalSupply, ProposalTxs memory txs ) internal returns (NounsDAOStorageV3.Proposal storage newProposal) { - uint256 updatePeriodEndBlock = block.number + ds.proposalUpdatablePeriodInBlocks; + uint64 updatePeriodEndBlock = uint64(block.number + ds.proposalUpdatablePeriodInBlocks); uint256 startBlock = updatePeriodEndBlock + ds.votingDelay; uint256 endBlock = startBlock + ds.votingPeriod; @@ -682,21 +875,14 @@ library NounsDAOV3Proposals { newProposal.id = proposalId; newProposal.proposer = msg.sender; newProposal.proposalThreshold = proposalThreshold_; - newProposal.eta = 0; newProposal.targets = txs.targets; newProposal.values = txs.values; newProposal.signatures = txs.signatures; newProposal.calldatas = txs.calldatas; newProposal.startBlock = startBlock; newProposal.endBlock = endBlock; - newProposal.forVotes = 0; - newProposal.againstVotes = 0; - newProposal.abstainVotes = 0; - newProposal.canceled = false; - newProposal.executed = false; - newProposal.vetoed = false; - newProposal.totalSupply = ds.nouns.totalSupply(); - newProposal.creationBlock = block.number; + newProposal.totalSupply = adjustedTotalSupply; + newProposal.creationBlock = uint64(block.number); newProposal.updatePeriodEndBlock = updatePeriodEndBlock; } @@ -742,11 +928,10 @@ library NounsDAOV3Proposals { function checkPropThreshold( NounsDAOStorageV3.StorageV3 storage ds, - uint256 votes + uint256 votes, + uint256 adjustedTotalSupply ) internal view returns (uint256 propThreshold) { - uint256 totalSupply = ds.nouns.totalSupply(); - propThreshold = bps2Uint(ds.proposalThresholdBPS, totalSupply); - + propThreshold = proposalThreshold(ds, adjustedTotalSupply); require(votes > propThreshold, 'NounsDAO::propose: proposer votes below proposal threshold'); } diff --git a/packages/nouns-contracts/contracts/governance/NounsDAOV3Votes.sol b/packages/nouns-contracts/contracts/governance/NounsDAOV3Votes.sol index 291b98d0a9..ac7798c750 100644 --- a/packages/nouns-contracts/contracts/governance/NounsDAOV3Votes.sol +++ b/packages/nouns-contracts/contracts/governance/NounsDAOV3Votes.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-3.0 -/// @title +/// @title Library for NounsDAOLogicV3 contract containing all the voting related code /********************************* * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * @@ -23,13 +23,6 @@ import { NounsDAOV3Proposals } from './NounsDAOV3Proposals.sol'; library NounsDAOV3Votes { using NounsDAOV3Proposals for NounsDAOStorageV3.StorageV3; - error VetoerBurned(); - error VetoerOnly(); - error CantVetoExecutedProposal(); - - /// @notice An event emitted when a proposal has been vetoed by vetoAddress - event ProposalVetoed(uint256 id); - /// @notice An event emitted when a vote has been cast on a proposal /// @param voter The address which casted a vote /// @param proposalId The proposal id which was voted on @@ -66,39 +59,6 @@ library NounsDAOV3Votes { /// @notice The maximum basefee the DAO will refund voters on uint256 public constant MAX_REFUND_BASE_FEE = 200 gwei; - /** - * @notice Vetoes a proposal only if sender is the vetoer and the proposal has not been executed. - * @param proposalId The id of the proposal to veto - */ - function veto(NounsDAOStorageV3.StorageV3 storage ds, uint256 proposalId) external { - if (ds.vetoer == address(0)) { - revert VetoerBurned(); - } - - if (msg.sender != ds.vetoer) { - revert VetoerOnly(); - } - - if (ds.stateInternal(proposalId) == NounsDAOStorageV3.ProposalState.Executed) { - revert CantVetoExecutedProposal(); - } - - NounsDAOStorageV3.Proposal storage proposal = ds._proposals[proposalId]; - - proposal.vetoed = true; - for (uint256 i = 0; i < proposal.targets.length; i++) { - ds.timelock.cancelTransaction( - proposal.targets[i], - proposal.values[i], - proposal.signatures[i], - proposal.calldatas[i], - proposal.eta - ); - } - - emit ProposalVetoed(proposalId); - } - /** * @notice Cast a vote for a proposal * @param proposalId The id of the proposal to vote on @@ -210,6 +170,9 @@ library NounsDAOV3Votes { /** * @notice Internal function that caries out voting logic + * In case of a vote during the 'last minute window', which changes the proposal outcome from being defeated to + * passing, and objection period is adding to the proposal's voting period. + * During the objection period, only votes against a proposal can be cast. * @param voter The voter that is casting their vote * @param proposalId The id of the proposal to vote on * @param support The support value for the vote. 0=against, 1=for, 2=abstain @@ -250,7 +213,7 @@ library NounsDAOV3Votes { require(receipt.hasVoted == false, 'NounsDAO::castVoteInternal: voter already voted'); /// @notice: Unlike GovernerBravo, votes are considered from the block the proposal was created in order to normalize quorumVotes and proposalThreshold metrics - uint96 votes = ds.nouns.getPriorVotes(voter, proposalVoteSnapshotBlock(ds, proposal)); + uint96 votes = ds.nouns.getPriorVotes(voter, proposalVoteSnapshotBlock(ds, proposalId, proposal)); bool isForVoteInLastMinuteWindow = false; if (support == 1) { @@ -280,7 +243,7 @@ library NounsDAOV3Votes { // second part of the vote flip check !ds.isDefeated(proposal) ) { - proposal.objectionPeriodEndBlock = proposal.endBlock + ds.objectionPeriodDurationInBlocks; + proposal.objectionPeriodEndBlock = uint64(proposal.endBlock + ds.objectionPeriodDurationInBlocks); emit ProposalObjectionPeriodSet(proposal.id, proposal.objectionPeriodEndBlock); } @@ -303,7 +266,10 @@ library NounsDAOV3Votes { NounsDAOStorageV3.Receipt storage receipt = proposal.receipts[voter]; require(receipt.hasVoted == false, 'NounsDAO::castVoteInternal: voter already voted'); - uint96 votes = receipt.votes = ds.nouns.getPriorVotes(voter, proposalVoteSnapshotBlock(ds, proposal)); + uint96 votes = receipt.votes = ds.nouns.getPriorVotes( + voter, + proposalVoteSnapshotBlock(ds, proposalId, proposal) + ); receipt.hasVoted = true; receipt.support = 0; proposal.againstVotes = proposal.againstVotes + votes; @@ -328,12 +294,14 @@ library NounsDAOV3Votes { function proposalVoteSnapshotBlock( NounsDAOStorageV3.StorageV3 storage ds, + uint256 proposalId, NounsDAOStorageV3.Proposal storage proposal ) internal view returns (uint256) { // The idea is to temporarily use this code that would still use `creationBlock` until all proposals are using // `startBlock`, then we can deploy a quick DAO fix that removes this line and only uses `startBlock`. // In that version upgrade we can also zero-out and remove this storage variable for max cleanup. - if (proposal.id < ds.voteSnapshotBlockSwitchProposalId || ds.voteSnapshotBlockSwitchProposalId == 0) { + uint256 voteSnapshotBlockSwitchProposalId = ds.voteSnapshotBlockSwitchProposalId; + if (proposalId < voteSnapshotBlockSwitchProposalId || voteSnapshotBlockSwitchProposalId == 0) { return proposal.creationBlock; } return proposal.startBlock; diff --git a/packages/nouns-contracts/contracts/governance/data/NounsDAOData.sol b/packages/nouns-contracts/contracts/governance/data/NounsDAOData.sol index 16759516e0..715598f750 100644 --- a/packages/nouns-contracts/contracts/governance/data/NounsDAOData.sol +++ b/packages/nouns-contracts/contracts/governance/data/NounsDAOData.sol @@ -21,8 +21,9 @@ import { OwnableUpgradeable } from '@openzeppelin/contracts-upgradeable/access/O import { NounsDAOV3Proposals } from '../NounsDAOV3Proposals.sol'; import { NounsTokenLike } from '../NounsDAOInterfaces.sol'; import { SignatureChecker } from '@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol'; +import { UUPSUpgradeable } from '@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol'; -contract NounsDAOData is OwnableUpgradeable { +contract NounsDAOData is OwnableUpgradeable, UUPSUpgradeable { /** * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * ERRORS @@ -329,4 +330,16 @@ contract NounsDAOData is OwnableUpgradeable { function isNouner(address account) internal view returns (bool) { return nounsToken.getPriorVotes(account, block.number - PRIOR_VOTES_BLOCKS_AGO) > 0; } + + /** + * @dev Function that should revert when `msg.sender` is not authorized to upgrade the contract. Called by + * {upgradeTo} and {upgradeToAndCall}. + * + * Normally, this function will use an xref:access.adoc[access control] modifier such as {Ownable-onlyOwner}. + * + * ```solidity + * function _authorizeUpgrade(address) internal override onlyOwner {} + * ``` + */ + function _authorizeUpgrade(address) internal view override onlyOwner {} } diff --git a/packages/nouns-contracts/contracts/governance/data/NounsDAODataProxy.sol b/packages/nouns-contracts/contracts/governance/data/NounsDAODataProxy.sol index 92f634dc78..5c718e0e22 100644 --- a/packages/nouns-contracts/contracts/governance/data/NounsDAODataProxy.sol +++ b/packages/nouns-contracts/contracts/governance/data/NounsDAODataProxy.sol @@ -17,12 +17,8 @@ pragma solidity ^0.8.6; -import { TransparentUpgradeableProxy } from '@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol'; +import { ERC1967Proxy } from '@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol'; -contract NounsDAODataProxy is TransparentUpgradeableProxy { - constructor( - address logic, - address admin, - bytes memory data - ) TransparentUpgradeableProxy(logic, admin, data) {} +contract NounsDAODataProxy is ERC1967Proxy { + constructor(address _logic, bytes memory _data) payable ERC1967Proxy(_logic, _data) {} } diff --git a/packages/nouns-contracts/contracts/governance/fork/ForkDAODeployer.sol b/packages/nouns-contracts/contracts/governance/fork/ForkDAODeployer.sol new file mode 100644 index 0000000000..1b4860b9ff --- /dev/null +++ b/packages/nouns-contracts/contracts/governance/fork/ForkDAODeployer.sol @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: GPL-3.0 + +/// @title The deployer of new Nouns DAO forks + +/********************************* + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + * ░░░░░░█████████░░█████████░░░ * + * ░░░░░░██░░░████░░██░░░████░░░ * + * ░░██████░░░████████░░░████░░░ * + * ░░██░░██░░░████░░██░░░████░░░ * + * ░░██░░██░░░████░░██░░░████░░░ * + * ░░░░░░█████████░░█████████░░░ * + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + *********************************/ + +pragma solidity ^0.8.6; + +import { ERC1967Proxy } from '@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol'; +import { IForkDAODeployer, INounsDAOForkEscrow, NounsDAOStorageV3 } from '../NounsDAOInterfaces.sol'; +import { NounsTokenFork } from './newdao/token/NounsTokenFork.sol'; +import { NounsAuctionHouseFork } from './newdao/NounsAuctionHouseFork.sol'; +import { NounsDAOExecutorV2 } from '../NounsDAOExecutorV2.sol'; +import { NounsDAOProxy } from '../NounsDAOProxy.sol'; +import { NounsDAOLogicV3 } from '../NounsDAOLogicV3.sol'; +import { NounsDAOLogicV1Fork } from './newdao/governance/NounsDAOLogicV1Fork.sol'; +import { NounsToken } from '../../NounsToken.sol'; +import { NounsAuctionHouse } from '../../NounsAuctionHouse.sol'; + +contract ForkDAODeployer is IForkDAODeployer { + event DAODeployed(address token, address auction, address governor, address treasury); + + /// @notice The token implementation address + address public tokenImpl; + + /// @notice The auction house implementation address + address public auctionImpl; + + /// @notice The treasury implementation address + address public treasuryImpl; + + /// @notice The governor implementation address + address public governorImpl; + + /// @notice The maximum duration of the governance delay in new DAOs + uint256 public delayedGovernanceMaxDuration; + + constructor( + address tokenImpl_, + address auctionImpl_, + address governorImpl_, + address treasuryImpl_, + uint256 delayedGovernanceMaxDuration_ + ) { + tokenImpl = tokenImpl_; + auctionImpl = auctionImpl_; + governorImpl = governorImpl_; + treasuryImpl = treasuryImpl_; + delayedGovernanceMaxDuration = delayedGovernanceMaxDuration_; + } + + /** + * @notice Deploys a new Nouns DAO fork, including a new token, auction house, governor, and treasury. + * All contracts are upgradable, and are almost entirely initialized with the same parameters as the original DAO. + * @param forkingPeriodEndTimestamp The timestamp at which the forking period ends + * @param forkEscrow The address of the fork escrow contract, used for claiming tokens that were escrowed in the original DAO + * and to get references to the original DAO's auction house and timelock + * @return treasury The address of the fork DAO treasury + * @return token The address of the fork DAO token + */ + function deployForkDAO(uint256 forkingPeriodEndTimestamp, INounsDAOForkEscrow forkEscrow) + external + returns (address treasury, address token) + { + token = address(new ERC1967Proxy(tokenImpl, '')); + address auction = address(new ERC1967Proxy(auctionImpl, '')); + address governor = address(new ERC1967Proxy(governorImpl, '')); + treasury = address(new ERC1967Proxy(treasuryImpl, '')); + + NounsAuctionHouse originalAuction = getOriginalAuction(forkEscrow); + NounsDAOExecutorV2 originalTimelock = getOriginalTimelock(forkEscrow); + + NounsTokenFork(token).initialize( + treasury, + auction, + forkEscrow, + forkEscrow.forkId(), + getStartNounId(originalAuction), + forkEscrow.numTokensInEscrow(), + forkingPeriodEndTimestamp + ); + + NounsAuctionHouseFork(auction).initialize( + treasury, + NounsToken(token), + originalAuction.weth(), + originalAuction.timeBuffer(), + originalAuction.reservePrice(), + originalAuction.minBidIncrementPercentage(), + originalAuction.duration() + ); + + initDAO(governor, treasury, token, originalTimelock); + + NounsDAOExecutorV2(payable(treasury)).initialize(governor, originalTimelock.delay()); + + emit DAODeployed(token, auction, governor, treasury); + } + + /** + * @dev Used to prevent the 'Stack too deep' error in the main deploy function. + */ + function initDAO( + address governor, + address treasury, + address token, + NounsDAOExecutorV2 originalTimelock + ) internal { + NounsDAOLogicV3 originalDAO = NounsDAOLogicV3(payable(originalTimelock.admin())); + NounsDAOLogicV1Fork(governor).initialize( + treasury, + token, + originalDAO.votingPeriod(), + originalDAO.votingDelay(), + originalDAO.proposalThresholdBPS(), + getMinQuorumVotesBPS(originalDAO), + originalDAO.erc20TokensToIncludeInFork(), + block.timestamp + delayedGovernanceMaxDuration + ); + } + + /** + * @dev Used to prevent the 'Stack too deep' error in the main deploy function. + */ + function getOriginalTimelock(INounsDAOForkEscrow forkEscrow) internal view returns (NounsDAOExecutorV2) { + NounsToken originalToken = NounsToken(address(forkEscrow.nounsToken())); + return NounsDAOExecutorV2(payable(originalToken.owner())); + } + + /** + * @dev Used to prevent the 'Stack too deep' error in the main deploy function. + */ + function getOriginalAuction(INounsDAOForkEscrow forkEscrow) internal view returns (NounsAuctionHouse) { + NounsToken originalToken = NounsToken(address(forkEscrow.nounsToken())); + return NounsAuctionHouse(originalToken.minter()); + } + + /** + * @dev Used to prevent the 'Stack too deep' error in the main deploy function. + */ + function getMinQuorumVotesBPS(NounsDAOLogicV3 originalDAO) internal view returns (uint16) { + NounsDAOStorageV3.DynamicQuorumParams memory dqParams = originalDAO.getDynamicQuorumParamsAt(block.number); + return dqParams.minQuorumVotesBPS; + } + + function getStartNounId(NounsAuctionHouse originalAuction) internal view returns (uint256) { + (uint256 nounId, , , , , ) = originalAuction.auction(); + return nounId; + } +} diff --git a/packages/nouns-contracts/contracts/governance/fork/NounsDAOForkEscrow.sol b/packages/nouns-contracts/contracts/governance/fork/NounsDAOForkEscrow.sol new file mode 100644 index 0000000000..71fff78d6b --- /dev/null +++ b/packages/nouns-contracts/contracts/governance/fork/NounsDAOForkEscrow.sol @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: GPL-3.0 + +/// @title Escrow contract for Nouns to be used to trigger a fork + +/********************************* + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + * ░░░░░░█████████░░█████████░░░ * + * ░░░░░░██░░░████░░██░░░████░░░ * + * ░░██████░░░████████░░░████░░░ * + * ░░██░░██░░░████░░██░░░████░░░ * + * ░░██░░██░░░████░░██░░░████░░░ * + * ░░░░░░█████████░░█████████░░░ * + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + *********************************/ + +pragma solidity ^0.8.6; + +import { NounsTokenLike } from '../NounsDAOInterfaces.sol'; +import { IERC721Receiver } from '@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol'; + +contract NounsDAOForkEscrow is IERC721Receiver { + /** + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + * IMMUTABLES + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + */ + + /// @notice Nouns governance contract + address public immutable dao; + + /// @notice Nouns token contract + NounsTokenLike public immutable nounsToken; + + /** + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + * STORAGE VARIABLES + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + */ + + /// @notice Current fork id + uint32 public forkId; + + /// @notice A mapping of which owner escrowed which token for which fork. + /// Later used in order to claim tokens in a forked DAO. + /// @dev forkId => tokenId => owner + mapping(uint32 => mapping(uint256 => address)) public escrowedTokensByForkId; + + /// @notice Number of tokens in escrow in the current fork contributing to the fork threshold. They can be unescrowed. + uint256 public numTokensInEscrow; + + error OnlyDAO(); + error OnlyNounsToken(); + error NotOwner(); + + constructor(address dao_, address nounsToken_) { + dao = dao_; + nounsToken = NounsTokenLike(nounsToken_); + } + + modifier onlyDAO() { + if (msg.sender != dao) { + revert OnlyDAO(); + } + _; + } + + /** + * @notice Escrows nouns tokens + * @dev Can only be called by the Nouns token contract, and initiated by the DAO contract + * @param operator The address which called the `safeTransferFrom` function, can only be the DAO contract + * @param from The address which previously owned the token + * @param tokenId The id of the token being escrowed + */ + function onERC721Received( + address operator, + address from, + uint256 tokenId, + bytes memory + ) public override returns (bytes4) { + if (msg.sender != address(nounsToken)) revert OnlyNounsToken(); + if (operator != dao) revert OnlyDAO(); + + escrowedTokensByForkId[forkId][tokenId] = from; + + numTokensInEscrow++; + + return IERC721Receiver.onERC721Received.selector; + } + + /** + * @notice Unescrows nouns tokens + * @dev Can only be called by the DAO contract + * @param owner The address which asks to unescrow, must be the address which escrowed the tokens + * @param tokenIds The ids of the tokens being unescrowed + */ + function returnTokensToOwner(address owner, uint256[] calldata tokenIds) external onlyDAO { + for (uint256 i = 0; i < tokenIds.length; i++) { + if (currentOwnerOf(tokenIds[i]) != owner) revert NotOwner(); + + nounsToken.transferFrom(address(this), owner, tokenIds[i]); + escrowedTokensByForkId[forkId][tokenIds[i]] = address(0); + } + + numTokensInEscrow -= tokenIds.length; + } + + /** + * @notice Closes the escrow, and increments the fork id. Once the escrow is closed, all the escrowed tokens + * can no longer be unescrowed by the owner, but can be withdrawn by the DAO. + * @dev Can only be called by the DAO contract + * @return closedForkId The fork id which was closed + */ + function closeEscrow() external onlyDAO returns (uint32 closedForkId) { + numTokensInEscrow = 0; + + closedForkId = forkId; + + forkId++; + } + + /** + * @notice Withdraws nouns tokens to the DAO + * @dev Can only be called by the DAO contract + * @param tokenIds The ids of the tokens being withdrawn + * @param to The address which will receive the tokens + */ + function withdrawTokensToDAO(uint256[] calldata tokenIds, address to) external onlyDAO { + for (uint256 i = 0; i < tokenIds.length; i++) { + if (currentOwnerOf(tokenIds[i]) != dao) revert NotOwner(); + + nounsToken.transferFrom(address(this), to, tokenIds[i]); + } + } + + /** + * @notice Returns the number of tokens owned by the DAO, excluding the ones in escrow + */ + function numTokensOwnedByDAO() external view returns (uint256) { + return nounsToken.balanceOf(address(this)) - numTokensInEscrow; + } + + /** + * @notice Returns the original owner of a token, when it was escrowed + * @param forkId_ The fork id in which the token was escrowed + * @param tokenId The id of the token + * @return The address of the original owner, or address(0) if not found + */ + function ownerOfEscrowedToken(uint32 forkId_, uint256 tokenId) external view returns (address) { + return escrowedTokensByForkId[forkId_][tokenId]; + } + + /** + * @notice Returns the current owner of a token, either the DAO or the account which escrowed it. + * If the token is currently in an active escrow, the original owner is still the owner. + * Otherwise, the DAO can withdraw it. + * @param tokenId The id of the token + * @return The address of the current owner, either the original owner or the address of the dao + */ + function currentOwnerOf(uint256 tokenId) public view returns (address) { + address owner = escrowedTokensByForkId[forkId][tokenId]; + if (owner == address(0)) { + return dao; + } else { + return owner; + } + } +} diff --git a/packages/nouns-contracts/contracts/governance/fork/NounsDAOV3Fork.sol b/packages/nouns-contracts/contracts/governance/fork/NounsDAOV3Fork.sol new file mode 100644 index 0000000000..69040adad5 --- /dev/null +++ b/packages/nouns-contracts/contracts/governance/fork/NounsDAOV3Fork.sol @@ -0,0 +1,231 @@ +// SPDX-License-Identifier: GPL-3.0 + +/// @title Library for NounsDAOLogicV3 contract containing the dao fork logic + +/********************************* + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + * ░░░░░░█████████░░█████████░░░ * + * ░░░░░░██░░░████░░██░░░████░░░ * + * ░░██████░░░████████░░░████░░░ * + * ░░██░░██░░░████░░██░░░████░░░ * + * ░░██░░██░░░████░░██░░░████░░░ * + * ░░░░░░█████████░░█████████░░░ * + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + *********************************/ + +pragma solidity ^0.8.6; + +import { NounsDAOStorageV3, INounsDAOForkEscrow, INounsDAOExecutorV2 } from '../NounsDAOInterfaces.sol'; +import { IERC20 } from '@openzeppelin/contracts/token/ERC20/IERC20.sol'; +import { NounsTokenFork } from './newdao/token/NounsTokenFork.sol'; + +library NounsDAOV3Fork { + error ForkThresholdNotMet(); + error ForkPeriodNotActive(); + error ForkPeriodActive(); + error AdminOnly(); + error ETHTransferFailed(); + error ERC20TransferFailed(); + + /// @notice Emitted when someones adds nouns to the fork escrow + event EscrowedToFork( + uint32 indexed forkId, + address indexed owner, + uint256[] tokenIds, + uint256[] proposalIds, + string reason + ); + + /// @notice Emitted when the owner withdraws their nouns from the fork escrow + event WithdrawFromForkEscrow(uint32 indexed forkId, address indexed owner, uint256[] tokenIds); + + /// @notice Emitted when the fork is executed and the forking period begins + event ExecuteFork( + uint32 indexed forkId, + address forkTreasury, + address forkToken, + uint256 forkEndTimestamp, + uint256 tokensInEscrow + ); + + /// @notice Emitted when someone joins a fork during the forking period + event JoinFork( + uint32 indexed forkId, + address indexed owner, + uint256[] tokenIds, + uint256[] proposalIds, + string reason + ); + + /// @notice Emitted when the DAO withdraws nouns from the fork escrow after a fork has been executed + event DAOWithdrawNounsFromEscrow(uint256[] tokenIds, address to); + + /** + * @notice Escrow Nouns to contribute to the fork threshold + * @dev Requires approving the tokenIds or the entire noun token to the DAO contract + * @param tokenIds the tokenIds to escrow. They will be sent to the DAO once the fork threshold is reached and the escrow is closed. + * @param proposalIds array of proposal ids which are the reason for wanting to fork. This will only be used to emit event. + * @param reason the reason for want to fork. This will only be used to emit event. + */ + function escrowToFork( + NounsDAOStorageV3.StorageV3 storage ds, + uint256[] calldata tokenIds, + uint256[] calldata proposalIds, + string calldata reason + ) external { + if (isForkPeriodActive(ds)) revert ForkPeriodActive(); + INounsDAOForkEscrow forkEscrow = ds.forkEscrow; + + for (uint256 i = 0; i < tokenIds.length; i++) { + ds.nouns.safeTransferFrom(msg.sender, address(forkEscrow), tokenIds[i]); + } + + emit EscrowedToFork(forkEscrow.forkId(), msg.sender, tokenIds, proposalIds, reason); + } + + /** + * @notice Withdraw Nouns from the fork escrow. Only possible if the fork has not been executed. + * Only allowed to withdraw tokens that the sender has escrowed. + * @param tokenIds the tokenIds to withdraw + */ + function withdrawFromForkEscrow(NounsDAOStorageV3.StorageV3 storage ds, uint256[] calldata tokenIds) external { + if (isForkPeriodActive(ds)) revert ForkPeriodActive(); + + INounsDAOForkEscrow forkEscrow = ds.forkEscrow; + forkEscrow.returnTokensToOwner(msg.sender, tokenIds); + + emit WithdrawFromForkEscrow(forkEscrow.forkId(), msg.sender, tokenIds); + } + + /** + * @notice Execute the fork. Only possible if the fork threshold has been met. + * This will deploy a new DAO and send the prorated part of the treasury to the new DAO's treasury. + * This will also close the active escrow and all nouns in the escrow will belong to the original DAO. + * @return forkTreasury The address of the new DAO's treasury + * @return forkToken The address of the new DAO's token + */ + function executeFork(NounsDAOStorageV3.StorageV3 storage ds) + external + returns (address forkTreasury, address forkToken) + { + if (isForkPeriodActive(ds)) revert ForkPeriodActive(); + INounsDAOForkEscrow forkEscrow = ds.forkEscrow; + + uint256 tokensInEscrow = forkEscrow.numTokensInEscrow(); + if (tokensInEscrow < forkThreshold(ds)) revert ForkThresholdNotMet(); + + uint256 forkEndTimestamp = block.timestamp + ds.forkPeriod; + + (forkTreasury, forkToken) = ds.forkDAODeployer.deployForkDAO(forkEndTimestamp, forkEscrow); + sendProRataTreasury(ds, forkTreasury, tokensInEscrow, adjustedTotalSupply(ds)); + uint32 forkId = forkEscrow.closeEscrow(); + + ds.forkDAOTreasury = forkTreasury; + ds.forkDAOToken = forkToken; + ds.forkEndTimestamp = forkEndTimestamp; + + emit ExecuteFork(forkId, forkTreasury, forkToken, forkEndTimestamp, tokensInEscrow); + } + + /** + * @notice Joins a fork while a fork is active + * Sends the tokens to the escrow contract. + * Sends a prorated part of the treasury to the new fork DAO's treasury. + * Mints new tokens in the new fork DAO with the same token ids. + * @param tokenIds the tokenIds to send to the DAO in exchange for joining the fork + */ + function joinFork( + NounsDAOStorageV3.StorageV3 storage ds, + uint256[] calldata tokenIds, + uint256[] calldata proposalIds, + string calldata reason + ) external { + if (!isForkPeriodActive(ds)) revert ForkPeriodNotActive(); + + INounsDAOForkEscrow forkEscrow = ds.forkEscrow; + sendProRataTreasury(ds, ds.forkDAOTreasury, tokenIds.length, adjustedTotalSupply(ds)); + + for (uint256 i = 0; i < tokenIds.length; i++) { + ds.nouns.transferFrom(msg.sender, address(forkEscrow), tokenIds[i]); + } + + NounsTokenFork(ds.forkDAOToken).claimDuringForkPeriod(msg.sender, tokenIds); + + emit JoinFork(forkEscrow.forkId() - 1, msg.sender, tokenIds, proposalIds, reason); + } + + /** + * @notice Withdraws nouns from the fork escrow after the fork has been executed + * @dev Only the DAO can call this function + * @param tokenIds the tokenIds to withdraw + * @param to the address to send the nouns to + */ + function withdrawDAONounsFromEscrow( + NounsDAOStorageV3.StorageV3 storage ds, + uint256[] calldata tokenIds, + address to + ) external { + if (msg.sender != ds.admin) { + revert AdminOnly(); + } + + ds.forkEscrow.withdrawTokensToDAO(tokenIds, to); + + emit DAOWithdrawNounsFromEscrow(tokenIds, to); + } + + /** + * @notice Returns the required number of tokens to escrow to trigger a fork + */ + function forkThreshold(NounsDAOStorageV3.StorageV3 storage ds) public view returns (uint256) { + return (adjustedTotalSupply(ds) * ds.forkThresholdBPS) / 10_000; + } + + /** + * @notice Returns the number of tokens currently in escrow, contributing to the fork threshold + */ + function numTokensInForkEscrow(NounsDAOStorageV3.StorageV3 storage ds) public view returns (uint256) { + return ds.forkEscrow.numTokensInEscrow(); + } + + /** + * @notice Returns the number of nouns in supply minus nouns owned by the DAO, i.e. held in the treasury or in an + * escrow after it has closed. + * This is used when calculating proposal threshold, quorum, fork threshold & treasury split. + */ + function adjustedTotalSupply(NounsDAOStorageV3.StorageV3 storage ds) internal view returns (uint256) { + return ds.nouns.totalSupply() - ds.nouns.balanceOf(address(ds.timelock)) - ds.forkEscrow.numTokensOwnedByDAO(); + } + + function isForkPeriodActive(NounsDAOStorageV3.StorageV3 storage ds) internal view returns (bool) { + return ds.forkEndTimestamp > block.timestamp; + } + + /** + * @notice Sends part of the DAO's treasury to the `newDAOTreasury` address. + * The amount sent is proportional to the `tokenCount` out of `totalSupply`. + * Sends ETH and ERC20 tokens listed in `ds.erc20TokensToIncludeInFork`. + */ + function sendProRataTreasury( + NounsDAOStorageV3.StorageV3 storage ds, + address newDAOTreasury, + uint256 tokenCount, + uint256 totalSupply + ) internal { + INounsDAOExecutorV2 timelock = ds.timelock; + uint256 ethToSend = (address(timelock).balance * tokenCount) / totalSupply; + + bool ethSent = timelock.sendETH(newDAOTreasury, ethToSend); + if (!ethSent) revert ETHTransferFailed(); + + uint256 erc20Count = ds.erc20TokensToIncludeInFork.length; + for (uint256 i = 0; i < erc20Count; ++i) { + IERC20 erc20token = IERC20(ds.erc20TokensToIncludeInFork[i]); + uint256 tokensToSend = (erc20token.balanceOf(address(timelock)) * tokenCount) / totalSupply; + bool erc20Sent = timelock.sendERC20(newDAOTreasury, address(erc20token), tokensToSend); + if (!erc20Sent) revert ERC20TransferFailed(); + } + } +} diff --git a/packages/nouns-contracts/contracts/governance/fork/newdao/NounsAuctionHouseFork.sol b/packages/nouns-contracts/contracts/governance/fork/newdao/NounsAuctionHouseFork.sol new file mode 100644 index 0000000000..ed0c0c04cb --- /dev/null +++ b/packages/nouns-contracts/contracts/governance/fork/newdao/NounsAuctionHouseFork.sol @@ -0,0 +1,282 @@ +// SPDX-License-Identifier: GPL-3.0 + +/// @title The Nouns DAO auction house, supporting UUPS upgrades + +/********************************* + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + * ░░░░░░█████████░░█████████░░░ * + * ░░░░░░██░░░████░░██░░░████░░░ * + * ░░██████░░░████████░░░████░░░ * + * ░░██░░██░░░████░░██░░░████░░░ * + * ░░██░░██░░░████░░██░░░████░░░ * + * ░░░░░░█████████░░█████████░░░ * + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + *********************************/ + +// LICENSE +// NounsAuctionHouseFork.sol is a modified version of NounsAuctionHouse.sol. +// NounsAuctionHouse.sol is a modified version of Zora's AuctionHouse.sol: +// https://github.com/ourzora/auction-house/blob/54a12ec1a6cf562e49f0a4917990474b11350a2d/contracts/AuctionHouse.sol +// +// AuctionHouse.sol source code Copyright Zora licensed under the GPL-3.0 license. +// With modifications by Nounders DAO. +// +// NounsAuctionHouseFork.sol Modifications: +// - Proxy pattern changed from Transparent to UUPS. +// - Owner is set in the initialize function, instead of in a follow-up transaction. + +pragma solidity ^0.8.6; + +import { PausableUpgradeable } from '@openzeppelin/contracts-upgradeable/security/PausableUpgradeable.sol'; +import { ReentrancyGuardUpgradeable } from '@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol'; +import { OwnableUpgradeable } from '@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol'; +import { IERC20 } from '@openzeppelin/contracts/token/ERC20/IERC20.sol'; +import { INounsAuctionHouse } from '../../../interfaces/INounsAuctionHouse.sol'; +import { INounsToken } from '../../../interfaces/INounsToken.sol'; +import { IWETH } from '../../../interfaces/IWETH.sol'; +import { UUPSUpgradeable } from '@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol'; + +contract NounsAuctionHouseFork is + INounsAuctionHouse, + PausableUpgradeable, + ReentrancyGuardUpgradeable, + OwnableUpgradeable, + UUPSUpgradeable +{ + string public constant NAME = 'NounsAuctionHouseFork'; + + // The Nouns ERC721 token contract + INounsToken public nouns; + + // The address of the WETH contract + address public weth; + + // The minimum amount of time left in an auction after a new bid is created + uint256 public timeBuffer; + + // The minimum price accepted in an auction + uint256 public reservePrice; + + // The minimum percentage difference between the last bid amount and the current bid + uint8 public minBidIncrementPercentage; + + // The duration of a single auction + uint256 public duration; + + // The active auction + INounsAuctionHouse.Auction public auction; + + /** + * @notice Initialize the auction house and base contracts, + * populate configuration values, and pause the contract. + * @dev This function can only be called once. + */ + function initialize( + address _owner, + INounsToken _nouns, + address _weth, + uint256 _timeBuffer, + uint256 _reservePrice, + uint8 _minBidIncrementPercentage, + uint256 _duration + ) external initializer { + __Pausable_init(); + __ReentrancyGuard_init(); + _transferOwnership(_owner); + + _pause(); + + nouns = _nouns; + weth = _weth; + timeBuffer = _timeBuffer; + reservePrice = _reservePrice; + minBidIncrementPercentage = _minBidIncrementPercentage; + duration = _duration; + } + + /** + * @notice Settle the current auction, mint a new Noun, and put it up for auction. + */ + function settleCurrentAndCreateNewAuction() external override nonReentrant whenNotPaused { + _settleAuction(); + _createAuction(); + } + + /** + * @notice Settle the current auction. + * @dev This function can only be called when the contract is paused. + */ + function settleAuction() external override whenPaused nonReentrant { + _settleAuction(); + } + + /** + * @notice Create a bid for a Noun, with a given amount. + * @dev This contract only accepts payment in ETH. + */ + function createBid(uint256 nounId) external payable override nonReentrant { + INounsAuctionHouse.Auction memory _auction = auction; + + require(_auction.nounId == nounId, 'Noun not up for auction'); + require(block.timestamp < _auction.endTime, 'Auction expired'); + require(msg.value >= reservePrice, 'Must send at least reservePrice'); + require( + msg.value >= _auction.amount + ((_auction.amount * minBidIncrementPercentage) / 100), + 'Must send more than last bid by minBidIncrementPercentage amount' + ); + + address payable lastBidder = _auction.bidder; + + // Refund the last bidder, if applicable + if (lastBidder != address(0)) { + _safeTransferETHWithFallback(lastBidder, _auction.amount); + } + + auction.amount = msg.value; + auction.bidder = payable(msg.sender); + + // Extend the auction if the bid was received within `timeBuffer` of the auction end time + bool extended = _auction.endTime - block.timestamp < timeBuffer; + if (extended) { + auction.endTime = _auction.endTime = block.timestamp + timeBuffer; + } + + emit AuctionBid(_auction.nounId, msg.sender, msg.value, extended); + + if (extended) { + emit AuctionExtended(_auction.nounId, _auction.endTime); + } + } + + /** + * @notice Pause the Nouns auction house. + * @dev This function can only be called by the owner when the + * contract is unpaused. While no new auctions can be started when paused, + * anyone can settle an ongoing auction. + */ + function pause() external override onlyOwner { + _pause(); + } + + /** + * @notice Unpause the Nouns auction house. + * @dev This function can only be called by the owner when the + * contract is paused. If required, this function will start a new auction. + */ + function unpause() external override onlyOwner { + _unpause(); + + if (auction.startTime == 0 || auction.settled) { + _createAuction(); + } + } + + /** + * @notice Set the auction time buffer. + * @dev Only callable by the owner. + */ + function setTimeBuffer(uint256 _timeBuffer) external override onlyOwner { + timeBuffer = _timeBuffer; + + emit AuctionTimeBufferUpdated(_timeBuffer); + } + + /** + * @notice Set the auction reserve price. + * @dev Only callable by the owner. + */ + function setReservePrice(uint256 _reservePrice) external override onlyOwner { + reservePrice = _reservePrice; + + emit AuctionReservePriceUpdated(_reservePrice); + } + + /** + * @notice Set the auction minimum bid increment percentage. + * @dev Only callable by the owner. + */ + function setMinBidIncrementPercentage(uint8 _minBidIncrementPercentage) external override onlyOwner { + minBidIncrementPercentage = _minBidIncrementPercentage; + + emit AuctionMinBidIncrementPercentageUpdated(_minBidIncrementPercentage); + } + + /** + * @notice Create an auction. + * @dev Store the auction details in the `auction` state variable and emit an AuctionCreated event. + * If the mint reverts, the minter was updated without pausing this contract first. To remedy this, + * catch the revert and pause this contract. + */ + function _createAuction() internal { + try nouns.mint() returns (uint256 nounId) { + uint256 startTime = block.timestamp; + uint256 endTime = startTime + duration; + + auction = Auction({ + nounId: nounId, + amount: 0, + startTime: startTime, + endTime: endTime, + bidder: payable(0), + settled: false + }); + + emit AuctionCreated(nounId, startTime, endTime); + } catch Error(string memory) { + _pause(); + } + } + + /** + * @notice Settle an auction, finalizing the bid and paying out to the owner. + * @dev If there are no bids, the Noun is burned. + */ + function _settleAuction() internal { + INounsAuctionHouse.Auction memory _auction = auction; + + require(_auction.startTime != 0, "Auction hasn't begun"); + require(!_auction.settled, 'Auction has already been settled'); + require(block.timestamp >= _auction.endTime, "Auction hasn't completed"); + + auction.settled = true; + + if (_auction.bidder == address(0)) { + nouns.burn(_auction.nounId); + } else { + nouns.transferFrom(address(this), _auction.bidder, _auction.nounId); + } + + if (_auction.amount > 0) { + _safeTransferETHWithFallback(owner(), _auction.amount); + } + + emit AuctionSettled(_auction.nounId, _auction.bidder, _auction.amount); + } + + /** + * @notice Transfer ETH. If the ETH transfer fails, wrap the ETH and try send it as WETH. + */ + function _safeTransferETHWithFallback(address to, uint256 amount) internal { + if (!_safeTransferETH(to, amount)) { + IWETH(weth).deposit{ value: amount }(); + IERC20(weth).transfer(to, amount); + } + } + + /** + * @notice Transfer ETH and return the success status. + * @dev This function only forwards 30,000 gas to the callee. + */ + function _safeTransferETH(address to, uint256 value) internal returns (bool) { + (bool success, ) = to.call{ value: value, gas: 30_000 }(new bytes(0)); + return success; + } + + /** + * @dev Reverts when `msg.sender` is not the owner of this contract; in the case of Noun DAOs it should be the + * DAO's treasury contract. + */ + function _authorizeUpgrade(address) internal view override onlyOwner {} +} diff --git a/packages/nouns-contracts/contracts/governance/fork/newdao/governance/NounsDAOEvents.sol b/packages/nouns-contracts/contracts/governance/fork/newdao/governance/NounsDAOEvents.sol new file mode 100644 index 0000000000..b9a63739f2 --- /dev/null +++ b/packages/nouns-contracts/contracts/governance/fork/newdao/governance/NounsDAOEvents.sol @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: BSD-3-Clause + +pragma solidity ^0.8.6; + +contract NounsDAOEvents { + /// @notice An event emitted when a new proposal is created + event ProposalCreated( + uint256 id, + address proposer, + address[] targets, + uint256[] values, + string[] signatures, + bytes[] calldatas, + uint256 startBlock, + uint256 endBlock, + string description + ); + + /// @notice An event emitted when a new proposal is created, which includes additional information + event ProposalCreatedWithRequirements( + uint256 id, + address proposer, + address[] targets, + uint256[] values, + string[] signatures, + bytes[] calldatas, + uint256 startBlock, + uint256 endBlock, + uint256 proposalThreshold, + uint256 quorumVotes, + string description + ); + + /// @notice An event emitted when a vote has been cast on a proposal + /// @param voter The address which casted a vote + /// @param proposalId The proposal id which was voted on + /// @param support Support value for the vote. 0=against, 1=for, 2=abstain + /// @param votes Number of votes which were cast by the voter + /// @param reason The reason given for the vote by the voter + event VoteCast(address indexed voter, uint256 proposalId, uint8 support, uint256 votes, string reason); + + /// @notice An event emitted when a proposal has been canceled + event ProposalCanceled(uint256 id); + + /// @notice An event emitted when a proposal has been queued in the NounsDAOExecutor + event ProposalQueued(uint256 id, uint256 eta); + + /// @notice An event emitted when a proposal has been executed in the NounsDAOExecutor + event ProposalExecuted(uint256 id); + + /// @notice An event emitted when the voting delay is set + event VotingDelaySet(uint256 oldVotingDelay, uint256 newVotingDelay); + + /// @notice An event emitted when the voting period is set + event VotingPeriodSet(uint256 oldVotingPeriod, uint256 newVotingPeriod); + + /// @notice Emitted when implementation is changed + event NewImplementation(address oldImplementation, address newImplementation); + + /// @notice Emitted when proposal threshold basis points is set + event ProposalThresholdBPSSet(uint256 oldProposalThresholdBPS, uint256 newProposalThresholdBPS); + + /// @notice Emitted when quorum votes basis points is set + event QuorumVotesBPSSet(uint256 oldQuorumVotesBPS, uint256 newQuorumVotesBPS); + + /// @notice Emitted when pendingAdmin is changed + event NewPendingAdmin(address oldPendingAdmin, address newPendingAdmin); + + /// @notice Emitted when pendingAdmin is accepted, which means admin is updated + event NewAdmin(address oldAdmin, address newAdmin); +} diff --git a/packages/nouns-contracts/contracts/governance/fork/newdao/governance/NounsDAOLogicV1Fork.sol b/packages/nouns-contracts/contracts/governance/fork/newdao/governance/NounsDAOLogicV1Fork.sol new file mode 100644 index 0000000000..0908df3cd2 --- /dev/null +++ b/packages/nouns-contracts/contracts/governance/fork/newdao/governance/NounsDAOLogicV1Fork.sol @@ -0,0 +1,751 @@ +// SPDX-License-Identifier: BSD-3-Clause + +/// @title The Nouns DAO logic version 1 + +/********************************* + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + * ░░░░░░█████████░░█████████░░░ * + * ░░░░░░██░░░████░░██░░░████░░░ * + * ░░██████░░░████████░░░████░░░ * + * ░░██░░██░░░████░░██░░░████░░░ * + * ░░██░░██░░░████░░██░░░████░░░ * + * ░░░░░░█████████░░█████████░░░ * + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + *********************************/ + +// LICENSE +// NounsDAOLogicV1Fork.sol is a modified version of NounsDAOLogicV1.sol. +// NounsDAOLogicV1.sol is a modified version of Compound Lab's GovernorBravoDelegate.sol: +// https://github.com/compound-finance/compound-protocol/blob/b9b14038612d846b83f8a009a82c38974ff2dcfe/contracts/Governance/GovernorBravoDelegate.sol +// +// GovernorBravoDelegate.sol source code Copyright 2020 Compound Labs, Inc. licensed under the BSD-3-Clause license. +// With modifications by Nounders DAO. +// +// Additional conditions of BSD-3-Clause can be found here: https://opensource.org/licenses/BSD-3-Clause +// +// MODIFICATIONS +// NounsDAOLogicV1Fork adds: +// - `quit(tokenIds)`, a function that allows token holders to quit the DAO, taking their pro rata funds, +// and sending their tokens to the DAO treasury. +// +// - `adjustedTotalSupply`, the total supply calculation used in DAO functions like quorum and proposal threshold, in +// which the DAO exludes tokens held by the treasury, such that tokens used to quit the DAO are not counted. +// +// - A function for the DAO to set which ERC20s are transferred pro rata in the `quit` function. +// +// - A new proposals getter function, since adding new fields to Proposal results in the default getter hitting a +// `Stack too deep` error. +// +// - A new Proposal field: `creationBlock`, used to resolve the `votingDelay` bug, in which editing `votingDelay` would +// change the votes snapshot block for proposals in-progress. +// +// NounsDAOLogicV1Fork modifies: +// - The proxy pattern from Compound's old Transparent-like proxy, to OpenZeppelin's recommended UUPS pattern. +// +// - `propose` +// - uses `adjutedTotalSupply` +// - includes a new 'delayed governance' feature which gives forkers from the original DAO time to claim their tokens +// with this new DAO; proposals are not allowed until all tokens are claimed, or until the delay expiration +// timestamp is reached. +// +// - `cancel` bugfix, allowing proposals to be canceled by anyone if the proposer's vote balance is equal to proposal +// threshold. +// +// - Removes the vetoer role and logic related to it. The quit function provides minority protection instead of the +// vetoer, and fork DAOs can upgrade their governor to include the vetoer feature if it's needed. +// +// NounsDAOLogicV1 adds: +// - Proposal Threshold basis points instead of fixed number +// due to the Noun token's increasing supply +// +// - Quorum Votes basis points instead of fixed number +// due to the Noun token's increasing supply +// +// - Per proposal storing of fixed `proposalThreshold` +// and `quorumVotes` calculated using the Noun token's total supply +// at the block the proposal was created and the basis point parameters +// +// - `ProposalCreatedWithRequirements` event that emits `ProposalCreated` parameters with +// the addition of `proposalThreshold` and `quorumVotes` +// +// - Votes are counted from the block a proposal is created instead of +// the proposal's voting start block to align with the parameters +// stored with the proposal +// +// - Veto ability which allows `veteor` to halt any proposal at any stage unless +// the proposal is executed. +// The `veto(uint proposalId)` logic is a modified version of `cancel(uint proposalId)` +// A `vetoed` flag was added to the `Proposal` struct to support this. +// +// NounsDAOLogicV1 removes: +// - `initialProposalId` and `_initiate()` due to this being the +// first instance of the governance contract unlike +// GovernorBravo which upgrades GovernorAlpha +// +// - Value passed along using `timelock.executeTransaction{value: proposal.value}` +// in `execute(uint proposalId)`. This contract should not hold funds and does not +// implement `receive()` or `fallback()` functions. +// + +pragma solidity ^0.8.6; + +import { UUPSUpgradeable } from '@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol'; +import { NounsDAOEvents } from './NounsDAOEvents.sol'; +import { NounsDAOStorageV1 } from './NounsDAOStorageV1.sol'; +import { NounsDAOExecutorV2 } from '../../../NounsDAOExecutorV2.sol'; +import { NounsTokenForkLike } from './NounsTokenForkLike.sol'; +import { IERC20 } from '@openzeppelin/contracts/token/ERC20/IERC20.sol'; +import { ReentrancyGuardUpgradeable } from '@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol'; + +contract NounsDAOLogicV1Fork is UUPSUpgradeable, ReentrancyGuardUpgradeable, NounsDAOStorageV1, NounsDAOEvents { + error AdminOnly(); + error WaitingForTokensToClaimOrExpiration(); + error QuitETHTransferFailed(); + error QuitERC20TransferFailed(); + + event ERC20TokensToIncludeInQuitSet(address[] oldErc20Tokens, address[] newErc20tokens); + event Quit(address indexed msgSender, uint256[] tokenIds); + + /// @notice The name of this contract + string public constant name = 'Nouns DAO'; + + /// @notice The minimum setable proposal threshold + uint256 public constant MIN_PROPOSAL_THRESHOLD_BPS = 1; // 1 basis point or 0.01% + + /// @notice The maximum setable proposal threshold + uint256 public constant MAX_PROPOSAL_THRESHOLD_BPS = 1_000; // 1,000 basis points or 10% + + /// @notice The minimum setable voting period + uint256 public constant MIN_VOTING_PERIOD = 5_760; // About 24 hours + + /// @notice The max setable voting period + uint256 public constant MAX_VOTING_PERIOD = 80_640; // About 2 weeks + + /// @notice The min setable voting delay + uint256 public constant MIN_VOTING_DELAY = 1; + + /// @notice The max setable voting delay + uint256 public constant MAX_VOTING_DELAY = 40_320; // About 1 week + + /// @notice The minimum setable quorum votes basis points + uint256 public constant MIN_QUORUM_VOTES_BPS = 200; // 200 basis points or 2% + + /// @notice The maximum setable quorum votes basis points + uint256 public constant MAX_QUORUM_VOTES_BPS = 2_000; // 2,000 basis points or 20% + + /// @notice The maximum number of actions that can be included in a proposal + uint256 public constant proposalMaxOperations = 10; // 10 actions + + /// @notice The EIP-712 typehash for the contract's domain + bytes32 public constant DOMAIN_TYPEHASH = + keccak256('EIP712Domain(string name,uint256 chainId,address verifyingContract)'); + + /// @notice The EIP-712 typehash for the ballot struct used by the contract + bytes32 public constant BALLOT_TYPEHASH = keccak256('Ballot(uint256 proposalId,uint8 support)'); + + /** + * @notice Used to initialize the contract during delegator contructor + * @param timelock_ The address of the NounsDAOExecutor + * @param nouns_ The address of the NOUN tokens + * @param votingPeriod_ The initial voting period + * @param votingDelay_ The initial voting delay + * @param proposalThresholdBPS_ The initial proposal threshold in basis points + * @param quorumVotesBPS_ The initial quorum votes threshold in basis points + * @param erc20TokensToIncludeInQuit_ The initial list of ERC20 tokens to include when quitting + * @param delayedGovernanceExpirationTimestamp_ The delayed governance expiration timestamp + */ + function initialize( + address timelock_, + address nouns_, + uint256 votingPeriod_, + uint256 votingDelay_, + uint256 proposalThresholdBPS_, + uint256 quorumVotesBPS_, + address[] memory erc20TokensToIncludeInQuit_, + uint256 delayedGovernanceExpirationTimestamp_ + ) public virtual { + __ReentrancyGuard_init_unchained(); + require(address(timelock) == address(0), 'NounsDAO::initialize: can only initialize once'); + require(timelock_ != address(0), 'NounsDAO::initialize: invalid timelock address'); + require(nouns_ != address(0), 'NounsDAO::initialize: invalid nouns address'); + require( + votingPeriod_ >= MIN_VOTING_PERIOD && votingPeriod_ <= MAX_VOTING_PERIOD, + 'NounsDAO::initialize: invalid voting period' + ); + require( + votingDelay_ >= MIN_VOTING_DELAY && votingDelay_ <= MAX_VOTING_DELAY, + 'NounsDAO::initialize: invalid voting delay' + ); + require( + proposalThresholdBPS_ >= MIN_PROPOSAL_THRESHOLD_BPS && proposalThresholdBPS_ <= MAX_PROPOSAL_THRESHOLD_BPS, + 'NounsDAO::initialize: invalid proposal threshold BPs' + ); + require( + quorumVotesBPS_ >= MIN_QUORUM_VOTES_BPS && quorumVotesBPS_ <= MAX_QUORUM_VOTES_BPS, + 'NounsDAO::initialize: invalid quorum votes BPs' + ); + + emit VotingPeriodSet(votingPeriod, votingPeriod_); + emit VotingDelaySet(votingDelay, votingDelay_); + emit ProposalThresholdBPSSet(proposalThresholdBPS, proposalThresholdBPS_); + emit QuorumVotesBPSSet(quorumVotesBPS, quorumVotesBPS_); + + admin = timelock_; + timelock = NounsDAOExecutorV2(payable(timelock_)); + nouns = NounsTokenForkLike(nouns_); + votingPeriod = votingPeriod_; + votingDelay = votingDelay_; + proposalThresholdBPS = proposalThresholdBPS_; + quorumVotesBPS = quorumVotesBPS_; + erc20TokensToIncludeInQuit = erc20TokensToIncludeInQuit_; + delayedGovernanceExpirationTimestamp = delayedGovernanceExpirationTimestamp_; + } + + function quit(uint256[] calldata tokenIds) external nonReentrant { + uint256 totalSupply = adjustedTotalSupply(); + + for (uint256 i = 0; i < tokenIds.length; i++) { + nouns.transferFrom(msg.sender, address(timelock), tokenIds[i]); + } + + for (uint256 i = 0; i < erc20TokensToIncludeInQuit.length; i++) { + IERC20 erc20token = IERC20(erc20TokensToIncludeInQuit[i]); + uint256 tokensToSend = (erc20token.balanceOf(address(timelock)) * tokenIds.length) / totalSupply; + bool erc20Sent = timelock.sendERC20(msg.sender, address(erc20token), tokensToSend); + if (!erc20Sent) revert QuitERC20TransferFailed(); + } + + uint256 ethToSend = (address(timelock).balance * tokenIds.length) / totalSupply; + bool ethSent = timelock.sendETH(msg.sender, ethToSend); + if (!ethSent) revert QuitETHTransferFailed(); + + emit Quit(msg.sender, tokenIds); + } + + function _setErc20TokensToIncludeInQuit(address[] calldata erc20tokens) external { + if (msg.sender != admin) revert AdminOnly(); + + address[] memory oldErc20TokensToIncludeInQuit = erc20TokensToIncludeInQuit; + erc20TokensToIncludeInQuit = erc20tokens; + + emit ERC20TokensToIncludeInQuitSet(oldErc20TokensToIncludeInQuit, erc20tokens); + } + + function adjustedTotalSupply() public view returns (uint256) { + return nouns.totalSupply() - nouns.balanceOf(address(timelock)); + } + + struct ProposalTemp { + uint256 totalSupply; + uint256 proposalThreshold; + uint256 latestProposalId; + uint256 startBlock; + uint256 endBlock; + } + + /** + * @notice Function used to propose a new proposal. Sender must have delegates above the proposal threshold + * Will revert as long as not all tokens were claimed, and as long as the delayed governance has not expired. + * @param targets Target addresses for proposal calls + * @param values Eth values for proposal calls + * @param signatures Function signatures for proposal calls + * @param calldatas Calldatas for proposal calls + * @param description String description of the proposal + * @return Proposal id of new proposal + */ + function propose( + address[] memory targets, + uint256[] memory values, + string[] memory signatures, + bytes[] memory calldatas, + string memory description + ) public returns (uint256) { + if (block.timestamp < delayedGovernanceExpirationTimestamp && nouns.remainingTokensToClaim() > 0) + revert WaitingForTokensToClaimOrExpiration(); + + ProposalTemp memory temp; + + temp.totalSupply = adjustedTotalSupply(); + + temp.proposalThreshold = bps2Uint(proposalThresholdBPS, temp.totalSupply); + + require( + nouns.getPriorVotes(msg.sender, block.number - 1) > temp.proposalThreshold, + 'NounsDAO::propose: proposer votes below proposal threshold' + ); + require( + targets.length == values.length && + targets.length == signatures.length && + targets.length == calldatas.length, + 'NounsDAO::propose: proposal function information arity mismatch' + ); + require(targets.length != 0, 'NounsDAO::propose: must provide actions'); + require(targets.length <= proposalMaxOperations, 'NounsDAO::propose: too many actions'); + + temp.latestProposalId = latestProposalIds[msg.sender]; + if (temp.latestProposalId != 0) { + ProposalState proposersLatestProposalState = state(temp.latestProposalId); + require( + proposersLatestProposalState != ProposalState.Active, + 'NounsDAO::propose: one live proposal per proposer, found an already active proposal' + ); + require( + proposersLatestProposalState != ProposalState.Pending, + 'NounsDAO::propose: one live proposal per proposer, found an already pending proposal' + ); + } + + temp.startBlock = block.number + votingDelay; + temp.endBlock = temp.startBlock + votingPeriod; + + proposalCount++; + Proposal storage newProposal = _proposals[proposalCount]; + + newProposal.id = proposalCount; + newProposal.proposer = msg.sender; + newProposal.proposalThreshold = temp.proposalThreshold; + newProposal.quorumVotes = bps2Uint(quorumVotesBPS, temp.totalSupply); + newProposal.eta = 0; + newProposal.targets = targets; + newProposal.values = values; + newProposal.signatures = signatures; + newProposal.calldatas = calldatas; + newProposal.startBlock = temp.startBlock; + newProposal.endBlock = temp.endBlock; + newProposal.forVotes = 0; + newProposal.againstVotes = 0; + newProposal.abstainVotes = 0; + newProposal.canceled = false; + newProposal.executed = false; + newProposal.creationBlock = block.number; + + latestProposalIds[newProposal.proposer] = newProposal.id; + + /// @notice Maintains backwards compatibility with GovernorBravo events + emit ProposalCreated( + newProposal.id, + msg.sender, + targets, + values, + signatures, + calldatas, + newProposal.startBlock, + newProposal.endBlock, + description + ); + + /// @notice Updated event with `proposalThreshold` and `quorumVotes` + emit ProposalCreatedWithRequirements( + newProposal.id, + msg.sender, + targets, + values, + signatures, + calldatas, + newProposal.startBlock, + newProposal.endBlock, + newProposal.proposalThreshold, + newProposal.quorumVotes, + description + ); + + return newProposal.id; + } + + /** + * @notice Queues a proposal of state succeeded + * @param proposalId The id of the proposal to queue + */ + function queue(uint256 proposalId) external { + require( + state(proposalId) == ProposalState.Succeeded, + 'NounsDAO::queue: proposal can only be queued if it is succeeded' + ); + Proposal storage proposal = _proposals[proposalId]; + uint256 eta = block.timestamp + timelock.delay(); + for (uint256 i = 0; i < proposal.targets.length; i++) { + queueOrRevertInternal( + proposal.targets[i], + proposal.values[i], + proposal.signatures[i], + proposal.calldatas[i], + eta + ); + } + proposal.eta = eta; + emit ProposalQueued(proposalId, eta); + } + + function queueOrRevertInternal( + address target, + uint256 value, + string memory signature, + bytes memory data, + uint256 eta + ) internal { + require( + !timelock.queuedTransactions(keccak256(abi.encode(target, value, signature, data, eta))), + 'NounsDAO::queueOrRevertInternal: identical proposal action already queued at eta' + ); + timelock.queueTransaction(target, value, signature, data, eta); + } + + /** + * @notice Executes a queued proposal if eta has passed + * @param proposalId The id of the proposal to execute + */ + function execute(uint256 proposalId) external { + require( + state(proposalId) == ProposalState.Queued, + 'NounsDAO::execute: proposal can only be executed if it is queued' + ); + Proposal storage proposal = _proposals[proposalId]; + proposal.executed = true; + for (uint256 i = 0; i < proposal.targets.length; i++) { + timelock.executeTransaction( + proposal.targets[i], + proposal.values[i], + proposal.signatures[i], + proposal.calldatas[i], + proposal.eta + ); + } + emit ProposalExecuted(proposalId); + } + + /** + * @notice Cancels a proposal only if sender is the proposer, or proposer delegates dropped below proposal threshold + * @param proposalId The id of the proposal to cancel + */ + function cancel(uint256 proposalId) external { + require(state(proposalId) != ProposalState.Executed, 'NounsDAO::cancel: cannot cancel executed proposal'); + + Proposal storage proposal = _proposals[proposalId]; + require( + msg.sender == proposal.proposer || + nouns.getPriorVotes(proposal.proposer, block.number - 1) <= proposal.proposalThreshold, + 'NounsDAO::cancel: proposer above threshold' + ); + + proposal.canceled = true; + for (uint256 i = 0; i < proposal.targets.length; i++) { + timelock.cancelTransaction( + proposal.targets[i], + proposal.values[i], + proposal.signatures[i], + proposal.calldatas[i], + proposal.eta + ); + } + + emit ProposalCanceled(proposalId); + } + + /** + * @notice Gets actions of a proposal + * @param proposalId the id of the proposal + * @return targets + * @return values + * @return signatures + * @return calldatas + */ + function getActions(uint256 proposalId) + external + view + returns ( + address[] memory targets, + uint256[] memory values, + string[] memory signatures, + bytes[] memory calldatas + ) + { + Proposal storage p = _proposals[proposalId]; + return (p.targets, p.values, p.signatures, p.calldatas); + } + + /** + * @notice Gets the receipt for a voter on a given proposal + * @param proposalId the id of proposal + * @param voter The address of the voter + * @return The voting receipt + */ + function getReceipt(uint256 proposalId, address voter) external view returns (Receipt memory) { + return _proposals[proposalId].receipts[voter]; + } + + /** + * @notice Gets the state of a proposal + * @param proposalId The id of the proposal + * @return Proposal state + */ + function state(uint256 proposalId) public view returns (ProposalState) { + require(proposalCount >= proposalId, 'NounsDAO::state: invalid proposal id'); + Proposal storage proposal = _proposals[proposalId]; + if (proposal.canceled) { + return ProposalState.Canceled; + } else if (block.number <= proposal.startBlock) { + return ProposalState.Pending; + } else if (block.number <= proposal.endBlock) { + return ProposalState.Active; + } else if (proposal.forVotes <= proposal.againstVotes || proposal.forVotes < proposal.quorumVotes) { + return ProposalState.Defeated; + } else if (proposal.eta == 0) { + return ProposalState.Succeeded; + } else if (proposal.executed) { + return ProposalState.Executed; + } else if (block.timestamp >= proposal.eta + timelock.GRACE_PERIOD()) { + return ProposalState.Expired; + } else { + return ProposalState.Queued; + } + } + + /** + * @notice Returns the proposal details given a proposal id. + * @dev this explicit getter solves the `Stack too deep` problem that arose after + * adding a new field to the Proposal struct. + * @param proposalId the proposal id to get the data for + * @return A `ProposalCondensed` struct with the proposal data + */ + function proposals(uint256 proposalId) external view returns (ProposalCondensed memory) { + Proposal storage proposal = _proposals[proposalId]; + return + ProposalCondensed({ + id: proposal.id, + proposer: proposal.proposer, + proposalThreshold: proposal.proposalThreshold, + quorumVotes: proposal.quorumVotes, + eta: proposal.eta, + startBlock: proposal.startBlock, + endBlock: proposal.endBlock, + forVotes: proposal.forVotes, + againstVotes: proposal.againstVotes, + abstainVotes: proposal.abstainVotes, + canceled: proposal.canceled, + executed: proposal.executed, + creationBlock: proposal.creationBlock + }); + } + + /** + * @notice Cast a vote for a proposal + * @param proposalId The id of the proposal to vote on + * @param support The support value for the vote. 0=against, 1=for, 2=abstain + */ + function castVote(uint256 proposalId, uint8 support) external { + emit VoteCast(msg.sender, proposalId, support, castVoteInternal(msg.sender, proposalId, support), ''); + } + + /** + * @notice Cast a vote for a proposal with a reason + * @param proposalId The id of the proposal to vote on + * @param support The support value for the vote. 0=against, 1=for, 2=abstain + * @param reason The reason given for the vote by the voter + */ + function castVoteWithReason( + uint256 proposalId, + uint8 support, + string calldata reason + ) external { + emit VoteCast(msg.sender, proposalId, support, castVoteInternal(msg.sender, proposalId, support), reason); + } + + /** + * @notice Cast a vote for a proposal by signature + * @dev External function that accepts EIP-712 signatures for voting on proposals. + */ + function castVoteBySig( + uint256 proposalId, + uint8 support, + uint8 v, + bytes32 r, + bytes32 s + ) external { + bytes32 domainSeparator = keccak256( + abi.encode(DOMAIN_TYPEHASH, keccak256(bytes(name)), getChainIdInternal(), address(this)) + ); + bytes32 structHash = keccak256(abi.encode(BALLOT_TYPEHASH, proposalId, support)); + bytes32 digest = keccak256(abi.encodePacked('\x19\x01', domainSeparator, structHash)); + address signatory = ecrecover(digest, v, r, s); + require(signatory != address(0), 'NounsDAO::castVoteBySig: invalid signature'); + emit VoteCast(signatory, proposalId, support, castVoteInternal(signatory, proposalId, support), ''); + } + + /** + * @notice Internal function that caries out voting logic + * @param voter The voter that is casting their vote + * @param proposalId The id of the proposal to vote on + * @param support The support value for the vote. 0=against, 1=for, 2=abstain + * @return The number of votes cast + */ + function castVoteInternal( + address voter, + uint256 proposalId, + uint8 support + ) internal returns (uint96) { + require(state(proposalId) == ProposalState.Active, 'NounsDAO::castVoteInternal: voting is closed'); + require(support <= 2, 'NounsDAO::castVoteInternal: invalid vote type'); + Proposal storage proposal = _proposals[proposalId]; + Receipt storage receipt = proposal.receipts[voter]; + require(receipt.hasVoted == false, 'NounsDAO::castVoteInternal: voter already voted'); + + /// @notice: Unlike GovernerBravo, votes are considered from the block the proposal was created in order to normalize quorumVotes and proposalThreshold metrics + uint96 votes = nouns.getPriorVotes(voter, proposal.creationBlock); + + if (support == 0) { + proposal.againstVotes = proposal.againstVotes + votes; + } else if (support == 1) { + proposal.forVotes = proposal.forVotes + votes; + } else if (support == 2) { + proposal.abstainVotes = proposal.abstainVotes + votes; + } + + receipt.hasVoted = true; + receipt.support = support; + receipt.votes = votes; + + return votes; + } + + /** + * @notice Admin function for setting the voting delay + * @param newVotingDelay new voting delay, in blocks + */ + function _setVotingDelay(uint256 newVotingDelay) external { + require(msg.sender == admin, 'NounsDAO::_setVotingDelay: admin only'); + require( + newVotingDelay >= MIN_VOTING_DELAY && newVotingDelay <= MAX_VOTING_DELAY, + 'NounsDAO::_setVotingDelay: invalid voting delay' + ); + uint256 oldVotingDelay = votingDelay; + votingDelay = newVotingDelay; + + emit VotingDelaySet(oldVotingDelay, votingDelay); + } + + /** + * @notice Admin function for setting the voting period + * @param newVotingPeriod new voting period, in blocks + */ + function _setVotingPeriod(uint256 newVotingPeriod) external { + require(msg.sender == admin, 'NounsDAO::_setVotingPeriod: admin only'); + require( + newVotingPeriod >= MIN_VOTING_PERIOD && newVotingPeriod <= MAX_VOTING_PERIOD, + 'NounsDAO::_setVotingPeriod: invalid voting period' + ); + uint256 oldVotingPeriod = votingPeriod; + votingPeriod = newVotingPeriod; + + emit VotingPeriodSet(oldVotingPeriod, votingPeriod); + } + + /** + * @notice Admin function for setting the proposal threshold basis points + * @dev newProposalThresholdBPS must be greater than the hardcoded min + * @param newProposalThresholdBPS new proposal threshold + */ + function _setProposalThresholdBPS(uint256 newProposalThresholdBPS) external { + require(msg.sender == admin, 'NounsDAO::_setProposalThresholdBPS: admin only'); + require( + newProposalThresholdBPS >= MIN_PROPOSAL_THRESHOLD_BPS && + newProposalThresholdBPS <= MAX_PROPOSAL_THRESHOLD_BPS, + 'NounsDAO::_setProposalThreshold: invalid proposal threshold' + ); + uint256 oldProposalThresholdBPS = proposalThresholdBPS; + proposalThresholdBPS = newProposalThresholdBPS; + + emit ProposalThresholdBPSSet(oldProposalThresholdBPS, proposalThresholdBPS); + } + + /** + * @notice Admin function for setting the quorum votes basis points + * @dev newQuorumVotesBPS must be greater than the hardcoded min + * @param newQuorumVotesBPS new proposal threshold + */ + function _setQuorumVotesBPS(uint256 newQuorumVotesBPS) external { + require(msg.sender == admin, 'NounsDAO::_setQuorumVotesBPS: admin only'); + require( + newQuorumVotesBPS >= MIN_QUORUM_VOTES_BPS && newQuorumVotesBPS <= MAX_QUORUM_VOTES_BPS, + 'NounsDAO::_setProposalThreshold: invalid proposal threshold' + ); + uint256 oldQuorumVotesBPS = quorumVotesBPS; + quorumVotesBPS = newQuorumVotesBPS; + + emit QuorumVotesBPSSet(oldQuorumVotesBPS, quorumVotesBPS); + } + + /** + * @notice Begins transfer of admin rights. The newPendingAdmin must call `_acceptAdmin` to finalize the transfer. + * @dev Admin function to begin change of admin. The newPendingAdmin must call `_acceptAdmin` to finalize the transfer. + * @param newPendingAdmin New pending admin. + */ + function _setPendingAdmin(address newPendingAdmin) external { + // Check caller = admin + require(msg.sender == admin, 'NounsDAO::_setPendingAdmin: admin only'); + + // Save current value, if any, for inclusion in log + address oldPendingAdmin = pendingAdmin; + + // Store pendingAdmin with value newPendingAdmin + pendingAdmin = newPendingAdmin; + + // Emit NewPendingAdmin(oldPendingAdmin, newPendingAdmin) + emit NewPendingAdmin(oldPendingAdmin, newPendingAdmin); + } + + /** + * @notice Accepts transfer of admin rights. msg.sender must be pendingAdmin + * @dev Admin function for pending admin to accept role and update admin + */ + function _acceptAdmin() external { + // Check caller is pendingAdmin and pendingAdmin ≠ address(0) + require(msg.sender == pendingAdmin && msg.sender != address(0), 'NounsDAO::_acceptAdmin: pending admin only'); + + // Save current values for inclusion in log + address oldAdmin = admin; + address oldPendingAdmin = pendingAdmin; + + // Store admin with value pendingAdmin + admin = pendingAdmin; + + // Clear the pending value + pendingAdmin = address(0); + + emit NewAdmin(oldAdmin, admin); + emit NewPendingAdmin(oldPendingAdmin, pendingAdmin); + } + + /** + * @notice Current proposal threshold using Noun Total Supply + * Differs from `GovernerBravo` which uses fixed amount + */ + function proposalThreshold() public view returns (uint256) { + return bps2Uint(proposalThresholdBPS, adjustedTotalSupply()); + } + + /** + * @notice Current quorum votes using Noun Total Supply + * Differs from `GovernerBravo` which uses fixed amount + */ + function quorumVotes() public view returns (uint256) { + return bps2Uint(quorumVotesBPS, adjustedTotalSupply()); + } + + function bps2Uint(uint256 bps, uint256 number) internal pure returns (uint256) { + return (number * bps) / 10000; + } + + function getChainIdInternal() internal view returns (uint256) { + uint256 chainId; + assembly { + chainId := chainid() + } + return chainId; + } + + function _authorizeUpgrade(address) internal view override { + require(msg.sender == admin, 'NounsDAO::_authorizeUpgrade: admin only'); + } +} diff --git a/packages/nouns-contracts/contracts/governance/fork/newdao/governance/NounsDAOStorageV1.sol b/packages/nouns-contracts/contracts/governance/fork/newdao/governance/NounsDAOStorageV1.sol new file mode 100644 index 0000000000..3ca52748ef --- /dev/null +++ b/packages/nouns-contracts/contracts/governance/fork/newdao/governance/NounsDAOStorageV1.sol @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: BSD-3-Clause + +pragma solidity ^0.8.6; + +import { NounsDAOExecutorV2 } from '../../../NounsDAOExecutorV2.sol'; +import { NounsTokenForkLike } from './NounsTokenForkLike.sol'; + +/** + * @title Storage for Governor Bravo Delegate + * @notice For future upgrades, do not change NounsDAOStorageV1. Create a new + * contract which implements NounsDAOStorageV1 and following the naming convention + * NounsDAOStorageVX. + */ +contract NounsDAOStorageV1 { + /// @notice Administrator for this contract + address public admin; + + /// @notice Pending administrator for this contract + address public pendingAdmin; + + /// @notice The delay before voting on a proposal may take place, once proposed, in blocks + uint256 public votingDelay; + + /// @notice The duration of voting on a proposal, in blocks + uint256 public votingPeriod; + + /// @notice The basis point number of votes required in order for a voter to become a proposer. *DIFFERS from GovernerBravo + uint256 public proposalThresholdBPS; + + /// @notice The basis point number of votes in support of a proposal required in order for a quorum to be reached and for a vote to succeed. *DIFFERS from GovernerBravo + uint256 public quorumVotesBPS; + + /// @notice The total number of proposals + uint256 public proposalCount; + + /// @notice The address of the Nouns DAO Executor NounsDAOExecutor + NounsDAOExecutorV2 public timelock; + + /// @notice The address of the Nouns tokens + NounsTokenForkLike public nouns; + + /// @notice The official record of all proposals ever proposed + mapping(uint256 => Proposal) public _proposals; + + /// @notice The latest proposal for each proposer + mapping(address => uint256) public latestProposalIds; + + uint256 public delayedGovernanceExpirationTimestamp; + + address[] public erc20TokensToIncludeInQuit; + + struct Proposal { + /// @notice Unique id for looking up a proposal + uint256 id; + /// @notice Creator of the proposal + address proposer; + /// @notice The number of votes needed to create a proposal at the time of proposal creation. *DIFFERS from GovernerBravo + uint256 proposalThreshold; + /// @notice The number of votes in support of a proposal required in order for a quorum to be reached and for a vote to succeed at the time of proposal creation. *DIFFERS from GovernerBravo + uint256 quorumVotes; + /// @notice The timestamp that the proposal will be available for execution, set once the vote succeeds + uint256 eta; + /// @notice the ordered list of target addresses for calls to be made + address[] targets; + /// @notice The ordered list of values (i.e. msg.value) to be passed to the calls to be made + uint256[] values; + /// @notice The ordered list of function signatures to be called + string[] signatures; + /// @notice The ordered list of calldata to be passed to each call + bytes[] calldatas; + /// @notice The block at which voting begins: holders must delegate their votes prior to this block + uint256 startBlock; + /// @notice The block at which voting ends: votes must be cast prior to this block + uint256 endBlock; + /// @notice Current number of votes in favor of this proposal + uint256 forVotes; + /// @notice Current number of votes in opposition to this proposal + uint256 againstVotes; + /// @notice Current number of votes for abstaining for this proposal + uint256 abstainVotes; + /// @notice Flag marking whether the proposal has been canceled + bool canceled; + /// @notice Flag marking whether the proposal has been executed + bool executed; + /// @notice Receipts of ballots for the entire set of voters + mapping(address => Receipt) receipts; + /// @notice The block at which this proposal was created + uint256 creationBlock; + } + + /// @notice Ballot receipt record for a voter + struct Receipt { + /// @notice Whether or not a vote has been cast + bool hasVoted; + /// @notice Whether or not the voter supports the proposal or abstains + uint8 support; + /// @notice The number of votes the voter had, which were cast + uint96 votes; + } + + /// @notice Possible states that a proposal may be in + enum ProposalState { + Pending, + Active, + Canceled, + Defeated, + Succeeded, + Queued, + Expired, + Executed + } + + struct ProposalCondensed { + /// @notice Unique id for looking up a proposal + uint256 id; + /// @notice Creator of the proposal + address proposer; + /// @notice The number of votes needed to create a proposal at the time of proposal creation. *DIFFERS from GovernerBravo + uint256 proposalThreshold; + /// @notice The minimum number of votes in support of a proposal required in order for a quorum to be reached and for a vote to succeed at the time of proposal creation. *DIFFERS from GovernerBravo + uint256 quorumVotes; + /// @notice The timestamp that the proposal will be available for execution, set once the vote succeeds + uint256 eta; + /// @notice The block at which voting begins: holders must delegate their votes prior to this block + uint256 startBlock; + /// @notice The block at which voting ends: votes must be cast prior to this block + uint256 endBlock; + /// @notice Current number of votes in favor of this proposal + uint256 forVotes; + /// @notice Current number of votes in opposition to this proposal + uint256 againstVotes; + /// @notice Current number of votes for abstaining for this proposal + uint256 abstainVotes; + /// @notice Flag marking whether the proposal has been canceled + bool canceled; + /// @notice Flag marking whether the proposal has been executed + bool executed; + /// @notice The block at which this proposal was created + uint256 creationBlock; + } +} diff --git a/packages/nouns-contracts/contracts/governance/fork/newdao/governance/NounsTokenForkLike.sol b/packages/nouns-contracts/contracts/governance/fork/newdao/governance/NounsTokenForkLike.sol new file mode 100644 index 0000000000..a14530b36e --- /dev/null +++ b/packages/nouns-contracts/contracts/governance/fork/newdao/governance/NounsTokenForkLike.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: BSD-3-Clause + +pragma solidity ^0.8.6; + +interface NounsTokenForkLike { + function getPriorVotes(address account, uint256 blockNumber) external view returns (uint96); + + function totalSupply() external view returns (uint256); + + function transferFrom( + address from, + address to, + uint256 tokenId + ) external; + + function balanceOf(address owner) external view returns (uint256 balance); + + function ownerOf(uint256 tokenId) external view returns (address owner); + + function remainingTokensToClaim() external view returns (uint256); +} diff --git a/packages/nouns-contracts/contracts/governance/fork/newdao/token/INounsTokenFork.sol b/packages/nouns-contracts/contracts/governance/fork/newdao/token/INounsTokenFork.sol new file mode 100644 index 0000000000..868d9bca23 --- /dev/null +++ b/packages/nouns-contracts/contracts/governance/fork/newdao/token/INounsTokenFork.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: GPL-3.0 + +/// @title Interface for NounsTokenFork + +/********************************* + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + * ░░░░░░█████████░░█████████░░░ * + * ░░░░░░██░░░████░░██░░░████░░░ * + * ░░██████░░░████████░░░████░░░ * + * ░░██░░██░░░████░░██░░░████░░░ * + * ░░██░░██░░░████░░██░░░████░░░ * + * ░░░░░░█████████░░█████████░░░ * + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + *********************************/ + +pragma solidity ^0.8.6; + +import { IERC721Upgradeable } from '@openzeppelin/contracts-upgradeable/token/ERC721/IERC721Upgradeable.sol'; +import { INounsDescriptorMinimal } from '../../../../interfaces/INounsDescriptorMinimal.sol'; +import { INounsSeeder } from '../../../../interfaces/INounsSeeder.sol'; + +interface INounsTokenFork is IERC721Upgradeable { + event NounCreated(uint256 indexed tokenId, INounsSeeder.Seed seed); + + event NounBurned(uint256 indexed tokenId); + + event NoundersDAOUpdated(address noundersDAO); + + event MinterUpdated(address minter); + + event MinterLocked(); + + event DescriptorUpdated(INounsDescriptorMinimal descriptor); + + event DescriptorLocked(); + + event SeederUpdated(INounsSeeder seeder); + + event SeederLocked(); + + function mint() external returns (uint256); + + function burn(uint256 tokenId) external; + + function dataURI(uint256 tokenId) external returns (string memory); + + function setMinter(address minter) external; + + function lockMinter() external; + + function setDescriptor(INounsDescriptorMinimal descriptor) external; + + function lockDescriptor() external; + + function setSeeder(INounsSeeder seeder) external; + + function lockSeeder() external; +} diff --git a/packages/nouns-contracts/contracts/governance/fork/newdao/token/NounsTokenFork.sol b/packages/nouns-contracts/contracts/governance/fork/newdao/token/NounsTokenFork.sol new file mode 100644 index 0000000000..d4c63da341 --- /dev/null +++ b/packages/nouns-contracts/contracts/governance/fork/newdao/token/NounsTokenFork.sol @@ -0,0 +1,309 @@ +// SPDX-License-Identifier: GPL-3.0 + +/// @title The Nouns ERC-721 token, adjusted for forks + +/********************************* + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + * ░░░░░░█████████░░█████████░░░ * + * ░░░░░░██░░░████░░██░░░████░░░ * + * ░░██████░░░████████░░░████░░░ * + * ░░██░░██░░░████░░██░░░████░░░ * + * ░░██░░██░░░████░░██░░░████░░░ * + * ░░░░░░█████████░░█████████░░░ * + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + *********************************/ + +pragma solidity ^0.8.6; + +import { OwnableUpgradeable } from '@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol'; +import { ERC721CheckpointableUpgradeable } from './base/ERC721CheckpointableUpgradeable.sol'; +import { INounsDescriptorMinimal } from '../../../../interfaces/INounsDescriptorMinimal.sol'; +import { INounsSeeder } from '../../../../interfaces/INounsSeeder.sol'; +import { INounsTokenFork } from './INounsTokenFork.sol'; +import { IERC721 } from '@openzeppelin/contracts/token/ERC721/IERC721.sol'; +import { UUPSUpgradeable } from '@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol'; +import { INounsDAOForkEscrow } from '../../../NounsDAOInterfaces.sol'; + +contract NounsTokenFork is INounsTokenFork, OwnableUpgradeable, ERC721CheckpointableUpgradeable, UUPSUpgradeable { + error OnlyOwner(); + error OnlyTokenOwnerCanClaim(); + error OnlyOriginalDAO(); + error NoundersCannotBeAddressZero(); + error OnlyDuringForkingPeriod(); + + string public constant NAME = 'NounsTokenFork'; + + /// @notice An address who has permissions to mint Nouns + address public minter; + + /// @notice The Nouns token URI descriptor + INounsDescriptorMinimal public descriptor; + + /// @notice The Nouns token seeder + INounsSeeder public seeder; + + /// @notice The escrow contract used to verify ownership of the original Nouns in the post-fork claiming process + INounsDAOForkEscrow public escrow; + + /// @notice The fork ID, used when querying the escrow for token ownership + uint32 public forkId; + + /// @notice How many tokens are still available to be claimed by Nouners who put their original Nouns in escrow + uint256 public remainingTokensToClaim; + + /// @notice The forking period expiration timestamp, afterwhich new tokens cannot be claimed by the original DAO + uint256 public forkingPeriodEndTimestamp; + + /// @notice Whether the minter can be updated + bool public isMinterLocked; + + /// @notice Whether the descriptor can be updated + bool public isDescriptorLocked; + + /// @notice Whether the seeder can be updated + bool public isSeederLocked; + + /// @notice The noun seeds + mapping(uint256 => INounsSeeder.Seed) public seeds; + + /// @notice The internal noun ID tracker + uint256 private _currentNounId; + + /// @notice IPFS content hash of contract-level metadata + string private _contractURIHash = 'QmZi1n79FqWt2tTLwCqiy6nLM6xLGRsEPQ5JmReJQKNNzX'; + + /** + * @notice Require that the minter has not been locked. + */ + modifier whenMinterNotLocked() { + require(!isMinterLocked, 'Minter is locked'); + _; + } + + /** + * @notice Require that the descriptor has not been locked. + */ + modifier whenDescriptorNotLocked() { + require(!isDescriptorLocked, 'Descriptor is locked'); + _; + } + + /** + * @notice Require that the seeder has not been locked. + */ + modifier whenSeederNotLocked() { + require(!isSeederLocked, 'Seeder is locked'); + _; + } + + /** + * @notice Require that the sender is the minter. + */ + modifier onlyMinter() { + require(msg.sender == minter, 'Sender is not the minter'); + _; + } + + function initialize( + address _owner, + address _minter, + INounsDAOForkEscrow _escrow, + uint32 _forkId, + uint256 startNounId, + uint256 tokensToClaim, + uint256 _forkingPeriodEndTimestamp + ) external initializer { + __ERC721_init('Nouns', 'NOUN'); + _transferOwnership(_owner); + minter = _minter; + escrow = _escrow; + forkId = _forkId; + _currentNounId = startNounId; + remainingTokensToClaim = tokensToClaim; + forkingPeriodEndTimestamp = _forkingPeriodEndTimestamp; + + NounsTokenFork originalToken = NounsTokenFork(address(escrow.nounsToken())); + descriptor = originalToken.descriptor(); + seeder = originalToken.seeder(); + } + + /** + * @notice Claim new tokens if you escrowed original Nouns and forked into a new DAO governed by holders of this + * token. + * @dev Reverts if the sender is not the owner of the escrowed token. + * @param tokenIds The token IDs to claim + */ + function claimFromEscrow(uint256[] calldata tokenIds) external { + for (uint256 i = 0; i < tokenIds.length; i++) { + uint256 nounId = tokenIds[i]; + if (escrow.ownerOfEscrowedToken(forkId, nounId) != msg.sender) revert OnlyTokenOwnerCanClaim(); + + _mintWithOriginalSeed(msg.sender, nounId); + } + + remainingTokensToClaim -= tokenIds.length; + } + + /** + * @notice The original DAO can claim tokens during the forking period, on behalf of Nouners who choose to join + * a new fork DAO. Does not allow the original DAO to claim once the forking period has ended. + * @dev Assumes the original DAO is honest during the forking period. + * @param to The recipient of the tokens + * @param tokenIds The token IDs to claim + */ + function claimDuringForkPeriod(address to, uint256[] calldata tokenIds) external { + if (msg.sender != escrow.dao()) revert OnlyOriginalDAO(); + if (block.timestamp > forkingPeriodEndTimestamp) revert OnlyDuringForkingPeriod(); + + for (uint256 i = 0; i < tokenIds.length; i++) { + uint256 nounId = tokenIds[i]; + _mintWithOriginalSeed(to, nounId); + } + } + + /** + * @notice The IPFS URI of contract-level metadata. + */ + function contractURI() public view returns (string memory) { + return string(abi.encodePacked('ipfs://', _contractURIHash)); + } + + /** + * @notice Set the _contractURIHash. + * @dev Only callable by the owner. + */ + function setContractURIHash(string memory newContractURIHash) external onlyOwner { + _contractURIHash = newContractURIHash; + } + + /** + * @notice Mint a Noun to the minter, along with a possible nounders reward + * Noun. Nounders reward Nouns are minted every 10 Nouns, starting at 0, + * until 183 nounder Nouns have been minted (5 years w/ 24 hour auctions). + * @dev Call _mintTo with the to address(es). + */ + function mint() public override onlyMinter returns (uint256) { + return _mintTo(minter, _currentNounId++); + } + + /** + * @notice Burn a noun. + */ + function burn(uint256 nounId) public override onlyMinter { + _burn(nounId); + emit NounBurned(nounId); + } + + /** + * @notice A distinct Uniform Resource Identifier (URI) for a given asset. + * @dev See {IERC721Metadata-tokenURI}. + */ + function tokenURI(uint256 tokenId) public view override returns (string memory) { + require(_exists(tokenId), 'NounsToken: URI query for nonexistent token'); + return descriptor.tokenURI(tokenId, seeds[tokenId]); + } + + /** + * @notice Similar to `tokenURI`, but always serves a base64 encoded data URI + * with the JSON contents directly inlined. + */ + function dataURI(uint256 tokenId) public view override returns (string memory) { + require(_exists(tokenId), 'NounsToken: URI query for nonexistent token'); + return descriptor.dataURI(tokenId, seeds[tokenId]); + } + + /** + * @notice Set the token minter. + * @dev Only callable by the owner when not locked. + */ + function setMinter(address _minter) external override onlyOwner whenMinterNotLocked { + minter = _minter; + + emit MinterUpdated(_minter); + } + + /** + * @notice Lock the minter. + * @dev This cannot be reversed and is only callable by the owner when not locked. + */ + function lockMinter() external override onlyOwner whenMinterNotLocked { + isMinterLocked = true; + + emit MinterLocked(); + } + + /** + * @notice Set the token URI descriptor. + * @dev Only callable by the owner when not locked. + */ + function setDescriptor(INounsDescriptorMinimal _descriptor) external override onlyOwner whenDescriptorNotLocked { + descriptor = _descriptor; + + emit DescriptorUpdated(_descriptor); + } + + /** + * @notice Lock the descriptor. + * @dev This cannot be reversed and is only callable by the owner when not locked. + */ + function lockDescriptor() external override onlyOwner whenDescriptorNotLocked { + isDescriptorLocked = true; + + emit DescriptorLocked(); + } + + /** + * @notice Set the token seeder. + * @dev Only callable by the owner when not locked. + */ + function setSeeder(INounsSeeder _seeder) external override onlyOwner whenSeederNotLocked { + seeder = _seeder; + + emit SeederUpdated(_seeder); + } + + /** + * @notice Lock the seeder. + * @dev This cannot be reversed and is only callable by the owner when not locked. + */ + function lockSeeder() external override onlyOwner whenSeederNotLocked { + isSeederLocked = true; + + emit SeederLocked(); + } + + /** + * @notice Mint a Noun with `nounId` to the provided `to` address. + */ + function _mintTo(address to, uint256 nounId) internal returns (uint256) { + INounsSeeder.Seed memory seed = seeds[nounId] = seeder.generateSeed(nounId, descriptor); + + _mint(to, nounId); + emit NounCreated(nounId, seed); + + return nounId; + } + + /** + * @notice Mint a new token using the original Nouns seed. + */ + function _mintWithOriginalSeed(address to, uint256 nounId) internal { + (uint48 background, uint48 body, uint48 accessory, uint48 head, uint48 glasses) = NounsTokenFork( + address(escrow.nounsToken()) + ).seeds(nounId); + INounsSeeder.Seed memory seed = INounsSeeder.Seed(background, body, accessory, head, glasses); + + seeds[nounId] = seed; + _mint(to, nounId); + + emit NounCreated(nounId, seed); + } + + /** + * @dev Reverts when `msg.sender` is not the owner of this contract; in the case of Noun DAOs it should be the + * DAO's treasury contract. + */ + function _authorizeUpgrade(address) internal view override onlyOwner {} +} diff --git a/packages/nouns-contracts/contracts/governance/fork/newdao/token/base/ERC721CheckpointableUpgradeable.sol b/packages/nouns-contracts/contracts/governance/fork/newdao/token/base/ERC721CheckpointableUpgradeable.sol new file mode 100644 index 0000000000..96f2f05581 --- /dev/null +++ b/packages/nouns-contracts/contracts/governance/fork/newdao/token/base/ERC721CheckpointableUpgradeable.sol @@ -0,0 +1,301 @@ +// SPDX-License-Identifier: BSD-3-Clause + +/// @title Vote checkpointing for an ERC-721 token + +/********************************* + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + * ░░░░░░█████████░░█████████░░░ * + * ░░░░░░██░░░████░░██░░░████░░░ * + * ░░██████░░░████████░░░████░░░ * + * ░░██░░██░░░████░░██░░░████░░░ * + * ░░██░░██░░░████░░██░░░████░░░ * + * ░░░░░░█████████░░█████████░░░ * + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + *********************************/ + +// LICENSE +// ERC721CheckpointableUpgradeable.sol is a modified version of ERC721Checkpointable.sol in this repository. +// ERC721Checkpointable.sol uses and modifies part of Compound Lab's Comp.sol: +// https://github.com/compound-finance/compound-protocol/blob/ae4388e780a8d596d97619d9704a931a2752c2bc/contracts/Governance/Comp.sol +// +// Comp.sol source code Copyright 2020 Compound Labs, Inc. licensed under the BSD-3-Clause license. +// With modifications by Nounders DAO. +// +// Additional conditions of BSD-3-Clause can be found here: https://opensource.org/licenses/BSD-3-Clause +// +// ERC721CheckpointableUpgradeable.sol MODIFICATIONS: +// - Inherits from OpenZeppelin's ERC721EnumerableUpgradeable.sol, removing the original modification Nouns made to +// ERC721.sol, where for each mint two Transfer events were emitted; this modified implementation sticks with the +// OpenZeppelin standard. +// - More importantly, this inheritance change makes the token upgradable, which we deemed important in the context of +// forks, in order to give new Nouns forks enough of a chance to modify their contracts to the new DAO's needs. +// - Fixes a critical bug in `delegateBySig`, where the previous version allowed delegating to address zero, which then +// reverts whenever that owner tries to delegate anew or transfer their tokens. The fix is simply to revert on any +// attempt to delegate to address zero. +// +// ERC721Checkpointable.sol MODIFICATIONS: +// Checkpointing logic from Comp.sol has been used with the following modifications: +// - `delegates` is renamed to `_delegates` and is set to private +// - `delegates` is a public function that uses the `_delegates` mapping look-up, but unlike +// Comp.sol, returns the delegator's own address if there is no delegate. +// This avoids the delegator needing to "delegate to self" with an additional transaction +// - `_transferTokens()` is renamed `_beforeTokenTransfer()` and adapted to hook into OpenZeppelin's ERC721 hooks. + +pragma solidity ^0.8.6; + +import { ERC721EnumerableUpgradeable } from '@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721EnumerableUpgradeable.sol'; + +abstract contract ERC721CheckpointableUpgradeable is ERC721EnumerableUpgradeable { + /// @notice Defines decimals as per ERC-20 convention to make integrations with 3rd party governance platforms easier + uint8 public constant decimals = 0; + + /// @notice A record of each accounts delegate + mapping(address => address) private _delegates; + + /// @notice A checkpoint for marking number of votes from a given block + struct Checkpoint { + uint32 fromBlock; + uint96 votes; + } + + /// @notice A record of votes checkpoints for each account, by index + mapping(address => mapping(uint32 => Checkpoint)) public checkpoints; + + /// @notice The number of checkpoints for each account + mapping(address => uint32) public numCheckpoints; + + /// @notice The EIP-712 typehash for the contract's domain + bytes32 public constant DOMAIN_TYPEHASH = + keccak256('EIP712Domain(string name,uint256 chainId,address verifyingContract)'); + + /// @notice The EIP-712 typehash for the delegation struct used by the contract + bytes32 public constant DELEGATION_TYPEHASH = + keccak256('Delegation(address delegatee,uint256 nonce,uint256 expiry)'); + + /// @notice A record of states for signing / validating signatures + mapping(address => uint256) public nonces; + + /// @notice An event thats emitted when an account changes its delegate + event DelegateChanged(address indexed delegator, address indexed fromDelegate, address indexed toDelegate); + + /// @notice An event thats emitted when a delegate account's vote balance changes + event DelegateVotesChanged(address indexed delegate, uint256 previousBalance, uint256 newBalance); + + /** + * @notice The votes a delegator can delegate, which is the current balance of the delegator. + * @dev Used when calling `_delegate()` + */ + function votesToDelegate(address delegator) public view returns (uint96) { + return safe96(balanceOf(delegator), 'ERC721Checkpointable::votesToDelegate: amount exceeds 96 bits'); + } + + /** + * @notice Overrides the standard `Comp.sol` delegates mapping to return + * the delegator's own address if they haven't delegated. + * This avoids having to delegate to oneself. + */ + function delegates(address delegator) public view returns (address) { + address current = _delegates[delegator]; + return current == address(0) ? delegator : current; + } + + /** + * @notice Adapted from `_transferTokens()` in `Comp.sol` to update delegate votes. + * @dev hooks into OpenZeppelin's `ERC721._transfer` + */ + function _beforeTokenTransfer( + address from, + address to, + uint256 tokenId + ) internal override { + super._beforeTokenTransfer(from, to, tokenId); + + /// @notice Differs from `_transferTokens()` to use `delegates` override method to simulate auto-delegation + _moveDelegates(delegates(from), delegates(to), 1); + } + + /** + * @notice Delegate votes from `msg.sender` to `delegatee` + * @param delegatee The address to delegate votes to + */ + function delegate(address delegatee) public { + if (delegatee == address(0)) delegatee = msg.sender; + return _delegate(msg.sender, delegatee); + } + + /** + * @notice Delegates votes from signatory to `delegatee` + * @param delegatee The address to delegate votes to + * @param nonce The contract state required to match the signature + * @param expiry The time at which to expire the signature + * @param v The recovery byte of the signature + * @param r Half of the ECDSA signature pair + * @param s Half of the ECDSA signature pair + */ + function delegateBySig( + address delegatee, + uint256 nonce, + uint256 expiry, + uint8 v, + bytes32 r, + bytes32 s + ) public { + require(delegatee != address(0), 'ERC721Checkpointable::delegateBySig: delegatee cannot be zero address'); + bytes32 domainSeparator = keccak256( + abi.encode(DOMAIN_TYPEHASH, keccak256(bytes(name())), getChainId(), address(this)) + ); + bytes32 structHash = keccak256(abi.encode(DELEGATION_TYPEHASH, delegatee, nonce, expiry)); + bytes32 digest = keccak256(abi.encodePacked('\x19\x01', domainSeparator, structHash)); + address signatory = ecrecover(digest, v, r, s); + require(signatory != address(0), 'ERC721Checkpointable::delegateBySig: invalid signature'); + require(nonce == nonces[signatory]++, 'ERC721Checkpointable::delegateBySig: invalid nonce'); + require(block.timestamp <= expiry, 'ERC721Checkpointable::delegateBySig: signature expired'); + return _delegate(signatory, delegatee); + } + + /** + * @notice Gets the current votes balance for `account` + * @param account The address to get votes balance + * @return The number of current votes for `account` + */ + function getCurrentVotes(address account) external view returns (uint96) { + uint32 nCheckpoints = numCheckpoints[account]; + return nCheckpoints > 0 ? checkpoints[account][nCheckpoints - 1].votes : 0; + } + + /** + * @notice Determine the prior number of votes for an account as of a block number + * @dev Block number must be a finalized block or else this function will revert to prevent misinformation. + * @param account The address of the account to check + * @param blockNumber The block number to get the vote balance at + * @return The number of votes the account had as of the given block + */ + function getPriorVotes(address account, uint256 blockNumber) public view returns (uint96) { + require(blockNumber < block.number, 'ERC721Checkpointable::getPriorVotes: not yet determined'); + + uint32 nCheckpoints = numCheckpoints[account]; + if (nCheckpoints == 0) { + return 0; + } + + // First check most recent balance + if (checkpoints[account][nCheckpoints - 1].fromBlock <= blockNumber) { + return checkpoints[account][nCheckpoints - 1].votes; + } + + // Next check implicit zero balance + if (checkpoints[account][0].fromBlock > blockNumber) { + return 0; + } + + uint32 lower = 0; + uint32 upper = nCheckpoints - 1; + while (upper > lower) { + uint32 center = upper - (upper - lower) / 2; // ceil, avoiding overflow + Checkpoint memory cp = checkpoints[account][center]; + if (cp.fromBlock == blockNumber) { + return cp.votes; + } else if (cp.fromBlock < blockNumber) { + lower = center; + } else { + upper = center - 1; + } + } + return checkpoints[account][lower].votes; + } + + function _delegate(address delegator, address delegatee) internal { + /// @notice differs from `_delegate()` in `Comp.sol` to use `delegates` override method to simulate auto-delegation + address currentDelegate = delegates(delegator); + + _delegates[delegator] = delegatee; + + emit DelegateChanged(delegator, currentDelegate, delegatee); + + uint96 amount = votesToDelegate(delegator); + + _moveDelegates(currentDelegate, delegatee, amount); + } + + function _moveDelegates( + address srcRep, + address dstRep, + uint96 amount + ) internal { + if (srcRep != dstRep && amount > 0) { + if (srcRep != address(0)) { + uint32 srcRepNum = numCheckpoints[srcRep]; + uint96 srcRepOld = srcRepNum > 0 ? checkpoints[srcRep][srcRepNum - 1].votes : 0; + uint96 srcRepNew = sub96(srcRepOld, amount, 'ERC721Checkpointable::_moveDelegates: amount underflows'); + _writeCheckpoint(srcRep, srcRepNum, srcRepOld, srcRepNew); + } + + if (dstRep != address(0)) { + uint32 dstRepNum = numCheckpoints[dstRep]; + uint96 dstRepOld = dstRepNum > 0 ? checkpoints[dstRep][dstRepNum - 1].votes : 0; + uint96 dstRepNew = add96(dstRepOld, amount, 'ERC721Checkpointable::_moveDelegates: amount overflows'); + _writeCheckpoint(dstRep, dstRepNum, dstRepOld, dstRepNew); + } + } + } + + function _writeCheckpoint( + address delegatee, + uint32 nCheckpoints, + uint96 oldVotes, + uint96 newVotes + ) internal { + uint32 blockNumber = safe32( + block.number, + 'ERC721Checkpointable::_writeCheckpoint: block number exceeds 32 bits' + ); + + if (nCheckpoints > 0 && checkpoints[delegatee][nCheckpoints - 1].fromBlock == blockNumber) { + checkpoints[delegatee][nCheckpoints - 1].votes = newVotes; + } else { + checkpoints[delegatee][nCheckpoints] = Checkpoint(blockNumber, newVotes); + numCheckpoints[delegatee] = nCheckpoints + 1; + } + + emit DelegateVotesChanged(delegatee, oldVotes, newVotes); + } + + function safe32(uint256 n, string memory errorMessage) internal pure returns (uint32) { + require(n < 2**32, errorMessage); + return uint32(n); + } + + function safe96(uint256 n, string memory errorMessage) internal pure returns (uint96) { + require(n < 2**96, errorMessage); + return uint96(n); + } + + function add96( + uint96 a, + uint96 b, + string memory errorMessage + ) internal pure returns (uint96) { + uint96 c = a + b; + require(c >= a, errorMessage); + return c; + } + + function sub96( + uint96 a, + uint96 b, + string memory errorMessage + ) internal pure returns (uint96) { + require(b <= a, errorMessage); + return a - b; + } + + function getChainId() internal view returns (uint256) { + uint256 chainId; + assembly { + chainId := chainid() + } + return chainId; + } +} diff --git a/packages/nouns-contracts/contracts/interfaces/INounsToken.sol b/packages/nouns-contracts/contracts/interfaces/INounsToken.sol index 35a1de045c..200855ef80 100644 --- a/packages/nouns-contracts/contracts/interfaces/INounsToken.sol +++ b/packages/nouns-contracts/contracts/interfaces/INounsToken.sol @@ -46,8 +46,6 @@ interface INounsToken is IERC721 { function dataURI(uint256 tokenId) external returns (string memory); - function setNoundersDAO(address noundersDAO) external; - function setMinter(address minter) external; function lockMinter() external; diff --git a/packages/nouns-contracts/contracts/test/NounsDAOExecutorHarness.sol b/packages/nouns-contracts/contracts/test/NounsDAOExecutorHarness.sol index 829a9630ae..4ec3dcdf4a 100644 --- a/packages/nouns-contracts/contracts/test/NounsDAOExecutorHarness.sol +++ b/packages/nouns-contracts/contracts/test/NounsDAOExecutorHarness.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.6; import '../governance/NounsDAOExecutor.sol'; +import '../governance/NounsDAOExecutorV2.sol'; interface Administered { function _acceptAdmin() external returns (uint256); @@ -34,3 +35,19 @@ contract NounsDAOExecutorTest is NounsDAOExecutor { administered._acceptAdmin(); } } + +contract NounsDAOExecutorV2Test is NounsDAOExecutorV2 { + function initialize(address admin_, uint256 delay_) public override { + super.initialize(admin_, 2 days); + delay = delay_; + } + + function harnessSetAdmin(address admin_) public { + require(msg.sender == admin); + admin = admin_; + } + + function harnessAcceptAdmin(Administered administered) public { + administered._acceptAdmin(); + } +} diff --git a/packages/nouns-contracts/contracts/test/NounsDAOLogicV3Harness.sol b/packages/nouns-contracts/contracts/test/NounsDAOLogicV3Harness.sol new file mode 100644 index 0000000000..7d62af6eb4 --- /dev/null +++ b/packages/nouns-contracts/contracts/test/NounsDAOLogicV3Harness.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.6; + +import '../governance/NounsDAOLogicV3.sol'; +import { NounsDAOV3Admin } from '../governance/NounsDAOV3Admin.sol'; + +contract NounsDAOLogicV3Harness is NounsDAOLogicV3 { + using NounsDAOV3Admin for StorageV3; + + function initialize( + address timelock_, + address nouns_, + address forkEscrow_, + address forkDAODeployer_, + address vetoer_, + NounsDAOParams calldata daoParams_, + DynamicQuorumParams calldata dynamicQuorumParams_ + ) public override { + if (address(ds.timelock) != address(0)) revert CanOnlyInitializeOnce(); + if (msg.sender != ds.admin) revert AdminOnly(); + if (timelock_ == address(0)) revert InvalidTimelockAddress(); + if (nouns_ == address(0)) revert InvalidNounsAddress(); + + ds.votingPeriod = daoParams_.votingPeriod; + ds.votingDelay = daoParams_.votingDelay; + ds.proposalThresholdBPS = daoParams_.proposalThresholdBPS; + ds.timelock = INounsDAOExecutorV2(timelock_); + ds.nouns = NounsTokenLike(nouns_); + ds.forkEscrow = INounsDAOForkEscrow(forkEscrow_); + ds.forkDAODeployer = IForkDAODeployer(forkDAODeployer_); + ds.vetoer = vetoer_; + _setDynamicQuorumParams( + dynamicQuorumParams_.minQuorumVotesBPS, + dynamicQuorumParams_.maxQuorumVotesBPS, + dynamicQuorumParams_.quorumCoefficient + ); + ds._setLastMinuteWindowInBlocks(daoParams_.lastMinuteWindowInBlocks); + ds._setObjectionPeriodDurationInBlocks(daoParams_.objectionPeriodDurationInBlocks); + ds._setProposalUpdatablePeriodInBlocks(daoParams_.proposalUpdatablePeriodInBlocks); + } +} diff --git a/packages/nouns-contracts/contracts/utils/ERC20Transferer.sol b/packages/nouns-contracts/contracts/utils/ERC20Transferer.sol new file mode 100644 index 0000000000..96accd87f0 --- /dev/null +++ b/packages/nouns-contracts/contracts/utils/ERC20Transferer.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: GPL-3.0 + +/********************************* + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + * ░░░░░░█████████░░█████████░░░ * + * ░░░░░░██░░░████░░██░░░████░░░ * + * ░░██████░░░████████░░░████░░░ * + * ░░██░░██░░░████░░██░░░████░░░ * + * ░░██░░██░░░████░░██░░░████░░░ * + * ░░░░░░█████████░░█████████░░░ * + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + *********************************/ + +pragma solidity ^0.8.16; + +import { IERC20 } from '@openzeppelin/contracts/token/ERC20/IERC20.sol'; + +/** + * @notice Utility contract for transferring entire balance of an ERC20. + * Useful for DAOs making proposals without knowing the exact amount at execution time. + */ +contract ERC20Transferer { + /** + * @notice Transfer the entire balance of the caller to `to`. + * Assumes it is approved on transfer funds on its behalf. + * @param token The token to transfer. + * @param to The address to transfer to. + * @return The amount transferred. + */ + function transferEntireBalance(address token, address to) external returns (uint256) { + uint256 balance = IERC20(token).balanceOf(msg.sender); + IERC20(token).transferFrom(msg.sender, to, balance); + return balance; + } +} diff --git a/packages/nouns-contracts/hardhat.config.ts b/packages/nouns-contracts/hardhat.config.ts index 2845bcfc4e..7d9f9c6aa6 100644 --- a/packages/nouns-contracts/hardhat.config.ts +++ b/packages/nouns-contracts/hardhat.config.ts @@ -18,7 +18,7 @@ const config: HardhatUserConfig = { settings: { optimizer: { enabled: true, - runs: 10_000, + runs: 200, }, }, }, diff --git a/packages/nouns-contracts/script/DeployDAOV3DataContractsBase.s.sol b/packages/nouns-contracts/script/DeployDAOV3DataContractsBase.s.sol new file mode 100644 index 0000000000..bc5ab25289 --- /dev/null +++ b/packages/nouns-contracts/script/DeployDAOV3DataContractsBase.s.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.15; + +import 'forge-std/Script.sol'; +import { NounsDAOLogicV1 } from '../contracts/governance/NounsDAOLogicV1.sol'; +import { NounsDAOData } from '../contracts/governance/data/NounsDAOData.sol'; +import { NounsDAODataProxy } from '../contracts/governance/data/NounsDAODataProxy.sol'; + +contract DeployDAOV3DataContractsBase is Script { + uint256 public constant CREATE_CANDIDATE_COST = 0.01 ether; + + NounsDAOLogicV1 public immutable daoProxy; + address public immutable timelockV2Proxy; + + constructor(address _daoProxy, address _timelockV2Proxy) { + daoProxy = NounsDAOLogicV1(payable(_daoProxy)); + timelockV2Proxy = _timelockV2Proxy; + } + + function run() public returns (NounsDAODataProxy dataProxy) { + uint256 deployerKey = vm.envUint('DEPLOYER_PRIVATE_KEY'); + + vm.startBroadcast(deployerKey); + + NounsDAOData dataLogic = new NounsDAOData(address(daoProxy.nouns()), address(daoProxy)); + + bytes memory initCallData = abi.encodeWithSignature( + 'initialize(address,uint256,uint256)', + timelockV2Proxy, + CREATE_CANDIDATE_COST, + 0 + ); + dataProxy = new NounsDAODataProxy(address(dataLogic), initCallData); + + vm.stopBroadcast(); + } +} diff --git a/packages/nouns-contracts/script/DeployDAOV3DataContractsGoerli.s.sol b/packages/nouns-contracts/script/DeployDAOV3DataContractsGoerli.s.sol new file mode 100644 index 0000000000..9848785800 --- /dev/null +++ b/packages/nouns-contracts/script/DeployDAOV3DataContractsGoerli.s.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.15; + +import 'forge-std/Script.sol'; +import { DeployDAOV3DataContractsBase } from './DeployDAOV3DataContractsBase.s.sol'; + +contract DeployDAOV3DataContractsGoerli is DeployDAOV3DataContractsBase { + address public constant NOUNS_DAO_PROXY_GOERLI = 0x34b74B5c1996b37e5e3EDB756731A5812FF43F67; + address public constant NOUNS_TIMELOCK_V2_PROXY_GOERLI = 0xc1A82A952d48E015beA401AC982AA3D019AAA91E; + + constructor() DeployDAOV3DataContractsBase(NOUNS_DAO_PROXY_GOERLI, NOUNS_TIMELOCK_V2_PROXY_GOERLI) {} +} diff --git a/packages/nouns-contracts/script/DeployDAOV3NewContractsBase.s.sol b/packages/nouns-contracts/script/DeployDAOV3NewContractsBase.s.sol new file mode 100644 index 0000000000..3205eeef4f --- /dev/null +++ b/packages/nouns-contracts/script/DeployDAOV3NewContractsBase.s.sol @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.15; + +import 'forge-std/Script.sol'; +import { NounsDAOExecutorV2 } from '../contracts/governance/NounsDAOExecutorV2.sol'; +import { NounsDAOExecutorV2Test } from '../contracts/test/NounsDAOExecutorHarness.sol'; +import { NounsDAOLogicV1 } from '../contracts/governance/NounsDAOLogicV1.sol'; +import { NounsDAOLogicV3 } from '../contracts/governance/NounsDAOLogicV3.sol'; +import { NounsDAOExecutorProxy } from '../contracts/governance/NounsDAOExecutorProxy.sol'; +import { INounsDAOExecutor } from '../contracts/governance/NounsDAOInterfaces.sol'; +import { NounsDAOForkEscrow } from '../contracts/governance/fork/NounsDAOForkEscrow.sol'; +import { NounsTokenFork } from '../contracts/governance/fork/newdao/token/NounsTokenFork.sol'; +import { NounsAuctionHouseFork } from '../contracts/governance/fork/newdao/NounsAuctionHouseFork.sol'; +import { NounsDAOLogicV1Fork } from '../contracts/governance/fork/newdao/governance/NounsDAOLogicV1Fork.sol'; +import { ForkDAODeployer } from '../contracts/governance/fork/ForkDAODeployer.sol'; +import { ERC20Transferer } from '../contracts/utils/ERC20Transferer.sol'; + +contract DeployDAOV3NewContractsBase is Script { + uint256 public constant DELAYED_GOV_DURATION = 30 days; + + NounsDAOLogicV1 public immutable daoProxy; + INounsDAOExecutor public immutable timelockV1; + bool public immutable deployTimelockV2Harness; // should be true only for testnets + + constructor( + address _daoProxy, + address _timelockV1, + bool _deployTimelockV2Harness + ) { + daoProxy = NounsDAOLogicV1(payable(_daoProxy)); + timelockV1 = INounsDAOExecutor(_timelockV1); + deployTimelockV2Harness = _deployTimelockV2Harness; + } + + function run() + public + returns ( + NounsDAOForkEscrow forkEscrow, + ForkDAODeployer forkDeployer, + NounsDAOLogicV3 daoV3Impl, + NounsDAOExecutorV2 timelockV2, + ERC20Transferer erc20Transferer + ) + { + uint256 deployerKey = vm.envUint('DEPLOYER_PRIVATE_KEY'); + + vm.startBroadcast(deployerKey); + + (forkEscrow, forkDeployer, daoV3Impl, timelockV2, erc20Transferer) = deployNewContracts(); + + vm.stopBroadcast(); + } + + function deployNewContracts() + internal + returns ( + NounsDAOForkEscrow forkEscrow, + ForkDAODeployer forkDeployer, + NounsDAOLogicV3 daoV3Impl, + NounsDAOExecutorV2 timelockV2, + ERC20Transferer erc20Transferer + ) + { + NounsDAOExecutorV2 timelockV2Impl; + if (deployTimelockV2Harness) { + timelockV2Impl = new NounsDAOExecutorV2Test(); + } else { + timelockV2Impl = new NounsDAOExecutorV2(); + } + + forkEscrow = new NounsDAOForkEscrow(address(daoProxy), address(daoProxy.nouns())); + forkDeployer = new ForkDAODeployer( + address(new NounsTokenFork()), + address(new NounsAuctionHouseFork()), + address(new NounsDAOLogicV1Fork()), + address(timelockV2Impl), + DELAYED_GOV_DURATION + ); + daoV3Impl = new NounsDAOLogicV3(); + timelockV2 = deployAndInitTimelockV2(address(timelockV2Impl)); + erc20Transferer = new ERC20Transferer(); + } + + function deployAndInitTimelockV2(address timelockV2Impl) internal returns (NounsDAOExecutorV2 timelockV2) { + bytes memory initCallData = abi.encodeWithSignature( + 'initialize(address,uint256)', + address(daoProxy), + timelockV1.delay() + ); + + timelockV2 = NounsDAOExecutorV2(payable(address(new NounsDAOExecutorProxy(timelockV2Impl, initCallData)))); + + return timelockV2; + } +} diff --git a/packages/nouns-contracts/script/DeployDAOV3NewContractsGoerli.s.sol b/packages/nouns-contracts/script/DeployDAOV3NewContractsGoerli.s.sol new file mode 100644 index 0000000000..9a2305fa40 --- /dev/null +++ b/packages/nouns-contracts/script/DeployDAOV3NewContractsGoerli.s.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.15; + +import 'forge-std/Script.sol'; +import { DeployDAOV3NewContractsBase } from './DeployDAOV3NewContractsBase.s.sol'; + +contract DeployDAOV3NewContractsGoerli is DeployDAOV3NewContractsBase { + address public constant NOUNS_DAO_PROXY_GOERLI = 0x34b74B5c1996b37e5e3EDB756731A5812FF43F67; + address public constant NOUNS_TIMELOCK_V1_GOERLI = 0x62e85a8dbc2799fB5D12BC59556bD3721D5E4CdE; + + constructor() DeployDAOV3NewContractsBase(NOUNS_DAO_PROXY_GOERLI, NOUNS_TIMELOCK_V1_GOERLI, true) {} +} diff --git a/packages/nouns-contracts/script/DeployDAOV3NewContractsMainnet.s.sol b/packages/nouns-contracts/script/DeployDAOV3NewContractsMainnet.s.sol new file mode 100644 index 0000000000..a97ec0ad49 --- /dev/null +++ b/packages/nouns-contracts/script/DeployDAOV3NewContractsMainnet.s.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.15; + +import 'forge-std/Script.sol'; +import { DeployDAOV3NewContractsBase } from './DeployDAOV3NewContractsBase.s.sol'; + +contract DeployDAOV3NewContractsMainnet is DeployDAOV3NewContractsBase { + address public constant NOUNS_DAO_PROXY_MAINNET = 0x6f3E6272A167e8AcCb32072d08E0957F9c79223d; + address public constant NOUNS_TIMELOCK_V1_MAINNET = 0x0BC3807Ec262cB779b38D65b38158acC3bfedE10; + + constructor() DeployDAOV3NewContractsBase(NOUNS_DAO_PROXY_MAINNET, NOUNS_TIMELOCK_V1_MAINNET, false) {} +} diff --git a/packages/nouns-contracts/script/ProposeDAOV3UpgradeGoerli.s.sol b/packages/nouns-contracts/script/ProposeDAOV3UpgradeGoerli.s.sol new file mode 100644 index 0000000000..934de1805d --- /dev/null +++ b/packages/nouns-contracts/script/ProposeDAOV3UpgradeGoerli.s.sol @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.15; + +import 'forge-std/Script.sol'; +import { NounsDAOLogicV1 } from '../contracts/governance/NounsDAOLogicV1.sol'; +import { NounsDAOForkEscrow } from '../contracts/governance/fork/NounsDAOForkEscrow.sol'; +import { ForkDAODeployer } from '../contracts/governance/fork/ForkDAODeployer.sol'; + +contract ProposeDAOV3UpgradeGoerli is Script { + NounsDAOLogicV1 public constant NOUNS_DAO_PROXY_GOERLI = + NounsDAOLogicV1(0x34b74B5c1996b37e5e3EDB756731A5812FF43F67); + address public constant NOUNS_TIMELOCK_V1_GOERLI = 0x62e85a8dbc2799fB5D12BC59556bD3721D5E4CdE; + + uint256 public constant ETH_TO_SEND_TO_NEW_TIMELOCK = 0.001 ether; + uint256 public constant FORK_PERIOD = 1 hours; + uint256 public constant FORK_THRESHOLD_BPS = 2000; + + address public constant STETH_GOERLI = 0x1643E812aE58766192Cf7D2Cf9567dF2C37e9B7F; + + address public constant AUCTION_HOUSE_PROXY_GOERLI = 0x3DFB1EFC72f2BA9268cBaf82527fD19592618A8D; + + function run() public returns (uint256 proposalId) { + uint256 proposerKey = vm.envUint('PROPOSER_KEY'); + address daoV3Implementation = vm.envAddress('DAO_V3_IMPL'); + address timelockV2 = vm.envAddress('TIMELOCK_V2'); + address forkEscrow = vm.envAddress('FORK_ESCROW'); + address forkDeployer = vm.envAddress('FORK_DEPLOYER'); + address erc20Transferer = vm.envAddress('ERC20_TRANSFERER'); + + string memory description = vm.readFile(vm.envString('PROPOSAL_DESCRIPTION_FILE')); + + address[] memory erc20TokensToIncludeInFork = new address[](1); + erc20TokensToIncludeInFork[0] = STETH_GOERLI; + + vm.startBroadcast(proposerKey); + + proposalId = propose( + NOUNS_DAO_PROXY_GOERLI, + daoV3Implementation, + timelockV2, + ETH_TO_SEND_TO_NEW_TIMELOCK, + erc20Transferer, + forkEscrow, + forkDeployer, + erc20TokensToIncludeInFork, + description + ); + console.log('Proposed proposalId: %d', proposalId); + + vm.stopBroadcast(); + } + + function propose( + NounsDAOLogicV1 daoProxy, + address daoV3Implementation, + address timelockV2, + uint256 ethToSendToNewTimelock, + address erc20Transferer, + address forkEscrow, + address forkDeployer, + address[] memory erc20TokensToIncludeInFork, + string memory description + ) internal returns (uint256 proposalId) { + uint8 numTxs = 8; + address[] memory targets = new address[](numTxs); + uint256[] memory values = new uint256[](numTxs); + string[] memory signatures = new string[](numTxs); + bytes[] memory calldatas = new bytes[](numTxs); + + // Can't send the entire ETH balance because we can't reference self.balance + // Would also be good to leave some ETH in case of queued proposals + // For both reasons, we will first sent a chunk of ETH, and send the rest in a followup proposal + uint256 i = 0; + targets[i] = timelockV2; + values[i] = ethToSendToNewTimelock; + signatures[i] = ''; + calldatas[i] = ''; + + // Upgrade to DAO V3 + i++; + targets[i] = address(daoProxy); + values[i] = 0; + signatures[i] = '_setImplementation(address)'; + calldatas[i] = abi.encode(daoV3Implementation); + + i++; + targets[i] = address(daoProxy); + values[i] = 0; + signatures[i] = '_setForkParams(address,address,address[],uint256,uint256)'; + calldatas[i] = abi.encode( + forkEscrow, + forkDeployer, + erc20TokensToIncludeInFork, + FORK_PERIOD, + FORK_THRESHOLD_BPS + ); + + i++; + targets[i] = address(daoProxy); + values[i] = 0; + signatures[i] = '_setVoteSnapshotBlockSwitchProposalId()'; + calldatas[i] = ''; + + i++; + targets[i] = AUCTION_HOUSE_PROXY_GOERLI; + values[i] = 0; + signatures[i] = 'transferOwnership(address)'; + calldatas[i] = abi.encode(timelockV2); + + i++; + targets[i] = STETH_GOERLI; + values[i] = 0; + signatures[i] = 'approve(address,uint256)'; + calldatas[i] = abi.encode(erc20Transferer, type(uint256).max); + + i++; + targets[i] = erc20Transferer; + values[i] = 0; + signatures[i] = 'transferEntireBalance(address,address)'; + calldatas[i] = abi.encode(STETH_GOERLI, timelockV2); + + i++; + targets[i] = address(daoProxy); + values[i] = 0; + signatures[i] = '_setTimelocksAndAdmin(address,address,address)'; + calldatas[i] = abi.encode(timelockV2, NOUNS_TIMELOCK_V1_GOERLI, timelockV2); + + proposalId = daoProxy.propose(targets, values, signatures, calldatas, description); + } +} diff --git a/packages/nouns-contracts/script/ProposeDAOV3UpgradeMainnet.s.sol b/packages/nouns-contracts/script/ProposeDAOV3UpgradeMainnet.s.sol new file mode 100644 index 0000000000..78029ee19f --- /dev/null +++ b/packages/nouns-contracts/script/ProposeDAOV3UpgradeMainnet.s.sol @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.15; + +import 'forge-std/Script.sol'; +import { NounsDAOLogicV1 } from '../contracts/governance/NounsDAOLogicV1.sol'; +import { NounsDAOForkEscrow } from '../contracts/governance/fork/NounsDAOForkEscrow.sol'; +import { ForkDAODeployer } from '../contracts/governance/fork/ForkDAODeployer.sol'; + +contract ProposeDAOV3UpgradeMainnet is Script { + NounsDAOLogicV1 public constant NOUNS_DAO_PROXY_MAINNET = + NounsDAOLogicV1(0x6f3E6272A167e8AcCb32072d08E0957F9c79223d); + address public constant NOUNS_TIMELOCK_V1_MAINNET = 0x0BC3807Ec262cB779b38D65b38158acC3bfedE10; + + uint256 public constant ETH_TO_SEND_TO_NEW_TIMELOCK = 10000 ether; + uint256 public constant FORK_PERIOD = 7 days; + uint256 public constant FORK_THRESHOLD_BPS = 2000; + + address public constant STETH_MAINNET = 0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84; + address public constant TOKEN_BUYER_MAINNET = 0x4f2aCdc74f6941390d9b1804faBc3E780388cfe5; + address public constant PAYER_MAINNET = 0xd97Bcd9f47cEe35c0a9ec1dc40C1269afc9E8E1D; + address public constant AUCTION_HOUSE_PROXY_MAINNET = 0x830BD73E4184ceF73443C15111a1DF14e495C706; + + function run() public returns (uint256 proposalId) { + uint256 proposerKey = vm.envUint('PROPOSER_KEY'); + address daoV3Implementation = vm.envAddress('DAO_V3_IMPL'); + address timelockV2 = vm.envAddress('TIMELOCK_V2'); + address forkEscrow = vm.envAddress('FORK_ESCROW'); + address forkDeployer = vm.envAddress('FORK_DEPLOYER'); + address erc20Transferer = vm.envAddress('ERC20_TRANSFERER'); + + string memory description = vm.readFile(vm.envString('PROPOSAL_DESCRIPTION_FILE')); + + address[] memory erc20TokensToIncludeInFork = new address[](1); + erc20TokensToIncludeInFork[0] = STETH_MAINNET; + + vm.startBroadcast(proposerKey); + + proposalId = propose( + NOUNS_DAO_PROXY_MAINNET, + daoV3Implementation, + timelockV2, + ETH_TO_SEND_TO_NEW_TIMELOCK, + erc20Transferer, + forkEscrow, + forkDeployer, + erc20TokensToIncludeInFork, + description + ); + console.log('Proposed proposalId: %d', proposalId); + + vm.stopBroadcast(); + } + + function propose( + NounsDAOLogicV1 daoProxy, + address daoV3Implementation, + address timelockV2, + uint256 ethToSendToNewTimelock, + address erc20Transferer, + address forkEscrow, + address forkDeployer, + address[] memory erc20TokensToIncludeInFork, + string memory description + ) internal returns (uint256 proposalId) { + uint8 numTxs = 10; + address[] memory targets = new address[](numTxs); + uint256[] memory values = new uint256[](numTxs); + string[] memory signatures = new string[](numTxs); + bytes[] memory calldatas = new bytes[](numTxs); + + // Can't send the entire ETH balance because we can't reference self.balance + // Would also be good to leave some ETH in case of queued proposals + // For both reasons, we will first sent a chunk of ETH, and send the rest in a followup proposal + uint256 i = 0; + targets[i] = timelockV2; + values[i] = ethToSendToNewTimelock; + signatures[i] = ''; + calldatas[i] = ''; + + // Upgrade to DAO V3 + i++; + targets[i] = address(daoProxy); + values[i] = 0; + signatures[i] = '_setImplementation(address)'; + calldatas[i] = abi.encode(daoV3Implementation); + + i++; + targets[i] = address(daoProxy); + values[i] = 0; + signatures[i] = '_setForkParams(address,address,address[],uint256,uint256)'; + calldatas[i] = abi.encode( + forkEscrow, + forkDeployer, + erc20TokensToIncludeInFork, + FORK_PERIOD, + FORK_THRESHOLD_BPS + ); + + i++; + targets[i] = address(daoProxy); + values[i] = 0; + signatures[i] = '_setVoteSnapshotBlockSwitchProposalId()'; + calldatas[i] = ''; + + i++; + targets[i] = TOKEN_BUYER_MAINNET; + values[i] = 0; + signatures[i] = 'transferOwnership(address)'; + calldatas[i] = abi.encode(timelockV2); + + i++; + targets[i] = PAYER_MAINNET; + values[i] = 0; + signatures[i] = 'transferOwnership(address)'; + calldatas[i] = abi.encode(timelockV2); + + i++; + targets[i] = AUCTION_HOUSE_PROXY_MAINNET; + values[i] = 0; + signatures[i] = 'transferOwnership(address)'; + calldatas[i] = abi.encode(timelockV2); + + i++; + targets[i] = STETH_MAINNET; + values[i] = 0; + signatures[i] = 'approve(address,uint256)'; + calldatas[i] = abi.encode(erc20Transferer, type(uint256).max); + + i++; + targets[i] = erc20Transferer; + values[i] = 0; + signatures[i] = 'transferEntireBalance(address,address)'; + calldatas[i] = abi.encode(STETH_MAINNET, timelockV2); + + i++; + targets[i] = address(daoProxy); + values[i] = 0; + signatures[i] = '_setTimelocksAndAdmin(address,address,address)'; + calldatas[i] = abi.encode(timelockV2, NOUNS_TIMELOCK_V1_MAINNET, timelockV2); + + proposalId = daoProxy.propose(targets, values, signatures, calldatas, description); + } +} diff --git a/packages/nouns-contracts/tasks/deploy-and-configure-short-times-dao-v3.ts b/packages/nouns-contracts/tasks/deploy-and-configure-short-times-dao-v3.ts new file mode 100644 index 0000000000..0afe5121f6 --- /dev/null +++ b/packages/nouns-contracts/tasks/deploy-and-configure-short-times-dao-v3.ts @@ -0,0 +1,124 @@ +import { task, types } from 'hardhat/config'; +import { printContractsTable } from './utils'; + +task( + 'deploy-and-configure-short-times-dao-v3', + 'Deploy and configure all contracts with short gov times for testing', +) + .addFlag('startAuction', 'Start the first auction upon deployment completion') + .addFlag('autoDeploy', 'Deploy all contracts without user interaction') + .addFlag('updateConfigs', 'Write the deployed addresses to the SDK and subgraph configs') + .addOptionalParam('weth', 'The WETH contract address') + .addOptionalParam('noundersdao', 'The nounders DAO contract address') + .addOptionalParam( + 'auctionTimeBuffer', + 'The auction time buffer (seconds)', + 30 /* 30 seconds */, + types.int, + ) + .addOptionalParam( + 'auctionReservePrice', + 'The auction reserve price (wei)', + 1 /* 1 wei */, + types.int, + ) + .addOptionalParam( + 'auctionMinIncrementBidPercentage', + 'The auction min increment bid percentage (out of 100)', + 2 /* 2% */, + types.int, + ) + .addOptionalParam( + 'auctionDuration', + 'The auction duration (seconds)', + 60 * 2 /* 2 minutes */, + types.int, + ) + .addOptionalParam('timelockDelay', 'The timelock delay (seconds)', 60 /* 1 min */, types.int) + .addOptionalParam( + 'votingPeriod', + 'The voting period (blocks)', + 80 /* 20 min (15s blocks) */, + types.int, + ) + .addOptionalParam('votingDelay', 'The voting delay (blocks)', 1, types.int) + .addOptionalParam( + 'proposalThresholdBps', + 'The proposal threshold (basis points)', + 100 /* 1% */, + types.int, + ) + .addOptionalParam( + 'minQuorumVotesBPS', + 'Min basis points input for dynamic quorum', + 1_000, + types.int, + ) // Default: 10% + .addOptionalParam( + 'maxQuorumVotesBPS', + 'Max basis points input for dynamic quorum', + 4_000, + types.int, + ) // Default: 40% + .addOptionalParam('quorumCoefficient', 'Dynamic quorum coefficient (float)', 1, types.float) + .addOptionalParam( + 'createCandidateCost', + 'Data contract proposal candidate creation cost in wei', + 100000000000000, // 0.0001 ether + types.int, + ) + .addOptionalParam( + 'updateCandidateCost', + 'Data contract proposal candidate update cost in wei', + 0, + types.int, + ) + .setAction(async (args, { run }) => { + // Deploy the Nouns DAO contracts and return deployment information + const contracts = await run('deploy-short-times-dao-v3', args); + + // Verify the contracts on Etherscan + await run('verify-etherscan-dao-v3', { + contracts, + }); + + // Populate the on-chain art + await run('populate-descriptor', { + nftDescriptor: contracts.NFTDescriptorV2.address, + nounsDescriptor: contracts.NounsDescriptorV2.address, + }); + + // Transfer ownership of all contract except for the auction house. + // We must maintain ownership of the auction house to kick off the first auction. + const executorAddress = contracts.NounsDAOExecutorProxy.instance.address; + await contracts.NounsDescriptorV2.instance.transferOwnership(executorAddress); + await contracts.NounsToken.instance.transferOwnership(executorAddress); + await contracts.NounsAuctionHouseProxyAdmin.instance.transferOwnership(executorAddress); + console.log( + 'Transferred ownership of the descriptor, token, and proxy admin contracts to the executor.', + ); + + // Optionally kick off the first auction and transfer ownership of the auction house + // to the Nouns DAO executor. + const auctionHouse = contracts.NounsAuctionHouse.instance.attach( + contracts.NounsAuctionHouseProxy.address, + ); + if (args.startAuction) { + await auctionHouse.unpause({ + gasLimit: 1_000_000, + }); + console.log('Started the first auction.'); + } + await auctionHouse.transferOwnership(executorAddress); + console.log('Transferred ownership of the auction house to the executor.'); + + // Optionally write the deployed addresses to the SDK and subgraph configs. + if (args.updateConfigs) { + await run('update-configs-dao-v3', { + contracts, + }); + } + + printContractsTable(contracts); + console.log('Deployment Complete.'); + }); diff --git a/packages/nouns-contracts/tasks/deploy-local-dao-v3.ts b/packages/nouns-contracts/tasks/deploy-local-dao-v3.ts index 8c25e4660d..8d71b1a3c6 100644 --- a/packages/nouns-contracts/tasks/deploy-local-dao-v3.ts +++ b/packages/nouns-contracts/tasks/deploy-local-dao-v3.ts @@ -1,23 +1,34 @@ import { default as NounsAuctionHouseABI } from '../abi/contracts/NounsAuctionHouse.sol/NounsAuctionHouse.json'; import { default as NounsDaoDataABI } from '../abi/contracts/governance/data/NounsDAOData.sol/NounsDAOData.json'; +import { default as NounsDAOExecutorV2ABI } from '../abi/contracts/governance/NounsDAOExecutorV2.sol/NounsDAOExecutorV2.json'; import { task, types } from 'hardhat/config'; import { Interface, parseUnits } from 'ethers/lib/utils'; import { Contract as EthersContract } from 'ethers'; import { ContractName } from './types'; type LocalContractName = - | Exclude + | Exclude< + ContractName, + 'NounsDAOLogicV1' | 'NounsDAOProxy' | 'NounsDAOLogicV2' | 'NounsDAOExecutor' + > | 'NounsDAOLogicV3' | 'NounsDAOProxyV3' | 'NounsDAOV3Admin' | 'NounsDAOV3DynamicQuorum' | 'NounsDAOV3Proposals' | 'NounsDAOV3Votes' + | 'NounsDAOV3Fork' + | 'NounsDAOForkEscrow' + | 'ForkDAODeployer' + | 'NounsTokenFork' + | 'NounsAuctionHouseFork' + | 'NounsDAOLogicV1Fork' + | 'NounsDAOExecutorV2' + | 'NounsDAOExecutorProxy' | 'WETH' | 'Multicall2' | 'NounsDAOData' - | 'NounsDAODataProxy' - | 'NounsDAODataProxyAdmin'; + | 'NounsDAODataProxy'; interface Contract { args?: (string | number | (() => string | undefined))[]; @@ -39,7 +50,13 @@ task('deploy-local-dao-v3', 'Deploy contracts to hardhat') .addOptionalParam('auctionDuration', 'The auction duration (seconds)', 60 * 2, types.int) // Default: 2 minutes .addOptionalParam('timelockDelay', 'The timelock delay (seconds)', 60 * 60 * 24 * 2, types.int) // Default: 2 days .addOptionalParam('votingPeriod', 'The voting period (blocks)', 4 * 60 * 24 * 3, types.int) // Default: 3 days - .addOptionalParam('votingDelay', 'The voting delay (blocks)', 1, types.int) // Default: 1 block + .addOptionalParam('votingDelay', 'The voting delay (blocks)', 100, types.int) // Default: 1 block + .addOptionalParam( + 'proposalUpdatablePeriodInBlocks', + 'The updatable period in blocks', + 100, + types.int, + ) // Default: 1 block .addOptionalParam('proposalThresholdBps', 'The proposal threshold (basis points)', 500, types.int) // Default: 5% .addOptionalParam( 'minQuorumVotesBPS', @@ -77,7 +94,7 @@ task('deploy-local-dao-v3', 'Deploy contracts to hardhat') const NOUNS_ART_NONCE_OFFSET = 5; const AUCTION_HOUSE_PROXY_NONCE_OFFSET = 10; - const GOVERNOR_N_DELEGATOR_NONCE_OFFSET = 17; + const GOVERNOR_N_DELEGATOR_NONCE_OFFSET = 24; const [deployer] = await ethers.getSigners(); const nonce = await deployer.getTransactionCount(); @@ -139,44 +156,72 @@ task('deploy-local-dao-v3', 'Deploy contracts to hardhat') ]), ], }, - NounsDAOExecutor: { - args: [expectedNounsDAOProxyAddress, args.timelockDelay], - }, NounsDAOV3DynamicQuorum: {}, - NounsDAOV3Admin: { - libraries: () => ({ - NounsDAOV3DynamicQuorum: contracts.NounsDAOV3DynamicQuorum.instance?.address as string, - }), - }, + NounsDAOV3Admin: {}, NounsDAOV3Proposals: {}, NounsDAOV3Votes: {}, + NounsDAOV3Fork: {}, NounsDAOLogicV3: { libraries: () => ({ NounsDAOV3Admin: contracts.NounsDAOV3Admin.instance?.address as string, NounsDAOV3DynamicQuorum: contracts.NounsDAOV3DynamicQuorum.instance?.address as string, NounsDAOV3Proposals: contracts.NounsDAOV3Proposals.instance?.address as string, NounsDAOV3Votes: contracts.NounsDAOV3Votes.instance?.address as string, + NounsDAOV3Fork: contracts.NounsDAOV3Fork.instance?.address as string, }), waitForConfirmation: true, }, + NounsDAOForkEscrow: { + args: [ + expectedNounsDAOProxyAddress, + () => contracts.NounsToken.instance?.address as string, + ], + }, + NounsTokenFork: {}, + NounsAuctionHouseFork: {}, + NounsDAOLogicV1Fork: {}, + NounsDAOExecutorV2: {}, + NounsDAOExecutorProxy: { + args: [ + () => contracts.NounsDAOExecutorV2.instance?.address, + () => + new Interface(NounsDAOExecutorV2ABI).encodeFunctionData('initialize', [ + expectedNounsDAOProxyAddress, + args.timelockDelay, + ]), + ], + }, + ForkDAODeployer: { + args: [ + () => contracts.NounsTokenFork.instance?.address, + () => contracts.NounsAuctionHouseFork.instance?.address, + () => contracts.NounsDAOLogicV1Fork.instance?.address, + () => contracts.NounsDAOExecutorV2.instance?.address, + 60 * 60 * 24 * 30, // 30 days + ], + }, NounsDAOProxyV3: { args: [ - () => contracts.NounsDAOExecutor.instance?.address, // timelock + () => contracts.NounsDAOExecutorProxy.instance?.address, // timelock () => contracts.NounsToken.instance?.address, // token + () => contracts.NounsDAOForkEscrow.instance?.address, // forkEscrow + () => contracts.ForkDAODeployer.instance?.address, // forkDAODeployer args.noundersdao || deployer.address, // vetoer - () => contracts.NounsDAOExecutor.instance?.address, // admin + () => contracts.NounsDAOExecutorProxy.instance?.address, // admin () => contracts.NounsDAOLogicV3.instance?.address, // implementation - args.votingPeriod, // votingPeriod - args.votingDelay, // votingDelay - args.proposalThresholdBps, // proposalThresholdBps + { + votingPeriod: args.votingPeriod, + votingDelay: args.votingDelay, + proposalThresholdBPS: args.proposalThresholdBps, + lastMinuteWindowInBlocks: 0, + objectionPeriodDurationInBlocks: 0, + proposalUpdatablePeriodInBlocks: 0, + }, // DAOParams { minQuorumVotesBPS: args.minQuorumVotesBPS, maxQuorumVotesBPS: args.maxQuorumVotesBPS, quorumCoefficient: parseUnits(args.quorumCoefficient.toString(), 6), }, // DynamicQuorumParams - 0, // lastMinuteWindowInBlocks - 0, // objectionPeriodDurationInBlocks - 0, // proposalUpdatablePeriodInBlocks ], waitForConfirmation: true, }, @@ -185,16 +230,12 @@ task('deploy-local-dao-v3', 'Deploy contracts to hardhat') args: [() => contracts.NounsToken.instance?.address, expectedNounsDAOProxyAddress], waitForConfirmation: true, }, - NounsDAODataProxyAdmin: { - waitForConfirmation: true, - }, NounsDAODataProxy: { args: [ () => contracts.NounsDAOData.instance?.address, - () => contracts.NounsDAODataProxyAdmin.instance?.address, () => new Interface(NounsDaoDataABI).encodeFunctionData('initialize', [ - contracts.NounsDAOExecutor.instance?.address, + contracts.NounsDAOExecutorProxy.instance?.address, args.createCandidateCost, args.updateCandidateCost, ]), diff --git a/packages/nouns-contracts/tasks/deploy-short-times-dao-v3.ts b/packages/nouns-contracts/tasks/deploy-short-times-dao-v3.ts new file mode 100644 index 0000000000..8f940e5591 --- /dev/null +++ b/packages/nouns-contracts/tasks/deploy-short-times-dao-v3.ts @@ -0,0 +1,383 @@ +import { default as NounsAuctionHouseABI } from '../abi/contracts/NounsAuctionHouse.sol/NounsAuctionHouse.json'; +import { default as NounsDAOExecutorV2ABI } from '../abi/contracts/governance/NounsDAOExecutorV2.sol/NounsDAOExecutorV2.json'; +import { default as NounsDaoDataABI } from '../abi/contracts/governance/data/NounsDAOData.sol/NounsDAOData.json'; +import { ChainId, ContractDeployment, ContractNamesDAOV3, DeployedContract } from './types'; +import { Interface, parseUnits } from 'ethers/lib/utils'; +import { task, types } from 'hardhat/config'; +import { constants } from 'ethers'; +import promptjs from 'prompt'; + +promptjs.colors = false; +promptjs.message = '> '; +promptjs.delimiter = ''; + +const proxyRegistries: Record = { + [ChainId.Mainnet]: '0xa5409ec958c83c3f309868babaca7c86dcb077c1', +}; +const wethContracts: Record = { + [ChainId.Mainnet]: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + [ChainId.Ropsten]: '0xc778417e063141139fce010982780140aa0cd5ab', + [ChainId.Kovan]: '0xd0a1e359811322d97991e03f863a0c30c2cf029c', + [ChainId.Goerli]: '0xB4FBF271143F4FBf7B91A5ded31805e42b2208d6', +}; + +const NOUNS_ART_NONCE_OFFSET = 4; +const AUCTION_HOUSE_PROXY_NONCE_OFFSET = 9; +const GOVERNOR_N_DELEGATOR_NONCE_OFFSET = 23; + +task('deploy-short-times-dao-v3', 'Deploy all Nouns contracts with short gov times for testing') + .addFlag('autoDeploy', 'Deploy all contracts without user interaction') + .addOptionalParam('weth', 'The WETH contract address', undefined, types.string) + .addOptionalParam('noundersdao', 'The nounders DAO contract address', undefined, types.string) + .addOptionalParam( + 'auctionTimeBuffer', + 'The auction time buffer (seconds)', + 30 /* 30 seconds */, + types.int, + ) + .addOptionalParam( + 'auctionReservePrice', + 'The auction reserve price (wei)', + 1 /* 1 wei */, + types.int, + ) + .addOptionalParam( + 'auctionMinIncrementBidPercentage', + 'The auction min increment bid percentage (out of 100)', + 2 /* 2% */, + types.int, + ) + .addOptionalParam( + 'auctionDuration', + 'The auction duration (seconds)', + 60 * 2 /* 2 minutes */, + types.int, + ) + .addOptionalParam('timelockDelay', 'The timelock delay (seconds)', 60 /* 1 min */, types.int) + .addOptionalParam( + 'votingPeriod', + 'The voting period (blocks)', + 80 /* 20 min (15s blocks) */, + types.int, + ) + .addOptionalParam('votingDelay', 'The voting delay (blocks)', 1, types.int) + .addOptionalParam( + 'proposalThresholdBps', + 'The proposal threshold (basis points)', + 100 /* 1% */, + types.int, + ) + .addOptionalParam( + 'minQuorumVotesBPS', + 'Min basis points input for dynamic quorum', + 1_000, + types.int, + ) // Default: 10% + .addOptionalParam( + 'maxQuorumVotesBPS', + 'Max basis points input for dynamic quorum', + 4_000, + types.int, + ) // Default: 40% + .addOptionalParam('quorumCoefficient', 'Dynamic quorum coefficient (float)', 1, types.float) + .addOptionalParam( + 'createCandidateCost', + 'Data contract proposal candidate creation cost in wei', + 100000000000000, // 0.0001 ether + types.int, + ) + .addOptionalParam( + 'updateCandidateCost', + 'Data contract proposal candidate update cost in wei', + 0, + types.int, + ) + .setAction(async (args, { ethers }) => { + const network = await ethers.provider.getNetwork(); + const [deployer] = await ethers.getSigners(); + + // prettier-ignore + const proxyRegistryAddress = proxyRegistries[network.chainId] ?? constants.AddressZero; + + if (!args.noundersdao) { + console.log( + `Nounders DAO address not provided. Setting to deployer (${deployer.address})...`, + ); + args.noundersdao = deployer.address; + } + if (!args.weth) { + const deployedWETHContract = wethContracts[network.chainId]; + if (!deployedWETHContract) { + throw new Error( + `Can not auto-detect WETH contract on chain ${network.name}. Provide it with the --weth arg.`, + ); + } + args.weth = deployedWETHContract; + } + + const nonce = await deployer.getTransactionCount(); + const expectedNounsArtAddress = ethers.utils.getContractAddress({ + from: deployer.address, + nonce: nonce + NOUNS_ART_NONCE_OFFSET, + }); + const expectedAuctionHouseProxyAddress = ethers.utils.getContractAddress({ + from: deployer.address, + nonce: nonce + AUCTION_HOUSE_PROXY_NONCE_OFFSET, + }); + const expectedNounsDAOProxyAddress = ethers.utils.getContractAddress({ + from: deployer.address, + nonce: nonce + GOVERNOR_N_DELEGATOR_NONCE_OFFSET, + }); + const deployment: Record = {} as Record< + ContractNamesDAOV3, + DeployedContract + >; + const contracts: Record = { + NFTDescriptorV2: {}, + SVGRenderer: {}, + NounsDescriptorV2: { + args: [expectedNounsArtAddress, () => deployment.SVGRenderer.address], + libraries: () => ({ + NFTDescriptorV2: deployment.NFTDescriptorV2.address, + }), + }, + Inflator: {}, + NounsArt: { + args: [() => deployment.NounsDescriptorV2.address, () => deployment.Inflator.address], + }, + NounsSeeder: {}, + NounsToken: { + args: [ + args.noundersdao, + expectedAuctionHouseProxyAddress, + () => deployment.NounsDescriptorV2.address, + () => deployment.NounsSeeder.address, + proxyRegistryAddress, + ], + }, + NounsAuctionHouse: { + waitForConfirmation: true, + }, + NounsAuctionHouseProxyAdmin: {}, + NounsAuctionHouseProxy: { + args: [ + () => deployment.NounsAuctionHouse.address, + () => deployment.NounsAuctionHouseProxyAdmin.address, + () => + new Interface(NounsAuctionHouseABI).encodeFunctionData('initialize', [ + deployment.NounsToken.address, + args.weth, + args.auctionTimeBuffer, + args.auctionReservePrice, + args.auctionMinIncrementBidPercentage, + args.auctionDuration, + ]), + ], + waitForConfirmation: true, + validateDeployment: () => { + const expected = expectedAuctionHouseProxyAddress.toLowerCase(); + const actual = deployment.NounsAuctionHouseProxy.address.toLowerCase(); + if (expected !== actual) { + throw new Error( + `Unexpected auction house proxy address. Expected: ${expected}. Actual: ${actual}.`, + ); + } + }, + }, + NounsDAOV3DynamicQuorum: {}, + NounsDAOV3Admin: {}, + NounsDAOV3Proposals: {}, + NounsDAOV3Votes: {}, + NounsDAOV3Fork: {}, + NounsDAOLogicV3: { + libraries: () => ({ + NounsDAOV3Admin: deployment.NounsDAOV3Admin.address, + NounsDAOV3DynamicQuorum: deployment.NounsDAOV3DynamicQuorum.address, + NounsDAOV3Proposals: deployment.NounsDAOV3Proposals.address, + NounsDAOV3Votes: deployment.NounsDAOV3Votes.address, + NounsDAOV3Fork: deployment.NounsDAOV3Fork.address, + }), + waitForConfirmation: true, + }, + NounsDAOForkEscrow: { + args: [expectedNounsDAOProxyAddress, () => deployment.NounsToken.address], + }, + NounsTokenFork: {}, + NounsAuctionHouseFork: {}, + NounsDAOLogicV1Fork: {}, + NounsDAOExecutorV2: {}, + NounsDAOExecutorProxy: { + args: [ + () => deployment.NounsDAOExecutorV2.address, + () => + new Interface(NounsDAOExecutorV2ABI).encodeFunctionData('initialize', [ + expectedNounsDAOProxyAddress, + args.timelockDelay, + ]), + ], + }, + ForkDAODeployer: { + args: [ + () => deployment.NounsTokenFork.address, + () => deployment.NounsAuctionHouseFork.address, + () => deployment.NounsDAOLogicV1Fork.address, + () => deployment.NounsDAOExecutorV2.address, + 60 * 60 * 24 * 30, // 30 days + ], + }, + NounsDAOProxyV3: { + args: [ + () => deployment.NounsDAOExecutorProxy.address, // timelock + () => deployment.NounsToken.address, // token + () => deployment.NounsDAOForkEscrow.address, // forkEscrow + () => deployment.ForkDAODeployer.address, // forkDAODeployer + args.noundersdao || deployer.address, // vetoer + () => deployment.NounsDAOExecutorProxy.address, // admin + () => deployment.NounsDAOLogicV3.address, // implementation + { + votingPeriod: args.votingPeriod, + votingDelay: args.votingDelay, + proposalThresholdBPS: args.proposalThresholdBps, + lastMinuteWindowInBlocks: 0, + objectionPeriodDurationInBlocks: 0, + proposalUpdatablePeriodInBlocks: 0, + }, // DAOParams + { + minQuorumVotesBPS: args.minQuorumVotesBPS, + maxQuorumVotesBPS: args.maxQuorumVotesBPS, + quorumCoefficient: parseUnits(args.quorumCoefficient.toString(), 6), + }, // DynamicQuorumParams + ], + waitForConfirmation: true, + validateDeployment: () => { + const expected = expectedNounsDAOProxyAddress.toLowerCase(); + const actual = deployment.NounsDAOProxyV3.address.toLowerCase(); + if (expected !== actual) { + throw new Error( + `Unexpected Nouns DAO proxy address. Expected: ${expected}. Actual: ${actual}.`, + ); + } + }, + }, + NounsDAOData: { + args: [() => deployment.NounsToken.address, expectedNounsDAOProxyAddress], + waitForConfirmation: true, + }, + NounsDAODataProxy: { + args: [ + () => deployment.NounsDAOData.address, + () => + new Interface(NounsDaoDataABI).encodeFunctionData('initialize', [ + deployment.NounsDAOExecutorProxy.address, + args.createCandidateCost, + args.updateCandidateCost, + ]), + ], + }, + }; + + for (const [name, contract] of Object.entries(contracts)) { + let gasPrice = await ethers.provider.getGasPrice(); + if (!args.autoDeploy) { + const gasInGwei = Math.round(Number(ethers.utils.formatUnits(gasPrice, 'gwei'))); + + promptjs.start(); + + const result = await promptjs.get([ + { + properties: { + gasPrice: { + type: 'integer', + required: true, + description: 'Enter a gas price (gwei)', + default: gasInGwei, + }, + }, + }, + ]); + gasPrice = ethers.utils.parseUnits(result.gasPrice.toString(), 'gwei'); + } + + let nameForFactory: string; + switch (name) { + case 'NounsDAOExecutorV2': + nameForFactory = 'NounsDAOExecutorV2Test'; + break; + case 'NounsDAOLogicV3': + nameForFactory = 'NounsDAOLogicV3Harness'; + break; + default: + nameForFactory = name; + break; + } + + const factory = await ethers.getContractFactory(nameForFactory, { + libraries: contract?.libraries?.(), + }); + + const deploymentGas = await factory.signer.estimateGas( + factory.getDeployTransaction( + ...(contract.args?.map(a => (typeof a === 'function' ? a() : a)) ?? []), + { + gasPrice, + }, + ), + ); + const deploymentCost = deploymentGas.mul(gasPrice); + + console.log( + `Estimated cost to deploy ${name}: ${ethers.utils.formatUnits( + deploymentCost, + 'ether', + )} ETH`, + ); + + if (!args.autoDeploy) { + const result = await promptjs.get([ + { + properties: { + confirm: { + pattern: /^(DEPLOY|SKIP|EXIT)$/, + description: + 'Type "DEPLOY" to confirm, "SKIP" to skip this contract, or "EXIT" to exit.', + }, + }, + }, + ]); + if (result.operation === 'SKIP') { + console.log(`Skipping ${name} deployment...`); + continue; + } + if (result.operation === 'EXIT') { + console.log('Exiting...'); + return; + } + } + console.log(`Deploying ${name}...`); + + const deployedContract = await factory.deploy( + ...(contract.args?.map(a => (typeof a === 'function' ? a() : a)) ?? []), + { + gasPrice, + }, + ); + + if (contract.waitForConfirmation) { + await deployedContract.deployed(); + } + + deployment[name as ContractNamesDAOV3] = { + name: nameForFactory, + instance: deployedContract, + address: deployedContract.address, + constructorArguments: contract.args?.map(a => (typeof a === 'function' ? a() : a)) ?? [], + libraries: contract?.libraries?.() ?? {}, + }; + + contract.validateDeployment?.(); + + console.log(`${name} contract deployed to ${deployedContract.address}`); + } + + return deployment; + }); diff --git a/packages/nouns-contracts/tasks/index.ts b/packages/nouns-contracts/tasks/index.ts index e4189aa2d8..d3b95f6f8d 100644 --- a/packages/nouns-contracts/tasks/index.ts +++ b/packages/nouns-contracts/tasks/index.ts @@ -25,3 +25,7 @@ export * from './deploy-short-times-daov1'; export * from './deploy-and-configure-short-times-daov1'; export * from './deploy-local-dao-v3'; export * from './run-local-dao-v3'; +export * from './deploy-short-times-dao-v3'; +export * from './deploy-and-configure-short-times-dao-v3'; +export * from './verify-etherscan-dao-v3'; +export * from './update-configs-dao-v3'; diff --git a/packages/nouns-contracts/tasks/run-local-dao-v3.ts b/packages/nouns-contracts/tasks/run-local-dao-v3.ts index 95cc2c060c..f2dde422fb 100644 --- a/packages/nouns-contracts/tasks/run-local-dao-v3.ts +++ b/packages/nouns-contracts/tasks/run-local-dao-v3.ts @@ -12,7 +12,10 @@ task( new Promise(resolve => setTimeout(resolve, 2_000)), ]); - const contracts = await run('deploy-local-dao-v3', { votingDelay: 10 }); + const contracts = await run('deploy-local-dao-v3', { + votingDelay: 1000, + proposalUpdatablePeriodInBlocks: 1000, + }); await run('populate-descriptor', { nftDescriptor: contracts.NFTDescriptorV2.instance.address, @@ -26,14 +29,13 @@ task( }); // Transfer ownership - const executorAddress = contracts.NounsDAOExecutor.instance.address; + const executorAddress = contracts.NounsDAOExecutorProxy.instance.address; await contracts.NounsDescriptorV2.instance.transferOwnership(executorAddress); await contracts.NounsToken.instance.transferOwnership(executorAddress); await contracts.NounsAuctionHouseProxyAdmin.instance.transferOwnership(executorAddress); await contracts.NounsAuctionHouse.instance .attach(contracts.NounsAuctionHouseProxy.instance.address) .transferOwnership(executorAddress); - await contracts.NounsDAODataProxyAdmin.instance.transferOwnership(executorAddress); console.log( 'Transferred ownership of the descriptor, token, and proxy admin contracts to the executor.', ); @@ -61,7 +63,7 @@ task( ); console.log(`Auction House Proxy address: ${contracts.NounsAuctionHouseProxy.instance.address}`); console.log(`Nouns ERC721 address: ${contracts.NounsToken.instance.address}`); - console.log(`Nouns DAO Executor address: ${contracts.NounsDAOExecutor.instance.address}`); + console.log(`Nouns DAO Executor address: ${contracts.NounsDAOExecutorProxy.instance.address}`); console.log(`Nouns DAO Proxy address: ${contracts.NounsDAOProxyV3.instance.address}`); console.log(`Data Proxy address: ${contracts.NounsDAODataProxy.instance.address}`); diff --git a/packages/nouns-contracts/tasks/types/index.ts b/packages/nouns-contracts/tasks/types/index.ts index 08a7711cc0..61d42e9b44 100644 --- a/packages/nouns-contracts/tasks/types/index.ts +++ b/packages/nouns-contracts/tasks/types/index.ts @@ -19,6 +19,25 @@ export type ContractNameDescriptorV1 = DescriptorV1ContractNames | 'NounsSeeder' // prettier-ignore export type ContractNamesDAOV2 = Exclude | 'NounsDAOLogicV2' | 'NounsDAOProxyV2'; +export type ContractNamesDAOV3 = + | Exclude + | 'NounsDAOLogicV3' + | 'NounsDAOProxyV3' + | 'NounsDAOV3Admin' + | 'NounsDAOV3DynamicQuorum' + | 'NounsDAOV3Proposals' + | 'NounsDAOV3Votes' + | 'NounsDAOV3Fork' + | 'NounsDAOForkEscrow' + | 'ForkDAODeployer' + | 'NounsTokenFork' + | 'NounsAuctionHouseFork' + | 'NounsDAOLogicV1Fork' + | 'NounsDAOExecutorV2' + | 'NounsDAOExecutorProxy' + | 'NounsDAOData' + | 'NounsDAODataProxy'; + export interface ContractDeployment { args?: (string | number | (() => string))[]; libraries?: () => Record; diff --git a/packages/nouns-contracts/tasks/update-configs-dao-v3.ts b/packages/nouns-contracts/tasks/update-configs-dao-v3.ts new file mode 100644 index 0000000000..a999eed5eb --- /dev/null +++ b/packages/nouns-contracts/tasks/update-configs-dao-v3.ts @@ -0,0 +1,68 @@ +import { task, types } from 'hardhat/config'; +import { ContractNamesDAOV3, DeployedContract } from './types'; +import { readFileSync, writeFileSync } from 'fs'; +import { execSync } from 'child_process'; +import { join } from 'path'; + +task('update-configs-dao-v3', 'Write the deployed addresses to the SDK and subgraph configs') + .addParam('contracts', 'Contract objects from the deployment', undefined, types.json) + .setAction( + async ( + { contracts }: { contracts: Record }, + { ethers }, + ) => { + const { name: network, chainId } = await ethers.provider.getNetwork(); + + // Update SDK addresses + const sdkPath = join(__dirname, '../../nouns-sdk'); + const addressesPath = join(sdkPath, 'src/contract/addresses.json'); + const addresses = JSON.parse(readFileSync(addressesPath, 'utf8')); + addresses[chainId] = { + nounsToken: contracts.NounsToken.address, + nounsSeeder: contracts.NounsSeeder.address, + nounsDescriptor: contracts.NounsDescriptorV2.address, + nftDescriptor: contracts.NFTDescriptorV2.address, + nounsAuctionHouse: contracts.NounsAuctionHouse.address, + nounsAuctionHouseProxy: contracts.NounsAuctionHouseProxy.address, + nounsAuctionHouseProxyAdmin: contracts.NounsAuctionHouseProxyAdmin.address, + nounsDaoExecutor: contracts.NounsDAOExecutorProxy.address, + nounsDAOProxy: contracts.NounsDAOProxyV3.address, + nounsDAOLogicV1: contracts.NounsDAOLogicV3.address, + nounsDAOData: contracts.NounsDAODataProxy.address, + }; + writeFileSync(addressesPath, JSON.stringify(addresses, null, 2)); + try { + execSync('yarn build', { + cwd: sdkPath, + }); + } catch { + console.log('Failed to re-build `@nouns/sdk`. Please rebuild manually.'); + } + console.log('Addresses written to the Nouns SDK.'); + + // Generate subgraph config + const configName = `${network}-fork`; + const subgraphConfigPath = join(__dirname, `../../nouns-subgraph/config/${configName}.json`); + const subgraphConfig = { + network, + nounsToken: { + address: contracts.NounsToken.address, + startBlock: contracts.NounsToken.instance.deployTransaction.blockNumber, + }, + nounsAuctionHouse: { + address: contracts.NounsAuctionHouseProxy.address, + startBlock: contracts.NounsAuctionHouseProxy.instance.deployTransaction.blockNumber, + }, + nounsDAO: { + address: contracts.NounsDAOProxyV3.address, + startBlock: contracts.NounsDAOProxyV3.instance.deployTransaction.blockNumber, + }, + nounsDAOData: { + addresses: contracts.NounsDAODataProxy.address, + startBlock: contracts.NounsDAODataProxy.instance.deployTransaction.blockNumber, + }, + }; + writeFileSync(subgraphConfigPath, JSON.stringify(subgraphConfig, null, 2)); + console.log('Subgraph config has been generated.'); + }, + ); diff --git a/packages/nouns-contracts/tasks/verify-etherscan-dao-v3.ts b/packages/nouns-contracts/tasks/verify-etherscan-dao-v3.ts new file mode 100644 index 0000000000..152f74e552 --- /dev/null +++ b/packages/nouns-contracts/tasks/verify-etherscan-dao-v3.ts @@ -0,0 +1,40 @@ +import { task, types } from 'hardhat/config'; +import { ContractName, ContractNamesDAOV3, DeployedContract } from './types'; + +// prettier-ignore +// These contracts require a fully qualified name to be passed because +// they share bytecode with the underlying contract. +const nameToFullyQualifiedName: Record = { + NounsAuctionHouseProxy: 'contracts/proxies/NounsAuctionHouseProxy.sol:NounsAuctionHouseProxy', + NounsAuctionHouseProxyAdmin: 'contracts/proxies/NounsAuctionHouseProxyAdmin.sol:NounsAuctionHouseProxyAdmin', + NounsDAOLogicV3Harness: 'contracts/test/NounsDAOLogicV3Harness.sol:NounsDAOLogicV3Harness', + NounsDAOExecutorV2Test: 'contracts/test/NounsDAOExecutorHarness.sol:NounsDAOExecutorV2Test', +}; + +task('verify-etherscan-dao-v3', 'Verify the Solidity contracts on Etherscan') + .addParam('contracts', 'Contract objects from the deployment', undefined, types.json) + .setAction( + async ({ contracts }: { contracts: Record }, hre) => { + for (const [, contract] of Object.entries(contracts)) { + console.log(`verifying ${contract.name}...`); + try { + const code = await contract.instance?.provider.getCode(contract.address); + if (code === '0x') { + console.log( + `${contract.name} contract deployment has not completed. waiting to verify...`, + ); + await contract.instance?.deployed(); + } + await hre.run('verify:verify', { + ...contract, + contract: nameToFullyQualifiedName[contract.name], + }); + } catch ({ message }) { + if ((message as string).includes('Reason: Already Verified')) { + continue; + } + console.error(message); + } + } + }, + ); diff --git a/packages/nouns-contracts/test/foundry/NounsArt.t.sol b/packages/nouns-contracts/test/foundry/NounsArt.t.sol index cb04f415ec..2e89678630 100644 --- a/packages/nouns-contracts/test/foundry/NounsArt.t.sol +++ b/packages/nouns-contracts/test/foundry/NounsArt.t.sol @@ -148,15 +148,16 @@ contract NounsArtTest is Test, DescriptorHelpers { } function testSetPaletteWorks() public { - vm.expectEmit(true, true, true, true); - emit PaletteSet(0); - vm.expectEmit(true, true, true, true); - emit PaletteSet(1); - bytes memory palette0 = hex'ffffffc5b9a1'; bytes memory palette1 = hex'cfc2ab63a0f9'; + vm.startPrank(descriptor); + vm.expectEmit(true, true, true, true); + emit PaletteSet(0); art.setPalette(0, palette0); + + vm.expectEmit(true, true, true, true); + emit PaletteSet(1); art.setPalette(1, palette1); vm.stopPrank(); @@ -168,15 +169,16 @@ contract NounsArtTest is Test, DescriptorHelpers { } function testSetPalettePointerWorks() public { - vm.expectEmit(true, true, true, true); - emit PaletteSet(0); - vm.expectEmit(true, true, true, true); - emit PaletteSet(1); - address pointer0 = SSTORE2.write(hex'ffffffc5b9a1'); address pointer1 = SSTORE2.write(hex'cfc2ab63a0f9'); + vm.startPrank(descriptor); + vm.expectEmit(true, true, true, true); + emit PaletteSet(0); art.setPalettePointer(0, pointer0); + + vm.expectEmit(true, true, true, true); + emit PaletteSet(1); art.setPalettePointer(1, pointer1); vm.stopPrank(); @@ -298,13 +300,14 @@ contract NounsArtTest is Test, DescriptorHelpers { function testAddBodiesWorksWithMultiplePages() public { assertEq(art.getBodiesTrait().storedImagesCount, 0); + + vm.startPrank(descriptor); vm.expectEmit(true, true, true, true); emit BodiesAdded(2); + art.addBodies(FIRST_TWO_IMAGES_COMPRESSED, FIRST_TWO_IMAGES_DEFLATED_LENGTH, uint16(2)); + vm.expectEmit(true, true, true, true); emit BodiesAdded(2); - - vm.startPrank(descriptor); - art.addBodies(FIRST_TWO_IMAGES_COMPRESSED, FIRST_TWO_IMAGES_DEFLATED_LENGTH, uint16(2)); art.addBodies(NEXT_TWO_IMAGES_COMPRESSED, NEXT_TWO_IMAGES_DEFLATED_LENGTH, uint16(2)); vm.stopPrank(); @@ -327,17 +330,17 @@ contract NounsArtTest is Test, DescriptorHelpers { function testAddBodiesFromPointerWorksWithMultiplePages() public { assertEq(art.getBodiesTrait().storedImagesCount, 0); - vm.expectEmit(true, true, true, true); - emit BodiesAdded(2); - vm.expectEmit(true, true, true, true); - emit BodiesAdded(2); vm.startPrank(descriptor); + vm.expectEmit(true, true, true, true); + emit BodiesAdded(2); art.addBodiesFromPointer( SSTORE2.write(FIRST_TWO_IMAGES_COMPRESSED), FIRST_TWO_IMAGES_DEFLATED_LENGTH, uint16(2) ); + vm.expectEmit(true, true, true, true); + emit BodiesAdded(2); art.addBodiesFromPointer(SSTORE2.write(NEXT_TWO_IMAGES_COMPRESSED), NEXT_TWO_IMAGES_DEFLATED_LENGTH, uint16(2)); vm.stopPrank(); @@ -378,13 +381,14 @@ contract NounsArtTest is Test, DescriptorHelpers { function testAddAccessoriesWorksWithMultiplePages() public { assertEq(art.getAccessoriesTrait().storedImagesCount, 0); + + vm.startPrank(descriptor); vm.expectEmit(true, true, true, true); emit AccessoriesAdded(2); + art.addAccessories(FIRST_TWO_IMAGES_COMPRESSED, FIRST_TWO_IMAGES_DEFLATED_LENGTH, uint16(2)); + vm.expectEmit(true, true, true, true); emit AccessoriesAdded(2); - - vm.startPrank(descriptor); - art.addAccessories(FIRST_TWO_IMAGES_COMPRESSED, FIRST_TWO_IMAGES_DEFLATED_LENGTH, uint16(2)); art.addAccessories(NEXT_TWO_IMAGES_COMPRESSED, NEXT_TWO_IMAGES_DEFLATED_LENGTH, uint16(2)); vm.stopPrank(); @@ -407,17 +411,17 @@ contract NounsArtTest is Test, DescriptorHelpers { function testAddAccessoriesFromPointerWorksWithMultiplePages() public { assertEq(art.getAccessoriesTrait().storedImagesCount, 0); - vm.expectEmit(true, true, true, true); - emit AccessoriesAdded(2); - vm.expectEmit(true, true, true, true); - emit AccessoriesAdded(2); vm.startPrank(descriptor); + vm.expectEmit(true, true, true, true); + emit AccessoriesAdded(2); art.addAccessoriesFromPointer( SSTORE2.write(FIRST_TWO_IMAGES_COMPRESSED), FIRST_TWO_IMAGES_DEFLATED_LENGTH, uint16(2) ); + vm.expectEmit(true, true, true, true); + emit AccessoriesAdded(2); art.addAccessoriesFromPointer( SSTORE2.write(NEXT_TWO_IMAGES_COMPRESSED), NEXT_TWO_IMAGES_DEFLATED_LENGTH, @@ -462,13 +466,14 @@ contract NounsArtTest is Test, DescriptorHelpers { function testAddHeadsWorksWithMultiplePages() public { assertEq(art.getHeadsTrait().storedImagesCount, 0); + + vm.startPrank(descriptor); vm.expectEmit(true, true, true, true); emit HeadsAdded(2); + art.addHeads(FIRST_TWO_IMAGES_COMPRESSED, FIRST_TWO_IMAGES_DEFLATED_LENGTH, uint16(2)); + vm.expectEmit(true, true, true, true); emit HeadsAdded(2); - - vm.startPrank(descriptor); - art.addHeads(FIRST_TWO_IMAGES_COMPRESSED, FIRST_TWO_IMAGES_DEFLATED_LENGTH, uint16(2)); art.addHeads(NEXT_TWO_IMAGES_COMPRESSED, NEXT_TWO_IMAGES_DEFLATED_LENGTH, uint16(2)); vm.stopPrank(); @@ -491,17 +496,17 @@ contract NounsArtTest is Test, DescriptorHelpers { function testAddHeadsFromPointerWorksWithMultiplePages() public { assertEq(art.getHeadsTrait().storedImagesCount, 0); - vm.expectEmit(true, true, true, true); - emit HeadsAdded(2); - vm.expectEmit(true, true, true, true); - emit HeadsAdded(2); vm.startPrank(descriptor); + vm.expectEmit(true, true, true, true); + emit HeadsAdded(2); art.addHeadsFromPointer( SSTORE2.write(FIRST_TWO_IMAGES_COMPRESSED), FIRST_TWO_IMAGES_DEFLATED_LENGTH, uint16(2) ); + vm.expectEmit(true, true, true, true); + emit HeadsAdded(2); art.addHeadsFromPointer(SSTORE2.write(NEXT_TWO_IMAGES_COMPRESSED), NEXT_TWO_IMAGES_DEFLATED_LENGTH, uint16(2)); vm.stopPrank(); @@ -542,13 +547,14 @@ contract NounsArtTest is Test, DescriptorHelpers { function testAddGlassesWorksWithMultiplePages() public { assertEq(art.getGlassesTrait().storedImagesCount, 0); + + vm.startPrank(descriptor); vm.expectEmit(true, true, true, true); emit GlassesAdded(2); + art.addGlasses(FIRST_TWO_IMAGES_COMPRESSED, FIRST_TWO_IMAGES_DEFLATED_LENGTH, uint16(2)); + vm.expectEmit(true, true, true, true); emit GlassesAdded(2); - - vm.startPrank(descriptor); - art.addGlasses(FIRST_TWO_IMAGES_COMPRESSED, FIRST_TWO_IMAGES_DEFLATED_LENGTH, uint16(2)); art.addGlasses(NEXT_TWO_IMAGES_COMPRESSED, NEXT_TWO_IMAGES_DEFLATED_LENGTH, uint16(2)); vm.stopPrank(); diff --git a/packages/nouns-contracts/test/foundry/NounsDAOData.t.sol b/packages/nouns-contracts/test/foundry/NounsDAOData.t.sol index 06ef9f3695..cb99c87c49 100644 --- a/packages/nouns-contracts/test/foundry/NounsDAOData.t.sol +++ b/packages/nouns-contracts/test/foundry/NounsDAOData.t.sol @@ -4,7 +4,6 @@ pragma solidity ^0.8.15; import 'forge-std/Test.sol'; import { NounsDAOData } from '../../contracts/governance/data/NounsDAOData.sol'; import { NounsDAODataProxy } from '../../contracts/governance/data/NounsDAODataProxy.sol'; -import { NounsDAODataProxyAdmin } from '../../contracts/governance/data/NounsDAODataProxyAdmin.sol'; import { NounsTokenLikeMock } from './helpers/NounsTokenLikeMock.sol'; import { NounsDAOV3Proposals } from '../../contracts/governance/NounsDAOV3Proposals.sol'; import { SigUtils } from './helpers/SigUtils.sol'; @@ -48,7 +47,6 @@ contract NounsDAODataTest is Test, SigUtils { event ETHWithdrawn(address indexed to, uint256 amount); NounsTokenLikeMock tokenLikeMock; - NounsDAODataProxyAdmin proxyAdmin; NounsDAODataProxy proxy; NounsDAOData data; address dataAdmin = makeAddr('data admin'); @@ -57,7 +55,6 @@ contract NounsDAODataTest is Test, SigUtils { function setUp() public { tokenLikeMock = new NounsTokenLikeMock(); NounsDAOData logic = new NounsDAOData(address(tokenLikeMock), nounsDao); - proxyAdmin = new NounsDAODataProxyAdmin(); bytes memory initCallData = abi.encodeWithSignature( 'initialize(address,uint256,uint256)', @@ -66,7 +63,7 @@ contract NounsDAODataTest is Test, SigUtils { 0.01 ether ); - proxy = new NounsDAODataProxy(address(logic), address(proxyAdmin), initCallData); + proxy = new NounsDAODataProxy(address(logic), initCallData); data = NounsDAOData(address(proxy)); diff --git a/packages/nouns-contracts/test/foundry/NounsDAOLogicV1V2Shared.t.sol b/packages/nouns-contracts/test/foundry/NounsDAOLogicV1V2Shared.t.sol index 8a8c393f44..84155ff681 100644 --- a/packages/nouns-contracts/test/foundry/NounsDAOLogicV1V2Shared.t.sol +++ b/packages/nouns-contracts/test/foundry/NounsDAOLogicV1V2Shared.t.sol @@ -4,11 +4,11 @@ pragma solidity ^0.8.15; import 'forge-std/Test.sol'; import { NounsDAOLogicV1 } from '../../contracts/governance/NounsDAOLogicV1.sol'; import { NounsDAOLogicV2 } from '../../contracts/governance/NounsDAOLogicV2.sol'; +import { NounsDAOLogicV3 } from '../../contracts/governance/NounsDAOLogicV3.sol'; import { NounsDAOProxy } from '../../contracts/governance/NounsDAOProxy.sol'; import { NounsDAOProxyV2 } from '../../contracts/governance/NounsDAOProxyV2.sol'; -import { NounsDAOStorageV1, NounsDAOStorageV2 } from '../../contracts/governance/NounsDAOInterfaces.sol'; +import { NounsDAOStorageV1, NounsDAOStorageV2, NounsDAOStorageV3 } from '../../contracts/governance/NounsDAOInterfaces.sol'; import { NounsDescriptorV2 } from '../../contracts/NounsDescriptorV2.sol'; -import { DeployUtils } from './helpers/DeployUtils.sol'; import { NounsToken } from '../../contracts/NounsToken.sol'; import { NounsSeeder } from '../../contracts/NounsSeeder.sol'; import { IProxyRegistry } from '../../contracts/external/opensea/IProxyRegistry.sol'; @@ -32,7 +32,13 @@ abstract contract NounsDAOLogicV1V2StateTest is NounsDAOLogicSharedBaseTest { function testPendingGivenProposalJustCreated() public { uint256 proposalId = propose(address(0x1234), 100, '', ''); - assertTrue(daoProxy.state(proposalId) == NounsDAOStorageV1.ProposalState.Pending); + uint256 state = uint256(NounsDAOLogicV3(payable(address(daoProxy))).state(proposalId)); + + if (daoVersion() < 3) { + assertEq(state, uint256(NounsDAOStorageV1.ProposalState.Pending)); + } else { + assertEq(state, uint256(NounsDAOStorageV3.ProposalState.Updatable)); + } } function testActiveGivenProposalPastVotingDelay() public { @@ -174,12 +180,30 @@ abstract contract NounsDAOLogicV1V2StateTest is NounsDAOLogicSharedBaseTest { } } +contract NounsDAOLogicV1ForkStateTest is NounsDAOLogicV1V2StateTest { + function daoVersion() internal pure override returns (uint256) { + return 1; + } + + function deployDAOProxy( + address, + address, + address + ) internal override returns (NounsDAOLogicV1) { + return deployForkDAOProxy(); + } +} + contract NounsDAOLogicV1StateTest is NounsDAOLogicV1V2StateTest { function daoVersion() internal pure override returns (uint256) { return 1; } - function deployDAOProxy(address timelock, address nounsToken, address vetoer) internal override returns (NounsDAOLogicV1) { + function deployDAOProxy( + address timelock, + address nounsToken, + address vetoer + ) internal override returns (NounsDAOLogicV1) { NounsDAOLogicV1 daoLogic = new NounsDAOLogicV1(); return @@ -206,7 +230,11 @@ contract NounsDAOLogicV2StateTest is NounsDAOLogicV1V2StateTest { return 2; } - function deployDAOProxy(address timelock, address nounsToken, address vetoer) internal override returns (NounsDAOLogicV1) { + function deployDAOProxy( + address timelock, + address nounsToken, + address vetoer + ) internal override returns (NounsDAOLogicV1) { NounsDAOLogicV2 daoLogic = new NounsDAOLogicV2(); return @@ -232,6 +260,20 @@ contract NounsDAOLogicV2StateTest is NounsDAOLogicV1V2StateTest { } } +contract NounsDAOLogicV3StateTest is NounsDAOLogicV1V2StateTest { + function deployDAOProxy( + address timelock, + address nounsToken, + address vetoer + ) internal override returns (NounsDAOLogicV1) { + return _createDAOV3Proxy(timelock, nounsToken, vetoer); + } + + function daoVersion() internal pure override returns (uint256) { + return 3; + } +} + abstract contract NounsDAOLogicV1V2VetoingTest is NounsDAOLogicSharedBaseTest { function setUp() public override { super.setUp(); @@ -432,7 +474,11 @@ contract NounsDAOLogicV1VetoingTest is NounsDAOLogicV1V2VetoingTest { return 1; } - function deployDAOProxy(address timelock, address nounsToken, address vetoer) internal override returns (NounsDAOLogicV1) { + function deployDAOProxy( + address timelock, + address nounsToken, + address vetoer + ) internal override returns (NounsDAOLogicV1) { NounsDAOLogicV1 daoLogic = new NounsDAOLogicV1(); return @@ -530,7 +576,11 @@ contract NounsDAOLogicV2VetoingTest is NounsDAOLogicV1V2VetoingTest { return 2; } - function deployDAOProxy(address timelock, address nounsToken, address vetoer) internal override returns (NounsDAOLogicV1) { + function deployDAOProxy( + address timelock, + address nounsToken, + address vetoer + ) internal override returns (NounsDAOLogicV1) { NounsDAOLogicV2 daoLogic = new NounsDAOLogicV2(); return diff --git a/packages/nouns-contracts/test/foundry/NounsDAOLogicV3/CancelProposal.t.sol b/packages/nouns-contracts/test/foundry/NounsDAOLogicV3/CancelProposal.t.sol new file mode 100644 index 0000000000..d3bd5f8a8f --- /dev/null +++ b/packages/nouns-contracts/test/foundry/NounsDAOLogicV3/CancelProposal.t.sol @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.15; + +import 'forge-std/Test.sol'; +import { NounsDAOLogicV3BaseTest } from './NounsDAOLogicV3BaseTest.sol'; +import { NounsDAOStorageV3 } from '../../../contracts/governance/NounsDAOInterfaces.sol'; +import { NounsDAOV3Proposals } from '../../../contracts/governance/NounsDAOV3Proposals.sol'; + +abstract contract ZeroState is NounsDAOLogicV3BaseTest { + address proposer = makeAddr('proposer'); + address rando = makeAddr('rando'); + address otherUser = makeAddr('otherUser'); + uint256 proposalId; + + address target = makeAddr('target'); + + event ProposalCanceled(uint256 id); + + function verifyProposerCanCancel() internal { + vm.expectEmit(true, true, true, true); + emit ProposalCanceled(proposalId); + vm.prank(proposer); + dao.cancel(proposalId); + assertEq(uint256(dao.state(proposalId)), uint256(NounsDAOStorageV3.ProposalState.Canceled)); + } + + function verifyRandoCantCancel() internal { + vm.expectRevert(bytes('NounsDAO::cancel: proposer above threshold')); + vm.prank(rando); + dao.cancel(proposalId); + } + + function verifyRandoCanCancelIfProposerLosesVotingPower() internal { + vm.prank(proposer); + nounsToken.delegate(otherUser); + vm.roll(block.number + 1); + + vm.prank(rando); + dao.cancel(proposalId); + } +} + +abstract contract ProposalUpdatableState is ZeroState { + function setUp() public virtual override { + super.setUp(); + + // mint 1 noun to proposer + vm.startPrank(minter); + nounsToken.mint(); + nounsToken.transferFrom(minter, proposer, 1); + vm.roll(block.number + 1); + vm.stopPrank(); + + proposalId = propose(proposer, target, 0, '', '', ''); + vm.roll(block.number + 1); + + assertEq(uint256(dao.state(proposalId)), uint256(NounsDAOStorageV3.ProposalState.Updatable)); + } +} + +abstract contract IsCancellable is ZeroState { + function test_proposerCanCancel() public { + verifyProposerCanCancel(); + } + + function test_randoCantCancel() public { + verifyRandoCantCancel(); + } + + function test_randoCanCancelIfProposerLosesVotingPower() public { + verifyRandoCanCancelIfProposerLosesVotingPower(); + } +} + +abstract contract IsNotCancellable is ZeroState { + function test_proposerCantCancel() public { + vm.expectRevert(NounsDAOV3Proposals.CantCancelProposalAtFinalState.selector); + vm.prank(proposer); + dao.cancel(proposalId); + } +} + +contract ProposalUpdatableStateTest is ProposalUpdatableState, IsCancellable { + function setUp() public override(ProposalUpdatableState, NounsDAOLogicV3BaseTest) { + ProposalUpdatableState.setUp(); + } +} + +abstract contract ProposalPendingState is ProposalUpdatableState { + function setUp() public virtual override { + super.setUp(); + + vm.roll(dao.proposalsV3(proposalId).updatePeriodEndBlock + 1); + assertEq(uint256(dao.state(proposalId)), uint256(NounsDAOStorageV3.ProposalState.Pending)); + } +} + +contract ProposalPendingStateTest is ProposalPendingState, IsCancellable { + function setUp() public override(ProposalPendingState, NounsDAOLogicV3BaseTest) { + ProposalPendingState.setUp(); + } +} + +abstract contract ProposalActiveState is ProposalPendingState { + function setUp() public virtual override { + super.setUp(); + + vm.roll(dao.proposalsV3(proposalId).startBlock + 1); + assertEq(uint256(dao.state(proposalId)), uint256(NounsDAOStorageV3.ProposalState.Active)); + } +} + +contract ProposalActiveStateTest is ProposalActiveState, IsCancellable { + function setUp() public override(ProposalActiveState, NounsDAOLogicV3BaseTest) { + ProposalActiveState.setUp(); + } +} + +abstract contract ProposalObjectionPeriodState is ProposalActiveState { + function setUp() public virtual override { + super.setUp(); + + vm.roll(dao.proposalsV3(proposalId).endBlock - 1); + vm.prank(proposer); + dao.castVote(proposalId, 1); + + vm.roll(dao.proposalsV3(proposalId).endBlock + 1); + assertEq(uint256(dao.state(proposalId)), uint256(NounsDAOStorageV3.ProposalState.ObjectionPeriod)); + } +} + +contract ProposalObjectionPeriodStateTest is ProposalObjectionPeriodState, IsCancellable { + function setUp() public override(ProposalObjectionPeriodState, NounsDAOLogicV3BaseTest) { + ProposalObjectionPeriodState.setUp(); + } +} + +abstract contract ProposalSucceededState is ProposalActiveState { + function setUp() public virtual override { + super.setUp(); + + vm.prank(proposer); + dao.castVote(proposalId, 1); + + vm.roll(dao.proposalsV3(proposalId).endBlock + 1); + assertEq(uint256(dao.state(proposalId)), uint256(NounsDAOStorageV3.ProposalState.Succeeded)); + } +} + +contract ProposalSucceededStateTest is ProposalSucceededState, IsCancellable { + function setUp() public override(ProposalSucceededState, NounsDAOLogicV3BaseTest) { + ProposalSucceededState.setUp(); + } +} + +abstract contract ProposalQueuedState is ProposalSucceededState { + function setUp() public virtual override { + super.setUp(); + + dao.queue(proposalId); + assertEq(uint256(dao.state(proposalId)), uint256(NounsDAOStorageV3.ProposalState.Queued)); + } +} + +contract ProposalQueuedStateTest is ProposalQueuedState, IsCancellable { + function setUp() public override(ProposalQueuedState, NounsDAOLogicV3BaseTest) { + ProposalQueuedState.setUp(); + } +} + +abstract contract ProposalExecutedState is ProposalQueuedState { + function setUp() public virtual override { + super.setUp(); + + vm.warp(dao.proposalsV3(proposalId).eta + 1); + dao.execute(proposalId); + assertEq(uint256(dao.state(proposalId)), uint256(NounsDAOStorageV3.ProposalState.Executed)); + } +} + +contract ProposalExecutedStateTest is ProposalExecutedState, IsNotCancellable { + function setUp() public override(ProposalExecutedState, NounsDAOLogicV3BaseTest) { + ProposalExecutedState.setUp(); + } +} + +abstract contract ProposalDefeatedState is ProposalActiveState { + function setUp() public virtual override { + super.setUp(); + + vm.roll(dao.proposalsV3(proposalId).endBlock + 1); + assertEq(uint256(dao.state(proposalId)), uint256(NounsDAOStorageV3.ProposalState.Defeated)); + } +} + +contract ProposalDefeatedStateTest is ProposalDefeatedState, IsNotCancellable { + function setUp() public override(ProposalDefeatedState, NounsDAOLogicV3BaseTest) { + ProposalDefeatedState.setUp(); + } +} diff --git a/packages/nouns-contracts/test/foundry/NounsDAOLogicV3/CancelProposalBySigs.t.sol b/packages/nouns-contracts/test/foundry/NounsDAOLogicV3/CancelProposalBySigs.t.sol new file mode 100644 index 0000000000..6647452dd1 --- /dev/null +++ b/packages/nouns-contracts/test/foundry/NounsDAOLogicV3/CancelProposalBySigs.t.sol @@ -0,0 +1,208 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.15; + +import 'forge-std/Test.sol'; +import { NounsDAOLogicV3BaseTest } from './NounsDAOLogicV3BaseTest.sol'; +import { NounsDAOStorageV3 } from '../../../contracts/governance/NounsDAOInterfaces.sol'; +import { NounsDAOV3Proposals } from '../../../contracts/governance/NounsDAOV3Proposals.sol'; + +abstract contract ZeroState is NounsDAOLogicV3BaseTest { + address proposer = makeAddr('proposer'); + address rando = makeAddr('rando'); + address otherUser = makeAddr('otherUser'); + uint256 proposalId; + address signerWithVote; + uint256 signerWithVotePK; + address target = makeAddr('target'); + + event ProposalCanceled(uint256 id); +} + +abstract contract ProposalUpdatableState is ZeroState { + function setUp() public virtual override { + super.setUp(); + + (signerWithVote, signerWithVotePK) = makeAddrAndKey('signerWithVote'); + + vm.startPrank(minter); + nounsToken.mint(); + nounsToken.transferFrom(minter, signerWithVote, 1); + vm.roll(block.number + 1); + vm.stopPrank(); + + NounsDAOV3Proposals.ProposalTxs memory txs = makeTxs(makeAddr('target'), 0, '', ''); + uint256 expirationTimestamp = block.timestamp + 1234; + NounsDAOStorageV3.ProposerSignature[] memory proposerSignatures = new NounsDAOStorageV3.ProposerSignature[](1); + proposerSignatures[0] = NounsDAOStorageV3.ProposerSignature( + signProposal(proposer, signerWithVotePK, txs, 'description', expirationTimestamp, address(dao)), + signerWithVote, + expirationTimestamp + ); + + vm.prank(proposer); + proposalId = dao.proposeBySigs( + proposerSignatures, + txs.targets, + txs.values, + txs.signatures, + txs.calldatas, + 'description' + ); + + vm.roll(block.number + 1); + + assertEq(uint256(dao.state(proposalId)), uint256(NounsDAOStorageV3.ProposalState.Updatable)); + } +} + +abstract contract IsCancellable is ZeroState { + function test_proposerCanCancel() public { + vm.expectEmit(true, true, true, true); + emit ProposalCanceled(proposalId); + vm.prank(proposer); + dao.cancel(proposalId); + assertEq(uint256(dao.state(proposalId)), uint256(NounsDAOStorageV3.ProposalState.Canceled)); + } + + function test_randoCantCancel() public { + vm.expectRevert(bytes('NounsDAO::cancel: proposer above threshold')); + vm.prank(rando); + dao.cancel(proposalId); + } + + function test_randoCanCancelIfSignerLosesVotingPower() public { + vm.prank(signerWithVote); + nounsToken.delegate(otherUser); + vm.roll(block.number + 1); + + vm.prank(rando); + dao.cancel(proposalId); + } +} + +abstract contract IsNotCancellable is ZeroState { + function test_proposerCantCancel() public { + vm.expectRevert(NounsDAOV3Proposals.CantCancelProposalAtFinalState.selector); + vm.prank(proposer); + dao.cancel(proposalId); + } +} + +contract ProposalUpdatableStateTest is ProposalUpdatableState, IsCancellable { + function setUp() public override(ProposalUpdatableState, NounsDAOLogicV3BaseTest) { + ProposalUpdatableState.setUp(); + } +} + +abstract contract ProposalPendingState is ProposalUpdatableState { + function setUp() public virtual override { + super.setUp(); + + vm.roll(dao.proposalsV3(proposalId).updatePeriodEndBlock + 1); + assertEq(uint256(dao.state(proposalId)), uint256(NounsDAOStorageV3.ProposalState.Pending)); + } +} + +contract ProposalPendingStateTest is ProposalPendingState, IsCancellable { + function setUp() public override(ProposalPendingState, NounsDAOLogicV3BaseTest) { + ProposalPendingState.setUp(); + } +} + +abstract contract ProposalActiveState is ProposalPendingState { + function setUp() public virtual override { + super.setUp(); + + vm.roll(dao.proposalsV3(proposalId).startBlock + 1); + assertEq(uint256(dao.state(proposalId)), uint256(NounsDAOStorageV3.ProposalState.Active)); + } +} + +contract ProposalActiveStateTest is ProposalActiveState, IsCancellable { + function setUp() public override(ProposalActiveState, NounsDAOLogicV3BaseTest) { + ProposalActiveState.setUp(); + } +} + +abstract contract ProposalObjectionPeriodState is ProposalActiveState { + function setUp() public virtual override { + super.setUp(); + + vm.roll(dao.proposalsV3(proposalId).endBlock - 1); + vm.prank(signerWithVote); + dao.castVote(proposalId, 1); + + vm.roll(dao.proposalsV3(proposalId).endBlock + 1); + assertEq(uint256(dao.state(proposalId)), uint256(NounsDAOStorageV3.ProposalState.ObjectionPeriod)); + } +} + +contract ProposalObjectionPeriodStateTest is ProposalObjectionPeriodState, IsCancellable { + function setUp() public override(ProposalObjectionPeriodState, NounsDAOLogicV3BaseTest) { + ProposalObjectionPeriodState.setUp(); + } +} + +abstract contract ProposalSucceededState is ProposalActiveState { + function setUp() public virtual override { + super.setUp(); + + vm.prank(signerWithVote); + dao.castVote(proposalId, 1); + + vm.roll(dao.proposalsV3(proposalId).endBlock + 1); + assertEq(uint256(dao.state(proposalId)), uint256(NounsDAOStorageV3.ProposalState.Succeeded)); + } +} + +contract ProposalSucceededStateTest is ProposalSucceededState, IsCancellable { + function setUp() public override(ProposalSucceededState, NounsDAOLogicV3BaseTest) { + ProposalSucceededState.setUp(); + } +} + +abstract contract ProposalQueuedState is ProposalSucceededState { + function setUp() public virtual override { + super.setUp(); + + dao.queue(proposalId); + assertEq(uint256(dao.state(proposalId)), uint256(NounsDAOStorageV3.ProposalState.Queued)); + } +} + +contract ProposalQueuedStateTest is ProposalQueuedState, IsCancellable { + function setUp() public override(ProposalQueuedState, NounsDAOLogicV3BaseTest) { + ProposalQueuedState.setUp(); + } +} + +abstract contract ProposalExecutedState is ProposalQueuedState { + function setUp() public virtual override { + super.setUp(); + + vm.warp(dao.proposalsV3(proposalId).eta + 1); + dao.execute(proposalId); + assertEq(uint256(dao.state(proposalId)), uint256(NounsDAOStorageV3.ProposalState.Executed)); + } +} + +contract ProposalExecutedStateTest is ProposalExecutedState, IsNotCancellable { + function setUp() public override(ProposalExecutedState, NounsDAOLogicV3BaseTest) { + ProposalExecutedState.setUp(); + } +} + +abstract contract ProposalDefeatedState is ProposalActiveState { + function setUp() public virtual override { + super.setUp(); + + vm.roll(dao.proposalsV3(proposalId).endBlock + 1); + assertEq(uint256(dao.state(proposalId)), uint256(NounsDAOStorageV3.ProposalState.Defeated)); + } +} + +contract ProposalDefeatedStateTest is ProposalDefeatedState, IsNotCancellable { + function setUp() public override(ProposalDefeatedState, NounsDAOLogicV3BaseTest) { + ProposalDefeatedState.setUp(); + } +} diff --git a/packages/nouns-contracts/test/foundry/NounsDAOLogicV3/ForkBlocksProposalExecution.t.sol b/packages/nouns-contracts/test/foundry/NounsDAOLogicV3/ForkBlocksProposalExecution.t.sol new file mode 100644 index 0000000000..fd55572337 --- /dev/null +++ b/packages/nouns-contracts/test/foundry/NounsDAOLogicV3/ForkBlocksProposalExecution.t.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.15; + +import 'forge-std/Test.sol'; +import { NounsDAOLogicV3BaseTest } from './NounsDAOLogicV3BaseTest.sol'; +import { NounsDAOV3Proposals } from '../../../contracts/governance/NounsDAOV3Proposals.sol'; + +abstract contract ExecutableProposalState is NounsDAOLogicV3BaseTest { + address user = makeAddr('user'); + uint256 proposalId; + + function setUp() public virtual override { + super.setUp(); + + vm.startPrank(minter); + nounsToken.mint(); + nounsToken.transferFrom(minter, user, 1); + vm.stopPrank(); + vm.roll(block.number + 1); + + // prep an executable proposal + proposalId = propose(user, makeAddr('target'), 0, '', '', ''); + + vm.roll(block.number + dao.votingDelay() + dao.proposalUpdatablePeriodInBlocks() + 1); + + vm.prank(user); + dao.castVote(proposalId, 1); + + vm.roll(block.number + dao.votingPeriod()); + dao.queue(proposalId); + + vm.warp(block.timestamp + timelock.delay()); + } +} + +contract ExecutableProposalStateTest is ExecutableProposalState { + function test_executionWorksWhenNoActiveFork() public { + dao.execute(proposalId); + } +} + +abstract contract ExecutableProposalWithActiveForkState is ExecutableProposalState { + uint256[] tokenIds; + + function setUp() public virtual override { + super.setUp(); + + vm.startPrank(user); + tokenIds = [1]; + nounsToken.approve(address(dao), 1); + dao.escrowToFork(tokenIds, new uint256[](0), ''); + vm.stopPrank(); + + dao.executeFork(); + } +} + +contract ExecutableProposalWithActiveForkStateTest is ExecutableProposalWithActiveForkState { + function test_executionRevertsDuringFork() public { + vm.expectRevert(NounsDAOV3Proposals.CannotExecuteDuringForkingPeriod.selector); + dao.execute(proposalId); + } + + function test_executionWorksAfterForkIsDone() public { + vm.warp(dao.forkEndTimestamp() + 1); + dao.execute(proposalId); + } +} diff --git a/packages/nouns-contracts/test/foundry/NounsDAOLogicV3/NounsDAOLogicV3BaseTest.sol b/packages/nouns-contracts/test/foundry/NounsDAOLogicV3/NounsDAOLogicV3BaseTest.sol index d59e92e9ee..e2500934cc 100644 --- a/packages/nouns-contracts/test/foundry/NounsDAOLogicV3/NounsDAOLogicV3BaseTest.sol +++ b/packages/nouns-contracts/test/foundry/NounsDAOLogicV3/NounsDAOLogicV3BaseTest.sol @@ -2,8 +2,9 @@ pragma solidity ^0.8.15; import 'forge-std/Test.sol'; -import { DeployUtils } from '../helpers/DeployUtils.sol'; +import { DeployUtilsV3 } from '../helpers/DeployUtilsV3.sol'; import { SigUtils, ERC1271Stub } from '../helpers/SigUtils.sol'; +import { ProxyRegistryMock } from '../helpers/ProxyRegistryMock.sol'; import { NounsDAOLogicV3 } from '../../../contracts/governance/NounsDAOLogicV3.sol'; import { NounsDAOV3Proposals } from '../../../contracts/governance/NounsDAOV3Proposals.sol'; import { NounsDAOProxyV3 } from '../../../contracts/governance/NounsDAOProxyV3.sol'; @@ -11,9 +12,10 @@ import { NounsDAOStorageV3 } from '../../../contracts/governance/NounsDAOInterfa import { NounsToken } from '../../../contracts/NounsToken.sol'; import { NounsSeeder } from '../../../contracts/NounsSeeder.sol'; import { IProxyRegistry } from '../../../contracts/external/opensea/IProxyRegistry.sol'; -import { NounsDAOExecutor } from '../../../contracts/governance/NounsDAOExecutor.sol'; +import { NounsDAOExecutorV2 } from '../../../contracts/governance/NounsDAOExecutorV2.sol'; +import { NounsDAOForkEscrow } from '../../../contracts/governance/fork/NounsDAOForkEscrow.sol'; -abstract contract NounsDAOLogicV3BaseTest is Test, DeployUtils, SigUtils { +abstract contract NounsDAOLogicV3BaseTest is Test, DeployUtilsV3, SigUtils { event ProposalUpdated( uint256 indexed id, address indexed proposer, @@ -35,7 +37,12 @@ abstract contract NounsDAOLogicV3BaseTest is Test, DeployUtils, SigUtils { string updateMessage ); - event ProposalDescriptionUpdated(uint256 indexed id, address indexed proposer, string description, string updateMessage); + event ProposalDescriptionUpdated( + uint256 indexed id, + address indexed proposer, + string description, + string updateMessage + ); event ProposalCreated( uint256 id, @@ -67,54 +74,22 @@ abstract contract NounsDAOLogicV3BaseTest is Test, DeployUtils, SigUtils { NounsToken nounsToken; NounsDAOLogicV3 dao; - NounsDAOExecutor timelock; + NounsDAOExecutorV2 timelock; address noundersDAO = makeAddr('nounders'); - address minter = makeAddr('minter'); + address minter; address vetoer = makeAddr('vetoer'); uint32 lastMinuteWindowInBlocks = 10; uint32 objectionPeriodDurationInBlocks = 10; uint32 proposalUpdatablePeriodInBlocks = 10; + address forkEscrow; function setUp() public virtual { - timelock = new NounsDAOExecutor(address(1), TIMELOCK_DELAY); - - nounsToken = new NounsToken( - noundersDAO, - minter, - _deployAndPopulateV2(), - new NounsSeeder(), - IProxyRegistry(address(0)) - ); - nounsToken.transferOwnership(address(timelock)); - - dao = NounsDAOLogicV3( - payable( - new NounsDAOProxyV3( - address(timelock), - address(nounsToken), - vetoer, - address(timelock), - address(new NounsDAOLogicV3()), - VOTING_PERIOD, - VOTING_DELAY, - PROPOSAL_THRESHOLD, - NounsDAOStorageV3.DynamicQuorumParams({ - minQuorumVotesBPS: 200, - maxQuorumVotesBPS: 2000, - quorumCoefficient: 10000 - }), - lastMinuteWindowInBlocks, - objectionPeriodDurationInBlocks, - proposalUpdatablePeriodInBlocks - ) - ) - ); - - vm.prank(address(timelock)); - timelock.setPendingAdmin(address(dao)); - vm.prank(address(dao)); - timelock.acceptAdmin(); + dao = _deployDAOV3(); + nounsToken = NounsToken(address(dao.nouns())); + minter = nounsToken.minter(); + timelock = NounsDAOExecutorV2(payable(address(dao.timelock()))); + forkEscrow = address(dao.forkEscrow()); } function propose( diff --git a/packages/nouns-contracts/test/foundry/NounsDAOLogicV3/NounsDAOV3Admin.t.sol b/packages/nouns-contracts/test/foundry/NounsDAOLogicV3/NounsDAOV3Admin.t.sol new file mode 100644 index 0000000000..5fdf61c62e --- /dev/null +++ b/packages/nouns-contracts/test/foundry/NounsDAOLogicV3/NounsDAOV3Admin.t.sol @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.15; + +import 'forge-std/Test.sol'; +import { NounsDAOLogicV3BaseTest } from './NounsDAOLogicV3BaseTest.sol'; +import { NounsDAOV3Admin } from '../../../contracts/governance/NounsDAOV3Admin.sol'; +import { NounsDAOProxy } from '../../../contracts/governance/NounsDAOProxy.sol'; + +contract NounsDAOLogicV3AdminTest is NounsDAOLogicV3BaseTest { + event ForkPeriodSet(uint256 oldForkPeriod, uint256 newForkPeriod); + event ForkThresholdSet(uint256 oldForkThreshold, uint256 newForkThreshold); + event ERC20TokensToIncludeInForkSet(address[] oldErc20Tokens, address[] newErc20tokens); + event ObjectionPeriodDurationSet( + uint32 oldObjectionPeriodDurationInBlocks, + uint32 newObjectionPeriodDurationInBlocks + ); + event ProposalUpdatablePeriodSet( + uint32 oldProposalUpdatablePeriodInBlocks, + uint32 newProposalUpdatablePeriodInBlocks + ); + + address[] tokens; + + function test_setForkPeriod_onlyAdmin() public { + vm.expectRevert(NounsDAOV3Admin.AdminOnly.selector); + dao._setForkPeriod(8 days); + } + + function test_setForkPeriod_works() public { + vm.prank(address(dao.timelock())); + vm.expectEmit(true, true, true, true); + emit ForkPeriodSet(7 days, 8 days); + dao._setForkPeriod(8 days); + + assertEq(dao.forkPeriod(), 8 days); + } + + function test_setForkPeriod_limitedByUpperBound() public { + vm.startPrank(address(dao.timelock())); + + // doesn't revert + dao._setForkPeriod(14 days); + + vm.expectRevert(NounsDAOV3Admin.ForkPeriodTooLong.selector); + dao._setForkPeriod(14 days + 1); + } + + function test_setForkThresholdBPS_onlyAdmin() public { + vm.expectRevert(NounsDAOV3Admin.AdminOnly.selector); + dao._setForkThresholdBPS(2000); + } + + function test_setForkThresholdBPS_works() public { + vm.prank(address(dao.timelock())); + vm.expectEmit(true, true, true, true); + emit ForkThresholdSet(2000, 1234); + dao._setForkThresholdBPS(1234); + + assertEq(dao.forkThresholdBPS(), 1234); + } + + function test_setErc20TokensToIncludeInFork_onlyAdmin() public { + tokens = [address(1), address(2)]; + + vm.expectRevert(NounsDAOV3Admin.AdminOnly.selector); + dao._setErc20TokensToIncludeInFork(tokens); + } + + function test_setErc20TokensToIncludeInFork_works() public { + tokens = [address(1), address(2)]; + + vm.prank(address(dao.timelock())); + vm.expectEmit(true, true, true, true); + emit ERC20TokensToIncludeInForkSet(new address[](0), tokens); + dao._setErc20TokensToIncludeInFork(tokens); + + assertEq(dao.erc20TokensToIncludeInFork(), tokens); + } + + function test_setForkEscrow_onlyAdmin() public { + vm.expectRevert(NounsDAOV3Admin.AdminOnly.selector); + dao._setForkEscrow(address(1)); + } + + function test_setForkEscrow_works() public { + vm.prank(address(dao.timelock())); + dao._setForkEscrow(address(1)); + + assertEq(address(dao.forkEscrow()), address(1)); + } + + function test_setTimelocks_onlyAdmin() public { + vm.expectRevert(NounsDAOV3Admin.AdminOnly.selector); + dao._setTimelocksAndAdmin(address(1), address(2), address(3)); + } + + function test_setTimelocks_works() public { + vm.prank(address(dao.timelock())); + dao._setTimelocksAndAdmin(address(1), address(2), address(3)); + + assertEq(address(dao.timelock()), address(1)); + assertEq(address(dao.timelockV1()), address(2)); + assertEq(NounsDAOProxy(payable(address(dao))).admin(), address(3)); + } + + function test_setVoteSnapshotBlockSwitchProposalId_onlyAdmin() public { + vm.expectRevert(NounsDAOV3Admin.AdminOnly.selector); + dao._setVoteSnapshotBlockSwitchProposalId(); + } + + function test_setVoteSnapshotBlockSwitchProposalId_setsToNextProposalId() public { + vm.prank(address(dao.timelock())); + dao._setVoteSnapshotBlockSwitchProposalId(); + + assertEq(dao.voteSnapshotBlockSwitchProposalId(), 1); + + // overwrite proposalCount + vm.store(address(dao), bytes32(uint256(8)), bytes32(uint256(100))); + + vm.prank(address(dao.timelock())); + dao._setVoteSnapshotBlockSwitchProposalId(); + + assertEq(dao.voteSnapshotBlockSwitchProposalId(), 101); + } + + function test_setObjectionPeriodDurationInBlocks_onlyAdmin() public { + vm.expectRevert(NounsDAOV3Admin.AdminOnly.selector); + dao._setObjectionPeriodDurationInBlocks(3 days / 12); + } + + function test_setObjectionPeriodDurationInBlocks_worksForAdmin() public { + uint32 blocks = 3 days / 12; + vm.expectEmit(true, true, true, true); + emit ObjectionPeriodDurationSet(10, blocks); + + vm.prank(address(dao.timelock())); + dao._setObjectionPeriodDurationInBlocks(blocks); + + assertEq(dao.objectionPeriodDurationInBlocks(), blocks); + } + + function test_setObjectionPeriodDurationInBlocks_givenValueAboveUpperBound_reverts() public { + uint32 blocks = 8 days / 12; + + vm.prank(address(dao.timelock())); + vm.expectRevert(NounsDAOV3Admin.InvalidObjectionPeriodDurationInBlocks.selector); + dao._setObjectionPeriodDurationInBlocks(blocks); + } + + function test_setProposalUpdatablePeriodInBlocks_onlyAdmin() public { + vm.expectRevert(NounsDAOV3Admin.AdminOnly.selector); + dao._setProposalUpdatablePeriodInBlocks(3 days / 12); + } + + function test_setProposalUpdatablePeriodInBlocks_worksForAdmin() public { + uint32 blocks = 3 days / 12; + vm.expectEmit(true, true, true, true); + emit ProposalUpdatablePeriodSet(10, blocks); + + vm.prank(address(dao.timelock())); + dao._setProposalUpdatablePeriodInBlocks(blocks); + + assertEq(dao.proposalUpdatablePeriodInBlocks(), blocks); + } + + function test_setProposalUpdatablePeriodInBlocks_givenValueAboveUpperBound_reverts() public { + uint32 blocks = 8 days / 12; + + vm.prank(address(dao.timelock())); + vm.expectRevert(NounsDAOV3Admin.InvalidProposalUpdatablePeriodInBlocks.selector); + dao._setProposalUpdatablePeriodInBlocks(blocks); + } +} diff --git a/packages/nouns-contracts/test/foundry/NounsDAOLogicV3/NounsDAOV3Fork.t.sol b/packages/nouns-contracts/test/foundry/NounsDAOLogicV3/NounsDAOV3Fork.t.sol new file mode 100644 index 0000000000..2731784c9d --- /dev/null +++ b/packages/nouns-contracts/test/foundry/NounsDAOLogicV3/NounsDAOV3Fork.t.sol @@ -0,0 +1,481 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.15; + +import 'forge-std/Test.sol'; +import { NounsDAOLogicV3BaseTest } from './NounsDAOLogicV3BaseTest.sol'; +import { ForkDAODeployerMock } from '../helpers/ForkDAODeployerMock.sol'; +import { ERC20Mock } from '../helpers/ERC20Mock.sol'; +import { NounsDAOV3Fork } from '../../../contracts/governance/fork/NounsDAOV3Fork.sol'; +import { NounsDAOForkEscrow } from '../../../contracts/governance/fork/NounsDAOForkEscrow.sol'; +import { IERC721 } from '@openzeppelin/contracts/token/ERC721/IERC721.sol'; +import { INounsDAOForkEscrow } from '../../../contracts/governance/NounsDAOInterfaces.sol'; + +abstract contract DAOForkZeroState is NounsDAOLogicV3BaseTest { + address tokenHolder = makeAddr('tokenHolder'); + address tokenHolder2 = makeAddr('tokenHolder2'); + uint256[] tokenIds; + uint256[] proposalIds; + ForkDAODeployerMock forkDAODeployer; + ERC20Mock erc20Mock = new ERC20Mock(); + address[] erc20Tokens = [address(erc20Mock)]; + INounsDAOForkEscrow escrow; + + function setUp() public virtual override { + super.setUp(); + + forkDAODeployer = new ForkDAODeployerMock(); + vm.startPrank(address(timelock)); + dao._setForkDAODeployer(address(forkDAODeployer)); + dao._setErc20TokensToIncludeInFork(erc20Tokens); + vm.stopPrank(); + + // Seed treasury with 1000 ETH and 300e18 of an erc20 token + deal(address(timelock), 1000 ether); + erc20Mock.mint(address(timelock), 300e18); + + // Mint total of 20 tokens. 18 to token holder, 2 to nounders + vm.startPrank(minter); + while (nounsToken.totalSupply() < 20) { + nounsToken.mint(); + nounsToken.transferFrom(minter, tokenHolder, nounsToken.totalSupply() - 1); + } + vm.stopPrank(); + assertEq(dao.nouns().balanceOf(tokenHolder), 18); + + escrow = dao.forkEscrow(); + } + + function assertOwnerOfTokens( + address token, + uint256[] memory tokenIds_, + address owner + ) internal { + for (uint256 i = 0; i < tokenIds_.length; i++) { + assertEq(IERC721(token).ownerOf(tokenIds_[i]), owner); + } + } +} + +contract DAOForkZeroStateTest is DAOForkZeroState { + function test_signalFork_transfersTokens() public { + tokenIds = [1, 2, 3]; + + vm.startPrank(tokenHolder); + nounsToken.setApprovalForAll(address(dao), true); + dao.escrowToFork(tokenIds, new uint256[](0), ''); + vm.stopPrank(); + + assertEq(dao.nouns().balanceOf(tokenHolder), 15); + } + + function test_escrowToForkEmitsEvent() public { + tokenIds = [1, 2, 3]; + proposalIds = [4, 5, 6]; + + vm.startPrank(tokenHolder); + nounsToken.setApprovalForAll(address(dao), true); + + vm.expectEmit(true, true, true, true); + emit NounsDAOV3Fork.EscrowedToFork(escrow.forkId(), tokenHolder, tokenIds, proposalIds, 'time to fork'); + dao.escrowToFork(tokenIds, proposalIds, 'time to fork'); + } + + function test_executeFork_reverts() public { + vm.expectRevert(NounsDAOV3Fork.ForkThresholdNotMet.selector); + dao.executeFork(); + } + + function test_joinFork_reverts() public { + tokenIds = [4, 5]; + vm.expectRevert(NounsDAOV3Fork.ForkPeriodNotActive.selector); + vm.prank(tokenHolder); + dao.joinFork(tokenIds, new uint256[](0), ''); + } + + function test_withdrawDAONounsFromEscrow_onlyAdmin() public { + vm.expectRevert(NounsDAOV3Fork.AdminOnly.selector); + dao.withdrawDAONounsFromEscrow(tokenIds, address(1)); + } +} + +abstract contract DAOForkSignaledUnderThresholdState is DAOForkZeroState { + function setUp() public virtual override { + super.setUp(); + + // signal fork with 3 tokens (15%) + tokenIds = [1, 2, 3]; + + vm.startPrank(tokenHolder); + nounsToken.setApprovalForAll(address(dao), true); + dao.escrowToFork(tokenIds, new uint256[](0), ''); + vm.stopPrank(); + } +} + +contract DAOForkSignaledUnderThresholdStateTest is DAOForkSignaledUnderThresholdState { + function test_executeFork_reverts() public { + vm.expectRevert(NounsDAOV3Fork.ForkThresholdNotMet.selector); + dao.executeFork(); + } + + function test_joinFork_reverts() public { + tokenIds = [4, 5]; + vm.expectRevert(NounsDAOV3Fork.ForkPeriodNotActive.selector); + vm.prank(tokenHolder); + dao.joinFork(tokenIds, new uint256[](0), ''); + } + + function test_unsignalFork_returnsTokens() public { + assertEq(dao.nouns().balanceOf(tokenHolder), 15); + + tokenIds = [1, 2, 3]; + + vm.expectEmit(true, true, true, true); + emit NounsDAOV3Fork.WithdrawFromForkEscrow(escrow.forkId(), tokenHolder, tokenIds); + vm.prank(tokenHolder); + dao.withdrawFromForkEscrow(tokenIds); + + assertEq(dao.nouns().balanceOf(tokenHolder), 18); + } + + function test_unsignalForkWithDifferentTokens_reverts() public { + // move Noun #7 to tokenHolder2 + vm.prank(tokenHolder); + nounsToken.transferFrom(tokenHolder, tokenHolder2, 7); + assertEq(dao.nouns().ownerOf(7), tokenHolder2); + + // tokenHolder2 signals fork with Noun #7 + vm.startPrank(tokenHolder2); + nounsToken.approve(address(dao), 7); + tokenIds = [7]; + dao.escrowToFork(tokenIds, new uint256[](0), ''); + vm.stopPrank(); + + tokenIds = [7]; + vm.expectRevert(NounsDAOForkEscrow.NotOwner.selector); + vm.prank(tokenHolder); + dao.withdrawFromForkEscrow(tokenIds); + } + + function test_withdrawTokensToDAO_reverts() public { + tokenIds = [1]; + vm.prank(address(dao.timelock())); + vm.expectRevert(NounsDAOForkEscrow.NotOwner.selector); + dao.withdrawDAONounsFromEscrow(tokenIds, address(1)); + } +} + +abstract contract DAOForkSignaledOverThresholdState is DAOForkSignaledUnderThresholdState { + function setUp() public virtual override { + super.setUp(); + + // signal fork with 5 tokens (25%) + tokenIds = [4, 5]; + + vm.startPrank(tokenHolder); + nounsToken.setApprovalForAll(address(dao), true); + dao.escrowToFork(tokenIds, new uint256[](0), ''); + vm.stopPrank(); + } +} + +contract DAOForkSignaledOverThresholdStateTest is DAOForkSignaledOverThresholdState { + function test_increaseForkThreshold() public { + vm.prank(address(dao.timelock())); + dao._setForkThresholdBPS(3_000); // 30% + + vm.expectRevert(NounsDAOV3Fork.ForkThresholdNotMet.selector); + dao.executeFork(); + + tokenIds = [6]; + vm.prank(tokenHolder); + dao.escrowToFork(tokenIds, new uint256[](0), ''); + + dao.executeFork(); + } + + function test_joinFork_reverts() public { + tokenIds = [6, 7]; + vm.expectRevert(NounsDAOV3Fork.ForkPeriodNotActive.selector); + vm.prank(tokenHolder); + dao.joinFork(tokenIds, new uint256[](0), ''); + } + + event ETHSent(address indexed to, uint256 amount, bool success); + event ERC20Sent(address indexed to, address indexed erc20Token, uint256 amount, bool success); + + function test_executeFork() public { + vm.expectEmit(true, true, true, true); + emit ETHSent(address(forkDAODeployer.mockTreasury()), 250 ether, true); + vm.expectEmit(true, true, true, true); + emit ERC20Sent(address(forkDAODeployer.mockTreasury()), address(erc20Mock), 75 ether, true); + vm.expectEmit(true, true, true, true); + emit NounsDAOV3Fork.ExecuteFork( + 0, + forkDAODeployer.mockTreasury(), + forkDAODeployer.mockToken(), + block.timestamp + dao.forkPeriod(), + 5 + ); + dao.executeFork(); + + // 25% of treasury should be sent to new DAO + assertEq(address(timelock).balance, 750 ether); + assertEq(address(forkDAODeployer.mockTreasury()).balance, 250 ether); + + // 25% of erc20 should be sent to new DAO + assertEq(erc20Mock.balanceOf(address(timelock)), 225e18); + assertEq(erc20Mock.balanceOf(address(forkDAODeployer.mockTreasury())), 75e18); + } + + function test_unsignalForkUnderThreshold_blocksExecuteFork() public { + tokenIds = [1, 2, 3]; + + vm.prank(tokenHolder); + dao.withdrawFromForkEscrow(tokenIds); + + vm.expectRevert(NounsDAOV3Fork.ForkThresholdNotMet.selector); + dao.executeFork(); + } + + function test_withdrawTokensToDAO_reverts() public { + tokenIds = [1]; + vm.prank(address(dao.timelock())); + vm.expectRevert(NounsDAOForkEscrow.NotOwner.selector); + dao.withdrawDAONounsFromEscrow(tokenIds, address(1)); + } + + function test_proposalThresholdIsLowered() public { + vm.prank(address(timelock)); + dao._setProposalThresholdBPS(1000); // 10% + + // Before fork execute + assertEq(dao.proposalThreshold(), 2); + + dao.executeFork(); + + // there are 20 tokens, but 5 are now the DAO's, so adjusted total supply is 15 + assertEq(dao.adjustedTotalSupply(), 15); + assertEq(dao.proposalThreshold(), 1); // 1.5 tokens + + // check that 2 tokens are enough for proposing + address someone = makeAddr('someone'); + vm.prank(tokenHolder); + nounsToken.transferFrom(tokenHolder, someone, 13); + vm.prank(tokenHolder); + nounsToken.transferFrom(tokenHolder, someone, 14); + vm.roll(block.number + 1); + propose(someone, address(0), 0, '', '', ''); + } +} + +abstract contract DAOForkExecutedState is DAOForkSignaledOverThresholdState { + function setUp() public virtual override { + super.setUp(); + + dao.executeFork(); + } +} + +contract DAOForkExecutedStateTest is DAOForkExecutedState { + function test_signalFork_reverts() public { + tokenIds = [8, 9]; + + vm.expectRevert(NounsDAOV3Fork.ForkPeriodActive.selector); + vm.prank(tokenHolder); + dao.escrowToFork(tokenIds, new uint256[](0), ''); + } + + function test_executeFork_reverts() public { + vm.expectRevert(NounsDAOV3Fork.ForkPeriodActive.selector); + dao.executeFork(); + } + + function test_unsignalFork_reverts() public { + tokenIds = [4, 5]; + + vm.expectRevert(NounsDAOV3Fork.ForkPeriodActive.selector); + vm.prank(tokenHolder); + dao.withdrawFromForkEscrow(tokenIds); + } + + function test_joinFork() public { + tokenIds = [8, 9]; + proposalIds = [1, 2]; + + vm.expectEmit(true, true, true, true); + emit NounsDAOV3Fork.JoinFork(escrow.forkId() - 1, tokenHolder, tokenIds, proposalIds, 'some reason'); + vm.prank(tokenHolder); + dao.joinFork(tokenIds, proposalIds, 'some reason'); + + // now 35% of the treasury is in the new DAO + assertEq(address(timelock).balance, 650 ether); + assertEq(address(forkDAODeployer.mockTreasury()).balance, 350 ether); + + assertEq(erc20Mock.balanceOf(address(timelock)), 195e18); + assertEq(erc20Mock.balanceOf(address(forkDAODeployer.mockTreasury())), 105e18); + + // 1 more token joins + tokenIds = [7]; + vm.prank(tokenHolder); + dao.joinFork(tokenIds, proposalIds, 'some reason'); + + // now 40% of the treasury is in the new DAO + assertEq(address(timelock).balance, 600 ether); + assertEq(address(forkDAODeployer.mockTreasury()).balance, 400 ether); + + assertEq(erc20Mock.balanceOf(address(timelock)), 180e18); + assertEq(erc20Mock.balanceOf(address(forkDAODeployer.mockTreasury())), 120e18); + + // DAO can withdraw the tokens sent in joinFork + tokenIds = [7, 8, 9]; + vm.expectEmit(true, true, true, true); + emit NounsDAOV3Fork.DAOWithdrawNounsFromEscrow(tokenIds, address(1)); + vm.prank(address(dao.timelock())); + dao.withdrawDAONounsFromEscrow(tokenIds, address(1)); + + assertEq(dao.nouns().ownerOf(7), address(1)); + assertEq(dao.nouns().ownerOf(8), address(1)); + assertEq(dao.nouns().ownerOf(9), address(1)); + } + + function test_withdrawTokensToDAO() public { + tokenIds = [1, 2, 3]; + vm.prank(address(dao.timelock())); + dao.withdrawDAONounsFromEscrow(tokenIds, address(1)); + + assertEq(dao.nouns().ownerOf(1), address(1)); + assertEq(dao.nouns().ownerOf(2), address(1)); + assertEq(dao.nouns().ownerOf(3), address(1)); + } +} + +abstract contract DAOForkExecutedActivePeriodOverState is DAOForkExecutedState { + function setUp() public virtual override { + super.setUp(); + + skip(FORK_PERIOD); + } +} + +contract DAOForkExecutedActivePeriodOverStateTest is DAOForkExecutedActivePeriodOverState { + function test_joinFork_reverts() public { + tokenIds = [8, 9]; + + vm.prank(tokenHolder); + vm.expectRevert(NounsDAOV3Fork.ForkPeriodNotActive.selector); + dao.joinFork(tokenIds, new uint256[](0), ''); + } + + function test_execute_reverts() public { + vm.expectRevert(NounsDAOV3Fork.ForkThresholdNotMet.selector); + dao.executeFork(); + } + + function test_withdrawTokensToDAO() public { + tokenIds = [1, 2, 3]; + vm.prank(address(dao.timelock())); + dao.withdrawDAONounsFromEscrow(tokenIds, address(1)); + + assertOwnerOfTokens(address(dao.nouns()), tokenIds, address(1)); + } + + function test_signalOnNewFork() public { + tokenIds = [11, 12]; + vm.prank(tokenHolder); + dao.escrowToFork(tokenIds, new uint256[](0), ''); + + assertOwnerOfTokens(address(dao.nouns()), tokenIds, address(forkEscrow)); + } +} + +abstract contract DAOSecondForkSignaledUnderThreshold is DAOForkExecutedActivePeriodOverState { + function setUp() public virtual override { + super.setUp(); + + tokenIds = [11, 12]; + vm.prank(tokenHolder); + dao.escrowToFork(tokenIds, new uint256[](0), ''); + } +} + +contract DAOSecondForkSignaledUnderThresholdTest is DAOSecondForkSignaledUnderThreshold { + function test_executeFork_reverts() public { + vm.expectRevert(NounsDAOV3Fork.ForkThresholdNotMet.selector); + dao.executeFork(); + } + + function test_joinFork_reverts() public { + tokenIds = [14, 15]; + vm.expectRevert(NounsDAOV3Fork.ForkPeriodNotActive.selector); + vm.prank(tokenHolder); + dao.joinFork(tokenIds, new uint256[](0), ''); + } + + function test_unsignalFork_returnsTokens() public { + tokenIds = [11, 12]; + assertOwnerOfTokens(address(dao.nouns()), tokenIds, address(forkEscrow)); + + vm.prank(tokenHolder); + dao.withdrawFromForkEscrow(tokenIds); + + assertOwnerOfTokens(address(dao.nouns()), tokenIds, tokenHolder); + } + + function test_withdrawTokensToDAO_reverts() public { + tokenIds = [11]; + vm.prank(address(dao.timelock())); + vm.expectRevert(NounsDAOForkEscrow.NotOwner.selector); + dao.withdrawDAONounsFromEscrow(tokenIds, address(1)); + } +} + +abstract contract DAOSecondForkSignaledOverThreshold is DAOSecondForkSignaledUnderThreshold { + function setUp() public virtual override { + super.setUp(); + + // adjusted total supply is 15, so for 20% 3 tokens are enough (15 * 0.2 = 3) + tokenIds = [13]; + vm.prank(tokenHolder); + dao.escrowToFork(tokenIds, new uint256[](0), ''); + } +} + +contract DAOSecondForkSignaledOverThresholdTest is DAOSecondForkSignaledOverThreshold { + function test_executeFork() public { + assertEq(address(timelock).balance, 750 ether); + assertEq(dao.adjustedTotalSupply(), 15); + + dao.executeFork(); + + assertEq(address(timelock).balance, 600 ether); + assertEq(address(forkDAODeployer.mockTreasury()).balance, 400 ether); + + assertEq(erc20Mock.balanceOf(address(timelock)), 180e18); + assertEq(erc20Mock.balanceOf(address(forkDAODeployer.mockTreasury())), 120e18); + + tokenIds = [11, 12, 13]; + vm.prank(address(dao.timelock())); + dao.withdrawDAONounsFromEscrow(tokenIds, address(1)); + + assertOwnerOfTokens(address(dao.nouns()), tokenIds, address(1)); + } +} + +contract DAOFork_SendFundsFailureTest is DAOForkSignaledOverThresholdState { + function test_givenERC20TransferFailure_reverts() public { + erc20Mock.setFailNextTransfer(true); + + vm.expectRevert(NounsDAOV3Fork.ERC20TransferFailed.selector); + dao.executeFork(); + } + + function test_givenETHTransferFailure_reverts() public { + forkDAODeployer.setTreasury(address(new ETHBlocker())); + + vm.expectRevert(NounsDAOV3Fork.ETHTransferFailed.selector); + dao.executeFork(); + } +} + +contract ETHBlocker {} diff --git a/packages/nouns-contracts/test/foundry/NounsDAOLogicV3/ProposeBySigs.t.sol b/packages/nouns-contracts/test/foundry/NounsDAOLogicV3/ProposeBySigs.t.sol index ed4b0d82a5..baf8bc5d7d 100644 --- a/packages/nouns-contracts/test/foundry/NounsDAOLogicV3/ProposeBySigs.t.sol +++ b/packages/nouns-contracts/test/foundry/NounsDAOLogicV3/ProposeBySigs.t.sol @@ -427,7 +427,17 @@ contract ProposeBySigsTest is NounsDAOLogicV3BaseTest { expectNewPropEvents(txs, proposerWithNoVotes, dao.proposalCount() + 1, 1, 0, expectedSigners); vm.prank(proposerWithNoVotes); - dao.proposeBySigs(proposerSignatures, txs.targets, txs.values, txs.signatures, txs.calldatas, 'description'); + uint256 proposalId = dao.proposeBySigs( + proposerSignatures, + txs.targets, + txs.values, + txs.signatures, + txs.calldatas, + 'description' + ); + + NounsDAOStorageV3.ProposalCondensed memory proposal = dao.proposalsV3(proposalId); + assertEq(proposal.signers, expectedSigners); } function test_givenProposerWithNoVotesAndERC1271SignerWithEnoughVotes_worksAndEmitsEvents() public { @@ -454,7 +464,4 @@ contract ProposeBySigsTest is NounsDAOLogicV3BaseTest { vm.prank(proposerWithNoVotes); dao.proposeBySigs(proposerSignatures, txs.targets, txs.values, txs.signatures, txs.calldatas, 'description'); } - - // TODO tests - // test for event } diff --git a/packages/nouns-contracts/test/foundry/NounsDAOLogicV3/UpdateProposal.t.sol b/packages/nouns-contracts/test/foundry/NounsDAOLogicV3/UpdateProposal.t.sol index 067cf7532e..45f3e994e5 100644 --- a/packages/nouns-contracts/test/foundry/NounsDAOLogicV3/UpdateProposal.t.sol +++ b/packages/nouns-contracts/test/foundry/NounsDAOLogicV3/UpdateProposal.t.sol @@ -245,6 +245,9 @@ contract UpdateProposalPermissionsTest is UpdateProposalBaseTest { } contract UpdateProposalTransactionsTest is UpdateProposalBaseTest { + function test_proposalsV3GetterReturnsUpdatableEndBlock() public { + assertEq(dao.proposalsV3(proposalId).updatePeriodEndBlock, block.number - 1 + proposalUpdatablePeriodInBlocks); + } function test_givenNoTxs_reverts() public { address[] memory targets = new address[](0); diff --git a/packages/nouns-contracts/test/foundry/NounsDAOLogicV3/UpgradeToDAOV3.t.sol b/packages/nouns-contracts/test/foundry/NounsDAOLogicV3/UpgradeToDAOV3.t.sol new file mode 100644 index 0000000000..e3903bc6f4 --- /dev/null +++ b/packages/nouns-contracts/test/foundry/NounsDAOLogicV3/UpgradeToDAOV3.t.sol @@ -0,0 +1,403 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.15; + +import 'forge-std/Test.sol'; +import { NounsDAOLogicV3BaseTest } from './NounsDAOLogicV3BaseTest.sol'; +import { NounsDAOLogicV1 } from '../../../contracts/governance/NounsDAOLogicV1.sol'; +import { NounsDAOLogicV2 } from '../../../contracts/governance/NounsDAOLogicV2.sol'; +import { NounsDAOLogicV3 } from '../../../contracts/governance/NounsDAOLogicV3.sol'; +import { DeployUtils } from '../helpers/DeployUtils.sol'; +import { NounsDAOExecutorV2 } from '../../../contracts/governance/NounsDAOExecutorV2.sol'; +import { NounsDAOExecutorProxy } from '../../../contracts/governance/NounsDAOExecutorProxy.sol'; +import { INounsDAOExecutor, NounsDAOStorageV2, NounsDAOStorageV3 } from '../../../contracts/governance/NounsDAOInterfaces.sol'; +import { NounsDAOForkEscrow } from '../../../contracts/governance/fork/NounsDAOForkEscrow.sol'; +import { ForkDAODeployer } from '../../../contracts/governance/fork/ForkDAODeployer.sol'; +import { ERC20Mock } from '../helpers/ERC20Mock.sol'; + +contract UpgradeToDAOV3Test is DeployUtils { + NounsDAOLogicV1 daoProxy; + address proposer = makeAddr('proposer'); + address proposer2 = makeAddr('proposer2'); + INounsDAOExecutor timelockV1; + ERC20Mock stETH = new ERC20Mock(); + + address[] targets; + uint256[] values; + string[] signatures; + bytes[] calldatas; + + event ProposalCreatedOnTimelockV1(uint256 id); + + function setUp() public virtual { + daoProxy = deployDAOV2(); + timelockV1 = daoProxy.timelock(); + + vm.startPrank(daoProxy.nouns().minter()); + daoProxy.nouns().mint(); + daoProxy.nouns().mint(); + daoProxy.nouns().transferFrom(daoProxy.nouns().minter(), proposer, 1); + daoProxy.nouns().transferFrom(daoProxy.nouns().minter(), proposer2, 2); + vm.stopPrank(); + vm.roll(block.number + 1); + + vm.deal(address(daoProxy.timelock()), 1000 ether); + } + + function test_upgradeToDAOV3() public { + address[] memory erc20TokensToIncludeInFork = new address[](1); + erc20TokensToIncludeInFork[0] = address(stETH); + ( + NounsDAOForkEscrow forkEscrow, + ForkDAODeployer forkDeployer, + NounsDAOLogicV3 daoV3Implementation, + NounsDAOExecutorV2 timelockV2 + ) = deployNewContracts(); + uint256 proposalId = proposeUpgradeToDAOV3( + address(daoV3Implementation), + address(timelockV2), + address(daoProxy.timelock()), + 500 ether, + forkEscrow, + forkDeployer, + erc20TokensToIncludeInFork + ); + + rollAndCastVote(proposer, proposalId, 1); + + queueAndExecute(proposalId); + + NounsDAOLogicV3 daoProxyAsV3 = NounsDAOLogicV3(payable(address(daoProxy))); + + assertEq(daoProxy.implementation(), address(daoV3Implementation)); + assertEq(daoProxyAsV3.timelockV1(), address(timelockV1)); + assertEq(address(daoProxy.timelock()), address(timelockV2)); + + // check fork params + assertEq(address(daoProxyAsV3.forkEscrow()), address(forkEscrow)); + assertEq(address(daoProxyAsV3.forkDAODeployer()), address(forkDeployer)); + assertEq(daoProxyAsV3.forkPeriod(), 7 days); + assertEq(daoProxyAsV3.forkThresholdBPS(), 2_000); + + address[] memory erc20sInFork = daoProxyAsV3.erc20TokensToIncludeInFork(); + assertEq(erc20sInFork.length, 1); + assertEq(erc20sInFork[0], address(stETH)); + + // check funds were transferred + assertEq(address(daoProxyAsV3.timelock()).balance, 500 ether); + assertEq(address(daoProxyAsV3.timelockV1()).balance, 500 ether); + } + + function test_proposalToSendETHWorksBeforeUpgrade() public { + uint256 proposalId = proposeToSendETH(proposer2, proposer2, 100 ether); + + rollAndCastVote(proposer, proposalId, 1); + + queueAndExecute(proposalId); + + assertEq(proposer2.balance, 100 ether); + } + + function test_proposalQueuedBeforeUpgrade_executeRevertsButExecuteOnV1Works() public { + uint256 proposalId = deployContractsAndProposeUpgradeToDAOV3(address(daoProxy.timelock()), 500 ether); + + uint256 proposalId2 = proposeToSendETH(proposer2, proposer2, 100 ether); + + rollAndCastVote(proposer, proposalId, 1); + + vm.prank(proposer2); + daoProxy.castVote(proposalId2, 1); + + vm.roll(block.number + daoProxy.votingPeriod() + 1); + daoProxy.queue(proposalId); + daoProxy.queue(proposalId2); + + vm.warp(block.timestamp + daoProxy.timelock().delay()); + daoProxy.execute(proposalId); + + vm.expectRevert("NounsDAOExecutor::executeTransaction: Transaction hasn't been queued."); + daoProxy.execute(proposalId2); + + NounsDAOLogicV3(payable(address(daoProxy))).executeOnTimelockV1(proposalId2); + assertEq(proposer2.balance, 100 ether); + } + + function test_proposalWasQueuedAfterUpgrade() public { + uint256 proposalId = deployContractsAndProposeUpgradeToDAOV3(address(daoProxy.timelock()), 500 ether); + + uint256 proposalId2 = proposeToSendETH(proposer2, proposer2, 100 ether); + + rollAndCastVote(proposer, proposalId, 1); + + vm.prank(proposer2); + daoProxy.castVote(proposalId2, 1); + + queueAndExecute(proposalId); + + daoProxy.queue(proposalId2); + vm.warp(block.timestamp + daoProxy.timelock().delay()); + daoProxy.execute(proposalId2); + + assertEq(proposer2.balance, 100 ether); + } + + function test_proposalAfterUpgrade() public { + upgradeToV3(); + + uint256 proposalId = proposeToSendETH(proposer2, proposer2, 100 ether); + + // check executeOnTimelockV1 is false + NounsDAOLogicV3 daoV3 = NounsDAOLogicV3(payable(address(daoProxy))); + NounsDAOStorageV3.ProposalCondensed memory proposal = daoV3.proposalsV3(proposalId); + assertFalse(proposal.executeOnTimelockV1); + + rollAndCastVote(proposer, proposalId, 1); + + queueAndExecute(proposalId); + + assertEq(proposer2.balance, 100 ether); + } + + function test_proposeOnTimelockV1() public { + upgradeToV3(); + + targets = [proposer]; + values = [400 ether]; + signatures = ['']; + calldatas = [bytes('')]; + vm.expectEmit(true, true, true, true); + emit ProposalCreatedOnTimelockV1(2); + vm.prank(proposer); + NounsDAOLogicV3 daoV3 = NounsDAOLogicV3(payable(address(daoProxy))); + uint256 proposalId = daoV3.proposeOnTimelockV1(targets, values, signatures, calldatas, 'send eth'); + + NounsDAOStorageV3.ProposalCondensed memory proposal = daoV3.proposalsV3(proposalId); + assertTrue(proposal.executeOnTimelockV1); + + rollAndCastVote(proposer, proposalId, 1); + queueAndExecute(proposalId); + + assertEq(proposer.balance, 400 ether); + assertEq(address(timelockV1).balance, 100 ether); + assertEq(address(daoProxy.timelock()).balance, 500 ether); + } + + function test_timelockV2IsUpgradable() public { + upgradeToV3(); + + targets = [address(daoProxy.timelock())]; + values = [0]; + signatures = ['upgradeTo(address)']; + address newTimelock = address(new NewTimelockMock()); + calldatas = [abi.encode(newTimelock)]; + vm.prank(proposer); + uint256 proposalId = daoProxy.propose(targets, values, signatures, calldatas, 'upgrade to 1234'); + + rollAndCastVote(proposer, proposalId, 1); + queueAndExecute(proposalId); + + assertEq(get1967Implementation(address(daoProxy.timelock())), address(newTimelock)); + assertEq(NewTimelockMock(payable(address(daoProxy.timelock()))).banner(), 'NewTimelockMock'); + } + + function test_daoCanBeUpgradedAfterUpgradeToV3() public { + upgradeToV3(); + + targets = [address(daoProxy)]; + values = [0]; + signatures = ['_setImplementation(address)']; + calldatas = [abi.encode(address(1234))]; + vm.prank(proposer); + uint256 proposalId = daoProxy.propose(targets, values, signatures, calldatas, 'upgrade to 1234'); + + rollAndCastVote(proposer, proposalId, 1); + queueAndExecute(proposalId); + + assertEq(daoProxy.implementation(), address(1234)); + } + + using stdStorage for StdStorage; + + function test_proposalCreatedInV2HasSameFieldsInV3() public { + vm.roll(block.number + 256); + + uint256 proposalId = proposeToSendETH(proposer2, proposer2, 100 ether); + rollAndCastVote(proposer, proposalId, 1); + queueAndExecute(proposalId); + + NounsDAOStorageV2.ProposalCondensed memory propV2 = NounsDAOLogicV2(payable(address(daoProxy))).proposals(1); + + upgradeToV3(); + + NounsDAOStorageV2.ProposalCondensed memory propV3 = NounsDAOLogicV3(payable(address(daoProxy))).proposals(1); + + assertEq(propV2.id, propV3.id); + assertEq(propV2.proposer, propV3.proposer); + assertEq(propV2.proposalThreshold, propV3.proposalThreshold); + assertEq(propV2.quorumVotes, propV3.quorumVotes); + assertEq(propV2.eta, propV3.eta); + assertEq(propV2.startBlock, propV3.startBlock); + assertEq(propV2.endBlock, propV3.endBlock); + assertEq(propV2.forVotes, propV3.forVotes); + assertEq(propV2.againstVotes, propV3.againstVotes); + assertEq(propV2.abstainVotes, propV3.abstainVotes); + assertEq(propV2.canceled, propV3.canceled); + assertEq(propV2.vetoed, propV3.vetoed); + assertEq(propV2.executed, propV3.executed); + assertEq(propV2.totalSupply, propV3.totalSupply); + assertEq(propV2.creationBlock, propV3.creationBlock); + } + + function upgradeToV3() internal returns (uint256 proposalId) { + proposalId = deployContractsAndProposeUpgradeToDAOV3(address(daoProxy.timelock()), 500 ether); + rollAndCastVote(proposer, proposalId, 1); + queueAndExecute(proposalId); + } + + function queueAndExecute(uint256 proposalId) internal { + vm.roll(block.number + daoProxy.votingPeriod() + 1); + daoProxy.queue(proposalId); + + vm.warp(block.timestamp + daoProxy.timelock().delay()); + daoProxy.execute(proposalId); + } + + function rollAndCastVote( + address voter, + uint256 proposalId, + uint8 support + ) internal { + vm.roll(block.number + daoProxy.votingDelay() + 1); + vm.prank(voter); + daoProxy.castVote(proposalId, support); + } + + function proposeToSendETH( + address proposer_, + address to, + uint256 amount + ) internal returns (uint256 proposalId) { + targets = [to]; + values = [amount]; + signatures = ['']; + calldatas = [bytes('')]; + + vm.prank(proposer_); + proposalId = daoProxy.propose(targets, values, signatures, calldatas, 'send eth'); + } + + function deployAndInitTimelockV2() internal returns (NounsDAOExecutorV2 timelockV2, address timelockV2Impl) { + timelockV2Impl = address(new NounsDAOExecutorV2()); + + bytes memory initCallData = abi.encodeWithSignature( + 'initialize(address,uint256)', + address(daoProxy), + timelockV1.delay() + ); + + timelockV2 = NounsDAOExecutorV2(payable(address(new NounsDAOExecutorProxy(timelockV2Impl, initCallData)))); + + assertEq(timelockV2.delay(), timelockV1.delay()); + assertEq(get1967Implementation(address(timelockV2)), timelockV2Impl); + + return (timelockV2, timelockV2Impl); + } + + function deployNewContracts() + internal + returns ( + NounsDAOForkEscrow forkEscrow, + ForkDAODeployer forkDeployer, + NounsDAOLogicV3 daoV3Impl, + NounsDAOExecutorV2 timelockV2 + ) + { + forkEscrow = new NounsDAOForkEscrow(address(daoProxy), address(daoProxy.nouns())); + forkDeployer = new ForkDAODeployer( + address(0), // tokenImpl_, + address(0), // auctionImpl_, + address(0), // governorImpl_, + address(0), // treasuryImpl_, + 30 days + ); + daoV3Impl = new NounsDAOLogicV3(); + (timelockV2, ) = deployAndInitTimelockV2(); + } + + function deployContractsAndProposeUpgradeToDAOV3(address timelockV1_, uint256 ethToSendToNewTimelock) + internal + returns (uint256 proposalId) + { + ( + NounsDAOForkEscrow forkEscrow, + ForkDAODeployer forkDeployer, + NounsDAOLogicV3 daoV3Impl, + NounsDAOExecutorV2 timelockV2 + ) = deployNewContracts(); + + address[] memory erc20TokensToIncludeInFork = new address[](1); + erc20TokensToIncludeInFork[0] = address(stETH); + proposalId = proposeUpgradeToDAOV3( + address(daoV3Impl), + address(timelockV2), + timelockV1_, + ethToSendToNewTimelock, + forkEscrow, + forkDeployer, + erc20TokensToIncludeInFork + ); + } + + function proposeUpgradeToDAOV3( + address daoV3Implementation, + address timelockV2, + address timelockV1_, + uint256 ethToSendToNewTimelock, + NounsDAOForkEscrow forkEscrow, + ForkDAODeployer forkDeployer, + address[] memory erc20TokensToIncludeInFork + ) internal returns (uint256 proposalId) { + targets = new address[](4); + values = new uint256[](4); + signatures = new string[](4); + calldatas = new bytes[](4); + + uint256 i = 0; + targets[i] = address(timelockV2); + values[i] = ethToSendToNewTimelock; + signatures[i] = ''; + calldatas[i] = ''; + + i++; + targets[i] = address(daoProxy); + values[i] = 0; + signatures[i] = '_setImplementation(address)'; + calldatas[i] = abi.encode(daoV3Implementation); + + i++; + targets[i] = address(daoProxy); + values[i] = 0; + signatures[i] = '_setForkParams(address,address,address[],uint256,uint256)'; + calldatas[i] = abi.encode( + address(forkEscrow), + address(forkDeployer), + erc20TokensToIncludeInFork, + 7 days, + 2_000 + ); + + i++; + targets[i] = address(daoProxy); + values[i] = 0; + signatures[i] = '_setTimelocksAndAdmin(address,address,address)'; + calldatas[i] = abi.encode(timelockV2, timelockV1_, timelockV2); + + vm.prank(proposer); + proposalId = daoProxy.propose(targets, values, signatures, calldatas, 'upgrade to v3'); + } +} + +contract NewTimelockMock is NounsDAOExecutorV2 { + function banner() public pure returns (string memory) { + return 'NewTimelockMock'; + } +} diff --git a/packages/nouns-contracts/test/foundry/NounsDAOLogicV3/UpgradeToDAOV3ForkMainnetTest.t.sol b/packages/nouns-contracts/test/foundry/NounsDAOLogicV3/UpgradeToDAOV3ForkMainnetTest.t.sol new file mode 100644 index 0000000000..1cd14f7abe --- /dev/null +++ b/packages/nouns-contracts/test/foundry/NounsDAOLogicV3/UpgradeToDAOV3ForkMainnetTest.t.sol @@ -0,0 +1,252 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.15; + +import 'forge-std/Test.sol'; +import { ProposeDAOV3UpgradeMainnet } from '../../../script/ProposeDAOV3UpgradeMainnet.s.sol'; +import { DeployDAOV3NewContractsMainnet } from '../../../script/DeployDAOV3NewContractsMainnet.s.sol'; +import { NounsDAOLogicV1 } from '../../../contracts/governance/NounsDAOLogicV1.sol'; +import { NounsDAOLogicV3 } from '../../../contracts/governance/NounsDAOLogicV3.sol'; +import { NounsDAOProxy } from '../../../contracts/governance/NounsDAOProxy.sol'; +import { NounsToken } from '../../../contracts/NounsToken.sol'; +import { NounsDAOExecutorV2 } from '../../../contracts/governance/NounsDAOExecutorV2.sol'; +import { INounsDAOExecutor, INounsDAOForkEscrow, IForkDAODeployer } from '../../../contracts/governance/NounsDAOInterfaces.sol'; +import { NounsDAOForkEscrow } from '../../../contracts/governance/fork/NounsDAOForkEscrow.sol'; +import { ForkDAODeployer } from '../../../contracts/governance/fork/ForkDAODeployer.sol'; +import { NounsDAOLogicV1Fork } from '../../../contracts/governance/fork/newdao/governance/NounsDAOLogicV1Fork.sol'; +import { Strings } from '@openzeppelin/contracts/utils/Strings.sol'; +import { ERC20Transferer } from '../../../contracts/utils/ERC20Transferer.sol'; +import { IERC20 } from '@openzeppelin/contracts/token/ERC20/IERC20.sol'; +import { NounsAuctionHouse } from '../../../contracts/NounsAuctionHouse.sol'; +import { ERC721Enumerable } from '../../../contracts/base/ERC721Enumerable.sol'; +import { NounsTokenFork } from '../../../contracts/governance/fork/newdao/token/NounsTokenFork.sol'; +import { NounsDAOLogicV1Fork } from '../../../contracts/governance/fork/newdao/governance/NounsDAOLogicV1Fork.sol'; + +interface IHasName { + function NAME() external pure returns (string memory); +} + +interface IOwnable { + function owner() external view returns (address); +} + +contract UpgradeToDAOV3ForkMainnetTest is Test { + address public constant NOUNDERS = 0x2573C60a6D127755aA2DC85e342F7da2378a0Cc5; + NounsToken public nouns = NounsToken(0x9C8fF314C9Bc7F6e59A9d9225Fb22946427eDC03); + uint256 proposalId; + address proposerAddr = vm.addr(0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb); + NounsDAOLogicV1 public constant NOUNS_DAO_PROXY_MAINNET = + NounsDAOLogicV1(0x6f3E6272A167e8AcCb32072d08E0957F9c79223d); + INounsDAOExecutor public constant NOUNS_TIMELOCK_V1_MAINNET = + INounsDAOExecutor(0x0BC3807Ec262cB779b38D65b38158acC3bfedE10); + address whaleAddr = 0xf6B6F07862A02C85628B3A9688beae07fEA9C863; + uint256 public constant INITIAL_ETH_IN_TREASURY = 12919915363316446110962; + uint256 public constant STETH_BALANCE = 14931432047776533741220; + address public constant STETH_MAINNET = 0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84; + address public constant TOKEN_BUYER_MAINNET = 0x4f2aCdc74f6941390d9b1804faBc3E780388cfe5; + address public constant PAYER_MAINNET = 0xd97Bcd9f47cEe35c0a9ec1dc40C1269afc9E8E1D; + address public constant AUCTION_HOUSE_PROXY_MAINNET = 0x830BD73E4184ceF73443C15111a1DF14e495C706; + + NounsDAOExecutorV2 timelockV2; + NounsDAOLogicV3 daoV3; + + uint256[] tokenIds; + address[] targets; + uint256[] values; + string[] signatures; + bytes[] calldatas; + + function setUp() public { + vm.createSelectFork(vm.envString('RPC_MAINNET'), 17315040); + + assertEq(address(NOUNS_TIMELOCK_V1_MAINNET).balance, INITIAL_ETH_IN_TREASURY); + + // give ourselves voting power + vm.prank(NOUNDERS); + nouns.delegate(proposerAddr); + + vm.roll(block.number + 1); + + // deploy contracts + + vm.setEnv('DEPLOYER_PRIVATE_KEY', '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); + + ( + NounsDAOForkEscrow forkEscrow, + ForkDAODeployer forkDeployer, + NounsDAOLogicV3 daoV3Impl, + NounsDAOExecutorV2 timelockV2_, + ERC20Transferer erc20Transferer_ + ) = new DeployDAOV3NewContractsMainnet().run(); + + timelockV2 = timelockV2_; + + // propose upgrade + + vm.setEnv('PROPOSER_KEY', '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'); + + vm.setEnv('DAO_V3_IMPL', Strings.toHexString(uint160(address(daoV3Impl)), 20)); + vm.setEnv('TIMELOCK_V2', Strings.toHexString(uint160(address(timelockV2)), 20)); + vm.setEnv('FORK_ESCROW', Strings.toHexString(uint160(address(forkEscrow)), 20)); + vm.setEnv('FORK_DEPLOYER', Strings.toHexString(uint160(address(forkDeployer)), 20)); + vm.setEnv('ERC20_TRANSFERER', Strings.toHexString(uint160(address(erc20Transferer_)), 20)); + vm.setEnv('PROPOSAL_DESCRIPTION_FILE', 'test/foundry/NounsDAOLogicV3/proposal-description.txt'); + + proposalId = new ProposeDAOV3UpgradeMainnet().run(); + + // simulate vote & proposal execution + executeUpgradeProposal(); + + daoV3 = NounsDAOLogicV3(payable(address(NOUNS_DAO_PROXY_MAINNET))); + } + + function executeUpgradeProposal() internal { + vm.roll(block.number + NOUNS_DAO_PROXY_MAINNET.votingDelay() + 1); + vm.prank(proposerAddr); + NOUNS_DAO_PROXY_MAINNET.castVote(proposalId, 1); + vm.prank(whaleAddr); + NOUNS_DAO_PROXY_MAINNET.castVote(proposalId, 1); + + vm.roll(block.number + NOUNS_DAO_PROXY_MAINNET.votingPeriod() + 1); + NOUNS_DAO_PROXY_MAINNET.queue(proposalId); + + vm.warp(block.timestamp + NOUNS_TIMELOCK_V1_MAINNET.delay()); + NOUNS_DAO_PROXY_MAINNET.execute(proposalId); + } + + function test_transfersETHToNewTimelock() public { + assertEq(address(daoV3.timelockV1()).balance, INITIAL_ETH_IN_TREASURY - 10_000 ether); + assertEq(address(daoV3.timelock()).balance, 10_000 ether); + } + + function test_timelockV2adminIsDAO() public { + assertEq(timelockV2.admin(), address(NOUNS_DAO_PROXY_MAINNET)); + } + + function test_timelockV2delayIsCopiedFromTimelockV1() public { + assertEq(timelockV2.delay(), NOUNS_TIMELOCK_V1_MAINNET.delay()); + } + + function test_forkEscrowConstructorParamsAreCorrect() public { + INounsDAOForkEscrow forkEscrow = daoV3.forkEscrow(); + assertEq(address(forkEscrow.dao()), address(NOUNS_DAO_PROXY_MAINNET)); + assertEq(address(forkEscrow.nounsToken()), address(nouns)); + } + + function test_forkDeployerSetsImplementationContracts() public { + IForkDAODeployer forkDeployer = daoV3.forkDAODeployer(); + assertEq(IHasName(forkDeployer.tokenImpl()).NAME(), 'NounsTokenFork'); + assertEq(IHasName(forkDeployer.auctionImpl()).NAME(), 'NounsAuctionHouseFork'); + assertEq(NounsDAOLogicV1Fork(forkDeployer.governorImpl()).name(), 'Nouns DAO'); + assertEq(IHasName(forkDeployer.treasuryImpl()).NAME(), 'NounsDAOExecutorV2'); + } + + function test_forkParams() public { + address[] memory erc20TokensToIncludeInFork = daoV3.erc20TokensToIncludeInFork(); + assertEq(erc20TokensToIncludeInFork.length, 1); + assertEq(erc20TokensToIncludeInFork[0], STETH_MAINNET); + + assertEq(daoV3.forkPeriod(), 7 days); + assertEq(daoV3.forkThresholdBPS(), 2000); + } + + function test_setsTimelocksAndAdmin() public { + assertEq(address(daoV3.timelock()), address(timelockV2)); + assertEq(address(daoV3.timelockV1()), address(NOUNS_TIMELOCK_V1_MAINNET)); + assertEq(NounsDAOProxy(payable(address(daoV3))).admin(), address(timelockV2)); + } + + function test_DAOV3Params() public { + assertEq(daoV3.lastMinuteWindowInBlocks(), 0); + assertEq(daoV3.objectionPeriodDurationInBlocks(), 0); + assertEq(daoV3.proposalUpdatablePeriodInBlocks(), 0); + + assertEq(daoV3.voteSnapshotBlockSwitchProposalId(), 299); + } + + function test_TokenBuyer_changedOwner() public { + assertEq(IOwnable(TOKEN_BUYER_MAINNET).owner(), address(timelockV2)); + } + + function test_Payer_changedOwner() public { + assertEq(IOwnable(PAYER_MAINNET).owner(), address(timelockV2)); + } + + function test_transfersAllstETH() public { + assertEq(IERC20(STETH_MAINNET).balanceOf(address(NOUNS_TIMELOCK_V1_MAINNET)), 1); + assertEq(IERC20(STETH_MAINNET).balanceOf(address(timelockV2)), STETH_BALANCE - 1); + } + + function test_AuctionHouse_changedOwner() public { + assertEq(IOwnable(AUCTION_HOUSE_PROXY_MAINNET).owner(), address(timelockV2)); + } + + function test_AuctionHouseRevenueGoesToNewTimelock() public { + assertEq(address(daoV3.timelock()).balance, 10_000 ether); + + (, uint256 amount, , uint256 endTime, , ) = NounsAuctionHouse(AUCTION_HOUSE_PROXY_MAINNET).auction(); + vm.warp(endTime + 1); + NounsAuctionHouse(AUCTION_HOUSE_PROXY_MAINNET).settleCurrentAndCreateNewAuction(); + + assertEq(address(daoV3.timelock()).balance, 10_000 ether + amount); + } + + function test_forkScenarioAfterUpgrade() public { + uint256[] memory whaleTokens = _getAllNounsOf(whaleAddr); + _escrowAllNouns(whaleAddr); + _escrowAllNouns(NOUNDERS); + _escrowAllNouns(0x5606B493c51316A9e65c9b2A00BbF7Ff92515A3E); + _escrowAllNouns(0xd1d1D4e36117aB794ec5d4c78cBD3a8904E691D0); + _escrowAllNouns(0x7dE92ca2D0768cDbA376Aac853234D4EEd8d8B5C); + _escrowAllNouns(0xFa4FC4ec2F81A4897743C5b4f45907c02ce06199); + + (address forkTreasury, address forkToken) = daoV3.executeFork(); + + vm.startPrank(whaleAddr); + NounsTokenFork(forkToken).claimFromEscrow(whaleTokens); + vm.roll(block.number + 1); + + NounsDAOLogicV1Fork forkDao = NounsDAOLogicV1Fork(NounsDAOExecutorV2(payable(forkTreasury)).admin()); + + targets = [makeAddr('wallet')]; + values = [50 ether]; + signatures = ['']; + calldatas = [bytes('')]; + + vm.expectRevert(NounsDAOLogicV1Fork.WaitingForTokensToClaimOrExpiration.selector); + forkDao.propose(targets, values, signatures, calldatas, 'new prop'); + + vm.warp(forkDao.delayedGovernanceExpirationTimestamp() + 1); + forkDao.propose(targets, values, signatures, calldatas, 'new prop'); + + vm.roll(block.number + forkDao.votingDelay() + 1); + forkDao.castVote(1, 1); + + vm.roll(block.number + forkDao.votingPeriod()); + forkDao.queue(1); + + vm.warp(block.timestamp + 2 days); + forkDao.execute(1); + + assertEq(makeAddr('wallet').balance, 50 ether); + } + + function _escrowAllNouns(address owner) internal { + vm.startPrank(owner); + daoV3.nouns().setApprovalForAll(address(daoV3), true); + daoV3.escrowToFork(_getAllNounsOf(owner), new uint256[](0), ''); + vm.stopPrank(); + } + + function _getAllNounsOf(address owner) internal view returns (uint256[] memory) { + ERC721Enumerable nouns_ = ERC721Enumerable(address(daoV3.nouns())); + uint256 numTokens = nouns_.balanceOf(owner); + + uint256[] memory tokens = new uint256[](numTokens); + + for (uint256 i; i < numTokens; i++) { + tokens[i] = nouns_.tokenOfOwnerByIndex(owner, i); + } + + return tokens; + } +} diff --git a/packages/nouns-contracts/test/foundry/NounsDAOLogicV3/proposal-description.txt b/packages/nouns-contracts/test/foundry/NounsDAOLogicV3/proposal-description.txt new file mode 100644 index 0000000000..720d40dd35 --- /dev/null +++ b/packages/nouns-contracts/test/foundry/NounsDAOLogicV3/proposal-description.txt @@ -0,0 +1 @@ +upgrade to v3 \ No newline at end of file diff --git a/packages/nouns-contracts/test/foundry/governance/NounsDAOLogicV3GasSnapshot.t.sol b/packages/nouns-contracts/test/foundry/governance/NounsDAOLogicV3GasSnapshot.t.sol index cd9ea76bd5..31096ed606 100644 --- a/packages/nouns-contracts/test/foundry/governance/NounsDAOLogicV3GasSnapshot.t.sol +++ b/packages/nouns-contracts/test/foundry/governance/NounsDAOLogicV3GasSnapshot.t.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.15; import 'forge-std/Test.sol'; import { NounsDAOLogicSharedBaseTest } from '../helpers/NounsDAOLogicSharedBase.t.sol'; -import { DeployUtils } from '../helpers/DeployUtils.sol'; +import { DeployUtilsV3 } from '../helpers/DeployUtilsV3.sol'; import { NounsDAOLogicV1 } from '../../../contracts/governance/NounsDAOLogicV1.sol'; import { NounsDAOLogicV2 } from '../../../contracts/governance/NounsDAOLogicV2.sol'; import { NounsDAOLogicV3 } from '../../../contracts/governance/NounsDAOLogicV3.sol'; @@ -13,8 +13,7 @@ import { NounsDAOProxyV3 } from '../../../contracts/governance/NounsDAOProxyV3.s import { NounsDAOStorageV2, NounsDAOStorageV3 } from '../../../contracts/governance/NounsDAOInterfaces.sol'; abstract contract NounsDAOLogic_GasSnapshot_propose is NounsDAOLogicSharedBaseTest { - - address immutable target = makeAddr("target"); + address immutable target = makeAddr('target'); function setUp() public override { super.setUp(); @@ -58,9 +57,8 @@ abstract contract NounsDAOLogic_GasSnapshot_propose is NounsDAOLogicSharedBaseTe } abstract contract NounsDAOLogic_GasSnapshot_castVote is NounsDAOLogicSharedBaseTest { - - address immutable nouner = makeAddr("nouner"); - address immutable target = makeAddr("target"); + address immutable nouner = makeAddr('nouner'); + address immutable target = makeAddr('target'); function setUp() public override { super.setUp(); @@ -99,28 +97,108 @@ abstract contract NounsDAOLogic_GasSnapshot_castVote is NounsDAOLogicSharedBaseT vm.prank(nouner); daoProxy.castVoteWithReason(1, 0, "I don't like this proposal"); } + + function test_castVote_lastMinuteFor() public { + vm.roll(block.number + VOTING_PERIOD - LAST_MINUTE_BLOCKS); + vm.prank(nouner); + daoProxy.castVote(1, 1); + } +} + +abstract contract NounsDAOLogic_GasSnapshot_castVoteDuringObjectionPeriod is NounsDAOLogicSharedBaseTest { + address immutable nouner = makeAddr('nouner'); + address immutable target = makeAddr('target'); + + function setUp() public override { + super.setUp(); + + vm.startPrank(minter); + nounsToken.mint(); + nounsToken.transferFrom(minter, proposer, 1); + nounsToken.mint(); + nounsToken.transferFrom(minter, nouner, 2); + vm.roll(block.number + 1); + vm.stopPrank(); + + givenProposal(); + vm.roll(block.number + daoProxy.votingDelay() + 1); + + // activate objection period + vm.roll(block.number + VOTING_PERIOD - LAST_MINUTE_BLOCKS); + vm.prank(proposer); + daoProxy.castVote(1, 1); + // enter objection period + vm.roll(block.number + LAST_MINUTE_BLOCKS + 1); + } + + function givenProposal() internal { + address[] memory targets = new address[](1); + targets[0] = target; + uint256[] memory values = new uint256[](1); + values[0] = 1 ether; + string[] memory signatures = new string[](1); + signatures[0] = ''; + bytes[] memory calldatas = new bytes[](1); + calldatas[0] = ''; + vm.prank(proposer); + daoProxy.propose(targets, values, signatures, calldatas, 'short description'); + } + + function test_castVote_duringObjectionPeriod_against() public { + vm.prank(nouner); + daoProxy.castVote(1, 0); + } } -contract NounsDAOLogic_GasSnapshot_V3_propose is DeployUtils, NounsDAOLogic_GasSnapshot_propose { - function deployDAOProxy(address timelock, address nounsToken, address vetoer) internal override returns (NounsDAOLogicV1) { +contract NounsDAOLogic_GasSnapshot_V3_propose is DeployUtilsV3, NounsDAOLogic_GasSnapshot_propose { + function deployDAOProxy( + address timelock, + address nounsToken, + address vetoer + ) internal override returns (NounsDAOLogicV1) { return _createDAOV3Proxy(timelock, nounsToken, vetoer); } } -contract NounsDAOLogic_GasSnapshot_V2_propose is DeployUtils, NounsDAOLogic_GasSnapshot_propose { - function deployDAOProxy(address timelock, address nounsToken, address vetoer) internal override returns (NounsDAOLogicV1) { +contract NounsDAOLogic_GasSnapshot_V2_propose is DeployUtilsV3, NounsDAOLogic_GasSnapshot_propose { + function deployDAOProxy( + address timelock, + address nounsToken, + address vetoer + ) internal override returns (NounsDAOLogicV1) { return _createDAOV2Proxy(timelock, nounsToken, vetoer); } } -contract NounsDAOLogic_GasSnapshot_V3_vote is DeployUtils, NounsDAOLogic_GasSnapshot_castVote { - function deployDAOProxy(address timelock, address nounsToken, address vetoer) internal override returns (NounsDAOLogicV1) { +contract NounsDAOLogic_GasSnapshot_V3_vote is DeployUtilsV3, NounsDAOLogic_GasSnapshot_castVote { + function deployDAOProxy( + address timelock, + address nounsToken, + address vetoer + ) internal override returns (NounsDAOLogicV1) { return _createDAOV3Proxy(timelock, nounsToken, vetoer); } } -contract NounsDAOLogic_GasSnapshot_V2_vote is DeployUtils, NounsDAOLogic_GasSnapshot_castVote { - function deployDAOProxy(address timelock, address nounsToken, address vetoer) internal override returns (NounsDAOLogicV1) { +contract NounsDAOLogic_GasSnapshot_V2_vote is DeployUtilsV3, NounsDAOLogic_GasSnapshot_castVote { + function deployDAOProxy( + address timelock, + address nounsToken, + address vetoer + ) internal override returns (NounsDAOLogicV1) { return _createDAOV2Proxy(timelock, nounsToken, vetoer); } -} \ No newline at end of file +} + +contract NounsDAOLogic_GasSnapshot_V3_voteDuringObjectionPeriod is + DeployUtilsV3, + NounsDAOLogic_GasSnapshot_castVoteDuringObjectionPeriod +{ + function deployDAOProxy( + address timelock, + address nounsToken, + address vetoer + ) internal override returns (NounsDAOLogicV1) { + return _createDAOV3Proxy(timelock, nounsToken, vetoer); + } +} diff --git a/packages/nouns-contracts/test/foundry/governance/fork/ForkDAODeployer.t.sol b/packages/nouns-contracts/test/foundry/governance/fork/ForkDAODeployer.t.sol new file mode 100644 index 0000000000..dba17f7008 --- /dev/null +++ b/packages/nouns-contracts/test/foundry/governance/fork/ForkDAODeployer.t.sol @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.15; + +import 'forge-std/Test.sol'; + +import { DeployUtilsFork } from '../../helpers/DeployUtilsFork.sol'; +import { NounsTokenFork } from '../../../../contracts/governance/fork/newdao/token/NounsTokenFork.sol'; +import { NounsDAOExecutorV2 } from '../../../../contracts/governance/NounsDAOExecutorV2.sol'; +import { NounsDAOLogicV1Fork } from '../../../../contracts/governance/fork/newdao/governance/NounsDAOLogicV1Fork.sol'; +import { NounsAuctionHouseFork } from '../../../../contracts/governance/fork/newdao/NounsAuctionHouseFork.sol'; +import { UUPSUpgradeable } from '@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol'; + +contract ForkDAODeployerTest is DeployUtilsFork { + NounsDAOLogicV1Fork dao; + NounsDAOExecutorV2 treasury; + NounsTokenFork token; + NounsAuctionHouseFork auction; + + function setUp() public { + (address treasuryAddress, address tokenAddress, address daoAddress) = _deployForkDAO(); + + token = NounsTokenFork(tokenAddress); + auction = NounsAuctionHouseFork(token.minter()); + dao = NounsDAOLogicV1Fork(daoAddress); + treasury = NounsDAOExecutorV2(payable(treasuryAddress)); + } + + function test_token_nonTreasuryCannotUpgrade() public { + NounsTokenFork newLogic = new NounsTokenFork(); + + vm.expectRevert('Ownable: caller is not the owner'); + token.upgradeTo(address(newLogic)); + } + + function test_token_treasuryCanUpgrade() public { + NounsTokenFork newLogic = new NounsTokenFork(); + + vm.prank(address(treasury)); + token.upgradeTo(address(newLogic)); + + assertEq(get1967Implementation(address(token)), address(newLogic)); + } + + function test_auction_nonTreasuryCannotUpgrade() public { + NounsAuctionHouseFork newLogic = new NounsAuctionHouseFork(); + + vm.expectRevert('Ownable: caller is not the owner'); + auction.upgradeTo(address(newLogic)); + } + + function test_auction_treasuryCanUpgrade() public { + NounsAuctionHouseFork newLogic = new NounsAuctionHouseFork(); + + vm.prank(address(treasury)); + auction.upgradeTo(address(newLogic)); + + assertEq(get1967Implementation(address(auction)), address(newLogic)); + } + + function test_dao_nonTreasuryCannotUpgrade() public { + NounsDAOLogicV1Fork newLogic = new NounsDAOLogicV1Fork(); + + vm.expectRevert('NounsDAO::_authorizeUpgrade: admin only'); + dao.upgradeTo(address(newLogic)); + } + + function test_dao_treasuryCanUpgrade() public { + NounsDAOLogicV1Fork newLogic = new NounsDAOLogicV1Fork(); + + vm.prank(address(treasury)); + dao.upgradeTo(address(newLogic)); + + assertEq(get1967Implementation(address(dao)), address(newLogic)); + } + + function test_treasury_nonTreasuryCannotUpgrade() public { + NounsDAOExecutorV2 newLogic = new NounsDAOExecutorV2(); + + vm.expectRevert('NounsDAOExecutor::_authorizeUpgrade: Call must come from NounsDAOExecutor.'); + treasury.upgradeTo(address(newLogic)); + } + + function test_treasury_treasuryCanUpgrade() public { + NounsDAOExecutorV2 newLogic = new NounsDAOExecutorV2(); + + vm.prank(address(treasury)); + treasury.upgradeTo(address(newLogic)); + + assertEq(get1967Implementation(address(treasury)), address(newLogic)); + } +} diff --git a/packages/nouns-contracts/test/foundry/governance/fork/ForkingEndToEnd.t.sol b/packages/nouns-contracts/test/foundry/governance/fork/ForkingEndToEnd.t.sol new file mode 100644 index 0000000000..6e4a7a0657 --- /dev/null +++ b/packages/nouns-contracts/test/foundry/governance/fork/ForkingEndToEnd.t.sol @@ -0,0 +1,303 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.15; + +import 'forge-std/Test.sol'; + +import { DeployUtilsFork } from '../../helpers/DeployUtilsFork.sol'; +import { NounsDAOLogicV3 } from '../../../../contracts/governance/NounsDAOLogicV3.sol'; +import { NounsToken } from '../../../../contracts/NounsToken.sol'; +import { NounsTokenFork } from '../../../../contracts/governance/fork/newdao/token/NounsTokenFork.sol'; +import { NounsDAOExecutorV2 } from '../../../../contracts/governance/NounsDAOExecutorV2.sol'; +import { NounsDAOLogicV1Fork } from '../../../../contracts/governance/fork/newdao/governance/NounsDAOLogicV1Fork.sol'; +import { NounsAuctionHouseFork } from '../../../../contracts/governance/fork/newdao/NounsAuctionHouseFork.sol'; +import { NounsTokenLike } from '../../../../contracts/governance/NounsDAOInterfaces.sol'; +import { INounsAuctionHouse } from '../../../../contracts/interfaces/INounsAuctionHouse.sol'; + +contract ForkingHappyFlowTest is DeployUtilsFork { + address minter; + NounsDAOLogicV3 daoV3; + NounsToken ogToken; + NounsTokenFork forkToken; + NounsDAOExecutorV2 forkTreasury; + NounsDAOLogicV1Fork forkDAO; + + address nounerInEscrow1 = makeAddr('nouner in escrow 1'); + address nounerInEscrow2 = makeAddr('nouner in escrow 2'); + address nounerForkJoiner1 = makeAddr('nouner fork joiner 1'); + address nounerForkJoiner2 = makeAddr('nouner fork joiner 2'); + address nounerNoFork1 = makeAddr('nouner no fork 1'); + address nounerNoFork2 = makeAddr('nouner no fork 2'); + + function test_forkHappyFlow() public { + daoV3 = _deployDAOV3(); + ogToken = NounsToken(address(daoV3.nouns())); + minter = ogToken.minter(); + dealNouns(); + // Sending the DAO ETH that will be sent to the fork treasury and a key assertion for this test. + vm.deal(address(daoV3.timelock()), 24 ether); + + uint256[] memory tokensInEscrow1 = getOwnedTokens(nounerInEscrow1); + uint256[] memory tokensInEscrow2 = getOwnedTokens(nounerInEscrow2); + + // Two Nouner accounts sigaling they want to fork. Their combined tokens meet the forking threshold. + escrowToFork(nounerInEscrow1); + escrowToFork(nounerInEscrow2); + + // Execute the fork and get contract addresses for DAO, treasury and token. + (address forkTreasuryAddress, address forkTokenAddress) = daoV3.executeFork(); + forkTreasury = NounsDAOExecutorV2(payable(forkTreasuryAddress)); + forkToken = NounsTokenFork(forkTokenAddress); + forkDAO = NounsDAOLogicV1Fork(forkTreasury.admin()); + + // Assert the fork treasury has the expected balance, which is the pro rata ETH of the two Nouner accounts that + // escrowed above. They have 4 Nouns out of a total supply of 12, so a third. DAO balance was 24 ETH, a third + // of that is 8 ETH. + assertEqUint(forkTreasuryAddress.balance, 8 ether); + + // Asserting that delayed governance is working - no one should be able to propose until all fork tokens have + // been claimed, or until the delay period expires. + // Later in this test we make sure we CAN propose once all tokens have been claimed. + vm.expectRevert(abi.encodeWithSelector(NounsDAOLogicV1Fork.WaitingForTokensToClaimOrExpiration.selector)); + proposeToFork(makeAddr('target'), 0, 'signature', 'data'); + + // Two additional Nouners join the fork during the forking period. + // This should grow the ETH balance sent from OG DAO to fork DAO. + joinFork(nounerForkJoiner1); + joinFork(nounerForkJoiner2); + + // Asserting the expected ETH amount was sent. We're now at two thirds of OG Nouns forking, so we expect + // two thirds of the ETH to be sent, which is 16 out of the original 24. + assertEqUint(forkTreasuryAddress.balance, 16 ether); + + // This Nouner is going to vanilla-ragequit soon, so making it explicit their ETH balance is zero before + // so the next balance assertion is clearly new ETH they received from quitting the fork DAO. + assertEqUint(nounerInEscrow1.balance, 0); + + // The Nouners that originally escrowed their Nouns, now claiming their new fork tokens. + vm.prank(nounerInEscrow1); + forkToken.claimFromEscrow(tokensInEscrow1); + vm.prank(nounerInEscrow2); + forkToken.claimFromEscrow(tokensInEscrow2); + vm.roll(block.number + 1); + + // Demonstrating we're able to submit a proposal now that all claimable tokens have been claimed. + vm.startPrank(nounerInEscrow1); + proposeToFork(makeAddr('target'), 0, 'signature', 'data'); + + // This Nouner executes quit, and we assert they received the expected ETH; it's a quarter of the fork + // DAO balance (4 Nouners with equal token balances, 1 quits), so 4 ETH of the 16 ETH balance. + forkToken.setApprovalForAll(address(forkDAO), true); + forkDAO.quit(tokensInEscrow1); + assertEqUint(nounerInEscrow1.balance, 4 ether); + } + + function dealNouns() internal { + address nounders = ogToken.noundersDAO(); + vm.startPrank(minter); + for (uint256 i = 0; i < 10; i++) { + ogToken.mint(); + } + + changePrank(nounders); + ogToken.transferFrom(nounders, nounerInEscrow1, 0); + + changePrank(minter); + ogToken.transferFrom(minter, nounerInEscrow1, 1); + ogToken.transferFrom(minter, nounerInEscrow2, 2); + ogToken.transferFrom(minter, nounerInEscrow2, 3); + ogToken.transferFrom(minter, nounerForkJoiner1, 4); + ogToken.transferFrom(minter, nounerForkJoiner1, 5); + ogToken.transferFrom(minter, nounerForkJoiner2, 6); + ogToken.transferFrom(minter, nounerForkJoiner2, 7); + ogToken.transferFrom(minter, nounerNoFork1, 8); + ogToken.transferFrom(minter, nounerNoFork1, 9); + + changePrank(nounders); + ogToken.transferFrom(nounders, nounerNoFork2, 10); + + changePrank(minter); + ogToken.transferFrom(minter, nounerNoFork2, 11); + + vm.stopPrank(); + } + + function escrowToFork(address nouner) internal { + vm.startPrank(nouner); + ogToken.setApprovalForAll(address(daoV3), true); + daoV3.escrowToFork(getOwnedTokens(nouner), new uint256[](0), ''); + vm.stopPrank(); + } + + function joinFork(address nouner) internal { + vm.startPrank(nouner); + ogToken.setApprovalForAll(address(daoV3), true); + daoV3.joinFork(getOwnedTokens(nouner), new uint256[](0), ''); + vm.stopPrank(); + } + + function getOwnedTokens(address nouner) internal view returns (uint256[] memory tokenIds) { + uint256 balance = ogToken.balanceOf(nouner); + tokenIds = new uint256[](balance); + for (uint256 i = 0; i < balance; i++) { + tokenIds[i] = ogToken.tokenOfOwnerByIndex(nouner, i); + } + } + + function proposeToFork( + address target, + uint256 value, + string memory signature, + bytes memory data + ) internal returns (uint256 proposalId) { + address[] memory targets = new address[](1); + targets[0] = target; + uint256[] memory values = new uint256[](1); + values[0] = value; + string[] memory signatures = new string[](1); + signatures[0] = signature; + bytes[] memory calldatas = new bytes[](1); + calldatas[0] = data; + proposalId = forkDAO.propose(targets, values, signatures, calldatas, 'my proposal'); + } +} + +abstract contract ForkDAOBase is DeployUtilsFork { + NounsDAOLogicV3 originalDAO; + NounsTokenLike originalToken; + NounsDAOLogicV1Fork forkDAO; + NounsDAOExecutorV2 forkTreasury; + NounsTokenFork forkToken; + NounsAuctionHouseFork forkAuction; + + address originalNouner = makeAddr('original nouner'); + address newNouner = makeAddr('new nouner'); + address proposalRecipient = makeAddr('recipient'); + + function setUp() public { + originalDAO = _deployDAOV3(); + originalToken = originalDAO.nouns(); + address originalMinter = originalToken.minter(); + + vm.startPrank(originalMinter); + originalToken.mint(); + originalToken.mint(); + originalToken.transferFrom(originalMinter, originalNouner, 1); + originalToken.transferFrom(originalMinter, originalNouner, 2); + + changePrank(originalNouner); + uint256[] memory tokenIds = new uint256[](2); + tokenIds[0] = 1; + tokenIds[1] = 2; + originalToken.setApprovalForAll(address(originalDAO), true); + originalDAO.escrowToFork(tokenIds, new uint256[](0), ''); + + (address treasuryAddress, address tokenAddress) = originalDAO.executeFork(); + + forkTreasury = NounsDAOExecutorV2(payable(treasuryAddress)); + forkDAO = NounsDAOLogicV1Fork(forkTreasury.admin()); + forkToken = NounsTokenFork(tokenAddress); + forkAuction = NounsAuctionHouseFork(forkToken.minter()); + + forkToken.claimFromEscrow(tokenIds); + vm.stopPrank(); + vm.roll(block.number + 1); + } + + function bidAndSettleAuction() internal { + INounsAuctionHouse.Auction memory auction = getAuction(); + uint256 newNounId = auction.nounId; + forkAuction.createBid{ value: 0.1 ether }(newNounId); + vm.warp(block.timestamp + auction.endTime); + forkAuction.settleCurrentAndCreateNewAuction(); + assertEq(forkToken.ownerOf(newNounId), newNouner); + vm.roll(block.number + 1); + } + + function getAuction() internal view returns (INounsAuctionHouse.Auction memory) { + ( + uint256 nounId, + uint256 amount, + uint256 startTime, + uint256 endTime, + address payable bidder, + bool settled + ) = forkAuction.auction(); + + return INounsAuctionHouse.Auction(nounId, amount, startTime, endTime, bidder, settled); + } + + function proposeToForkAndRollToVoting( + address target, + uint256 value, + string memory signature, + bytes memory data + ) internal returns (uint256 proposalId) { + address[] memory targets = new address[](1); + targets[0] = target; + uint256[] memory values = new uint256[](1); + values[0] = value; + string[] memory signatures = new string[](1); + signatures[0] = signature; + bytes[] memory calldatas = new bytes[](1); + calldatas[0] = data; + proposalId = forkDAO.propose(targets, values, signatures, calldatas, 'my proposal'); + vm.roll(block.number + forkDAO.votingDelay() + 1); + } + + function queueAndExecute(uint256 propId) internal { + vm.roll(block.number + forkDAO.votingPeriod()); + forkDAO.queue(propId); + vm.warp(block.timestamp + forkTreasury.delay()); + forkDAO.execute(propId); + } +} + +contract ForkDAOProposalAndAuctionHappyFlowTest is ForkDAOBase { + function test_resumeAuctionViaProposal_buyOnAuctionAndPropose() public { + // Execute the proposal to resume the auction + vm.startPrank(originalNouner); + uint256 unpauseAuctionPropId = proposeToForkAndRollToVoting(address(forkAuction), 0, 'unpause()', ''); + forkDAO.castVote(unpauseAuctionPropId, 1); + queueAndExecute(unpauseAuctionPropId); + + // Buy a fork noun on auction as newNouner + vm.deal(newNouner, 1 ether); + changePrank(newNouner); + bidAndSettleAuction(); + + // Execute a proposal created by newNouner + vm.deal(address(forkTreasury), 0.142 ether); + uint256 transferProp = proposeToForkAndRollToVoting(proposalRecipient, 0.142 ether, '', ''); + forkDAO.castVote(transferProp, 1); + queueAndExecute(transferProp); + vm.stopPrank(); + + assertEq(proposalRecipient.balance, 0.142 ether); + } +} + +contract ForkDAOCanUpgradeItsTokenTest is ForkDAOBase { + function test_upgradeTokenWorks() public { + vm.expectRevert(); + TokenUpgrade(address(forkToken)).theUpgradeWorked(); + + TokenUpgrade newTokenLogic = new TokenUpgrade(); + vm.startPrank(originalNouner); + uint256 propId = proposeToForkAndRollToVoting( + address(forkToken), + 0, + 'upgradeTo(address)', + abi.encode(newTokenLogic) + ); + forkDAO.castVote(propId, 1); + queueAndExecute(propId); + + assertTrue(TokenUpgrade(address(forkToken)).theUpgradeWorked()); + } +} + +contract TokenUpgrade is NounsTokenFork { + function theUpgradeWorked() public pure returns (bool) { + return true; + } +} diff --git a/packages/nouns-contracts/test/foundry/governance/fork/NounsDAOForkEscrow.t.sol b/packages/nouns-contracts/test/foundry/governance/fork/NounsDAOForkEscrow.t.sol new file mode 100644 index 0000000000..553191d585 --- /dev/null +++ b/packages/nouns-contracts/test/foundry/governance/fork/NounsDAOForkEscrow.t.sol @@ -0,0 +1,248 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.15; + +import 'forge-std/Test.sol'; + +import { NounsDAOForkEscrow, NounsTokenLike } from '../../../../contracts/governance/fork/NounsDAOForkEscrow.sol'; +import { ERC721Mock } from '../../helpers/ERC721Mock.sol'; +import { IERC721 } from '@openzeppelin/contracts/token/ERC721/IERC721.sol'; + +contract DAOMock { + IERC721 token; + + constructor(IERC721 token_) { + token = token_; + } + + function sendTokensToEscrow(address escrow, uint256[] memory tokenIds) public { + for (uint256 i = 0; i < tokenIds.length; i++) { + token.safeTransferFrom(msg.sender, escrow, tokenIds[i]); + } + } +} + +abstract contract ZeroState is Test { + NounsDAOForkEscrow escrow; + ERC721Mock token = new ERC721Mock(); + DAOMock dao; + + function setUp() public virtual { + dao = new DAOMock(token); + escrow = new NounsDAOForkEscrow(address(dao), address(token)); + } +} + +contract ZeroStateTest is ZeroState { + function test_numTokensInEscrow_isZero() public { + assertEq(escrow.numTokensInEscrow(), 0); + } + + function test_numTokensOwnedByDAO_isZero() public { + assertEq(escrow.numTokensOwnedByDAO(), 0); + } + + function test_onERC721Received_onlyNounsToken() public { + vm.expectRevert(NounsDAOForkEscrow.OnlyNounsToken.selector); + escrow.onERC721Received(address(0), address(0), 0, ''); + } + + function test_onERC721Received_onlyFromDAO() public { + token.mint(address(this), 1234); + + vm.expectRevert(NounsDAOForkEscrow.OnlyDAO.selector); + token.safeTransferFrom(address(this), address(escrow), 1234); + } + + function test_returnTokensToOwner_onlyDAO() public { + vm.expectRevert(NounsDAOForkEscrow.OnlyDAO.selector); + uint256[] memory tokenIds = new uint256[](0); + escrow.returnTokensToOwner(makeAddr('user1'), tokenIds); + } + + function test_closeEscrow_onlyDAO() public { + vm.expectRevert(NounsDAOForkEscrow.OnlyDAO.selector); + escrow.closeEscrow(); + } + + function test_tokenIsSentToEscrowWithoutMarking() public { + token.mint(address(escrow), 123); + + assertEq(token.ownerOf(123), address(escrow)); + assertEq(escrow.numTokensInEscrow(), 0); + assertEq(escrow.numTokensOwnedByDAO(), 1); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = 123; + vm.prank(address(dao)); + escrow.withdrawTokensToDAO(tokenIds, makeAddr('timelock')); + + assertEq(token.ownerOf(123), makeAddr('timelock')); + assertEq(escrow.numTokensOwnedByDAO(), 0); + } +} + +abstract contract TwoUsersEscrowedState is ZeroState { + address user1 = makeAddr('user1'); + address user2 = makeAddr('user2'); + uint256[] user1tokenIds; + uint256[] user2tokenIds; + + function setUp() public virtual override { + super.setUp(); + user1tokenIds = token.mintBatch(user1, 2); + + vm.startPrank(user1); + token.setApprovalForAll(address(dao), true); + dao.sendTokensToEscrow(address(escrow), user1tokenIds); + + user2tokenIds = token.mintBatch(user2, 2); + + changePrank(user2); + token.setApprovalForAll(address(dao), true); + dao.sendTokensToEscrow(address(escrow), user2tokenIds); + + vm.stopPrank(); + } +} + +contract TwoUsersEscrowedStateTest is TwoUsersEscrowedState { + function test_numTokensIsEscrow() public { + assertEq(escrow.numTokensInEscrow(), 4); + } + + function test_numTokensOwnedByDAO_isZero() public { + assertEq(escrow.numTokensOwnedByDAO(), 0); + } + + function test_canUnescrowToOwner() public { + vm.prank(address(dao)); + escrow.returnTokensToOwner(user1, user1tokenIds); + + assertEq(token.ownerOf(0), user1); + assertEq(token.ownerOf(1), user1); + } + + function test_cannotUnescrowTokensOfOtherOwners() public { + vm.prank(address(dao)); + vm.expectRevert(NounsDAOForkEscrow.NotOwner.selector); + escrow.returnTokensToOwner(user1, user2tokenIds); + } + + function test_daoCannotWithdrawTokensYet() public { + vm.prank(address(dao)); + vm.expectRevert(NounsDAOForkEscrow.NotOwner.selector); + escrow.withdrawTokensToDAO(user1tokenIds, makeAddr('timelock')); + } +} + +abstract contract OneUserUnescrowedState is TwoUsersEscrowedState { + function setUp() public virtual override { + super.setUp(); + vm.prank(address(dao)); + escrow.returnTokensToOwner(user1, user1tokenIds); + } +} + +contract OneUserUnescrowedStateTest is OneUserUnescrowedState { + function test_numTokensIsEscrow() public { + assertEq(escrow.numTokensInEscrow(), 2); + } + + function test_numTokensOwnedByDAO_isZero() public { + assertEq(escrow.numTokensOwnedByDAO(), 0); + } + + function test_otherUserCanWithdraw() public { + vm.prank(address(dao)); + escrow.returnTokensToOwner(user2, user2tokenIds); + } +} + +abstract contract EscrowClosedState is OneUserUnescrowedState { + uint32 closedForkId; + + function setUp() public virtual override { + super.setUp(); + vm.prank(address(dao)); + closedForkId = escrow.closeEscrow(); + } +} + +contract EscrowClosedStateTest is EscrowClosedState { + function test_numTokensIsEscrow() public { + assertEq(escrow.numTokensInEscrow(), 0); + } + + function test_numTokensOwnedByDAO_isZero() public { + assertEq(escrow.numTokensOwnedByDAO(), 2); + } + + function test_canWithdrawTokensToDAO() public { + vm.prank(address(dao)); + escrow.withdrawTokensToDAO(user2tokenIds, makeAddr('timelock')); + + assertEq(token.ownerOf(2), makeAddr('timelock')); + assertEq(token.ownerOf(3), makeAddr('timelock')); + } + + function test_cannotReturnTokensToOwner() public { + vm.prank(address(dao)); + vm.expectRevert(NounsDAOForkEscrow.NotOwner.selector); + escrow.returnTokensToOwner(user2, user2tokenIds); + } + + function test_ownerOfEscrowedToken() public { + assertEq(escrow.ownerOfEscrowedToken(closedForkId, 0), address(0)); + assertEq(escrow.ownerOfEscrowedToken(closedForkId, 1), address(0)); + + assertEq(escrow.ownerOfEscrowedToken(closedForkId, 2), user2); + assertEq(escrow.ownerOfEscrowedToken(closedForkId, 3), user2); + } +} + +abstract contract EscrowedTokensAfterClosingState is EscrowClosedState { + function setUp() public virtual override { + super.setUp(); + + vm.startPrank(user1); + token.setApprovalForAll(address(dao), true); + dao.sendTokensToEscrow(address(escrow), user1tokenIds); + + vm.stopPrank(); + } +} + +contract EscrowedTokensAfterClosingStateTest is EscrowedTokensAfterClosingState { + function test_numTokensIsEscrow() public { + assertEq(escrow.numTokensInEscrow(), 2); + } + + function test_numTokensOwnedByDAO_isZero() public { + assertEq(escrow.numTokensOwnedByDAO(), 2); + } + + function test_canWithdrawTokensToDAO_fromPreviousFork() public { + vm.prank(address(dao)); + escrow.withdrawTokensToDAO(user2tokenIds, makeAddr('timelock')); + + assertEq(token.ownerOf(2), makeAddr('timelock')); + assertEq(token.ownerOf(3), makeAddr('timelock')); + } + + function test_cannotWithdrawTokensToDAO_fromCurrentFork() public { + vm.prank(address(dao)); + vm.expectRevert(NounsDAOForkEscrow.NotOwner.selector); + escrow.withdrawTokensToDAO(user1tokenIds, makeAddr('timelock')); + } + + function test_cannotReturnTokensToOwner_fromPreviousFork() public { + vm.prank(address(dao)); + vm.expectRevert(NounsDAOForkEscrow.NotOwner.selector); + escrow.returnTokensToOwner(user2, user2tokenIds); + } + + function test_canReturnTokensToOwner_fromCurrentFork() public { + vm.prank(address(dao)); + escrow.returnTokensToOwner(user1, user1tokenIds); + } +} diff --git a/packages/nouns-contracts/test/foundry/governance/fork/NounsDAOLogicV1Fork.t.sol b/packages/nouns-contracts/test/foundry/governance/fork/NounsDAOLogicV1Fork.t.sol new file mode 100644 index 0000000000..8027f669b0 --- /dev/null +++ b/packages/nouns-contracts/test/foundry/governance/fork/NounsDAOLogicV1Fork.t.sol @@ -0,0 +1,428 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.15; + +import 'forge-std/Test.sol'; + +import { DeployUtilsFork } from '../../helpers/DeployUtilsFork.sol'; +import { NounsDAOLogicV3 } from '../../../../contracts/governance/NounsDAOLogicV3.sol'; +import { NounsToken } from '../../../../contracts/NounsToken.sol'; +import { NounsTokenFork } from '../../../../contracts/governance/fork/newdao/token/NounsTokenFork.sol'; +import { NounsDAOExecutorV2 } from '../../../../contracts/governance/NounsDAOExecutorV2.sol'; +import { NounsDAOLogicV1Fork } from '../../../../contracts/governance/fork/newdao/governance/NounsDAOLogicV1Fork.sol'; +import { NounsDAOStorageV1 } from '../../../../contracts/governance/fork/newdao/governance/NounsDAOStorageV1.sol'; +import { NounsDAOForkEscrowMock } from '../../helpers/NounsDAOForkEscrowMock.sol'; +import { NounsTokenLikeMock } from '../../helpers/NounsTokenLikeMock.sol'; +import { NounsTokenLike } from '../../../../contracts/governance/NounsDAOInterfaces.sol'; +import { ERC20Mock } from '../../helpers/ERC20Mock.sol'; +import { MaliciousForkDAOQuitter } from '../../helpers/MaliciousForkDAOQuitter.sol'; + +abstract contract NounsDAOLogicV1ForkBase is DeployUtilsFork { + NounsDAOLogicV1Fork dao; + address timelock; + NounsTokenFork token; + address proposer = makeAddr('proposer'); + + function setUp() public virtual { + (address treasuryAddress, address tokenAddress, address daoAddress) = _deployForkDAO(); + dao = NounsDAOLogicV1Fork(daoAddress); + token = NounsTokenFork(tokenAddress); + timelock = treasuryAddress; + + // a block buffer so prop.startBlock - votingDelay might land on a valid block. + // in the old way of calling getPriorVotes in vote casting. + vm.roll(block.number + 1); + + vm.startPrank(token.minter()); + token.mint(); + token.transferFrom(token.minter(), proposer, 0); + vm.stopPrank(); + + vm.roll(block.number + 1); + } + + function propose() internal returns (uint256) { + return propose(address(1), 0, 'signature', ''); + } + + function propose( + address target, + uint256 value, + string memory signature, + bytes memory data + ) internal returns (uint256) { + vm.prank(proposer); + address[] memory targets = new address[](1); + targets[0] = target; + uint256[] memory values = new uint256[](1); + values[0] = value; + string[] memory signatures = new string[](1); + signatures[0] = signature; + bytes[] memory calldatas = new bytes[](1); + calldatas[0] = data; + return dao.propose(targets, values, signatures, calldatas, 'my proposal'); + } +} + +contract NounsDAOLogicV1Fork_votingDelayBugFix_Test is NounsDAOLogicV1ForkBase { + uint256 proposalId; + uint256 creationBlock; + + function setUp() public override { + super.setUp(); + + proposalId = propose(); + creationBlock = block.number; + + vm.roll(block.number + dao.votingDelay() + 1); + } + + function test_propose_savesCreationBlockAsExpected() public { + assertEq(dao.proposals(proposalId).creationBlock, creationBlock); + } + + function test_proposeAndCastVote_voteCountedAsExpected() public { + vm.prank(proposer); + dao.castVote(proposalId, 1); + + assertEq(dao.proposals(proposalId).forVotes, 1); + } + + function test_proposeAndCastVote_editingVotingDelayDoesntChangeVoteCount() public { + vm.startPrank(address(dao.timelock())); + dao._setVotingDelay(dao.votingDelay() + 3); + + changePrank(proposer); + dao.castVote(proposalId, 1); + + assertEq(dao.proposals(proposalId).forVotes, 1); + } +} + +contract NounsDAOLogicV1Fork_cancelProposalUnderThresholdBugFix_Test is NounsDAOLogicV1ForkBase { + uint256 proposalId; + + function setUp() public override { + super.setUp(); + + vm.prank(timelock); + dao._setProposalThresholdBPS(1_000); + + vm.startPrank(token.minter()); + for (uint256 i = 0; i < 9; ++i) { + token.mint(); + } + token.transferFrom(token.minter(), proposer, 1); + vm.stopPrank(); + vm.roll(block.number + 1); + + proposalId = propose(); + } + + function test_cancel_nonProposerCanCancelWhenProposerBalanceEqualsThreshold() public { + vm.prank(proposer); + token.transferFrom(proposer, address(1), 1); + vm.roll(block.number + 1); + assertEq(token.getPriorVotes(proposer, block.number - 1), dao.proposalThreshold()); + + vm.prank(makeAddr('not proposer')); + dao.cancel(proposalId); + + assertTrue(dao.proposals(proposalId).canceled); + } + + function test_cancel_nonProposerCanCancelWhenProposerBalanceIsLessThanThreshold() public { + vm.startPrank(proposer); + token.transferFrom(proposer, address(1), 0); + token.transferFrom(proposer, address(1), 1); + vm.roll(block.number + 1); + assertEq(token.getPriorVotes(proposer, block.number - 1), dao.proposalThreshold() - 1); + + changePrank(makeAddr('not proposer')); + dao.cancel(proposalId); + + assertTrue(dao.proposals(proposalId).canceled); + } + + function test_cancel_nonProposerCannotCancelWhenProposerBalanceIsGtThreshold() public { + assertEq(token.getPriorVotes(proposer, block.number - 1), dao.proposalThreshold() + 1); + + vm.startPrank(makeAddr('not proposer')); + vm.expectRevert('NounsDAO::cancel: proposer above threshold'); + dao.cancel(proposalId); + } +} + +abstract contract ForkWithEscrow is NounsDAOLogicV1ForkBase { + NounsDAOForkEscrowMock escrow; + NounsTokenLike originalToken; + NounsDAOLogicV3 originalDAO; + + address owner1 = makeAddr('owner1'); + + function setUp() public virtual override { + originalDAO = _deployDAOV3(); + originalToken = originalDAO.nouns(); + address originalMinter = originalToken.minter(); + + // Minting original tokens + vm.startPrank(originalMinter); + originalToken.mint(); + originalToken.mint(); + originalToken.transferFrom(originalMinter, proposer, 1); + originalToken.transferFrom(originalMinter, owner1, 2); + + // Escrowing original tokens + changePrank(proposer); + originalToken.setApprovalForAll(address(originalDAO), true); + uint256[] memory proposerTokens = new uint256[](1); + proposerTokens[0] = 1; + originalDAO.escrowToFork(proposerTokens, new uint256[](0), ''); + + changePrank(owner1); + originalToken.setApprovalForAll(address(originalDAO), true); + uint256[] memory owner1Tokens = new uint256[](1); + owner1Tokens[0] = 2; + originalDAO.escrowToFork(owner1Tokens, new uint256[](0), ''); + + vm.stopPrank(); + + (address treasuryAddress, address tokenAddress, address daoAddress) = _deployForkDAO(originalDAO.forkEscrow()); + + dao = NounsDAOLogicV1Fork(daoAddress); + token = NounsTokenFork(tokenAddress); + timelock = treasuryAddress; + } +} + +contract NounsDAOLogicV1Fork_DelayedGovernance_Test is ForkWithEscrow { + function setUp() public override { + super.setUp(); + } + + function test_propose_givenTokenToClaim_reverts() public { + vm.expectRevert(abi.encodeWithSelector(NounsDAOLogicV1Fork.WaitingForTokensToClaimOrExpiration.selector)); + propose(); + } + + function test_propose_givenPartialClaim_reverts() public { + uint256[] memory tokens = new uint256[](1); + tokens[0] = 1; + vm.prank(proposer); + token.claimFromEscrow(tokens); + + vm.expectRevert(abi.encodeWithSelector(NounsDAOLogicV1Fork.WaitingForTokensToClaimOrExpiration.selector)); + propose(); + } + + function test_propose_givenFullClaim_works() public { + uint256[] memory tokens = new uint256[](1); + tokens[0] = 1; + vm.prank(proposer); + token.claimFromEscrow(tokens); + + tokens[0] = 2; + vm.prank(owner1); + token.claimFromEscrow(tokens); + + // mining one block so proposer prior votes getter sees their tokens. + vm.roll(block.number + 1); + + propose(); + } + + function test_propose_givenTokensToClaimAndDelayedGovernanceExpires_works() public { + uint256[] memory tokens = new uint256[](1); + tokens[0] = 1; + vm.prank(proposer); + token.claimFromEscrow(tokens); + // mining one block so proposer prior votes getter sees their tokens. + vm.roll(block.number + 1); + + vm.warp(dao.delayedGovernanceExpirationTimestamp()); + + propose(); + } +} + +contract NounsDAOLogicV1Fork_Quit_Test is NounsDAOLogicV1ForkBase { + address quitter = makeAddr('quitter'); + uint256[] quitterTokens; + ERC20Mock token1; + ERC20Mock token2; + uint256 constant TOKEN1_BALANCE = 12345; + uint256 constant TOKEN2_BALANCE = 8765; + address[] tokens; + + function setUp() public override { + super.setUp(); + + // Set up ERC20s owned by the DAO + mintERC20s(); + vm.prank(address(dao.timelock())); + dao._setErc20TokensToIncludeInQuit(tokens); + + // Send ETH to the DAO + vm.deal(address(dao.timelock()), 120 ether); + + mintNounsToQuitter(); + + vm.prank(quitter); + token.setApprovalForAll(address(dao), true); + } + + function test_quit_tokensAreSentToTreasury() public { + vm.prank(quitter); + dao.quit(quitterTokens); + + assertEq(token.balanceOf(timelock), 2); + } + + function test_quit_sendsProRataETHAndERC20s() public { + assertEq(quitter.balance, 0); + assertEq(token1.balanceOf(quitter), 0); + assertEq(token2.balanceOf(quitter), 0); + + vm.prank(quitter); + dao.quit(quitterTokens); + + assertEq(quitter.balance, 24 ether); + assertEq(token1.balanceOf(quitter), (TOKEN1_BALANCE * 2) / 10); + assertEq(token2.balanceOf(quitter), (TOKEN2_BALANCE * 2) / 10); + } + + function test_quit_reentranceReverts() public { + MaliciousForkDAOQuitter reentrancyQuitter = new MaliciousForkDAOQuitter(dao); + transferQuitterTokens(address(reentrancyQuitter)); + + vm.startPrank(address(reentrancyQuitter)); + token.setApprovalForAll(address(dao), true); + + vm.expectRevert(abi.encodeWithSelector(NounsDAOLogicV1Fork.QuitETHTransferFailed.selector)); + dao.quit(quitterTokens); + } + + function test_quit_givenRecipientRejectsETH_reverts() public { + ETHBlocker blocker = new ETHBlocker(); + transferQuitterTokens(address(blocker)); + + vm.startPrank(address(blocker)); + token.setApprovalForAll(address(dao), true); + + vm.expectRevert(abi.encodeWithSelector(NounsDAOLogicV1Fork.QuitETHTransferFailed.selector)); + dao.quit(quitterTokens); + } + + function test_quit_givenERC20SendFailure_reverts() public { + token1.setFailNextTransfer(true); + + vm.prank(quitter); + vm.expectRevert(abi.encodeWithSelector(NounsDAOLogicV1Fork.QuitERC20TransferFailed.selector)); + dao.quit(quitterTokens); + } + + function transferQuitterTokens(address to) internal { + uint256 quitterBalance = token.balanceOf(quitter); + uint256[] memory tokenIds = new uint256[](quitterBalance); + for (uint256 i = 0; i < quitterBalance; ++i) { + tokenIds[i] = token.tokenOfOwnerByIndex(quitter, i); + } + for (uint256 i = 0; i < tokenIds.length; ++i) { + vm.prank(quitter); + token.transferFrom(quitter, to, tokenIds[i]); + } + vm.roll(block.number + 1); + } + + function mintERC20s() internal { + token1 = new ERC20Mock(); + token1.mint(address(dao.timelock()), TOKEN1_BALANCE); + token2 = new ERC20Mock(); + token2.mint(address(dao.timelock()), TOKEN2_BALANCE); + tokens.push(address(token1)); + tokens.push(address(token2)); + } + + function mintNounsToQuitter() internal { + address minter = token.minter(); + vm.startPrank(minter); + while (token.totalSupply() < 10) { + uint256 tokenId = token.mint(); + address to = proposer; + if (tokenId > 7) { + to = quitter; + quitterTokens.push(tokenId); + } + token.transferFrom(token.minter(), to, tokenId); + } + vm.stopPrank(); + + vm.roll(block.number + 1); + + assertEq(token.totalSupply(), 10); + assertEq(token.balanceOf(quitter), 2); + } +} + +contract NounsDAOLogicV1Fork_AdjustedTotalSupply_Test is NounsDAOLogicV1ForkBase { + uint256 constant TOTAL_MINTED = 20; + uint256 constant MIN_ID_FOR_QUITTER = TOTAL_MINTED - ((2 * TOTAL_MINTED) / 10); // 20% of tokens go to quitter + + address quitter = makeAddr('quitter'); + uint256[] quitterTokens; + + function setUp() public override { + super.setUp(); + + address minter = token.minter(); + vm.startPrank(minter); + while (token.totalSupply() < TOTAL_MINTED) { + uint256 tokenId = token.mint(); + address to = proposer; + if (tokenId >= MIN_ID_FOR_QUITTER) { + to = quitter; + quitterTokens.push(tokenId); + } + token.transferFrom(token.minter(), to, tokenId); + } + vm.stopPrank(); + + vm.roll(block.number + 1); + + vm.prank(quitter); + token.setApprovalForAll(address(dao), true); + + vm.startPrank(address(dao.timelock())); + dao._setProposalThresholdBPS(1000); + dao._setQuorumVotesBPS(2000); + vm.stopPrank(); + } + + function test_proposalThreshold_usesAdjustedTotalSupply() public { + assertEq(dao.proposalThreshold(), 2); + + vm.prank(quitter); + dao.quit(quitterTokens); + + assertEq(dao.proposalThreshold(), 1); + } + + function test_quorumVotes_usesAdjustedTotalSupply() public { + assertEq(dao.quorumVotes(), 4); + + vm.prank(quitter); + dao.quit(quitterTokens); + + assertEq(dao.quorumVotes(), 3); + } + + function test_propose_setsThresholdAndQuorumUsingAdjustedTotalSupply() public { + vm.prank(quitter); + dao.quit(quitterTokens); + uint256 proposalId = propose(); + + assertEq(dao.proposals(proposalId).proposalThreshold, 1); + assertEq(dao.proposals(proposalId).quorumVotes, 3); + } +} + +contract ETHBlocker {} diff --git a/packages/nouns-contracts/test/foundry/governance/fork/NounsTokenFork.t.sol b/packages/nouns-contracts/test/foundry/governance/fork/NounsTokenFork.t.sol new file mode 100644 index 0000000000..61600a86eb --- /dev/null +++ b/packages/nouns-contracts/test/foundry/governance/fork/NounsTokenFork.t.sol @@ -0,0 +1,214 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.15; + +import 'forge-std/Test.sol'; + +import { DeployUtilsFork } from '../../helpers/DeployUtilsFork.sol'; +import { NounsTokenFork } from '../../../../contracts/governance/fork/newdao/token/NounsTokenFork.sol'; +import { NounsDAOForkEscrowMock } from '../../helpers/NounsDAOForkEscrowMock.sol'; +import { NounsSeeder } from '../../../../contracts/NounsSeeder.sol'; +import { NounsDescriptorV2 } from '../../../../contracts/NounsDescriptorV2.sol'; +import { NounsToken } from '../../../../contracts/NounsToken.sol'; +import { IProxyRegistry } from '../../../../contracts/external/opensea/IProxyRegistry.sol'; +import { NounsTokenLike } from '../../../../contracts/governance/NounsDAOInterfaces.sol'; +import { ECDSA } from '@openzeppelin/contracts/utils/cryptography/ECDSA.sol'; + +abstract contract NounsTokenForkBase is DeployUtilsFork { + NounsTokenFork token; + NounsToken originalToken; + uint32 forkId; + NounsSeeder seeder; + NounsDescriptorV2 descriptor; + NounsDAOForkEscrowMock escrow; + + address treasury = makeAddr('treasury'); + address minter = makeAddr('minter'); + address originalDAO = makeAddr('original dao'); + + address nouner; + uint256 nounerPK; + uint256[] tokenIds; + + function setUp() public virtual { + (nouner, nounerPK) = makeAddrAndKey('nouner'); + descriptor = _deployAndPopulateV2(); + seeder = new NounsSeeder(); + + originalToken = new NounsToken(makeAddr('noundersDAO'), minter, descriptor, seeder, IProxyRegistry(address(1))); + vm.startPrank(minter); + for (uint256 i = 0; i < 4; ++i) { + uint256 tokenId = originalToken.mint(); + originalToken.transferFrom(address(minter), nouner, tokenId); + } + vm.stopPrank(); + + forkId = 1; + escrow = new NounsDAOForkEscrowMock(forkId, originalDAO, NounsTokenLike(address(originalToken))); + + tokenIds.push(1); + tokenIds.push(4); + tokenIds.push(2); + + token = new NounsTokenFork(); + token.initialize(treasury, minter, escrow, forkId, 0, 3, block.timestamp + FORK_PERIOD); + vm.startPrank(token.owner()); + token.setSeeder(seeder); + token.setDescriptor(descriptor); + vm.stopPrank(); + } +} + +contract NounsTokenFork_Mint_Test is NounsTokenForkBase { + function test_mint_noFounderRewards() public { + vm.startPrank(token.minter()); + while (token.totalSupply() < 20) token.mint(); + + // founder rewards would send tokens straight to the founders + // while other tokens stay with the minter until someone buys them on auction. + assertEq(token.balanceOf(token.minter()), token.totalSupply()); + } +} + +contract NounsTokenFork_ClaimDuringForkPeriod_Test is NounsTokenForkBase { + address recipient = makeAddr('recipient'); + + function test_givenMsgSenderNotOriginalDAO_reverts() public { + vm.expectRevert(abi.encodeWithSelector(NounsTokenFork.OnlyOriginalDAO.selector)); + vm.prank(makeAddr('not DAO')); + token.claimDuringForkPeriod(recipient, new uint256[](0)); + } + + function test_givenForkingPeriodExpired_reverts() public { + vm.warp(token.forkingPeriodEndTimestamp() + 1); + + vm.prank(originalDAO); + vm.expectRevert(abi.encodeWithSelector(NounsTokenFork.OnlyDuringForkingPeriod.selector)); + token.claimDuringForkPeriod(recipient, new uint256[](0)); + } + + function test_mintsTokensSameIDsSameSeeds() public { + vm.prank(originalDAO); + token.claimDuringForkPeriod(recipient, tokenIds); + + assertEq(token.balanceOf(recipient), 3); + assertEq(token.ownerOf(1), recipient); + assertEq(token.ownerOf(4), recipient); + assertEq(token.ownerOf(2), recipient); + assertEq(token.tokenURI(1), originalToken.tokenURI(1)); + assertEq(token.tokenURI(4), originalToken.tokenURI(4)); + assertEq(token.tokenURI(2), originalToken.tokenURI(2)); + } +} + +contract NounsTokenFork_ClaimFromEscrow_Test is NounsTokenForkBase { + function setUp() public override { + super.setUp(); + escrow.setOwnerOfTokens(nouner, tokenIds); + } + + function test_givenMsgSenderThanIsntTheOriginalTokenOwner_reverts() public { + vm.prank(makeAddr('not owner')); + vm.expectRevert(abi.encodeWithSelector(NounsTokenFork.OnlyTokenOwnerCanClaim.selector)); + token.claimFromEscrow(tokenIds); + } + + function test_givenValidSender_mintsTokensSameIDsSameSeeds() public { + vm.prank(nouner); + token.claimFromEscrow(tokenIds); + + assertEq(token.ownerOf(1), nouner); + assertEq(token.ownerOf(4), nouner); + assertEq(token.ownerOf(2), nouner); + assertEq(token.tokenURI(1), originalToken.tokenURI(1)); + assertEq(token.tokenURI(4), originalToken.tokenURI(4)); + assertEq(token.tokenURI(2), originalToken.tokenURI(2)); + + assertEq(token.remainingTokensToClaim(), 0); + } + + function test_givenValidSenderTriesTwice_reverts() public { + vm.startPrank(nouner); + token.claimFromEscrow(tokenIds); + + vm.expectRevert('ERC721: token already minted'); + token.claimFromEscrow(tokenIds); + } +} + +contract NounsTokenFork_DelegateBySig_Test is NounsTokenForkBase { + function setUp() public override { + super.setUp(); + + vm.startPrank(token.minter()); + token.mint(); + token.mint(); + token.transferFrom(token.minter(), nouner, 0); + token.transferFrom(token.minter(), nouner, 1); + vm.stopPrank(); + + vm.roll(block.number + 1); + } + + function test_givenDelegateeAddressZero_reverts() public { + address delegatee = address(0); + uint256 nonce = 0; + uint256 expiry = block.timestamp + 1234; + (uint8 v, bytes32 r, bytes32 s) = signDelegation(delegatee, nonce, expiry, nounerPK); + + vm.expectRevert('ERC721Checkpointable::delegateBySig: delegatee cannot be zero address'); + token.delegateBySig(delegatee, nonce, expiry, v, r, s); + } + + function test_givenValidDelegatee_worksAndLaterTransfersWork() public { + address delegatee = makeAddr('delegatee'); + uint256 nonce = 0; + uint256 expiry = block.timestamp + 1234; + (uint8 v, bytes32 r, bytes32 s) = signDelegation(delegatee, nonce, expiry, nounerPK); + + token.delegateBySig(delegatee, nonce, expiry, v, r, s); + + assertEq(token.delegates(nouner), delegatee); + assertEq(token.getCurrentVotes(delegatee), 2); + + address recipient = makeAddr('recipient'); + vm.prank(nouner); + token.transferFrom(nouner, recipient, 0); + + assertEq(token.getCurrentVotes(delegatee), 1); + assertEq(token.getCurrentVotes(recipient), 1); + } + + function signDelegation( + address delegatee, + uint256 nonce, + uint256 expiry, + uint256 pk + ) + internal + returns ( + uint8 v, + bytes32 r, + bytes32 s + ) + { + bytes32 domainSeparator = keccak256( + abi.encode( + keccak256('EIP712Domain(string name,uint256 chainId,address verifyingContract)'), + keccak256(bytes(token.name())), + block.chainid, + address(token) + ) + ); + + bytes32 structHash = keccak256( + abi.encode( + keccak256('Delegation(address delegatee,uint256 nonce,uint256 expiry)'), + delegatee, + nonce, + expiry + ) + ); + + return vm.sign(pk, ECDSA.toTypedDataHash(domainSeparator, structHash)); + } +} diff --git a/packages/nouns-contracts/test/foundry/helpers/DeployUtils.sol b/packages/nouns-contracts/test/foundry/helpers/DeployUtils.sol index 50f69435d3..66b87b326d 100644 --- a/packages/nouns-contracts/test/foundry/helpers/DeployUtils.sol +++ b/packages/nouns-contracts/test/foundry/helpers/DeployUtils.sol @@ -9,16 +9,17 @@ import { NounsArt } from '../../../contracts/NounsArt.sol'; import { NounsDAOExecutor } from '../../../contracts/governance/NounsDAOExecutor.sol'; import { NounsDAOLogicV1 } from '../../../contracts/governance/NounsDAOLogicV1.sol'; import { NounsDAOLogicV2 } from '../../../contracts/governance/NounsDAOLogicV2.sol'; -import { NounsDAOLogicV3 } from '../../../contracts/governance/NounsDAOLogicV3.sol'; import { IProxyRegistry } from '../../../contracts/external/opensea/IProxyRegistry.sol'; import { NounsDescriptor } from '../../../contracts/NounsDescriptor.sol'; import { NounsSeeder } from '../../../contracts/NounsSeeder.sol'; import { NounsToken } from '../../../contracts/NounsToken.sol'; import { NounsDAOProxy } from '../../../contracts/governance/NounsDAOProxy.sol'; -import { NounsDAOStorageV2, NounsDAOStorageV3 } from '../../../contracts/governance/NounsDAOInterfaces.sol'; +import { NounsDAOStorageV2 } from '../../../contracts/governance/NounsDAOInterfaces.sol'; import { NounsDAOProxyV2 } from '../../../contracts/governance/NounsDAOProxyV2.sol'; -import { NounsDAOProxyV3 } from '../../../contracts/governance/NounsDAOProxyV3.sol'; import { Inflator } from '../../../contracts/Inflator.sol'; +import { NounsAuctionHouse } from '../../../contracts/NounsAuctionHouse.sol'; +import { NounsAuctionHouseProxy } from '../../../contracts/proxies/NounsAuctionHouseProxy.sol'; +import { NounsAuctionHouseProxyAdmin } from '../../../contracts/proxies/NounsAuctionHouseProxyAdmin.sol'; abstract contract DeployUtils is Test, DescriptorHelpers { uint256 constant TIMELOCK_DELAY = 2 days; @@ -26,6 +27,12 @@ abstract contract DeployUtils is Test, DescriptorHelpers { uint256 constant VOTING_DELAY = 1; uint256 constant PROPOSAL_THRESHOLD = 1; uint256 constant QUORUM_VOTES_BPS = 2000; + uint32 constant LAST_MINUTE_BLOCKS = 10; + uint32 constant OBJECTION_PERIOD_BLOCKS = 10; + uint32 constant UPDATABLE_PERIOD_BLOCKS = 10; + uint256 constant DELAYED_GOV_DURATION = 30 days; + uint256 constant FORK_PERIOD = 7 days; + uint256 constant FORK_THRESHOLD_BPS = 2_000; // 20% function _deployAndPopulateDescriptor() internal returns (NounsDescriptor) { NounsDescriptor descriptor = new NounsDescriptor(); @@ -82,36 +89,6 @@ abstract contract DeployUtils is Test, DescriptorHelpers { return (address(nounsToken), address(proxy)); } - function _createDAOV3Proxy( - address timelock, - address nounsToken, - address vetoer - ) internal returns (NounsDAOLogicV1) { - return - NounsDAOLogicV1( - payable( - new NounsDAOProxyV3( - timelock, - nounsToken, - vetoer, - timelock, - address(new NounsDAOLogicV3()), - VOTING_PERIOD, - VOTING_DELAY, - PROPOSAL_THRESHOLD, - NounsDAOStorageV3.DynamicQuorumParams({ - minQuorumVotesBPS: 200, - maxQuorumVotesBPS: 2000, - quorumCoefficient: 10000 - }), - 0, - 0, - 0 - ) - ) - ); - } - function _createDAOV2Proxy( address timelock, address nounsToken, @@ -138,4 +115,41 @@ abstract contract DeployUtils is Test, DescriptorHelpers { ) ); } + + function deployDAOV2() internal returns (NounsDAOLogicV1) { + NounsDAOExecutor timelock = new NounsDAOExecutor(address(1), TIMELOCK_DELAY); + + NounsAuctionHouse auctionLogic = new NounsAuctionHouse(); + NounsAuctionHouseProxyAdmin auctionAdmin = new NounsAuctionHouseProxyAdmin(); + NounsAuctionHouseProxy auctionProxy = new NounsAuctionHouseProxy( + address(auctionLogic), + address(auctionAdmin), + '' + ); + auctionAdmin.transferOwnership(address(timelock)); + + NounsDescriptorV2 descriptor = _deployAndPopulateV2(); + NounsToken nounsToken = new NounsToken( + makeAddr('noundersDAO'), + address(auctionProxy), + descriptor, + new NounsSeeder(), + IProxyRegistry(address(0)) + ); + NounsDAOLogicV1 daoProxy = _createDAOV2Proxy(address(timelock), address(nounsToken), makeAddr('vetoer')); + + vm.prank(address(timelock)); + timelock.setPendingAdmin(address(daoProxy)); + vm.prank(address(daoProxy)); + timelock.acceptAdmin(); + + nounsToken.transferOwnership(address(timelock)); + + return daoProxy; + } + + function get1967Implementation(address proxy) internal returns (address) { + bytes32 slot = bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1); + return address(uint160(uint256(vm.load(proxy, slot)))); + } } diff --git a/packages/nouns-contracts/test/foundry/helpers/DeployUtilsFork.sol b/packages/nouns-contracts/test/foundry/helpers/DeployUtilsFork.sol new file mode 100644 index 0000000000..a5ab116563 --- /dev/null +++ b/packages/nouns-contracts/test/foundry/helpers/DeployUtilsFork.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.6; + +import 'forge-std/Test.sol'; +import { DeployUtilsV3 } from './DeployUtilsV3.sol'; +import { NounsDAOLogicV3 } from '../../../contracts/governance/NounsDAOLogicV3.sol'; +import { NounsDAOExecutorV2 } from '../../../contracts/governance/NounsDAOExecutorV2.sol'; +import { ForkDAODeployer } from '../../../contracts/governance/fork/ForkDAODeployer.sol'; +import { NounsTokenFork } from '../../../contracts/governance/fork/newdao/token/NounsTokenFork.sol'; +import { NounsAuctionHouseFork } from '../../../contracts/governance/fork/newdao/NounsAuctionHouseFork.sol'; +import { NounsDAOLogicV1Fork } from '../../../contracts/governance/fork/newdao/governance/NounsDAOLogicV1Fork.sol'; +import { INounsDAOForkEscrow } from '../../../contracts/governance/NounsDAOInterfaces.sol'; + +abstract contract DeployUtilsFork is DeployUtilsV3 { + function _deployForkDAO(INounsDAOForkEscrow escrow) + public + returns ( + address treasury, + address token, + address dao + ) + { + ForkDAODeployer deployer = new ForkDAODeployer( + address(new NounsTokenFork()), + address(new NounsAuctionHouseFork()), + address(new NounsDAOLogicV1Fork()), + address(new NounsDAOExecutorV2()), + DELAYED_GOV_DURATION + ); + + (treasury, token) = deployer.deployForkDAO(block.timestamp + FORK_PERIOD, escrow); + dao = NounsDAOExecutorV2(payable(treasury)).admin(); + } + + function _deployForkDAO() + public + returns ( + address treasury, + address token, + address dao + ) + { + NounsDAOLogicV3 originalDAO = _deployDAOV3(); + return _deployForkDAO(originalDAO.forkEscrow()); + } +} diff --git a/packages/nouns-contracts/test/foundry/helpers/DeployUtilsV3.sol b/packages/nouns-contracts/test/foundry/helpers/DeployUtilsV3.sol new file mode 100644 index 0000000000..8a755415f1 --- /dev/null +++ b/packages/nouns-contracts/test/foundry/helpers/DeployUtilsV3.sol @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.6; + +import 'forge-std/Test.sol'; +import { DeployUtils } from './DeployUtils.sol'; +import { NounsDAOLogicV1 } from '../../../contracts/governance/NounsDAOLogicV1.sol'; +import { NounsDAOLogicV3 } from '../../../contracts/governance/NounsDAOLogicV3.sol'; +import { NounsDAOProxyV3 } from '../../../contracts/governance/NounsDAOProxyV3.sol'; +import { NounsDAOForkEscrow } from '../../../contracts/governance/fork/NounsDAOForkEscrow.sol'; +import { NounsDAOExecutorV2 } from '../../../contracts/governance/NounsDAOExecutorV2.sol'; +import { ERC1967Proxy } from '@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol'; +import { NounsAuctionHouse } from '../../../contracts/NounsAuctionHouse.sol'; +import { NounsAuctionHouseProxy } from '../../../contracts/proxies/NounsAuctionHouseProxy.sol'; +import { NounsAuctionHouseProxyAdmin } from '../../../contracts/proxies/NounsAuctionHouseProxyAdmin.sol'; +import { NounsToken } from '../../../contracts/NounsToken.sol'; +import { NounsSeeder } from '../../../contracts/NounsSeeder.sol'; +import { ProxyRegistryMock } from './ProxyRegistryMock.sol'; +import { ForkDAODeployer } from '../../../contracts/governance/fork/ForkDAODeployer.sol'; +import { NounsTokenFork } from '../../../contracts/governance/fork/newdao/token/NounsTokenFork.sol'; +import { NounsAuctionHouseFork } from '../../../contracts/governance/fork/newdao/NounsAuctionHouseFork.sol'; +import { NounsDAOLogicV1Fork } from '../../../contracts/governance/fork/newdao/governance/NounsDAOLogicV1Fork.sol'; +import { NounsDAOStorageV3 } from '../../../contracts/governance/NounsDAOInterfaces.sol'; + +abstract contract DeployUtilsV3 is DeployUtils { + function _createDAOV3Proxy( + address timelock, + address nounsToken, + address vetoer + ) internal returns (NounsDAOLogicV1 dao) { + uint256 nonce = vm.getNonce(address(this)); + address predictedForkEscrowAddress = computeCreateAddress(address(this), nonce + 2); + dao = NounsDAOLogicV1( + payable( + new NounsDAOProxyV3( + timelock, + nounsToken, + predictedForkEscrowAddress, + address(0), + vetoer, + timelock, + address(new NounsDAOLogicV3()), + NounsDAOStorageV3.NounsDAOParams({ + votingPeriod: VOTING_PERIOD, + votingDelay: VOTING_DELAY, + proposalThresholdBPS: PROPOSAL_THRESHOLD, + lastMinuteWindowInBlocks: LAST_MINUTE_BLOCKS, + objectionPeriodDurationInBlocks: OBJECTION_PERIOD_BLOCKS, + proposalUpdatablePeriodInBlocks: 0 + }), + NounsDAOStorageV3.DynamicQuorumParams({ + minQuorumVotesBPS: 200, + maxQuorumVotesBPS: 2000, + quorumCoefficient: 10000 + }) + ) + ) + ); + address(new NounsDAOForkEscrow(address(dao), address(nounsToken))); + } + + function _deployDAOV3() internal returns (NounsDAOLogicV3) { + address noundersDAO = makeAddr('noundersDAO'); + address vetoer = makeAddr('vetoer'); + + NounsDAOExecutorV2 timelock = NounsDAOExecutorV2( + payable(address(new ERC1967Proxy(address(new NounsDAOExecutorV2()), ''))) + ); + timelock.initialize(address(1), TIMELOCK_DELAY); + + NounsAuctionHouse auctionLogic = new NounsAuctionHouse(); + NounsAuctionHouseProxyAdmin auctionAdmin = new NounsAuctionHouseProxyAdmin(); + NounsAuctionHouseProxy auctionProxy = new NounsAuctionHouseProxy( + address(auctionLogic), + address(auctionAdmin), + '' + ); + auctionAdmin.transferOwnership(address(timelock)); + + NounsToken nounsToken = new NounsToken( + noundersDAO, + address(auctionProxy), + _deployAndPopulateV2(), + new NounsSeeder(), + new ProxyRegistryMock() + ); + nounsToken.transferOwnership(address(timelock)); + address daoLogicImplementation = address(new NounsDAOLogicV3()); + + uint256 nonce = vm.getNonce(address(this)); + address predictedForkEscrowAddress = computeCreateAddress(address(this), nonce + 6); + + ForkDAODeployer forkDeployer = new ForkDAODeployer( + address(new NounsTokenFork()), + address(new NounsAuctionHouseFork()), + address(new NounsDAOLogicV1Fork()), + address(new NounsDAOExecutorV2()), + DELAYED_GOV_DURATION + ); + + NounsDAOLogicV3 dao = NounsDAOLogicV3( + payable( + new NounsDAOProxyV3( + address(timelock), + address(nounsToken), + predictedForkEscrowAddress, + address(forkDeployer), + vetoer, + address(timelock), + daoLogicImplementation, + NounsDAOStorageV3.NounsDAOParams({ + votingPeriod: VOTING_PERIOD, + votingDelay: VOTING_DELAY, + proposalThresholdBPS: PROPOSAL_THRESHOLD, + lastMinuteWindowInBlocks: LAST_MINUTE_BLOCKS, + objectionPeriodDurationInBlocks: OBJECTION_PERIOD_BLOCKS, + proposalUpdatablePeriodInBlocks: UPDATABLE_PERIOD_BLOCKS + }), + NounsDAOStorageV3.DynamicQuorumParams({ + minQuorumVotesBPS: 200, + maxQuorumVotesBPS: 2000, + quorumCoefficient: 10000 + }) + ) + ) + ); + + address(new NounsDAOForkEscrow(address(dao), address(nounsToken))); + + vm.prank(address(timelock)); + NounsAuctionHouse(address(auctionProxy)).initialize(nounsToken, makeAddr('weth'), 2, 0, 1, 10 minutes); + + vm.prank(address(timelock)); + timelock.setPendingAdmin(address(dao)); + vm.prank(address(dao)); + timelock.acceptAdmin(); + + vm.startPrank(address(timelock)); + dao._setForkPeriod(FORK_PERIOD); + dao._setForkThresholdBPS(FORK_THRESHOLD_BPS); + vm.stopPrank(); + + return dao; + } +} diff --git a/packages/nouns-contracts/test/foundry/helpers/ERC20Mock.sol b/packages/nouns-contracts/test/foundry/helpers/ERC20Mock.sol new file mode 100644 index 0000000000..7f855fad79 --- /dev/null +++ b/packages/nouns-contracts/test/foundry/helpers/ERC20Mock.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.15; + +import { ERC20 } from '@openzeppelin/contracts/token/ERC20/ERC20.sol'; + +contract ERC20Mock is ERC20 { + bool public failNextTransfer; + + constructor() ERC20('Mock', 'MOCK') {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + function transfer(address recipient, uint256 amount) public virtual override returns (bool) { + super.transfer(recipient, amount); + if (failNextTransfer) { + failNextTransfer = false; + return false; + } + return true; + } + + function setFailNextTransfer(bool failNextTransfer_) external { + failNextTransfer = failNextTransfer_; + } +} diff --git a/packages/nouns-contracts/test/foundry/helpers/ERC721Mock.sol b/packages/nouns-contracts/test/foundry/helpers/ERC721Mock.sol new file mode 100644 index 0000000000..4231c9861d --- /dev/null +++ b/packages/nouns-contracts/test/foundry/helpers/ERC721Mock.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.15; + +import { ERC721 } from '@openzeppelin/contracts/token/ERC721/ERC721.sol'; + +contract ERC721Mock is ERC721 { + constructor() ERC721('Mock', 'MOCK') {} + + uint256 counter; + + function mint(address to, uint256 tokenId) external { + _mint(to, tokenId); + } + + function mintBatch(address to, uint256 numTokens) external returns (uint256[] memory) { + uint256[] memory tokenIds = new uint256[](numTokens); + for (uint256 i = 0; i < numTokens; i++) { + tokenIds[i] = counter; + _mint(to, counter++); + } + return tokenIds; + } +} diff --git a/packages/nouns-contracts/test/foundry/helpers/ForkDAODeployerMock.sol b/packages/nouns-contracts/test/foundry/helpers/ForkDAODeployerMock.sol new file mode 100644 index 0000000000..9e1784c29a --- /dev/null +++ b/packages/nouns-contracts/test/foundry/helpers/ForkDAODeployerMock.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.15; + +import 'forge-std/Test.sol'; +import { IForkDAODeployer, INounsDAOForkEscrow } from '../../../contracts/governance/NounsDAOInterfaces.sol'; + +contract ForkDAOTokenMock { + function claimDuringForkPeriod(address to, uint256[] calldata tokenIds) external {} +} + +contract ForkDAODeployerMock is IForkDAODeployer, StdCheats { + address public mockTreasury = makeAddr('mock treasury'); + address public mockToken = address(new ForkDAOTokenMock()); + + function deployForkDAO(uint256, INounsDAOForkEscrow) external view returns (address treasury, address token) { + treasury = mockTreasury; + token = mockToken; + } + + function setTreasury(address treasury) public { + mockTreasury = treasury; + } + + function tokenImpl() external pure returns (address) { + return address(0); + } + + function auctionImpl() external pure returns (address) { + return address(0); + } + + function governorImpl() external pure returns (address) { + return address(0); + } + + function treasuryImpl() external pure returns (address) { + return address(0); + } +} diff --git a/packages/nouns-contracts/test/foundry/helpers/MaliciousForkDAOQuitter.sol b/packages/nouns-contracts/test/foundry/helpers/MaliciousForkDAOQuitter.sol new file mode 100644 index 0000000000..25dc927253 --- /dev/null +++ b/packages/nouns-contracts/test/foundry/helpers/MaliciousForkDAOQuitter.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.15; + +import { NounsDAOLogicV1Fork } from '../../../contracts/governance/fork/newdao/governance/NounsDAOLogicV1Fork.sol'; + +contract MaliciousForkDAOQuitter { + NounsDAOLogicV1Fork public dao; + uint256[] public tokenIds; + bool triedReentry; + + constructor(NounsDAOLogicV1Fork dao_) { + dao = dao_; + } + + function setTokenIds(uint256[] calldata tokenIds_) external { + tokenIds = tokenIds_; + } + + receive() external payable { + if (!triedReentry) { + triedReentry = true; + dao.quit(tokenIds); + } + } +} diff --git a/packages/nouns-contracts/test/foundry/helpers/NounsDAOForkEscrowMock.sol b/packages/nouns-contracts/test/foundry/helpers/NounsDAOForkEscrowMock.sol new file mode 100644 index 0000000000..c1b9763086 --- /dev/null +++ b/packages/nouns-contracts/test/foundry/helpers/NounsDAOForkEscrowMock.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.15; + +import { INounsDAOForkEscrow, NounsTokenLike } from '../../../contracts/governance/NounsDAOInterfaces.sol'; + +contract NounsDAOForkEscrowMock is INounsDAOForkEscrow { + uint32 public forkId; + address public dao; + NounsTokenLike public nounsToken; + + /// @dev forkId => tokenId => owner + mapping(uint32 => mapping(uint256 => address)) public escrowedTokensByForkId; + + constructor( + uint32 forkId_, + address dao_, + NounsTokenLike nounsToken_ + ) { + forkId = forkId_; + dao = dao_; + nounsToken = nounsToken_; + } + + function markOwner(address owner, uint256[] calldata tokenIds) external {} + + function returnTokensToOwner(address owner, uint256[] calldata tokenIds) external {} + + function closeEscrow() external pure returns (uint32) { + return 1; + } + + function numTokensInEscrow() external view returns (uint256) {} + + function numTokensOwnedByDAO() external view returns (uint256) {} + + function withdrawTokensToDAO(uint256[] calldata tokenIds, address to) external {} + + function ownerOfEscrowedToken(uint32 forkId_, uint256 tokenId) external view returns (address) { + return escrowedTokensByForkId[forkId_][tokenId]; + } + + function setOwnerOfTokens(address owner, uint256[] calldata tokenIds) external { + for (uint256 i = 0; i < tokenIds.length; ++i) { + escrowedTokensByForkId[forkId][tokenIds[i]] = owner; + } + } +} diff --git a/packages/nouns-contracts/test/foundry/helpers/NounsDAOLogicSharedBase.t.sol b/packages/nouns-contracts/test/foundry/helpers/NounsDAOLogicSharedBase.t.sol index 51f5ee7341..f1856f2b37 100644 --- a/packages/nouns-contracts/test/foundry/helpers/NounsDAOLogicSharedBase.t.sol +++ b/packages/nouns-contracts/test/foundry/helpers/NounsDAOLogicSharedBase.t.sol @@ -7,14 +7,14 @@ import { NounsDAOLogicV2 } from '../../../contracts/governance/NounsDAOLogicV2.s import { NounsDAOProxy } from '../../../contracts/governance/NounsDAOProxy.sol'; import { NounsDAOProxyV2 } from '../../../contracts/governance/NounsDAOProxyV2.sol'; import { NounsDescriptorV2 } from '../../../contracts/NounsDescriptorV2.sol'; -import { DeployUtils } from './DeployUtils.sol'; +import { DeployUtilsFork } from './DeployUtilsFork.sol'; import { NounsToken } from '../../../contracts/NounsToken.sol'; import { NounsSeeder } from '../../../contracts/NounsSeeder.sol'; import { IProxyRegistry } from '../../../contracts/external/opensea/IProxyRegistry.sol'; import { NounsDAOExecutor } from '../../../contracts/governance/NounsDAOExecutor.sol'; import { Utils } from './Utils.sol'; -abstract contract NounsDAOLogicSharedBaseTest is Test, DeployUtils { +abstract contract NounsDAOLogicSharedBaseTest is Test, DeployUtilsFork { NounsDAOLogicV1 daoProxy; NounsToken nounsToken; NounsDAOExecutor timelock = new NounsDAOExecutor(address(1), TIMELOCK_DELAY); @@ -42,7 +42,11 @@ abstract contract NounsDAOLogicSharedBaseTest is Test, DeployUtils { utils = new Utils(); } - function deployDAOProxy(address timelock, address nounsToken, address vetoer) internal virtual returns (NounsDAOLogicV1); + function deployDAOProxy( + address timelock, + address nounsToken, + address vetoer + ) internal virtual returns (NounsDAOLogicV1); function daoVersion() internal virtual returns (uint256) { return 0; // override to specify version @@ -106,4 +110,22 @@ abstract contract NounsDAOLogicSharedBaseTest is Test, DeployUtils { function daoProxyAsV2() internal view returns (NounsDAOLogicV2) { return NounsDAOLogicV2(payable(address(daoProxy))); } + + function deployForkDAOProxy() internal returns (NounsDAOLogicV1) { + (address treasuryAddress, address tokenAddress, address daoAddress) = _deployForkDAO(); + timelock = NounsDAOExecutor(payable(treasuryAddress)); + nounsToken = NounsToken(tokenAddress); + minter = nounsToken.minter(); + + NounsDAOLogicV1 dao = NounsDAOLogicV1(daoAddress); + + vm.startPrank(address(dao.timelock())); + dao._setVotingPeriod(votingPeriod); + dao._setVotingDelay(votingDelay); + dao._setProposalThresholdBPS(proposalThresholdBPS); + dao._setQuorumVotesBPS(1000); + vm.stopPrank(); + + return NounsDAOLogicV1(daoAddress); + } } diff --git a/packages/nouns-contracts/test/foundry/helpers/NounsTokenLikeMock.sol b/packages/nouns-contracts/test/foundry/helpers/NounsTokenLikeMock.sol index 4886270a38..45e58abc36 100644 --- a/packages/nouns-contracts/test/foundry/helpers/NounsTokenLikeMock.sol +++ b/packages/nouns-contracts/test/foundry/helpers/NounsTokenLikeMock.sol @@ -2,9 +2,19 @@ pragma solidity ^0.8.15; import { NounsTokenLike } from '../../../contracts/governance/NounsDAOInterfaces.sol'; +import { INounsDescriptorMinimal } from '../../../contracts/interfaces/INounsDescriptorMinimal.sol'; +import { INounsSeeder } from '../../../contracts/interfaces/INounsSeeder.sol'; contract NounsTokenLikeMock is NounsTokenLike { + address public noundersDAO; + INounsDescriptorMinimal public descriptor; + INounsSeeder public seeder; mapping(address => mapping(uint256 => uint96)) priorVotes; + mapping(uint256 => INounsSeeder.Seed) public seeds; + + function setSeed(uint256 nounId, INounsSeeder.Seed memory seed) external { + seeds[nounId] = seed; + } function getPriorVotes(address account, uint256 blockNumber) external view returns (uint96) { return priorVotes[account][blockNumber]; @@ -21,4 +31,42 @@ contract NounsTokenLikeMock is NounsTokenLike { ) external { priorVotes[account][blockNumber] = votes; } + + function balanceOf(address) external pure returns (uint256 balance) { + return 0; + } + + function ownerOf(uint256) external pure returns (address owner) { + return address(0); + } + + function transferFrom( + address from, + address to, + uint256 tokenId + ) external { + // noop + } + + function safeTransferFrom( + address from, + address to, + uint256 tokenId + ) external { + // noop + } + + function setNoundersDAO(address _noundersDAO) external { + noundersDAO = _noundersDAO; + } + + function mint() public pure returns (uint256) { + return 0; + } + + function minter() external pure returns (address) { + return address(0); + } + + function setApprovalForAll(address operator, bool approved) external {} } diff --git a/packages/nouns-contracts/test/foundry/helpers/ProxyRegistryMock.sol b/packages/nouns-contracts/test/foundry/helpers/ProxyRegistryMock.sol new file mode 100644 index 0000000000..b5c8f939d1 --- /dev/null +++ b/packages/nouns-contracts/test/foundry/helpers/ProxyRegistryMock.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.15; + +import { IProxyRegistry } from '../../../contracts/external/opensea/IProxyRegistry.sol'; + +contract ProxyRegistryMock is IProxyRegistry { + function proxies(address) external pure returns (address) { + return address(0); + } +} \ No newline at end of file diff --git a/packages/nouns-contracts/test/governance/NounsDAO/V2/voteRefund.test.ts b/packages/nouns-contracts/test/governance/NounsDAO/V2/voteRefund.test.ts index 18141913c3..1b83776122 100644 --- a/packages/nouns-contracts/test/governance/NounsDAO/V2/voteRefund.test.ts +++ b/packages/nouns-contracts/test/governance/NounsDAO/V2/voteRefund.test.ts @@ -29,7 +29,7 @@ const { expect } = chai; const realLongReason = "Judge: The defense may proceed. Roark: Your Honor, I shall call no witnesses. This will be my testimony and my summation. Judge: Take the oath. Court Clerk: Do you swear to tell the truth, the whole truth, and nothing but the truth, so help you God? Roark: I do. Thousands of years ago, the first man discovered how to make fire. He was probably burned at the stake he had taught his brothers to light, but he left them a gift they had not conceived, and he lifted darkness off the earth. Throughout the centuries, there were men who took first steps down new roads, armed with nothing but their own vision. The great creators -- the thinkers, the artists, the scientists, the inventors -- stood alone against the men of their time. Every new thought was opposed; every new invention was denounced. But the men of unborrowed vision went ahead. They fought, they suffered, and they paid. But they won. No creator was prompted by a desire to please his brothers. His brothers hated the gift he offered. His truth was his only motive. His work was his only goal. His work -- not those who used it. His creation -- not the benefits others derived from it -- the creation which gave form to his truth. He held his truth above all things and against all men. He went ahead whether others agreed with him or not, with his integrity as his only banner. He served nothing and no one. He lived for himself. And only by living for himself was he able to achieve the things which are the glory of mankind. Such is the nature of achievement. Man cannot survive except through his mind. He comes on earth unarmed. His brain is his only weapon. But the mind is an attribute of the individual. There is no such thing as a collective brain. The man who thinks must think and act on his own. The reasoning mind cannot work under any form of compulsion. It cannot be subordinated to the needs, opinions, or wishes of others. It is not an object of sacrifice. The creator stands on his own judgment; the parasite follows the opinions of others. The creator thinks; the parasite copies. The creator produces; the parasite loots. The creator's concern is the conquest of nature; the parasite's concern is the conquest of men. The creator requires independence. He neither serves nor rules. He deals with men by free exchange and voluntary choice. The parasite seeks power. He wants to bind all men together in common action and common slavery. He claims that man is only a tool for the use of others -- that he must think as they think, act as they act, and live in selfless, joyless servitude to any need but his own. Look at history: Everything we have, every great achievement has come from the independent work of some independent mind. Every horror and destruction came from attempts to force men into a herd of brainless, soulless robots -- without personal rights, without person ambition, without will, hope, or dignity. It is an ancient conflict. It has another name: \"The individual against the collective.\" Our country, the noblest country in the history of men, was based on the principle of individualism, the principle of man's \"inalienable rights.\" It was a country where a man was free to seek his own happiness, to gain and produce, not to give up and renounce; to prosper, not to starve; to achieve, not to plunder; to hold as his highest possession a sense of his personal value, and as his highest virtue his self-respect. Look at the results. That is what the collectivists are now asking you to destroy, as much of the earth has been destroyed. I am an architect. I know what is to come by the principle on which it is built. We are approaching a world in which I cannot permit myself to live. My ideas are my property. They were taken from me by force, by breach of contract. No appeal was left to me. It was believed that my work belonged to others, to do with as they pleased. They had a claim upon me without my consent -- that it was my duty to serve them without choice or reward. Now you know why a dynamited Courtland. I designed Courtland. I made it possible. I destroyed it. I agreed to design it for the purpose of it seeing built as I wished. That was the price I set for my work. I was not paid. My building was disfigured at the whim of others who took all the benefits of my work and gave me nothing in return. I came here to say that I do not recognize anyone's right to one minute of my life, nor to any part of my energy, nor to any achievement of mine -- no matter who makes the claim! It had to be said: The world is perishing from an orgy of self-sacrificing. I came here to be heard in the name of every man of independence still left in the world. I wanted to state my terms. I do not care to work or live on any others. My terms are: A man's RIGHT to exist for his own sake."; const LONG_REASON = realLongReason + realLongReason; -const REFUND_ERROR_MARGIN = ethers.utils.parseEther('0.00014'); +const REFUND_ERROR_MARGIN = ethers.utils.parseEther('0.00015'); const MAX_PRIORITY_FEE_CAP = ethers.utils.parseUnits('2', 'gwei'); const DEFAULT_GAS_OPTIONS = { maxPriorityFeePerGas: MAX_PRIORITY_FEE_CAP }; const MAX_REFUND_GAS_USED = BigNumber.from(200_000); diff --git a/packages/nouns-sdk/src/contract/addresses.json b/packages/nouns-sdk/src/contract/addresses.json index abf3ddea2c..f20303d2b0 100644 --- a/packages/nouns-sdk/src/contract/addresses.json +++ b/packages/nouns-sdk/src/contract/addresses.json @@ -25,16 +25,17 @@ "nounsDAOLogicV1": "0x27c9e62fc1F1334eb2A090506675Ec31df878E00" }, "5": { - "nounsToken": "0x1d0030f304b542D5A5F77CC0E5945fd79ec89D8E", - "nounsSeeder": "0xF6a38E8235916334268da317EC84F5dfcfB9e023", - "nounsDescriptor": "0xB6D0AF8C27930E13005Bf447d54be8235724a102", - "nftDescriptor": "0x9417e5d955e6e1deA90499Baa527C9d6360b737f", - "nounsAuctionHouse": "0x047B373629148A30299e11a1582d9EAd8181ff46", - "nounsAuctionHouseProxy": "0xC143D0F48E0ba1e547Ff0fE489b4DC7A90063683", - "nounsAuctionHouseProxyAdmin": "0xC6Fb7F9707ba1E0B0fC008bFC72c4C1DA255695E", - "nounsDaoExecutor": "0x2f12ABA664E6D2b4DDD264E2a175d29703836AaE", - "nounsDAOProxy": "0xD08faCeb444dbb6b063a51C2ddFb564Fa0f8Dce0", - "nounsDAOLogicV1": "0x4B245a12e8D449e79120526C350262B587Eb3b7d" + "nounsToken": "0x30656985039923EAa1eBb968fe84A1277581f602", + "nounsSeeder": "0x81EDe38Efc867818BF77978514f5dCa76C20A050", + "nounsDescriptor": "0x02e813dbeaDDEFE9921f4a2093206AefeF4453d6", + "nftDescriptor": "0xbb4F08516268Be9BC43978Edf50eD8A1ACEEae26", + "nounsAuctionHouse": "0x017B4Fd1a03308Df03CB9a03A303FE9E6bC8aB7d", + "nounsAuctionHouseProxy": "0x3DFB1EFC72f2BA9268cBaf82527fD19592618A8D", + "nounsAuctionHouseProxyAdmin": "0x071EFA7D3aE5248e2a2A814b35d978507C77afb7", + "nounsDaoExecutor": "0x62e85a8dbc2799fB5D12BC59556bD3721D5E4CdE", + "nounsDAOProxy": "0x34b74B5c1996b37e5e3EDB756731A5812FF43F67", + "nounsDAOLogicV1": "0x8217eeCe8C797Aa0206988C31307fA96467f9DA6", + "nounsDAOData": "0x3F2e938318f5a9438853D25083DbE7CFB9A0652d" }, "31337": { "nounsToken": "0xa513e6e4b8f2a923d98304ec87f64353c4d5c853", @@ -44,8 +45,9 @@ "nounsAuctionHouse": "0x2279b7a0a67db372996a5fab50d91eaa73d2ebe6", "nounsAuctionHouseProxy": "0x610178da211fef7d417bc0e6fed39f05609ad788", "nounsAuctionHouseProxyAdmin": "0x8a791620dd6260079bf849dc5567adc3f2fdc318", - "nounsDaoExecutor": "0xb7f8bc63bbcad18155201308c8f3540b07f84f5e", - "nounsDAOProxy": "0x9A9f2CCfdE556A7E9Ff0848998Aa4a0CFD8863AE", - "nounsDAOLogicV1": "0x959922be3caee4b8cd9a407cc3ac1c251c2007b1" + "nounsDaoExecutor": "0x4ed7c70F96B99c776995fB64377f0d4aB3B0e1C1", + "nounsDAOProxy": "0xa85233C63b9Ee964Add6F2cffe00Fd84eb32338f", + "nounsDAOLogicV1": "0x959922be3caee4b8cd9a407cc3ac1c251c2007b1", + "nounsDAOData": "0x09635F643e140090A9A8Dcd712eD6285858ceBef" } } diff --git a/packages/nouns-sdk/src/contract/types.ts b/packages/nouns-sdk/src/contract/types.ts index 4d64e33a3b..1013f698df 100644 --- a/packages/nouns-sdk/src/contract/types.ts +++ b/packages/nouns-sdk/src/contract/types.ts @@ -18,6 +18,7 @@ export interface ContractAddresses { nounsDAOProxy: string; nounsDAOLogicV1: string; nounsDAOLogicV2?: string; + nounsDAOData?: string; } export interface Contracts { diff --git a/packages/nouns-subgraph/config/goerli.json b/packages/nouns-subgraph/config/goerli.json index 2721292080..21d3fb848e 100644 --- a/packages/nouns-subgraph/config/goerli.json +++ b/packages/nouns-subgraph/config/goerli.json @@ -1,15 +1,19 @@ { "network": "goerli", "nounsToken": { - "address": "0x1d0030f304b542D5A5F77CC0E5945fd79ec89D8E", - "startBlock": 7734001 + "address": "0x30656985039923EAa1eBb968fe84A1277581f602", + "startBlock": 9084048 }, "nounsAuctionHouse": { - "address": "0xC143D0F48E0ba1e547Ff0fE489b4DC7A90063683", - "startBlock": 7734001 + "address": "0x3DFB1EFC72f2BA9268cBaf82527fD19592618A8D", + "startBlock": 9084048 }, "nounsDAO": { - "address": "0xD08faCeb444dbb6b063a51C2ddFb564Fa0f8Dce0", - "startBlock": 7734001 + "address": "0x34b74B5c1996b37e5e3EDB756731A5812FF43F67", + "startBlock": 9084048 + }, + "nounsDAOData": { + "address": "0x3F2e938318f5a9438853D25083DbE7CFB9A0652d", + "startBlock": 9084048 } } diff --git a/packages/nouns-subgraph/config/hardhat.json b/packages/nouns-subgraph/config/hardhat.json index 4ac9305e6c..4f6a16f94f 100644 --- a/packages/nouns-subgraph/config/hardhat.json +++ b/packages/nouns-subgraph/config/hardhat.json @@ -9,11 +9,11 @@ "startBlock": 0 }, "nounsDAO": { - "address": "0x9A9f2CCfdE556A7E9Ff0848998Aa4a0CFD8863AE", + "address": "0xa85233C63b9Ee964Add6F2cffe00Fd84eb32338f", "startBlock": 0 }, "nounsDAOData": { - "address": "0x59b670e9fA9D0A427751Af201D676719a970857b", + "address": "0x09635F643e140090A9A8Dcd712eD6285858ceBef", "startBlock": 0 } } diff --git a/packages/nouns-subgraph/docker-compose.yml b/packages/nouns-subgraph/docker-compose.yml index e84cdb2cc2..02cc1987e5 100644 --- a/packages/nouns-subgraph/docker-compose.yml +++ b/packages/nouns-subgraph/docker-compose.yml @@ -31,14 +31,11 @@ services: image: postgres ports: - '5432:5432' - command: - [ - "postgres", - "-cshared_preload_libraries=pg_stat_statements" - ] + command: ['postgres', '-cshared_preload_libraries=pg_stat_statements'] environment: POSTGRES_USER: graph-node POSTGRES_PASSWORD: let-me-in POSTGRES_DB: graph-node + POSTGRES_INITDB_ARGS: '-E UTF8 --locale=C' volumes: - ./data/postgres:/var/lib/postgresql/data diff --git a/packages/nouns-subgraph/schema.graphql b/packages/nouns-subgraph/schema.graphql index 78d60605a4..14d176caed 100644 --- a/packages/nouns-subgraph/schema.graphql +++ b/packages/nouns-subgraph/schema.graphql @@ -521,3 +521,101 @@ type ProposalFeedback @entity(immutable: true) { "The optional feedback reason free text" reason: String } + +type Fork @entity { + "The fork ID given by the escrow contract" + id: ID! + + "The fork ID as int, to make it easier to query for the latest fork" + forkID: BigInt! + + tokensInEscrowCount: Int! + + escrowDeposits: [EscrowDeposit!]! @derivedFrom(field: "fork") + + escrowWithdrawals: [EscrowWithdrawal!]! @derivedFrom(field: "fork") + + executed: Boolean + + executedAt: BigInt + + forkingPeriodEndTimestamp: BigInt + + forkTreasury: Bytes + + forkToken: Bytes + + tokensForkingCount: Int! + + escrowedNouns: [EscrowedNoun!]! @derivedFrom(field: "fork") + + joinedNouns: [ForkJoinedNoun!]! @derivedFrom(field: "fork") +} + +type EscrowDeposit @entity(immutable: true) { + id: ID! + + fork: Fork! + + createdAt: BigInt! + + owner: Delegate! + + tokenIDs: [BigInt!]! + + proposalIDs: [BigInt!]! + + reason: String +} + +type EscrowWithdrawal @entity(immutable: true) { + id: ID! + + fork: Fork! + + createdAt: BigInt! + + owner: Delegate! + + tokenIDs: [BigInt!]! +} + +type ForkJoin @entity(immutable: true) { + id: ID! + + fork: Fork! + + createdAt: BigInt! + + owner: Delegate! + + tokenIDs: [BigInt!]! + + proposalIDs: [BigInt!]! + + reason: String +} + +type EscrowedNoun @entity(immutable: true) { + id: ID! + + fork: Fork! + + noun: Noun! + + owner: Delegate! + + escrowDeposit: EscrowDeposit! +} + +type ForkJoinedNoun @entity(immutable: true) { + id: ID! + + fork: Fork! + + noun: Noun! + + owner: Delegate! + + forkJoin: ForkJoin! +} diff --git a/packages/nouns-subgraph/src/nouns-dao-data.ts b/packages/nouns-subgraph/src/nouns-dao-data.ts index ee66c39cf2..9df51636a2 100644 --- a/packages/nouns-subgraph/src/nouns-dao-data.ts +++ b/packages/nouns-subgraph/src/nouns-dao-data.ts @@ -91,7 +91,7 @@ export function handleSignatureAdded(event: SignatureAdded): void { const candidateId = event.params.proposer.toHexString().concat('-').concat(event.params.slug); const candidate = getOrCreateProposalCandidate(candidateId); - const latestVersion = ProposalCandidateVersion.load(candidate.latestVersion!)!; + const latestVersion = ProposalCandidateVersion.load(candidate.latestVersion)!; if (latestVersion.encodedProposalHash != event.params.encodedPropHash) { log.error('Wrong encodedProposalHash. Latest version: {}. Event: {}. tx_hash: {}', [ latestVersion.encodedProposalHash.toHexString(), @@ -101,7 +101,7 @@ export function handleSignatureAdded(event: SignatureAdded): void { return; } - candidateSig.version = candidate.latestVersion!; + candidateSig.version = candidate.latestVersion; candidateSig.signer = getOrCreateDelegate(event.params.signer.toHexString()).id; candidateSig.sig = event.params.sig; candidateSig.expirationTimestamp = event.params.expirationTimestamp; diff --git a/packages/nouns-subgraph/src/nouns-dao.ts b/packages/nouns-subgraph/src/nouns-dao.ts index fcc932e824..2df8327092 100644 --- a/packages/nouns-subgraph/src/nouns-dao.ts +++ b/packages/nouns-subgraph/src/nouns-dao.ts @@ -1,4 +1,4 @@ -import { BigInt, Bytes, log } from '@graphprotocol/graph-ts'; +import { Bytes, log, ethereum, store } from '@graphprotocol/graph-ts'; import { ProposalCreatedWithRequirements, ProposalCreatedWithRequirements1, @@ -15,6 +15,10 @@ import { ProposalDescriptionUpdated, ProposalTransactionsUpdated, SignatureCancelled, + EscrowedToFork, + WithdrawFromForkEscrow, + ExecuteFork, + JoinFork, } from './types/NounsDAO/NounsDAO'; import { getOrCreateDelegate, @@ -24,6 +28,7 @@ import { getOrCreateDelegateWithNullOption, getOrCreateDynamicQuorumParams, getOrCreateProposalVersion, + getOrCreateFork, } from './utils/helpers'; import { BIGINT_ONE, @@ -37,7 +42,15 @@ import { } from './utils/constants'; import { dynamicQuorumVotes } from './utils/dynamicQuorum'; import { ParsedProposalV3, extractTitle } from './custom-types/ParsedProposalV3'; -import { Proposal, ProposalCandidateSignature } from './types/schema'; +import { + Proposal, + ProposalCandidateSignature, + EscrowDeposit, + EscrowWithdrawal, + ForkJoin, + ForkJoinedNoun, + EscrowedNoun, +} from './types/schema'; export function handleProposalCreatedWithRequirements( event: ProposalCreatedWithRequirements1, @@ -339,3 +352,106 @@ function captureProposalVersion( previousVersion.updateMessage = updateMessage; previousVersion.save(); } + +export function handleEscrowedToFork(event: EscrowedToFork): void { + const fork = getOrCreateFork(event.params.forkId); + + const deposit = new EscrowDeposit(genericUniqueId(event)); + deposit.fork = fork.id; + deposit.createdAt = event.block.timestamp; + deposit.owner = getOrCreateDelegate(event.params.owner.toHexString()).id; + deposit.tokenIDs = event.params.tokenIds; + deposit.proposalIDs = event.params.proposalIds; + deposit.reason = event.params.reason; + deposit.save(); + + fork.tokensInEscrowCount += event.params.tokenIds.length; + // Add escrowed Nouns to the list of Nouns connected to their escrow event + // Using an entity rather than just Noun IDs thinking it's helpful in creating the UI timeline view + for (let i = 0; i < event.params.tokenIds.length; i++) { + const id = fork.id.toString().concat('-').concat(event.params.tokenIds[i].toString()); + const noun = new EscrowedNoun(id); + noun.fork = fork.id; + noun.noun = event.params.tokenIds[i].toString(); + noun.owner = deposit.owner; + noun.escrowDeposit = deposit.id; + noun.save(); + } + + fork.save(); +} + +export function handleWithdrawFromForkEscrow(event: WithdrawFromForkEscrow): void { + const fork = getOrCreateFork(event.params.forkId); + + const withdrawal = new EscrowWithdrawal(genericUniqueId(event)); + withdrawal.fork = fork.id; + withdrawal.createdAt = event.block.timestamp; + withdrawal.owner = getOrCreateDelegate(event.params.owner.toHexString()).id; + withdrawal.tokenIDs = event.params.tokenIds; + withdrawal.save(); + + fork.tokensInEscrowCount -= event.params.tokenIds.length; + + // Remove escrowed Nouns from the list + for (let i = 0; i < event.params.tokenIds.length; i++) { + const id = fork.id.toString().concat('-').concat(event.params.tokenIds[i].toString()); + store.remove('EscrowedNoun', id); + } + + fork.save(); +} + +export function handleExecuteFork(event: ExecuteFork): void { + const fork = getOrCreateFork(event.params.forkId); + + fork.executed = true; + fork.executedAt = event.block.timestamp; + fork.forkingPeriodEndTimestamp = event.params.forkEndTimestamp; + fork.forkTreasury = event.params.forkTreasury; + fork.forkToken = event.params.forkToken; + + if (fork.tokensInEscrowCount != event.params.tokensInEscrow.toI32()) { + log.warning('Number of tokens in escrow mismatch. Indexed count: {} vs Event count: {}', [ + fork.tokensInEscrowCount.toString(), + event.params.tokensInEscrow.toString(), + ]); + fork.tokensInEscrowCount = event.params.tokensInEscrow.toI32(); + } + fork.tokensForkingCount = fork.tokensInEscrowCount; + + fork.save(); +} + +export function handleJoinFork(event: JoinFork): void { + const fork = getOrCreateFork(event.params.forkId); + + const join = new ForkJoin(genericUniqueId(event)); + join.fork = fork.id; + join.createdAt = event.block.timestamp; + join.owner = getOrCreateDelegate(event.params.owner.toHexString()).id; + join.tokenIDs = event.params.tokenIds; + join.proposalIDs = event.params.proposalIds; + join.reason = event.params.reason; + join.save(); + + fork.tokensForkingCount += event.params.tokenIds.length; + + // Add newly joined Nouns to the list of joined Nouns + // Using an entity rather than just Noun IDs thinking it's helpful in creating the UI timeline view + for (let i = 0; i < event.params.tokenIds.length; i++) { + const id = fork.id.toString().concat('-').concat(event.params.tokenIds[i].toString()); + const noun = new ForkJoinedNoun(id); + noun.fork = fork.id; + noun.noun = event.params.tokenIds[i].toString(); + noun.owner = join.owner; + noun.forkJoin = join.id; + noun.save(); + } + + fork.save(); +} + +function genericUniqueId(event: ethereum.Event): string { + return event.transaction.hash.toHexString().concat('-').concat(event.logIndex.toString()); +} diff --git a/packages/nouns-subgraph/src/utils/helpers.ts b/packages/nouns-subgraph/src/utils/helpers.ts index 5d95ef56dd..e9d0881c82 100644 --- a/packages/nouns-subgraph/src/utils/helpers.ts +++ b/packages/nouns-subgraph/src/utils/helpers.ts @@ -11,6 +11,7 @@ import { ProposalCandidate, ProposalCandidateSignature, ProposalFeedback, + Fork, } from '../types/schema'; import { ZERO_ADDRESS, BIGINT_ZERO, BIGINT_ONE } from './constants'; @@ -200,3 +201,16 @@ export function getOrCreateProposalFeedback(id: string): ProposalFeedback { } return feedback; } + +export function getOrCreateFork(id: BigInt): Fork { + let fork = Fork.load(id.toString()); + if (fork == null) { + fork = new Fork(id.toString()); + fork.forkID = id; + fork.tokensInEscrowCount = 0; + fork.tokensForkingCount = 0; + fork.escrowedNouns = new Array(); + fork.joinedNouns = new Array(); + } + return fork; +} diff --git a/packages/nouns-subgraph/subgraph.yaml.mustache b/packages/nouns-subgraph/subgraph.yaml.mustache index 601fd283dc..fde1f26f03 100644 --- a/packages/nouns-subgraph/subgraph.yaml.mustache +++ b/packages/nouns-subgraph/subgraph.yaml.mustache @@ -116,6 +116,14 @@ dataSources: handler: handleProposalObjectionPeriodSet - event: SignatureCancelled(indexed address,bytes) handler: handleSignatureCanceled + - event: EscrowedToFork(indexed uint32,indexed address,uint256[],uint256[],string) + handler: handleEscrowedToFork + - event: WithdrawFromForkEscrow(indexed uint32,indexed address,uint256[]) + handler: handleWithdrawFromForkEscrow + - event: ExecuteFork(indexed uint32,address,address,uint256,uint256) + handler: handleExecuteFork + - event: JoinFork(indexed uint32,indexed address,uint256[],uint256[],string) + handler: handleJoinFork - kind: ethereum/contract name: NounsDAOData network: {{network}} diff --git a/packages/nouns-subgraph/tests/nouns-dao.test.ts b/packages/nouns-subgraph/tests/nouns-dao.test.ts index 5f2fc1975e..b52ab38527 100644 --- a/packages/nouns-subgraph/tests/nouns-dao.test.ts +++ b/packages/nouns-subgraph/tests/nouns-dao.test.ts @@ -8,8 +8,8 @@ import { beforeEach, afterEach, } from 'matchstick-as/assembly/index'; -import { Address, BigInt, Bytes } from '@graphprotocol/graph-ts'; -import { Proposal, ProposalVersion } from '../src/types/schema'; +import { Address, BigInt, Bytes, ethereum } from '@graphprotocol/graph-ts'; +import { EscrowDeposit, EscrowedNoun, Proposal, ProposalVersion } from '../src/types/schema'; import { handleProposalCreatedWithRequirements, handleVoteCast, @@ -21,6 +21,8 @@ import { handleProposalUpdated, handleProposalDescriptionUpdated, handleProposalTransactionsUpdated, + handleEscrowedToFork, + handleWithdrawFromForkEscrow, } from '../src/nouns-dao'; import { createProposalCreatedWithRequirementsEventV1, @@ -35,6 +37,8 @@ import { createProposalUpdatedEvent, createProposalDescriptionUpdatedEvent, createProposalTransactionsUpdatedEvent, + createEscrowedToForkEvent, + createWithdrawFromForkEscrowEvent, } from './utils'; import { BIGINT_10K, @@ -47,6 +51,7 @@ import { getOrCreateDynamicQuorumParams, getGovernanceEntity, getOrCreateDelegate, + getOrCreateFork, } from '../src/utils/helpers'; import { extractTitle, ParsedProposalV3 } from '../src/custom-types/ParsedProposalV3'; @@ -679,3 +684,67 @@ describe('ParsedProposalV3', () => { }); }); }); + +describe('forking', () => { + describe('escrow deposit and withdraw', () => { + afterAll(() => { + clearStore(); + }); + + test('one deposit with 3 nouns, one withdrawal of 1 noun, results in 2 escrowed nouns', () => { + const escrowBlockTimestamp = BigInt.fromI32(946684800); + const withdrawBlockTimestamp = BigInt.fromI32(956684800); + const nouner = Address.fromString('0x0000000000000000000000000000000000000001'); + const depositTokenIds = [BigInt.fromI32(1), BigInt.fromI32(4), BigInt.fromI32(2)]; + const withdrawTokenIds = [BigInt.fromI32(1)]; + const proposalIds = [BigInt.fromI32(1234)]; + const forkId = BIGINT_ZERO; + + handleEscrowedToFork( + createEscrowedToForkEvent( + txHash, + BIGINT_ZERO, + escrowBlockTimestamp, + nouner, + depositTokenIds, + proposalIds, + 'some reason', + forkId, + ), + ); + + handleWithdrawFromForkEscrow( + createWithdrawFromForkEscrowEvent( + txHash, + BIGINT_ZERO, + withdrawBlockTimestamp, + nouner, + withdrawTokenIds, + forkId, + ), + ); + + const fork = getOrCreateFork(forkId); + assert.i32Equals(fork.tokensInEscrowCount, 2); + assert.i32Equals(fork.escrowedNouns.length, 2); + + let escrowedNoun = EscrowedNoun.load(forkId.toString().concat('-4'))!; + let escrowDespositId = txHash.toHexString().concat('-0'); + assert.stringEquals(escrowedNoun.fork, forkId.toString()); + assert.stringEquals(escrowedNoun.noun, '4'); + assert.stringEquals(escrowedNoun.owner, nouner.toHexString()); + assert.stringEquals(escrowedNoun.escrowDeposit, escrowDespositId); + + let escrowDeposit = EscrowDeposit.load(escrowDespositId)!; + assert.stringEquals(escrowDeposit.fork, forkId.toString()); + assert.bigIntEquals(escrowDeposit.createdAt, escrowBlockTimestamp); + assert.stringEquals(escrowDeposit.owner, nouner.toHexString()); + assert.i32Equals(escrowDeposit.tokenIDs.length, 3); + assert.bigIntEquals(escrowDeposit.tokenIDs[0], BigInt.fromI32(1)); + assert.bigIntEquals(escrowDeposit.tokenIDs[1], BigInt.fromI32(4)); + assert.bigIntEquals(escrowDeposit.tokenIDs[2], BigInt.fromI32(2)); + assert.bigIntEquals(escrowDeposit.proposalIDs[0], BigInt.fromI32(1234)); + assert.stringEquals(escrowDeposit.reason!, 'some reason'); + }); + }); +}); diff --git a/packages/nouns-subgraph/tests/utils.ts b/packages/nouns-subgraph/tests/utils.ts index 7886d49a05..32b74d75ee 100644 --- a/packages/nouns-subgraph/tests/utils.ts +++ b/packages/nouns-subgraph/tests/utils.ts @@ -10,6 +10,8 @@ import { ProposalUpdated, ProposalDescriptionUpdated, ProposalTransactionsUpdated, + EscrowedToFork, + WithdrawFromForkEscrow, } from '../src/types/NounsDAO/NounsDAO'; import { handleMinQuorumVotesBPSSet, @@ -496,3 +498,61 @@ export function createProposalTransactionsUpdatedEvent( return newEvent; } + +export function createEscrowedToForkEvent( + txHash: Bytes, + logIndex: BigInt, + blockTimestamp: BigInt, + owner: Address, + tokenIds: Array, + proposalIds: Array, + reason: string, + forkId: BigInt, +): EscrowedToFork { + let newEvent = changetype(newMockEvent()); + + newEvent.transaction.hash = txHash; + newEvent.logIndex = logIndex; + newEvent.block.timestamp = blockTimestamp; + + newEvent.parameters = new Array(); + newEvent.parameters.push( + new ethereum.EventParam('forkId', ethereum.Value.fromUnsignedBigInt(forkId)), + ); + newEvent.parameters.push(new ethereum.EventParam('owner', ethereum.Value.fromAddress(owner))); + newEvent.parameters.push( + new ethereum.EventParam('tokenIds', ethereum.Value.fromUnsignedBigIntArray(tokenIds)), + ); + newEvent.parameters.push( + new ethereum.EventParam('proposalIds', ethereum.Value.fromUnsignedBigIntArray(proposalIds)), + ); + newEvent.parameters.push(new ethereum.EventParam('reason', ethereum.Value.fromString(reason))); + + return newEvent; +} + +export function createWithdrawFromForkEscrowEvent( + txHash: Bytes, + logIndex: BigInt, + blockTimestamp: BigInt, + owner: Address, + tokenIds: Array, + forkId: BigInt, +): WithdrawFromForkEscrow { + let newEvent = changetype(newMockEvent()); + + newEvent.transaction.hash = txHash; + newEvent.logIndex = logIndex; + newEvent.block.timestamp = blockTimestamp; + + newEvent.parameters = new Array(); + newEvent.parameters.push( + new ethereum.EventParam('forkId', ethereum.Value.fromUnsignedBigInt(forkId)), + ); + newEvent.parameters.push(new ethereum.EventParam('owner', ethereum.Value.fromAddress(owner))); + newEvent.parameters.push( + new ethereum.EventParam('tokenIds', ethereum.Value.fromUnsignedBigIntArray(tokenIds)), + ); + + return newEvent; +} diff --git a/packages/nouns-webapp/package.json b/packages/nouns-webapp/package.json index 6b68a474e8..b4bd253b63 100644 --- a/packages/nouns-webapp/package.json +++ b/packages/nouns-webapp/package.json @@ -5,9 +5,9 @@ "dependencies": { "@apollo/client": "^3.3.21", "@davatar/react": "^1.10.3", - "@fortawesome/fontawesome-svg-core": "1.2.35", - "@fortawesome/free-solid-svg-icons": "5.15.3", - "@fortawesome/react-fontawesome": "0.1.14", + "@fortawesome/fontawesome-svg-core": "^6.3.0", + "@fortawesome/free-solid-svg-icons": "^6.4.0", + "@fortawesome/react-fontawesome": "0.2.0", "@heroicons/react": "^1.0.6", "@lingui/cli": "^3.9.0", "@lingui/core": "^3.9.0", @@ -46,6 +46,7 @@ "ramda": "^0.27.1", "react": "^17.0.2", "react-bootstrap": "^2.0.0", + "react-diff-viewer": "^3.1.1", "react-dom": "^17.0.2", "react-markdown": "^7.0.0", "react-number-format": "^5.1.2", diff --git a/packages/nouns-webapp/public/loading-noggles.svg b/packages/nouns-webapp/public/loading-noggles.svg new file mode 100644 index 0000000000..a7517c4a8e --- /dev/null +++ b/packages/nouns-webapp/public/loading-noggles.svg @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/nouns-webapp/src/App.tsx b/packages/nouns-webapp/src/App.tsx index 570934488a..c9d553ec30 100644 --- a/packages/nouns-webapp/src/App.tsx +++ b/packages/nouns-webapp/src/App.tsx @@ -24,6 +24,14 @@ import relativeTime from 'dayjs/plugin/relativeTime'; import { AvatarProvider } from '@davatar/react'; import dayjs from 'dayjs'; import DelegatePage from './pages/DelegatePage'; +import CreateCandidatePage from './pages/CreateCandidate'; +import CandidatePage from './pages/Candidate'; +import EditProposalPage from './pages/EditProposal'; +import EditCandidatePage from './pages/EditCandidate'; +import ProposalHistory from './pages/ProposalHistory'; + +import CandidateProposals from './pages/CandidateProposals'; +import CandidateHistoryPage from './pages/CandidateHistoryPage'; function App() { const { account, chainId, library } = useEthers(); @@ -63,8 +71,21 @@ function App() { /> + + + + + + + + + diff --git a/packages/nouns-webapp/src/components/CandidateCard/CandidateCard.module.css b/packages/nouns-webapp/src/components/CandidateCard/CandidateCard.module.css new file mode 100644 index 0000000000..7d9b1d78cb --- /dev/null +++ b/packages/nouns-webapp/src/components/CandidateCard/CandidateCard.module.css @@ -0,0 +1,74 @@ +.candidateLink { + padding: 1rem; + margin-top: 0.4rem; + display: flex; + flex-direction: column; + border: 1px solid #e2e3e8; + box-sizing: border-box; + border-radius: 16px; + background: #f4f4f8; + font-size: 22px; + font-family: 'PT Root UI'; + font-weight: bold; + text-decoration: none; + color: inherit; + margin-bottom: 1rem; +} + +.candidateLink:hover { + background: white; + color: inherit !important; + cursor: pointer; +} + +.candidateTitle { + width: 100%; +} + +.footer { + border-top: 1px solid rgba(0, 0, 0, 0.1); + padding-top: 10px; + margin-top: 10px; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; +} + +.candidateSponsors { + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + gap: 5px; +} + +.candidateSponsors span { + font-size: 16px; + font-weight: normal; + margin-left: 10px; + display: block; +} + +.candidateSponsors strong { + font-weight: bold; +} + +.proposer { + font-size: 16px; + font-weight: normal; + margin: 0; + padding: 0; +} +.proposer a { + font-weight: bold; + text-decoration: none; + color: var(--brand-color-red); +} + +.timestamp { + font-size: 16px; + font-weight: bold; + margin: 0; + padding: 0; +} diff --git a/packages/nouns-webapp/src/components/CandidateCard/CandidateSponsorImage.tsx b/packages/nouns-webapp/src/components/CandidateCard/CandidateSponsorImage.tsx new file mode 100644 index 0000000000..ccb43aa848 --- /dev/null +++ b/packages/nouns-webapp/src/components/CandidateCard/CandidateSponsorImage.tsx @@ -0,0 +1,10 @@ +import { BigNumber } from 'ethers'; +import { StandaloneNounImage } from '../StandaloneNoun'; + +type Props = { + nounId: number; +}; + +export default function CandidateSponsorImage({ nounId }: Props) { + return ; +} diff --git a/packages/nouns-webapp/src/components/CandidateCard/CandidateSponsors.module.css b/packages/nouns-webapp/src/components/CandidateCard/CandidateSponsors.module.css new file mode 100644 index 0000000000..9c159bebee --- /dev/null +++ b/packages/nouns-webapp/src/components/CandidateCard/CandidateSponsors.module.css @@ -0,0 +1,25 @@ +.sponsors { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: flex-start; + align-items: center; + align-content: center; + gap: 5px; +} +.sponsorAvatar { + max-width: 32px; + width: 100%; +} +.sponsorAvatar img { + width: 100%; + border-radius: 100%; +} +.emptySponsorSpot { + width: 32px; + height: 32px; + border-radius: 100%; + background: #e8e8ec; + + border: 1px dashed #a7a7aa; +} diff --git a/packages/nouns-webapp/src/components/CandidateCard/CandidateSponsors.tsx b/packages/nouns-webapp/src/components/CandidateCard/CandidateSponsors.tsx new file mode 100644 index 0000000000..10e9380a25 --- /dev/null +++ b/packages/nouns-webapp/src/components/CandidateCard/CandidateSponsors.tsx @@ -0,0 +1,65 @@ +import React, { useState } from 'react'; +import classes from './CandidateSponsors.module.css'; +import { CandidateSignature } from '../../wrappers/nounsData'; +import CandidateSponsorImage from './CandidateSponsorImage'; +import { useQuery } from '@apollo/client'; +import { useBlockNumber } from '@usedapp/core'; +import { Delegates, delegateNounsAtBlockQuery } from '../../wrappers/subgraph'; +import { Link } from 'react-router-dom'; + +type Props = { + signers: CandidateSignature[]; + nounsRequired: number; +}; + +function CandidateSponsors({ signers, nounsRequired }: Props) { + const [signerSpots, setSignerSpots] = useState(); + const [signerCountOverflow, setSignerCountOverflow] = useState(0); + const currentBlock = useBlockNumber(); + const signerIds = signers?.map(s => s.signer.id) ?? []; + + const { data: delegateSnapshot } = useQuery( + delegateNounsAtBlockQuery(signerIds ?? [], currentBlock ?? 0), + ); + const { delegates } = delegateSnapshot || {}; + const delegateToNounIds = delegates?.reduce>((acc, curr) => { + acc[curr.id] = curr?.nounsRepresented?.map(nr => nr.id) ?? []; + return acc; + }, {}); + const nounIds = Object.values(delegateToNounIds ?? {}).flat(); + + React.useEffect(() => { + if (signers && signers.length < nounsRequired) { + setSignerSpots(signers); + } else if (signers && signers.length > nounsRequired) { + setSignerCountOverflow(signers.length - nounsRequired); + setSignerSpots(signers.slice(0, nounsRequired)); + } else { + setSignerSpots(signers); + } + }, [signers, nounsRequired]); + + console.log('todo: add signerCountOverflow element', signerCountOverflow); + + return ( +
+ {signerSpots && + signerSpots.length > 0 && + delegateToNounIds && + nounIds.map(nounId => { + return ( + + + + ); + })} + {Array(nounsRequired - signers.length) + .fill(0) + .map((_, index) => ( +
+ ))} +
+ ); +} + +export default CandidateSponsors; diff --git a/packages/nouns-webapp/src/components/CandidateCard/index.tsx b/packages/nouns-webapp/src/components/CandidateCard/index.tsx new file mode 100644 index 0000000000..61bafec40d --- /dev/null +++ b/packages/nouns-webapp/src/components/CandidateCard/index.tsx @@ -0,0 +1,59 @@ +import classes from './CandidateCard.module.css'; +import clsx from 'clsx'; +import { PartialProposalCandidate } from '../../wrappers/nounsData'; +import CandidateSponsors from './CandidateSponsors'; +import ShortAddress from '../ShortAddress'; +import dayjs from 'dayjs'; +import { Trans } from '@lingui/macro'; +import { Link } from 'react-router-dom'; +import { buildEtherscanAddressLink } from '../../utils/etherscan'; + +type Props = { + candidate: PartialProposalCandidate; + nounsRequired: number; +}; + +function CandidateCard({ candidate, nounsRequired }: Props) { + return ( + +
+ + {candidate.latestVersion.title} + +

+ by{' '} + + + +

+ +
+
+ + + + {candidate.latestVersion.versionSignatures.length} / {nounsRequired} + {' '} + sponsors + +
+

+ {dayjs.unix(candidate.lastUpdatedTimestamp).fromNow()} +

+
+
+ + ); +} + +export default CandidateCard; diff --git a/packages/nouns-webapp/src/components/CandidateSponsors/CandidateSponsors.module.css b/packages/nouns-webapp/src/components/CandidateSponsors/CandidateSponsors.module.css new file mode 100644 index 0000000000..b1c7cca6e0 --- /dev/null +++ b/packages/nouns-webapp/src/components/CandidateSponsors/CandidateSponsors.module.css @@ -0,0 +1,312 @@ +.wrapper { + position: sticky; + top: 20px; + @media (max-width: 1200px) { + position: relative; + top: 0; + } +} + +.interiorWrapper { + border: 1px solid #e6e6e6; + padding: 15px; + border-radius: 12px; + text-align: center; +} + +.header { + margin-bottom: 4px; + font-size: 20px; +} + +.subhead { + margin: 0 0 10px; + padding-top: 0; + font-size: 14px; + line-height: 1.1; + color: #646465; +} + +.sponsorsList { + text-align: left; + padding: 0; + gap: 16px; + display: flex; + flex-direction: column; + margin: 10px 0; +} + +.sponsorsList li { + /* placeholder styles */ + border: 1px solid #e6e6e6; + padding: 10px; + border-radius: 12px; + margin-bottom: 10px; + background-color: #f3f3f3; + list-style-type: none; + margin: 0; +} + +.sponsorsList p { + margin: 0; + padding: 0; +} +.sponsorsList li.sponsor { + background-color: #fbfbfc; + border-width: 1px; + position: relative; +} + +.sponsorsList li.placeholder { + border-style: dashed; + border-width: 2px; + min-height: 40px; +} + +.details { + display: flex; + flex-direction: row; + justify-content: space-between; +} + +.reason { + font-size: 13px; + color: #646465; + border-top: 1px solid #e6e6e6; + margin-top: 8px; + padding-top: 10px; + line-height: 1.2; +} + +.reasonWrapper { + display: -webkit-box; + -webkit-line-clamp: 4; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.reasonWrapper.reasonShown { + display: block; + -webkit-line-clamp: unset; + overflow: visible; +} + +.sponsorName a { + font-size: 14px; + font-weight: bold; + color: #000000 !important; + text-decoration: none; +} + +.sponsorName a:hover { + text-decoration: underline; +} + +.expiration { + font-size: 13px; + color: #646465; +} + +.sponsorInfo p { + margin: 0; + padding: 0; + line-height: 1.1; +} + +.voteCount { + margin: 0; + padding: 0; + font-weight: bold; + font-size: 13px; + color: #646465; +} + +.button { + padding: 10px; + border-radius: 8px; + background-color: #000; + border: 1px solid #e6e6e6; + font-size: 14px; + font-weight: bold; + color: #fff; + cursor: pointer; +} + +.button:disabled { + background-color: #f4f4f8; + border: #f4f4f8; + color: #8c8d92; + border: 1px solid #e2e3e8; + pointer-events: none; +} + +.removeSignature { + border: none; + text-align: center; + border-top: 1px solid #e6e6e6; + margin-top: 8px; + padding-top: 10px; + line-height: 1.1; +} + +.removeSignature button { + background: transparent; + border: none; + font-size: 14px; + font-weight: bold; + /* color: (var(--brand-color-red)); */ + color: #e40536; + cursor: pointer; + padding: 0; + margin: 0; +} + +.readMore { + display: block; + width: 100%; + text-align: center; + font-size: 13px; + font-weight: bold; + color: #000 !important; + background: rgb(251, 251, 252); + background: linear-gradient(0deg, rgba(251, 251, 252, 1) 33%, rgba(251, 251, 252, 0) 100%); + padding: 20px 3px 4px; + position: relative; + z-index: 2; + margin-top: -20px; + border: none; +} + +.withoutVotesMsg { + text-align: center; + margin: 0; + padding: 0; + font-size: 13px; + line-height: 1; + color: var(--brand-gray-light-text); + padding: 0 20px; + margin: 0 auto; +} + +.thresholdMet { + color: var(--brand-color-green); + font-weight: bold; + font-size: 14px; + margin: 0 0 4px; + padding: 0; +} + +.formOverlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(255, 255, 255); + z-index: 100; + display: flex; + justify-content: center; + align-items: center; + border-radius: 12px; + border: 1px solid #e6e6e6; +} + +.formOverlay .closeButton, +.cancelStatusOverlay .closeButton { + position: absolute; + top: 10px; + right: 10px; + font-size: 20px; + color: #000; + cursor: pointer; + border: none; + background: transparent; + z-index: 99; +} + +.formWrapper { + text-align: left; + width: 100%; + padding: 15px; +} +.formWrapper input, +.formWrapper textarea { + width: 100%; +} +.formWrapper button { + display: block; + margin: 0 auto; + font-size: 18px; +} + +.formLabel { + font-weight: bold; + margin-bottom: 2px; +} + +.formWrapper input, +.formWrapper textarea { + padding: 10px; + font-size: 14px; + border: 1px solid #aaa !important; + border-radius: 8px; + margin-bottom: 10px; +} + +.aboutText { + font-size: 14px; + color: #646465; + line-height: 1.2; + margin-top: 15px; +} + +.aboutText p { + margin: 0 0 4px; + padding: 0; +} + +.transactionStatus { + font-size: 14px; + color: #646465; + line-height: 1.2; + margin-top: 15px; + text-align: center; +} + +.cancelStatusOverlay, +.submitSignatureStatusOverlay { + position: absolute; + top: 3px; + left: 3px; + background-color: #fff; + text-align: center; + padding: 10px; + height: calc(100% - 6px); + width: calc(100% - 6px); + display: flex; + flex-direction: column; + justify-content: center; +} +.cancelStatusOverlay .cancelStatusOverlayTitle, +.submitSignatureStatusOverlay { + font-size: 18px; + font-weight: bold; + text-align: center; +} + +.loadingNoggles { + margin: 0 auto; + max-width: 90px; +} + +.submitSignatureStatusOverlay { + padding: 10px; + z-index: 100; + background-color: #fff; +} +.closeLink { + text-decoration: underline; + border: none; + background-color: transparent; + font-weight: bold; +} diff --git a/packages/nouns-webapp/src/components/CandidateSponsors/Signature.tsx b/packages/nouns-webapp/src/components/CandidateSponsors/Signature.tsx new file mode 100644 index 0000000000..f575a60710 --- /dev/null +++ b/packages/nouns-webapp/src/components/CandidateSponsors/Signature.tsx @@ -0,0 +1,158 @@ +import React, { useEffect } from 'react'; +import classes from './CandidateSponsors.module.css'; +import clsx from 'clsx'; +import { useBlockNumber } from '@usedapp/core'; +import { useCancelSignature } from '../../wrappers/nounsDao'; +import dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; +import { useQuery } from '@apollo/client'; +import { Delegates, delegateNounsAtBlockQuery } from '../../wrappers/subgraph'; +import ShortAddress from '../ShortAddress'; +import { buildEtherscanAddressLink } from '../../utils/etherscan'; + +type CandidateSignatureProps = { + reason: string; + expirationTimestamp: number; + signer: string; + isAccountSigner: boolean; + sig: string; + handleSignerCountDecrease: Function; +}; + +const Signature: React.FC = props => { + const [isReasonShown, setIsReasonShown] = React.useState(false); + const [isCancelSignaturePending, setIsCancelSignaturePending] = React.useState(false); + const [cancelStatusOverlay, setCancelStatusOverlay] = React.useState<{ + title: string; + message: string; + show: boolean; + }>(); + dayjs.extend(relativeTime); + const expiration = dayjs(dayjs.unix(props.expirationTimestamp / 1000)).fromNow(); + // get votes for signer + const blockNumber = useBlockNumber(); + const { data: delegateSnapshot } = useQuery( + delegateNounsAtBlockQuery([props.signer], blockNumber || 0), + ); + const { cancelSig, cancelSigState } = useCancelSignature(); + async function cancel() { + // await + await cancelSig(props.sig); + } + + useEffect(() => { + switch (cancelSigState.status) { + case 'None': + setIsCancelSignaturePending(false); + break; + case 'Mining': + setIsCancelSignaturePending(true); + break; + case 'Success': + setCancelStatusOverlay({ + title: 'Success', + message: 'Signature removed', + show: true, + }); + setIsCancelSignaturePending(false); + props.handleSignerCountDecrease(delegateSnapshot?.delegates[0]?.nounsRepresented.length); + break; + case 'Fail': + setCancelStatusOverlay({ + title: 'Transaction Failed', + message: cancelSigState?.errorMessage || 'Please try again.', + show: true, + }); + setIsCancelSignaturePending(false); + break; + case 'Exception': + setCancelStatusOverlay({ + title: 'Error', + message: cancelSigState?.errorMessage || 'Please try again.', + show: true, + }); + setIsCancelSignaturePending(false); + break; + } + // todo: make these deps more specific + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cancelSigState, setCancelStatusOverlay]); + + return ( +
  • +
    +
    +
    +

    + + + +

    +

    Expires {expiration}

    +
    +

    + {delegateSnapshot?.delegates[0]?.nounsRepresented.length} votes +

    +
    + {props.reason && ( +
    setIsReasonShown(!isReasonShown)}> +
    50 && classes.reasonShown, + )} + > +

    {props.reason}

    +
    + {!isReasonShown && props.reason.length > 50 && ( + + )} +
    + )} + {props.isAccountSigner && ( +
    + {isCancelSignaturePending ? ( + loading + ) : ( + + )} + +
    + {cancelStatusOverlay?.show && ( +
    + {(cancelSigState.status === 'Exception' || cancelSigState.status === 'Fail') && ( + + )} +
    + {cancelStatusOverlay.title} +
    +
    + {cancelStatusOverlay.message} +
    +
    + )} +
    +
    + )} +
    +
  • + ); +}; + +export default Signature; diff --git a/packages/nouns-webapp/src/components/CandidateSponsors/SignatureForm.tsx b/packages/nouns-webapp/src/components/CandidateSponsors/SignatureForm.tsx new file mode 100644 index 0000000000..dd27395b5c --- /dev/null +++ b/packages/nouns-webapp/src/components/CandidateSponsors/SignatureForm.tsx @@ -0,0 +1,246 @@ +import React, { useEffect, useState } from 'react'; +import classes from './CandidateSponsors.module.css'; +import dayjs from 'dayjs'; +import { Trans } from '@lingui/macro'; +import { useEthers } from '@usedapp/core'; +import { ethers } from 'ethers'; +import config, { CHAIN_ID } from '../../config'; +import { useCandidateProposal, useAddSignature } from '../../wrappers/nounsData'; + +const domain = { + name: 'Nouns DAO', + chainId: CHAIN_ID, + verifyingContract: config.addresses.nounsDAOProxy, +}; + +const createProposalTypes = { + Proposal: [ + { name: 'proposer', type: 'address' }, + { name: 'targets', type: 'address[]' }, + { name: 'values', type: 'uint256[]' }, + { name: 'signatures', type: 'string[]' }, + { name: 'calldatas', type: 'bytes[]' }, + { name: 'description', type: 'string' }, + { name: 'expiry', type: 'uint256' }, + ], +}; + +const updateProposalTypes = { + UpdateProposal: [ + { name: 'proposalId', type: 'uint256' }, + { name: 'proposer', type: 'address' }, + { name: 'targets', type: 'address[]' }, + { name: 'values', type: 'uint256[]' }, + { name: 'signatures', type: 'string[]' }, + { name: 'calldatas', type: 'bytes[]' }, + { name: 'description', type: 'string' }, + { name: 'expiry', type: 'uint256' }, + ], +}; + +type Props = { + id: string; + transactionState: 'None' | 'Success' | 'Mining' | 'Fail' | 'Exception'; + setTransactionState: Function; +}; + +function SignatureForm(props: Props) { + const [reasonText, setReasonText] = React.useState(''); + const [expirationDate, setExpirationDate] = React.useState(); + + const { library } = useEthers(); + const signer = library?.getSigner(); + + const [proposalIdToUpdate, + // setProposalIdToUpdate + // todo: does this need to be set? + ] = useState(''); + + const candidateProposal = useCandidateProposal(props.id); + const { addSignature, addSignatureState } = useAddSignature(); + const [isAddSignaturePending, setIsAddSignaturePending] = useState(false); + async function calcProposalEncodeData( + proposer: any, + targets: any, + values: any, + signatures: any[], + calldatas: any[], + description: string, + ) { + const signatureHashes = signatures.map((sig: string) => + ethers.utils.keccak256(ethers.utils.toUtf8Bytes(sig)), + ); + + const calldatasHashes = calldatas.map((calldata: ethers.utils.BytesLike) => + ethers.utils.keccak256(calldata), + ); + + const encodedData = ethers.utils.defaultAbiCoder.encode( + ['address', 'bytes32', 'bytes32', 'bytes32', 'bytes32', 'bytes32'], + [ + proposer, + ethers.utils.keccak256(ethers.utils.solidityPack(['address[]'], [targets])), + ethers.utils.keccak256(ethers.utils.solidityPack(['uint256[]'], [values])), + ethers.utils.keccak256(ethers.utils.solidityPack(['bytes32[]'], [signatureHashes])), + ethers.utils.keccak256(ethers.utils.solidityPack(['bytes32[]'], [calldatasHashes])), + ethers.utils.keccak256(ethers.utils.toUtf8Bytes(description)), + ], + ); + + return encodedData; + } + + async function sign() { + if (!candidateProposal) return; + let signature; + if (proposalIdToUpdate && candidateProposal) { + const value = { + proposer: candidateProposal.proposer, + targets: candidateProposal.version.targets, + values: candidateProposal.version.values, + signatures: candidateProposal.version.signatures, + calldatas: candidateProposal.version.calldatas, + description: candidateProposal.version.description, + expiry: expirationDate, + proposalId: proposalIdToUpdate, + }; + signature = await signer!._signTypedData(domain, updateProposalTypes, value); + } else { + if (!candidateProposal) return; + const value = { + proposer: candidateProposal.proposer, + targets: candidateProposal.version.targets, + values: candidateProposal.version.values, + signatures: candidateProposal.version.signatures, + calldatas: candidateProposal.version.calldatas, + description: candidateProposal.version.description, + expiry: expirationDate, + }; + signature = await signer!._signTypedData(domain, createProposalTypes, value); + } + + const encodedProp = await calcProposalEncodeData( + candidateProposal.proposer, + candidateProposal.version.targets, + candidateProposal.version.values, + candidateProposal.version.signatures, + candidateProposal.version.calldatas, + candidateProposal.version.description, + ); + + await addSignature( + signature, + expirationDate, + candidateProposal.proposer, + candidateProposal.slug, + encodedProp, + reasonText, + ); + } + + const [submitSignatureStatusMessage, setSubmitSignatureStatusMessage] = React.useState<{ + title: string; + message: string; + show: boolean; + }>(); + + useEffect(() => { + switch (addSignatureState.status) { + case 'None': + setIsAddSignaturePending(false); + break; + case 'Mining': + setIsAddSignaturePending(true); + break; + case 'Success': + setSubmitSignatureStatusMessage({ + title: 'Success', + message: 'Signature added', + show: true, + }); + setIsAddSignaturePending(false); + break; + case 'Fail': + setSubmitSignatureStatusMessage({ + title: 'Transaction Failed', + message: addSignatureState?.errorMessage || 'Please try again.', + show: true, + }); + setIsAddSignaturePending(false); + break; + case 'Exception': + setSubmitSignatureStatusMessage({ + title: 'Error', + message: addSignatureState?.errorMessage || 'Please try again.', + show: true, + }); + setIsAddSignaturePending(false); + break; + } + }, [addSignatureState, setSubmitSignatureStatusMessage]); + + return ( +
    + {!candidateProposal ? ( +

    Error loading candidate details

    + ) : ( + <> +

    Sponsor this proposal candidate

    +