From 42fbe07bfb95fee8d103ed03c3f5e963711b74c9 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Sun, 29 Jan 2023 14:59:02 +0100 Subject: [PATCH 001/218] Revert "remove POC webapp code from this branch" This reverts commit 4da6193b8488c8b91f5594241cc281de7fc39a3c. --- packages/nouns-webapp/src/App.tsx | 6 + .../DraftProposalsStorage.ts | 43 +++ .../src/pages/CreateDraftProposal/index.tsx | 280 ++++++++++++++++++ .../src/pages/DraftProposal/index.tsx | 201 +++++++++++++ .../src/pages/DraftProposals/index.tsx | 26 ++ .../nouns-webapp/src/wrappers/nounsDao.ts | 10 + 6 files changed, 566 insertions(+) create mode 100644 packages/nouns-webapp/src/pages/CreateDraftProposal/DraftProposalsStorage.ts create mode 100644 packages/nouns-webapp/src/pages/CreateDraftProposal/index.tsx create mode 100644 packages/nouns-webapp/src/pages/DraftProposal/index.tsx create mode 100644 packages/nouns-webapp/src/pages/DraftProposals/index.tsx diff --git a/packages/nouns-webapp/src/App.tsx b/packages/nouns-webapp/src/App.tsx index 570934488a..7ab9a54a3e 100644 --- a/packages/nouns-webapp/src/App.tsx +++ b/packages/nouns-webapp/src/App.tsx @@ -14,6 +14,7 @@ import Footer from './components/Footer'; import AuctionPage from './pages/Auction'; import GovernancePage from './pages/Governance'; import CreateProposalPage from './pages/CreateProposal'; +import CreateDraftProposalPage from './pages/CreateDraftProposal'; import VotePage from './pages/Vote'; import NoundersPage from './pages/Nounders'; import ExplorePage from './pages/Explore'; @@ -24,6 +25,8 @@ import relativeTime from 'dayjs/plugin/relativeTime'; import { AvatarProvider } from '@davatar/react'; import dayjs from 'dayjs'; import DelegatePage from './pages/DelegatePage'; +import DraftProposals from './pages/DraftProposals'; +import DraftProposalPage from './pages/DraftProposal'; function App() { const { account, chainId, library } = useEthers(); @@ -63,6 +66,9 @@ function App() { /> + + + diff --git a/packages/nouns-webapp/src/pages/CreateDraftProposal/DraftProposalsStorage.ts b/packages/nouns-webapp/src/pages/CreateDraftProposal/DraftProposalsStorage.ts new file mode 100644 index 0000000000..73e5ca6ecc --- /dev/null +++ b/packages/nouns-webapp/src/pages/CreateDraftProposal/DraftProposalsStorage.ts @@ -0,0 +1,43 @@ + +const STORAGE_KEY = 'draft-proposals'; + +export interface DraftProposal { + proposalContent: ProposalContent; + signatures: ProposalSignaure[]; +} + +export interface ProposalSignaure { + signer: string; + signature: string; + expiry: number; +} + +export interface ProposalContent { + proposer: string; + targets: string[]; + values: string[]; + signatures: string[]; + calldatas: string[]; + description: string; +} + +export const saveDraftProposal = (draftProposal: DraftProposal) => { + const draftProposals = JSON.parse(window.localStorage.getItem(STORAGE_KEY) || '[]'); + draftProposals.push(draftProposal); + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(draftProposals)); +} + +export const getDraftProposals = () : DraftProposal[] => { + return JSON.parse(window.localStorage.getItem(STORAGE_KEY) || '[]'); +} + +export const addSignature = (signature: ProposalSignaure, proposalId: number) : DraftProposal => { + const draftProposals = getDraftProposals(); + const draftProposal = draftProposals[proposalId]; + const signatures = draftProposal['signatures']; + signatures.push(signature); + draftProposals[proposalId] = draftProposal; + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(draftProposals)); + + return draftProposal; +} \ No newline at end of file diff --git a/packages/nouns-webapp/src/pages/CreateDraftProposal/index.tsx b/packages/nouns-webapp/src/pages/CreateDraftProposal/index.tsx new file mode 100644 index 0000000000..984f1a22da --- /dev/null +++ b/packages/nouns-webapp/src/pages/CreateDraftProposal/index.tsx @@ -0,0 +1,280 @@ +import { Col, Alert, Button } from 'react-bootstrap'; +import Section from '../../layout/Section'; +import { + ProposalState, + ProposalTransaction, + useProposal, + useProposalCount, + useProposalThreshold, + usePropose, +} from '../../wrappers/nounsDao'; +import { useUserVotes } from '../../wrappers/nounToken'; +import classes from '../CreateProposal/CreateProposal.module.css'; +import { Link, useHistory } from 'react-router-dom'; +import { useEthers } from '@usedapp/core'; +import { AlertModal, setAlertModal } from '../../state/slices/application'; +import ProposalEditor from '../../components/ProposalEditor'; +import ProposalTransactions from '../../components/ProposalTransactions'; +import { withStepProgress } from 'react-stepz'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useAppDispatch } from '../../hooks'; +import { Trans } from '@lingui/macro'; +import clsx from 'clsx'; +import navBarButtonClasses from '../../components/NavBarButton/NavBarButton.module.css'; +import ProposalActionModal from '../../components/ProposalActionsModal'; +import config from '../../config'; +import { useEthNeeded } from '../../utils/tokenBuyerContractUtils/tokenBuyer'; +import { saveDraftProposal } from './DraftProposalsStorage'; + +const CreateDraftProposalPage = () => { + const history = useHistory(); + const { account } = useEthers(); + const latestProposalId = useProposalCount(); + const latestProposal = useProposal(latestProposalId ?? 0); + const availableVotes = useUserVotes(); + const proposalThreshold = useProposalThreshold(); + + const { propose, proposeState } = usePropose(); + + const [proposalTransactions, setProposalTransactions] = useState([]); + const [titleValue, setTitleValue] = useState(''); + const [bodyValue, setBodyValue] = useState(''); + + const [totalUSDCPayment, setTotalUSDCPayment] = useState(0); + const [tokenBuyerTopUpEth, setTokenBuyerTopUpETH] = useState('0'); + const ethNeeded = useEthNeeded(config.addresses.tokenBuyer ?? '', totalUSDCPayment); + + const handleAddProposalAction = useCallback( + (transaction: ProposalTransaction) => { + if (!transaction.address.startsWith('0x')) { + transaction.address = `0x${transaction.address}`; + } + if (!transaction.calldata.startsWith('0x')) { + transaction.calldata = `0x${transaction.calldata}`; + } + + if (transaction.usdcValue) { + setTotalUSDCPayment(totalUSDCPayment + transaction.usdcValue); + } + + setProposalTransactions([...proposalTransactions, transaction]); + setShowTransactionFormModal(false); + }, + [proposalTransactions, totalUSDCPayment], + ); + + const handleRemoveProposalAction = useCallback( + (index: number) => { + setTotalUSDCPayment(totalUSDCPayment - (proposalTransactions[index].usdcValue ?? 0)); + setProposalTransactions(proposalTransactions.filter((_, i) => i !== index)); + }, + [proposalTransactions, totalUSDCPayment], + ); + + useEffect(() => { + if (ethNeeded !== undefined && ethNeeded !== tokenBuyerTopUpEth) { + const hasTokenBuyterTopTop = + proposalTransactions.filter(txn => txn.address === config.addresses.tokenBuyer).length > 0; + + // Add a new top up txn if one isn't there already, else add to the existing one + if (parseInt(ethNeeded) > 0 && !hasTokenBuyterTopTop) { + handleAddProposalAction({ + address: config.addresses.tokenBuyer ?? '', + value: ethNeeded ?? '0', + calldata: '0x', + signature: '', + }); + } else { + if (parseInt(ethNeeded) > 0) { + const indexOfTokenBuyerTopUp = + proposalTransactions + .map((txn, index: number) => { + if (txn.address === config.addresses.tokenBuyer) { + return index; + } else { + return -1; + } + }) + .filter(n => n >= 0) ?? new Array(); + + const txns = proposalTransactions; + if (indexOfTokenBuyerTopUp.length > 0) { + txns[indexOfTokenBuyerTopUp[0]].value = ethNeeded; + setProposalTransactions(txns); + } + } + } + + setTokenBuyerTopUpETH(ethNeeded ?? '0'); + } + }, [ + ethNeeded, + handleAddProposalAction, + handleRemoveProposalAction, + proposalTransactions, + tokenBuyerTopUpEth, + ]); + + const handleTitleInput = useCallback( + (title: string) => { + setTitleValue(title); + }, + [setTitleValue], + ); + + const handleBodyInput = useCallback( + (body: string) => { + setBodyValue(body); + }, + [setBodyValue], + ); + + const isFormInvalid = useMemo( + () => !proposalTransactions.length || titleValue === '' || bodyValue === '', + [proposalTransactions, titleValue, bodyValue], + ); + + const hasEnoughVote = Boolean( + availableVotes && proposalThreshold !== undefined && availableVotes > proposalThreshold, + ); + + const handleCreateProposal = async () => { + const description = `# ${titleValue}\n\n${bodyValue}`; + saveDraftProposal({ + proposalContent: { + proposer: account || '', + targets: proposalTransactions.map(({ address }) => address), // Targets + values: proposalTransactions.map(({ value }) => value ?? '0'), // Values + signatures: proposalTransactions.map(({ signature }) => signature), // Signatures + calldatas: proposalTransactions.map(({ calldata }) => calldata), // Calldatas + description: description + }, + signatures: [] + }); + + history.push('/draft-proposals'); + }; + + const [showTransactionFormModal, setShowTransactionFormModal] = useState(false); + const [isProposePending, setProposePending] = useState(false); + + const dispatch = useAppDispatch(); + const setModal = useCallback((modal: AlertModal) => dispatch(setAlertModal(modal)), [dispatch]); + + useEffect(() => { + switch (proposeState.status) { + case 'None': + setProposePending(false); + break; + case 'Mining': + setProposePending(true); + break; + case 'Success': + setModal({ + title: Success, + message: Proposal Created!, + show: true, + }); + setProposePending(false); + break; + case 'Fail': + setModal({ + title: Transaction Failed, + message: proposeState?.errorMessage || Please try again., + show: true, + }); + setProposePending(false); + break; + case 'Exception': + setModal({ + title: Error, + message: proposeState?.errorMessage || Please try again., + show: true, + }); + setProposePending(false); + break; + } + }, [proposeState, setModal]); + + return ( +
+ setShowTransactionFormModal(false)} + show={showTransactionFormModal} + onActionAdd={handleAddProposalAction} + /> + + +
+ + + +

+ Create Draft Proposal +

+
+ + + Tip + + :{' '} + + Add one or more proposal actions and describe your proposal for the community. The + proposal cannot be modified after submission, so please verify all information before + submitting. The voting period will begin after 2 days and last for 5 days. + +
+
+ + You MUST maintain enough voting power to meet the proposal threshold until your + proposal is executed. If you fail to do so, anyone can cancel your proposal. + +
+
+ +
+ + + {totalUSDCPayment > 0 && ( + + + Note + + :{' '} + + Because this proposal contains a USDC fund transfer action we've added an additional + ETH transaction to refill the TokenBuyer contract. This action allows to DAO to + continue to trustlessly acquire USDC to fund proposals like this. + + + )} + +
+ +
+ +
+ ); +}; + +export default withStepProgress(CreateDraftProposalPage); diff --git a/packages/nouns-webapp/src/pages/DraftProposal/index.tsx b/packages/nouns-webapp/src/pages/DraftProposal/index.tsx new file mode 100644 index 0000000000..d2a6c8856e --- /dev/null +++ b/packages/nouns-webapp/src/pages/DraftProposal/index.tsx @@ -0,0 +1,201 @@ +import { JsonRpcSigner } from "@ethersproject/providers"; +import { Trans } from '@lingui/macro'; +import { useEthers } from "@usedapp/core"; +import { useCallback, useEffect, useState } from "react"; +import { Button, Container } from "react-bootstrap"; +import ReactMarkdown from "react-markdown"; +import { RouteComponentProps } from "react-router-dom"; +import remarkBreaks from "remark-breaks"; +import config, { CHAIN_ID } from "../../config"; +import { useAppDispatch } from "../../hooks"; +import Section from "../../layout/Section"; +import { AlertModal, setAlertModal } from "../../state/slices/application"; +import { useProposeBySigs, useUpdateProposalBySigs } from "../../wrappers/nounsDao"; +import { addSignature, DraftProposal, getDraftProposals, ProposalContent } from "../CreateDraftProposal/DraftProposalsStorage"; + +const domain = { + name: 'Nouns DAO', + chainId: CHAIN_ID, + verifyingContract: config.addresses.nounsDAOProxy +}; + +const types = { + 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: 'uint40' } + ] +}; + +const DraftProposalPage = ({ + match: { + params: { id }, + }, +}: RouteComponentProps<{ id: string }>) => { + const proposalId = Number.parseInt(id); + const { library, chainId } = useEthers(); + const signer = library?.getSigner(); + const [draftProposal, setDraftProposal] = useState(undefined); + const [expiry, setExpiry] = useState(Math.round(Date.now() / 1000)); + const [proposalIdToUpdate, setProposalIdToUpdate] = useState(''); + + useEffect(() => { + const draftProposals = getDraftProposals(); + setDraftProposal(draftProposals[proposalId]); + }, []); + + + async function sign() { + if (!draftProposal) return; + + const value = { + ...draftProposal.proposalContent, + 'expiry': expiry + }; + + const signature = await signer!._signTypedData(domain, types, value); + const updatedDraftProposal = addSignature( + { + signer: await signer!.getAddress(), + signature: signature!, + expiry: expiry}, + proposalId); + setDraftProposal(updatedDraftProposal); + } + + const [isProposePending, setProposePending] = useState(false); + + const dispatch = useAppDispatch(); + const setModal = useCallback((modal: AlertModal) => dispatch(setAlertModal(modal)), [dispatch]); + const { proposeBySigs, proposeBySigsState } = useProposeBySigs(); + const { updateProposalBySigs, updateProposalBySigState } = useUpdateProposalBySigs(); + + useEffect(() => { + switch (proposeBySigsState.status) { + case 'None': + setProposePending(false); + break; + case 'Mining': + setProposePending(true); + break; + case 'Success': + setModal({ + title: Success, + message: Proposal Created!, + show: true, + }); + setProposePending(false); + break; + case 'Fail': + setModal({ + title: Transaction Failed, + message: proposeBySigsState?.errorMessage || Please try again., + show: true, + }); + setProposePending(false); + break; + case 'Exception': + setModal({ + title: Error, + message: proposeBySigsState?.errorMessage || Please try again., + show: true, + }); + setProposePending(false); + break; + } + }, [proposeBySigsState, setModal]); + + useEffect(() => { + switch (updateProposalBySigState.status) { + case 'None': + setProposePending(false); + break; + case 'Mining': + setProposePending(true); + break; + case 'Success': + setModal({ + title: Success, + message: Proposal Updated!, + show: true, + }); + setProposePending(false); + break; + case 'Fail': + setModal({ + title: Transaction Failed, + message: updateProposalBySigState?.errorMessage || Please try again., + show: true, + }); + setProposePending(false); + break; + case 'Exception': + setModal({ + title: Error, + message: updateProposalBySigState?.errorMessage || Please try again., + show: true, + }); + setProposePending(false); + break; + } + }, [updateProposalBySigState, setModal]); + + async function proposeBySigsClicked() { + await proposeBySigs( + draftProposal?.signatures.map(s => [s.signature, s.signer, s.expiry]), + draftProposal?.proposalContent.targets, + draftProposal?.proposalContent.values, + draftProposal?.proposalContent.signatures, + draftProposal?.proposalContent.calldatas, + draftProposal?.proposalContent.description, + ); + } + + async function updateProposalBySigsClicked() { + const proposalId = Number.parseInt(proposalIdToUpdate); + + await updateProposalBySigs( + proposalId, + draftProposal?.signatures.map(s => [s.signature, s.signer, s.expiry]), + draftProposal?.proposalContent.targets, + draftProposal?.proposalContent.values, + draftProposal?.proposalContent.signatures, + draftProposal?.proposalContent.calldatas, + draftProposal?.proposalContent.description, + ) + } + + return ( +
+

Draft Proposal {id}

+ {draftProposal && ( + + )} +
+        {JSON.stringify(draftProposal, null, 4)}
+      
+ + + + + + + + + + +
+ ) +} + +export default DraftProposalPage; \ No newline at end of file diff --git a/packages/nouns-webapp/src/pages/DraftProposals/index.tsx b/packages/nouns-webapp/src/pages/DraftProposals/index.tsx new file mode 100644 index 0000000000..c63fbe02a6 --- /dev/null +++ b/packages/nouns-webapp/src/pages/DraftProposals/index.tsx @@ -0,0 +1,26 @@ +import { Link } from "react-router-dom"; +import Section from "../../layout/Section"; +import { getDraftProposals } from "../CreateDraftProposal/DraftProposalsStorage"; + +const DraftProposals = () => { + const draftProposals = getDraftProposals(); + + const proposals = draftProposals.map((p, i) => { + return ( +
+ {i}. {p.proposalContent.description.split('\n')[0]} +
+ ); + }); + + return ( +
+
Draft Proposals!
+
+ {proposals} +
+
+ ) +} + +export default DraftProposals; \ No newline at end of file diff --git a/packages/nouns-webapp/src/wrappers/nounsDao.ts b/packages/nouns-webapp/src/wrappers/nounsDao.ts index afe20a62b8..fb4901823d 100644 --- a/packages/nouns-webapp/src/wrappers/nounsDao.ts +++ b/packages/nouns-webapp/src/wrappers/nounsDao.ts @@ -568,6 +568,16 @@ export const usePropose = () => { return { propose, proposeState }; }; +export const useProposeBySigs = () => { + const { send: proposeBySigs, state: proposeBySigsState } = useContractFunction(nounsDaoContract, 'proposeBySigs'); + return { proposeBySigs, proposeBySigsState }; +}; + +export const useUpdateProposalBySigs = () => { + const { send: updateProposalBySigs, state: updateProposalBySigState } = useContractFunction(nounsDaoContract, 'updateProposalBySigs'); + return { updateProposalBySigs, updateProposalBySigState }; +} + export const useQueueProposal = () => { const { send: queueProposal, state: queueProposalState } = useContractFunction( nounsDaoContract, From 2fc4c79c3f1f7ef78c2d57bb626b077ce53d31e7 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Mon, 30 Jan 2023 11:02:16 +0100 Subject: [PATCH 002/218] POC frontend code for update proposal --- .../governance/NounsDAOInterfaces.sol | 13 + .../contracts/governance/NounsDAOLogicV3.sol | 15 +- .../governance/NounsDAOV3Proposals.sol | 2 +- .../nouns-contracts/tasks/run-local-dao-v3.ts | 2 +- packages/nouns-subgraph/schema.graphql | 6 + packages/nouns-subgraph/src/nouns-dao.ts | 16 + .../nouns-subgraph/subgraph.yaml.mustache | 4 +- packages/nouns-webapp/src/App.tsx | 2 + .../src/pages/UpdateProposal/index.tsx | 277 ++++++++++++++++++ .../nouns-webapp/src/pages/Vote/index.tsx | 12 +- .../nouns-webapp/src/wrappers/nounsDao.ts | 5 + 11 files changed, 336 insertions(+), 18 deletions(-) create mode 100644 packages/nouns-webapp/src/pages/UpdateProposal/index.tsx diff --git a/packages/nouns-contracts/contracts/governance/NounsDAOInterfaces.sol b/packages/nouns-contracts/contracts/governance/NounsDAOInterfaces.sol index 653efb5d42..53533da4a7 100644 --- a/packages/nouns-contracts/contracts/governance/NounsDAOInterfaces.sol +++ b/packages/nouns-contracts/contracts/governance/NounsDAOInterfaces.sol @@ -126,6 +126,19 @@ contract NounsDAOEventsV2 is NounsDAOEvents { event NewPendingVetoer(address oldPendingVetoer, address newPendingVetoer); } +contract NounsDAOEventsV3 is NounsDAOEventsV2 { + /// @notice Emitted when a proposal is updated + event ProposalUpdated( + uint256 indexed id, + address indexed proposer, + address[] targets, + uint256[] values, + string[] signatures, + bytes[] calldatas, + string description + ); +} + contract NounsDAOProxyStorage { /// @notice Administrator for this contract address public admin; diff --git a/packages/nouns-contracts/contracts/governance/NounsDAOLogicV3.sol b/packages/nouns-contracts/contracts/governance/NounsDAOLogicV3.sol index d19ddf2202..2201cf50e2 100644 --- a/packages/nouns-contracts/contracts/governance/NounsDAOLogicV3.sol +++ b/packages/nouns-contracts/contracts/governance/NounsDAOLogicV3.sol @@ -58,25 +58,12 @@ import { NounsDAOV3DynamicQuorum } from './NounsDAOV3DynamicQuorum.sol'; import { NounsDAOV3Votes } from './NounsDAOV3Votes.sol'; import { NounsDAOV3Proposals } from './NounsDAOV3Proposals.sol'; -contract NounsDAOLogicV3 is NounsDAOStorageV3 { +contract NounsDAOLogicV3 is NounsDAOStorageV3, NounsDAOEventsV3 { using NounsDAOV3Admin for StorageV3; using NounsDAOV3DynamicQuorum for StorageV3; using NounsDAOV3Votes for StorageV3; using NounsDAOV3Proposals for StorageV3; - /// @dev This is here for typechain to add this event to the NounsDAOLogicV3 generated class - event ProposalCreated( - uint256 id, - address proposer, - address[] targets, - uint256[] values, - string[] signatures, - bytes[] calldatas, - uint256 startBlock, - uint256 endBlock, - string description - ); - /** * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * CONSTANTS diff --git a/packages/nouns-contracts/contracts/governance/NounsDAOV3Proposals.sol b/packages/nouns-contracts/contracts/governance/NounsDAOV3Proposals.sol index 55f91a63a9..df75b6f574 100644 --- a/packages/nouns-contracts/contracts/governance/NounsDAOV3Proposals.sol +++ b/packages/nouns-contracts/contracts/governance/NounsDAOV3Proposals.sol @@ -67,7 +67,7 @@ library NounsDAOV3Proposals { ); event ProposalUpdated( - uint256 id, + uint256 indexed id, address indexed proposer, address[] targets, uint256[] values, diff --git a/packages/nouns-contracts/tasks/run-local-dao-v3.ts b/packages/nouns-contracts/tasks/run-local-dao-v3.ts index 4fd6bccac7..3e5cecfea0 100644 --- a/packages/nouns-contracts/tasks/run-local-dao-v3.ts +++ b/packages/nouns-contracts/tasks/run-local-dao-v3.ts @@ -12,7 +12,7 @@ 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: 5*10}); await run('populate-descriptor', { nftDescriptor: contracts.NFTDescriptorV2.instance.address, diff --git a/packages/nouns-subgraph/schema.graphql b/packages/nouns-subgraph/schema.graphql index 8b023d91c6..a97a039e91 100644 --- a/packages/nouns-subgraph/schema.graphql +++ b/packages/nouns-subgraph/schema.graphql @@ -206,6 +206,12 @@ type Proposal @entity { "The proposal creation block" createdBlock: BigInt! + "The proposal last updated timestamp" + lastUpdatedTimestamp: BigInt + + "The proposal last updated block" + lastUpdatedBlock: BigInt + "The proposal creation transaction hash" createdTransactionHash: Bytes! diff --git a/packages/nouns-subgraph/src/nouns-dao.ts b/packages/nouns-subgraph/src/nouns-dao.ts index bbf2ea4264..1730108f3d 100644 --- a/packages/nouns-subgraph/src/nouns-dao.ts +++ b/packages/nouns-subgraph/src/nouns-dao.ts @@ -9,6 +9,7 @@ import { MinQuorumVotesBPSSet, MaxQuorumVotesBPSSet, QuorumCoefficientSet, + ProposalUpdated, } from './types/NounsDAO/NounsDAO'; import { getOrCreateDelegate, @@ -100,6 +101,21 @@ export function handleProposalCreatedWithRequirements( proposal.save(); } +export function handleProposalUpdated(event: ProposalUpdated): void { + let proposal = getOrCreateProposal(event.params.id.toString()); + + proposal.targets = changetype(event.params.targets); + proposal.values = event.params.values; + proposal.signatures = event.params.signatures; + proposal.calldatas = event.params.calldatas; + proposal.description = event.params.description.split('\\n').join('\n'); // The Graph's AssemblyScript version does not support string.replace + + proposal.lastUpdatedBlock = event.block.number; + proposal.lastUpdatedTimestamp = event.block.timestamp; + + proposal.save(); +} + export function handleProposalCanceled(event: ProposalCanceled): void { let proposal = getOrCreateProposal(event.params.id.toString()); diff --git a/packages/nouns-subgraph/subgraph.yaml.mustache b/packages/nouns-subgraph/subgraph.yaml.mustache index e45a570edc..db834cca05 100644 --- a/packages/nouns-subgraph/subgraph.yaml.mustache +++ b/packages/nouns-subgraph/subgraph.yaml.mustache @@ -83,10 +83,12 @@ dataSources: - Governance abis: - name: NounsDAO - file: ../nouns-contracts/abi/contracts/governance/NounsDAOLogicV2.sol/NounsDAOLogicV2.json + file: ../nouns-contracts/abi/contracts/governance/NounsDAOLogicV3.sol/NounsDAOLogicV3.json eventHandlers: - event: ProposalCreatedWithRequirements(uint256,address,address[],uint256[],string[],bytes[],uint256,uint256,uint256,uint256,string) handler: handleProposalCreatedWithRequirements + - event: ProposalUpdated(indexed uint256,indexed address,address[],uint256[],string[],bytes[],string) + handler: handleProposalUpdated - event: ProposalCanceled(uint256) handler: handleProposalCanceled - event: ProposalVetoed(uint256) diff --git a/packages/nouns-webapp/src/App.tsx b/packages/nouns-webapp/src/App.tsx index 7ab9a54a3e..f58fb7baba 100644 --- a/packages/nouns-webapp/src/App.tsx +++ b/packages/nouns-webapp/src/App.tsx @@ -14,6 +14,7 @@ import Footer from './components/Footer'; import AuctionPage from './pages/Auction'; import GovernancePage from './pages/Governance'; import CreateProposalPage from './pages/CreateProposal'; +import UpdateProposalPage from './pages/UpdateProposal'; import CreateDraftProposalPage from './pages/CreateDraftProposal'; import VotePage from './pages/Vote'; import NoundersPage from './pages/Nounders'; @@ -67,6 +68,7 @@ function App() { + diff --git a/packages/nouns-webapp/src/pages/UpdateProposal/index.tsx b/packages/nouns-webapp/src/pages/UpdateProposal/index.tsx new file mode 100644 index 0000000000..4dd8677159 --- /dev/null +++ b/packages/nouns-webapp/src/pages/UpdateProposal/index.tsx @@ -0,0 +1,277 @@ +import { Col, Alert, Button } from 'react-bootstrap'; +import Section from '../../layout/Section'; +import { + ProposalState, + ProposalTransaction, + useProposal, + useProposalCount, + useProposalThreshold, + usePropose, + useUpdateProposal, +} from '../../wrappers/nounsDao'; +import { useUserVotes } from '../../wrappers/nounToken'; +import classes from '../CreateProposal/CreateProposal.module.css'; +import { Link, RouteComponentProps } from 'react-router-dom'; +import { useEthers } from '@usedapp/core'; +import { AlertModal, setAlertModal } from '../../state/slices/application'; +import ProposalEditor from '../../components/ProposalEditor'; +import CreateProposalButton from '../../components/CreateProposalButton'; +import ProposalTransactions from '../../components/ProposalTransactions'; +import { withStepProgress } from 'react-stepz'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useAppDispatch } from '../../hooks'; +import { Trans } from '@lingui/macro'; +import clsx from 'clsx'; +import navBarButtonClasses from '../../components/NavBarButton/NavBarButton.module.css'; +import ProposalActionModal from '../../components/ProposalActionsModal'; +import config from '../../config'; +import { useEthNeeded } from '../../utils/tokenBuyerContractUtils/tokenBuyer'; + +const UpdateProposalPage = ({ + match: { + params: { id }, + }, +}: RouteComponentProps<{ id: string }>) => { + const { account } = useEthers(); + const latestProposalId = useProposalCount(); + const latestProposal = useProposal(latestProposalId ?? 0); + const availableVotes = useUserVotes(); + const proposalThreshold = useProposalThreshold(); + + const { updateProposal, updateProposalState } = useUpdateProposal(); + + const [proposalTransactions, setProposalTransactions] = useState([]); + const [titleValue, setTitleValue] = useState(''); + const [bodyValue, setBodyValue] = useState(''); + + const [totalUSDCPayment, setTotalUSDCPayment] = useState(0); + const [tokenBuyerTopUpEth, setTokenBuyerTopUpETH] = useState('0'); + const ethNeeded = useEthNeeded(config.addresses.tokenBuyer ?? '', totalUSDCPayment); + + const handleAddProposalAction = useCallback( + (transaction: ProposalTransaction) => { + if (!transaction.address.startsWith('0x')) { + transaction.address = `0x${transaction.address}`; + } + if (!transaction.calldata.startsWith('0x')) { + transaction.calldata = `0x${transaction.calldata}`; + } + + if (transaction.usdcValue) { + setTotalUSDCPayment(totalUSDCPayment + transaction.usdcValue); + } + + setProposalTransactions([...proposalTransactions, transaction]); + setShowTransactionFormModal(false); + }, + [proposalTransactions, totalUSDCPayment], + ); + + const handleRemoveProposalAction = useCallback( + (index: number) => { + setTotalUSDCPayment(totalUSDCPayment - (proposalTransactions[index].usdcValue ?? 0)); + setProposalTransactions(proposalTransactions.filter((_, i) => i !== index)); + }, + [proposalTransactions, totalUSDCPayment], + ); + + useEffect(() => { + if (ethNeeded !== undefined && ethNeeded !== tokenBuyerTopUpEth) { + const hasTokenBuyterTopTop = + proposalTransactions.filter(txn => txn.address === config.addresses.tokenBuyer).length > 0; + + // Add a new top up txn if one isn't there already, else add to the existing one + if (parseInt(ethNeeded) > 0 && !hasTokenBuyterTopTop) { + handleAddProposalAction({ + address: config.addresses.tokenBuyer ?? '', + value: ethNeeded ?? '0', + calldata: '0x', + signature: '', + }); + } else { + if (parseInt(ethNeeded) > 0) { + const indexOfTokenBuyerTopUp = + proposalTransactions + .map((txn, index: number) => { + if (txn.address === config.addresses.tokenBuyer) { + return index; + } else { + return -1; + } + }) + .filter(n => n >= 0) ?? new Array(); + + const txns = proposalTransactions; + if (indexOfTokenBuyerTopUp.length > 0) { + txns[indexOfTokenBuyerTopUp[0]].value = ethNeeded; + setProposalTransactions(txns); + } + } + } + + setTokenBuyerTopUpETH(ethNeeded ?? '0'); + } + }, [ + ethNeeded, + handleAddProposalAction, + handleRemoveProposalAction, + proposalTransactions, + tokenBuyerTopUpEth, + ]); + + const handleTitleInput = useCallback( + (title: string) => { + setTitleValue(title); + }, + [setTitleValue], + ); + + const handleBodyInput = useCallback( + (body: string) => { + setBodyValue(body); + }, + [setBodyValue], + ); + + const isFormInvalid = useMemo( + () => !proposalTransactions.length || titleValue === '' || bodyValue === '', + [proposalTransactions, titleValue, bodyValue], + ); + + const hasEnoughVote = Boolean( + availableVotes && proposalThreshold !== undefined && availableVotes > proposalThreshold, + ); + + const handleUpdateProposal = async () => { + if (!proposalTransactions?.length) return; + + await updateProposal( + id, // proposalId + proposalTransactions.map(({ address }) => address), // Targets + proposalTransactions.map(({ value }) => value ?? '0'), // Values + proposalTransactions.map(({ signature }) => signature), // Signatures + proposalTransactions.map(({ calldata }) => calldata), // Calldatas + `# ${titleValue}\n\n${bodyValue}`, // Description + ); + }; + + const [showTransactionFormModal, setShowTransactionFormModal] = useState(false); + const [isProposePending, setProposePending] = useState(false); + + const dispatch = useAppDispatch(); + const setModal = useCallback((modal: AlertModal) => dispatch(setAlertModal(modal)), [dispatch]); + + useEffect(() => { + switch (updateProposalState.status) { + case 'None': + setProposePending(false); + break; + case 'Mining': + setProposePending(true); + break; + case 'Success': + setModal({ + title: Success, + message: Proposal Updated!, + show: true, + }); + setProposePending(false); + break; + case 'Fail': + setModal({ + title: Transaction Failed, + message: updateProposalState?.errorMessage || Please try again., + show: true, + }); + setProposePending(false); + break; + case 'Exception': + setModal({ + title: Error, + message: updateProposalState?.errorMessage || Please try again., + show: true, + }); + setProposePending(false); + break; + } + }, [updateProposalState, setModal]); + + return ( +
+ setShowTransactionFormModal(false)} + show={showTransactionFormModal} + onActionAdd={handleAddProposalAction} + /> + + +
+ + + +

+ Update Proposal {id} +

+
+ + + Tip + + :{' '} + + Add one or more proposal actions and describe your proposal for the community. The + proposal cannot be modified after submission, so please verify all information before + submitting. The voting period will begin after 2 days and last for 5 days. + +
+
+ + You MUST maintain enough voting power to meet the proposal threshold until your + proposal is executed. If you fail to do so, anyone can cancel your proposal. + +
+
+ +
+ + + {totalUSDCPayment > 0 && ( + + + Note + + :{' '} + + Because this proposal contains a USDC fund transfer action we've added an additional + ETH transaction to refill the TokenBuyer contract. This action allows to DAO to + continue to trustlessly acquire USDC to fund proposals like this. + + + )} + + + +
+ ); +}; + +export default UpdateProposalPage; diff --git a/packages/nouns-webapp/src/pages/Vote/index.tsx b/packages/nouns-webapp/src/pages/Vote/index.tsx index 20830376a8..ffda7c2fd4 100644 --- a/packages/nouns-webapp/src/pages/Vote/index.tsx +++ b/packages/nouns-webapp/src/pages/Vote/index.tsx @@ -10,7 +10,7 @@ import { } from '../../wrappers/nounsDao'; import { useUserVotesAsOfBlock } from '../../wrappers/nounToken'; import classes from './Vote.module.css'; -import { RouteComponentProps } from 'react-router-dom'; +import { Link, RouteComponentProps } from 'react-router-dom'; import { TransactionStatus, useBlockNumber, useEthers } from '@usedapp/core'; import { AlertModal, setAlertModal } from '../../state/slices/application'; import dayjs from 'dayjs'; @@ -122,6 +122,8 @@ const VotePage = ({ const isCancellable = isInNonFinalState && proposal?.proposer?.toLowerCase() === account?.toLowerCase(); + const isUpdateable = proposal?.status == ProposalState.PENDING && proposal?.proposer?.toLowerCase() === account?.toLowerCase(); + const isAwaitingStateChange = () => { if (hasSucceeded) { return true; @@ -376,6 +378,14 @@ const VotePage = ({ )} + { isUpdateable && ( + + + + + + )} +

setIsDelegateView(!isDelegateView)} className={classes.toggleDelegateVoteView} diff --git a/packages/nouns-webapp/src/wrappers/nounsDao.ts b/packages/nouns-webapp/src/wrappers/nounsDao.ts index fb4901823d..0bcb2b31fd 100644 --- a/packages/nouns-webapp/src/wrappers/nounsDao.ts +++ b/packages/nouns-webapp/src/wrappers/nounsDao.ts @@ -568,6 +568,11 @@ export const usePropose = () => { return { propose, proposeState }; }; +export const useUpdateProposal = () => { + const { send: updateProposal, state: updateProposalState } = useContractFunction(nounsDaoContract, 'updateProposal'); + return { updateProposal, updateProposalState }; +} + export const useProposeBySigs = () => { const { send: proposeBySigs, state: proposeBySigsState } = useContractFunction(nounsDaoContract, 'proposeBySigs'); return { proposeBySigs, proposeBySigsState }; From a96e5305537f4aff6da67277f92dc75b8711b2be Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Tue, 31 Jan 2023 15:09:34 +0100 Subject: [PATCH 003/218] POC frontend code for objection period showing the proposal state and allowing voting during the objection period --- .../contracts/governance/NounsDAOInterfaces.sol | 3 +++ .../contracts/governance/NounsDAOV3Votes.sol | 5 +++++ packages/nouns-subgraph/schema.graphql | 3 +++ packages/nouns-subgraph/src/nouns-dao.ts | 9 +++++++++ packages/nouns-subgraph/subgraph.yaml.mustache | 2 ++ .../nouns-webapp/src/components/ProposalStatus/index.tsx | 3 +++ packages/nouns-webapp/src/pages/Vote/index.tsx | 3 ++- packages/nouns-webapp/src/wrappers/nounsDao.ts | 5 +++++ packages/nouns-webapp/src/wrappers/subgraph.ts | 2 ++ 9 files changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/nouns-contracts/contracts/governance/NounsDAOInterfaces.sol b/packages/nouns-contracts/contracts/governance/NounsDAOInterfaces.sol index 53533da4a7..0f9ed4ac28 100644 --- a/packages/nouns-contracts/contracts/governance/NounsDAOInterfaces.sol +++ b/packages/nouns-contracts/contracts/governance/NounsDAOInterfaces.sol @@ -137,6 +137,9 @@ contract NounsDAOEventsV3 is NounsDAOEventsV2 { bytes[] calldatas, string description ); + + /// @notice Emitted when a proposal is set to have an objection period + event ProposalObjectionPeriodSet(uint256 indexed id, uint256 objectionPeriodEndBlock); } contract NounsDAOProxyStorage { diff --git a/packages/nouns-contracts/contracts/governance/NounsDAOV3Votes.sol b/packages/nouns-contracts/contracts/governance/NounsDAOV3Votes.sol index fdf410caeb..7e3545b83c 100644 --- a/packages/nouns-contracts/contracts/governance/NounsDAOV3Votes.sol +++ b/packages/nouns-contracts/contracts/governance/NounsDAOV3Votes.sol @@ -41,6 +41,9 @@ library NounsDAOV3Votes { /// @notice Emitted when a voter cast a vote requesting a gas refund. event RefundableVote(address indexed voter, uint256 refundAmount, bool refundSent); + /// @notice Emitted when a proposal is set to have an objection period + event ProposalObjectionPeriodSet(uint256 indexed id, uint256 objectionPeriodEndBlock); + /// @notice The name of this contract string public constant name = 'Nouns DAO'; @@ -270,6 +273,8 @@ library NounsDAOV3Votes { (proposal.endBlock - block.number < ds.lastMinuteWindowInBlocks) ) { proposal.objectionPeriodEndBlock = proposal.endBlock + ds.objectionPeriodDurationInBlocks; + + emit ProposalObjectionPeriodSet(proposal.id, proposal.objectionPeriodEndBlock); } receipt.hasVoted = true; diff --git a/packages/nouns-subgraph/schema.graphql b/packages/nouns-subgraph/schema.graphql index a97a039e91..e979fa9f18 100644 --- a/packages/nouns-subgraph/schema.graphql +++ b/packages/nouns-subgraph/schema.graphql @@ -262,6 +262,9 @@ type Proposal @entity { "Dynamic quorum param snapshot: the dynamic quorum coefficient" quorumCoefficient: BigInt! + + "The block at which objection period ends. Zero if no objection period" + objectionPeriodEndBlock: BigInt } type Vote @entity { diff --git a/packages/nouns-subgraph/src/nouns-dao.ts b/packages/nouns-subgraph/src/nouns-dao.ts index 1730108f3d..9f6ad6e110 100644 --- a/packages/nouns-subgraph/src/nouns-dao.ts +++ b/packages/nouns-subgraph/src/nouns-dao.ts @@ -10,6 +10,7 @@ import { MaxQuorumVotesBPSSet, QuorumCoefficientSet, ProposalUpdated, + ProposalObjectionPeriodSet, } from './types/NounsDAO/NounsDAO'; import { getOrCreateDelegate, @@ -116,6 +117,14 @@ export function handleProposalUpdated(event: ProposalUpdated): void { proposal.save(); } +export function handleProposalObjectionPeriodSet(event: ProposalObjectionPeriodSet): void { + let proposal = getOrCreateProposal(event.params.id.toString()); + + proposal.objectionPeriodEndBlock = event.params.objectionPeriodEndBlock; + + proposal.save(); +} + export function handleProposalCanceled(event: ProposalCanceled): void { let proposal = getOrCreateProposal(event.params.id.toString()); diff --git a/packages/nouns-subgraph/subgraph.yaml.mustache b/packages/nouns-subgraph/subgraph.yaml.mustache index db834cca05..d225500e97 100644 --- a/packages/nouns-subgraph/subgraph.yaml.mustache +++ b/packages/nouns-subgraph/subgraph.yaml.mustache @@ -89,6 +89,8 @@ dataSources: handler: handleProposalCreatedWithRequirements - event: ProposalUpdated(indexed uint256,indexed address,address[],uint256[],string[],bytes[],string) handler: handleProposalUpdated + - event: ProposalObjectionPeriodSet(indexed uint256,uint256) + handler: handleProposalObjectionPeriodSet - event: ProposalCanceled(uint256) handler: handleProposalCanceled - event: ProposalVetoed(uint256) diff --git a/packages/nouns-webapp/src/components/ProposalStatus/index.tsx b/packages/nouns-webapp/src/components/ProposalStatus/index.tsx index 0b0a57d59e..b25bafdc2b 100644 --- a/packages/nouns-webapp/src/components/ProposalStatus/index.tsx +++ b/packages/nouns-webapp/src/components/ProposalStatus/index.tsx @@ -8,6 +8,7 @@ const statusVariant = (status: ProposalState | undefined) => { switch (status) { case ProposalState.PENDING: case ProposalState.ACTIVE: + case ProposalState.OBJECTION_PERIOD: return classes.primary; case ProposalState.SUCCEEDED: case ProposalState.EXECUTED: @@ -43,6 +44,8 @@ const statusText = (status: ProposalState | undefined) => { return Vetoed; case ProposalState.EXPIRED: return Expired; + case ProposalState.OBJECTION_PERIOD: + return Objection period; default: return Undetermined; } diff --git a/packages/nouns-webapp/src/pages/Vote/index.tsx b/packages/nouns-webapp/src/pages/Vote/index.tsx index ffda7c2fd4..310dd52154 100644 --- a/packages/nouns-webapp/src/pages/Vote/index.tsx +++ b/packages/nouns-webapp/src/pages/Vote/index.tsx @@ -118,6 +118,7 @@ const VotePage = ({ ProposalState.ACTIVE, ProposalState.SUCCEEDED, ProposalState.QUEUED, + ProposalState.OBJECTION_PERIOD ].includes(proposal?.status!); const isCancellable = isInNonFinalState && proposal?.proposer?.toLowerCase() === account?.toLowerCase(); @@ -308,7 +309,7 @@ const VotePage = ({ } const isWalletConnected = !(activeAccount === undefined); - const isActiveForVoting = startDate?.isBefore(now) && endDate?.isAfter(now); + const isActiveForVoting = proposal?.status === ProposalState.ACTIVE || proposal?.status === ProposalState.OBJECTION_PERIOD; const forNouns = getNounVotes(data, 1); const againstNouns = getNounVotes(data, 0); diff --git a/packages/nouns-webapp/src/wrappers/nounsDao.ts b/packages/nouns-webapp/src/wrappers/nounsDao.ts index 0bcb2b31fd..e2c36fca7d 100644 --- a/packages/nouns-webapp/src/wrappers/nounsDao.ts +++ b/packages/nouns-webapp/src/wrappers/nounsDao.ts @@ -42,6 +42,7 @@ export enum ProposalState { EXPIRED, EXECUTED, VETOED, + OBJECTION_PERIOD } interface ProposalCallResult { @@ -107,6 +108,7 @@ export interface PartialProposalSubgraphEntity { endBlock: string; executionETA: string | null; quorumVotes: string; + objectionPeriodEndBlock: string; } export interface ProposalSubgraphEntity extends ProposalTransactionDetails, PartialProposalSubgraphEntity { @@ -352,6 +354,9 @@ const getProposalState = ( return ProposalState.UNDETERMINED; } if (blockNumber > parseInt(proposal.endBlock)) { + if (blockNumber <= parseInt(proposal.objectionPeriodEndBlock)) { + return ProposalState.OBJECTION_PERIOD; + } const forVotes = new BigNumber(proposal.forVotes); if (forVotes.lte(proposal.againstVotes) || forVotes.lt(proposal.quorumVotes)) { return ProposalState.DEFEATED; diff --git a/packages/nouns-webapp/src/wrappers/subgraph.ts b/packages/nouns-webapp/src/wrappers/subgraph.ts index 2f965570d4..ff591a050b 100644 --- a/packages/nouns-webapp/src/wrappers/subgraph.ts +++ b/packages/nouns-webapp/src/wrappers/subgraph.ts @@ -69,6 +69,7 @@ export const proposalQuery = (id: string | number) => gql` createdBlock startBlock endBlock + objectionPeriodEndBlock executionETA targets values @@ -94,6 +95,7 @@ export const partialProposalsQuery = (first = 1_000) => gql` executionETA startBlock endBlock + objectionPeriodEndBlock } } `; From e8d0b4e009ab502319df6f29199f5bbe763aa53d Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Fri, 10 Feb 2023 15:22:06 +0100 Subject: [PATCH 004/218] add newlines --- packages/nouns-subgraph/schema.graphql | 2 +- packages/nouns-subgraph/src/nouns-dao.ts | 2 +- packages/nouns-subgraph/subgraph.yaml.mustache | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/nouns-subgraph/schema.graphql b/packages/nouns-subgraph/schema.graphql index 83630cde02..24abf2bdd0 100644 --- a/packages/nouns-subgraph/schema.graphql +++ b/packages/nouns-subgraph/schema.graphql @@ -374,4 +374,4 @@ type DynamicQuorumParams @entity { "The block from which proposals are using DQ, based on when we first see configuration being set" dynamicQuorumStartBlock: BigInt -} \ No newline at end of file +} diff --git a/packages/nouns-subgraph/src/nouns-dao.ts b/packages/nouns-subgraph/src/nouns-dao.ts index ee2bfe333b..ada740ec11 100644 --- a/packages/nouns-subgraph/src/nouns-dao.ts +++ b/packages/nouns-subgraph/src/nouns-dao.ts @@ -272,4 +272,4 @@ export function handleProposalObjectionPeriodSet(event: ProposalObjectionPeriodS const proposal = getOrCreateProposal(event.params.id.toString()); proposal.objectionPeriodEndBlock = event.params.objectionPeriodEndBlock; proposal.save(); -} \ No newline at end of file +} diff --git a/packages/nouns-subgraph/subgraph.yaml.mustache b/packages/nouns-subgraph/subgraph.yaml.mustache index 69ce43aad8..fabfe61dde 100644 --- a/packages/nouns-subgraph/subgraph.yaml.mustache +++ b/packages/nouns-subgraph/subgraph.yaml.mustache @@ -108,4 +108,4 @@ dataSources: - event: ProposalUpdated(indexed uint256,indexed address,address[],uint256[],string[],bytes[],string) handler: handleProposalUpdated - event: ProposalObjectionPeriodSet(indexed uint256,uint256) - handler: handleProposalObjectionPeriodSet \ No newline at end of file + handler: handleProposalObjectionPeriodSet From 9c2437959eaf9a6fff00de0722c10685da222f32 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Fri, 10 Feb 2023 15:53:28 +0100 Subject: [PATCH 005/218] poc frontend code for objection period done --- packages/nouns-webapp/src/pages/Vote/index.tsx | 3 ++- packages/nouns-webapp/src/wrappers/nounsDao.ts | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/nouns-webapp/src/pages/Vote/index.tsx b/packages/nouns-webapp/src/pages/Vote/index.tsx index 310dd52154..f3b759b7dc 100644 --- a/packages/nouns-webapp/src/pages/Vote/index.tsx +++ b/packages/nouns-webapp/src/pages/Vote/index.tsx @@ -86,10 +86,11 @@ const VotePage = ({ ) : undefined; + const endBlock = proposal?.objectionPeriodEndBlock || proposal?.endBlock; const endDate = proposal && timestamp && currentBlock ? dayjs(timestamp).add( - AVERAGE_BLOCK_TIME_IN_SECS * (proposal.endBlock - currentBlock), + AVERAGE_BLOCK_TIME_IN_SECS * (endBlock! - currentBlock), 'seconds', ) : undefined; diff --git a/packages/nouns-webapp/src/wrappers/nounsDao.ts b/packages/nouns-webapp/src/wrappers/nounsDao.ts index 5f0ca03057..172d6fa337 100644 --- a/packages/nouns-webapp/src/wrappers/nounsDao.ts +++ b/packages/nouns-webapp/src/wrappers/nounsDao.ts @@ -79,6 +79,7 @@ export interface PartialProposal { endBlock: number; eta: Date | undefined; quorumVotes: number; + objectionPeriodEndBlock: number; } export interface Proposal extends PartialProposal { @@ -457,6 +458,7 @@ const parseSubgraphProposal = ( eta: proposal.executionETA ? new Date(Number(proposal.executionETA) * 1000) : undefined, details: formatProposalTransactionDetails(proposal), transactionHash: proposal.createdTransactionHash, + objectionPeriodEndBlock: parseInt(proposal.objectionPeriodEndBlock) }; }; @@ -516,6 +518,7 @@ export const useAllProposalsViaChain = (skip = false): PartialProposalData => { startBlock: parseInt(proposal?.startBlock?.toString() ?? ''), endBlock: parseInt(proposal?.endBlock?.toString() ?? ''), + objectionPeriodEndBlock: 0, // TODO: this should read from the contract forCount: parseInt(proposal?.forVotes?.toString() ?? '0'), againstCount: parseInt(proposal?.againstVotes?.toString() ?? '0'), abstainCount: parseInt(proposal?.abstainVotes?.toString() ?? '0'), From 6ea55bba5e936eda4bda9da87b40bc6158e282b8 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Mon, 13 Feb 2023 15:39:09 +0100 Subject: [PATCH 006/218] use different typehash for updateProposalBySigs --- .../src/pages/DraftProposal/index.tsx | 50 ++++++++++++++----- 1 file changed, 37 insertions(+), 13 deletions(-) diff --git a/packages/nouns-webapp/src/pages/DraftProposal/index.tsx b/packages/nouns-webapp/src/pages/DraftProposal/index.tsx index d2a6c8856e..1e26490bab 100644 --- a/packages/nouns-webapp/src/pages/DraftProposal/index.tsx +++ b/packages/nouns-webapp/src/pages/DraftProposal/index.tsx @@ -19,7 +19,7 @@ const domain = { verifyingContract: config.addresses.nounsDAOProxy }; -const types = { +const createProposalTypes = { Proposal: [ { name: 'proposer', type: 'address' }, { name: 'targets', type: 'address[]' }, @@ -27,7 +27,20 @@ const types = { { name: 'signatures', type: 'string[]' }, { name: 'calldatas', type: 'bytes[]' }, { name: 'description', type: 'string' }, - { name: 'expiry', type: 'uint40' } + { 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' } ] }; @@ -40,7 +53,7 @@ const DraftProposalPage = ({ const { library, chainId } = useEthers(); const signer = library?.getSigner(); const [draftProposal, setDraftProposal] = useState(undefined); - const [expiry, setExpiry] = useState(Math.round(Date.now() / 1000)); + const [expiry, setExpiry] = useState(Math.round(Date.now() / 1000) + 60*60*24); const [proposalIdToUpdate, setProposalIdToUpdate] = useState(''); useEffect(() => { @@ -52,12 +65,23 @@ const DraftProposalPage = ({ async function sign() { if (!draftProposal) return; - const value = { - ...draftProposal.proposalContent, - 'expiry': expiry - }; - - const signature = await signer!._signTypedData(domain, types, value); + let signature; + + if (proposalIdToUpdate) { + const value = { + ...draftProposal.proposalContent, + 'expiry': expiry, + 'proposalId': proposalIdToUpdate + }; + signature = await signer!._signTypedData(domain, updateProposalTypes, value); + } else { + const value = { + ...draftProposal.proposalContent, + 'expiry': expiry + }; + signature = await signer!._signTypedData(domain, createProposalTypes, value); + } + const updatedDraftProposal = addSignature( { signer: await signer!.getAddress(), @@ -183,15 +207,15 @@ const DraftProposalPage = ({ + - From 189a92d891793a0240c3522503396ff5cdec2c47 Mon Sep 17 00:00:00 2001 From: ripe <109935398+ripe0x@users.noreply.github.com> Date: Tue, 11 Apr 2023 14:12:12 -0700 Subject: [PATCH 007/218] initial scaffolding for candidates tab on proposal page --- .../CandidateCard/CandidateCard.module.css | 54 ++++ .../CandidateSponsors.module.css | 25 ++ .../CandidateCard/CandidateSponsors.tsx | 48 +++ .../src/components/CandidateCard/index.tsx | 91 ++++++ .../components/Proposals/Proposals.module.css | 62 ++++ .../src/components/Proposals/index.tsx | 273 +++++++++++------- .../src/hooks/useGetCountDownCopy.tsx | 63 ++++ .../src/pages/Governance/index.tsx | 115 ++++---- 8 files changed, 568 insertions(+), 163 deletions(-) create mode 100644 packages/nouns-webapp/src/components/CandidateCard/CandidateCard.module.css create mode 100644 packages/nouns-webapp/src/components/CandidateCard/CandidateSponsors.module.css create mode 100644 packages/nouns-webapp/src/components/CandidateCard/CandidateSponsors.tsx create mode 100644 packages/nouns-webapp/src/components/CandidateCard/index.tsx create mode 100644 packages/nouns-webapp/src/hooks/useGetCountDownCopy.tsx 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..add484522d --- /dev/null +++ b/packages/nouns-webapp/src/components/CandidateCard/CandidateCard.module.css @@ -0,0 +1,54 @@ +.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; +} + +.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; +} \ No newline at end of file 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..a83ef92a23 --- /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; +} \ No newline at end of file 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..b51ea0721d --- /dev/null +++ b/packages/nouns-webapp/src/components/CandidateCard/CandidateSponsors.tsx @@ -0,0 +1,48 @@ +import React, { useState } from 'react'; +import classes from './CandidateSponsors.module.css'; + +type Props = { + sponsors: `0x${string}`[]; +}; + +function CandidateSponsors({ sponsors }: Props) { + const minSponsorCount = 5; + const [sponsorSpots, setSponsorSpots] = useState<`0x${string}`[] | ''[]>(); + const [emptySponsorSpots, setEmptySponsorSpots] = useState(); + const [sponsorCountOverflow, setSponsorCountOverflow] = useState(0); + React.useEffect(() => { + if (sponsors.length < minSponsorCount) { + // const emptySpots = Array(minSponsorCount - sponsors.length).fill(''); + // setSponsorSpots([...sponsors, ...emptySpots]); + setSponsorSpots(sponsors); + const emptySpots: number[] = Array(minSponsorCount - sponsors.length).fill(0); + setEmptySponsorSpots(emptySpots); + } else if (sponsors.length > minSponsorCount) { + setSponsorCountOverflow(sponsors.length - minSponsorCount); + setSponsorSpots(sponsors.slice(0, minSponsorCount)); + } else { + setSponsorSpots(sponsors); + } + }, [sponsors]); + + return ( +

+ {sponsorSpots?.length && + sponsorSpots.map(sponsor => { + return ( + + {/* get noun image of sponsor */} + Sponsor's noun image + + ); + })} + {/* empty sponsor spots */} + + {emptySponsorSpots && + emptySponsorSpots.length < minSponsorCount && + emptySponsorSpots.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..a52d87d19a --- /dev/null +++ b/packages/nouns-webapp/src/components/CandidateCard/index.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import classes from './CandidateCard.module.css'; +import clsx from 'clsx'; +import { PartialProposal, ProposalState } from '../../wrappers/nounsDao'; +import proposalStatusClasses from '../ProposalStatus/ProposalStatus.module.css'; +import { i18n } from '@lingui/core'; +import { ClockIcon } from '@heroicons/react/solid'; +import ProposalStatus from '../ProposalStatus'; +import { useGetCountdownCopy } from '../../hooks/useGetCountDownCopy'; +import { useBlockNumber } from '@usedapp/core'; +import { useActiveLocale } from '../../hooks/useActivateLocale'; +import { isMobileScreen } from '../../utils/isMobile'; +import CandidateSponsors from './CandidateSponsors'; +import { Trans } from '@lingui/macro'; + +type Props = { + candidate: PartialProposal; +}; + +// temporary hard-coded list of candidate sponsors +const candidateSponsorsList: `0x${string}`[] = [ + '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', + '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', + '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', +]; + +function CandidateCard({ candidate }: Props) { + const currentBlock = useBlockNumber(); + const isMobile = isMobileScreen(); + const activeLocale = useActiveLocale(); + const minSponsorCount = 5; + + const countDownCopy = useGetCountdownCopy(candidate, currentBlock || 0, activeLocale); + const isPropInStateToHaveCountDown = + candidate.status === ProposalState.PENDING || + candidate.status === ProposalState.ACTIVE || + candidate.status === ProposalState.QUEUED; + + const countdownPill = ( +
+
+
+ + + {' '} + {countDownCopy} +
+
+
+ ); + return ( + +
+ + {candidate.title} + +

+ by [candidate.proposer] +

+ +
+
+ + + + {candidateSponsorsList.length}/ {minSponsorCount}{' '} + {' '} + sponsors + +
+
[x days left]
+
+ {/* {isPropInStateToHaveCountDown && ( +
{countdownPill}
+ )} +
+ +
*/} +
+ + {isPropInStateToHaveCountDown && ( +
{countdownPill}
+ )} + + ); +} + +export default CandidateCard; diff --git a/packages/nouns-webapp/src/components/Proposals/Proposals.module.css b/packages/nouns-webapp/src/components/Proposals/Proposals.module.css index a7d33f54a7..0fc1327864 100644 --- a/packages/nouns-webapp/src/components/Proposals/Proposals.module.css +++ b/packages/nouns-webapp/src/components/Proposals/Proposals.module.css @@ -10,10 +10,57 @@ margin-bottom: 1rem; } +.section { + margin: 0 auto; + width: 100%; +} + +.sectionWrapper { + border-bottom: 1px solid rgba(0, 0, 0, 0.1); +} + +.headerWrapper { + display: flex; + flex-direction: row; + justify-content: space-between; + margin-left: auto; + margin-right: auto; +} + +.tabs { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 10px; + margin: 0; +} + +.tab { + background-color: #fff; + border: 1px solid transparent; + border-radius: 12px; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + font-family: 'Londrina Solid'; + padding: 10px 14px 6px; + font-size: 32px; + line-height: 1; +} + +.activeTab { + border: 1px solid rgba(0, 0, 0, 0.1); + border-bottom: 1px solid #fff; + padding: 10px 14px 10px; + position: relative; + top: 2px; +} + .heading { font-family: 'Londrina Solid'; font-size: 40px; margin: 0 !important; + width: auto; } .generateBtn { @@ -132,7 +179,22 @@ padding-top: 0.8rem; } +.alert { + width: 100%; +} + +.proposalsList { + margin-left: auto; + margin-right: auto; +} + @media (max-width: 992px) { + .headerWrapper { + display: flex; + flex-direction: column; + justify-content: start; + } + .desktopCountdownWrapper { display: none; } diff --git a/packages/nouns-webapp/src/components/Proposals/index.tsx b/packages/nouns-webapp/src/components/Proposals/index.tsx index 9428940101..6723492e09 100644 --- a/packages/nouns-webapp/src/components/Proposals/index.tsx +++ b/packages/nouns-webapp/src/components/Proposals/index.tsx @@ -1,5 +1,5 @@ import { PartialProposal, ProposalState, useProposalThreshold } from '../../wrappers/nounsDao'; -import { Alert, Button } from 'react-bootstrap'; +import { Alert, Button, Col } from 'react-bootstrap'; import ProposalStatus from '../ProposalStatus'; import classes from './Proposals.module.css'; import { useHistory } from 'react-router-dom'; @@ -19,6 +19,8 @@ import DelegationModal from '../DelegationModal'; import { i18n } from '@lingui/core'; import en from 'dayjs/locale/en'; import { AVERAGE_BLOCK_TIME_IN_SECS } from '../../utils/constants'; +import Section from '../../layout/Section'; +import CandidateCard from '../CandidateCard'; dayjs.extend(relativeTime); @@ -86,6 +88,9 @@ const Proposals = ({ proposals }: { proposals: PartialProposal[] }) => { const hasEnoughVotesToPropose = account !== undefined && connectedAccountNounVotes >= threshold; const hasNounBalance = (useUserNounTokenBalance() ?? 0) > 0; + const [activeTab, setActiveTab] = useState(0); + const tabs = ['Proposals', 'Candidates']; + const nullStateCopy = () => { if (account !== null) { if (connectedAccountNounVotes > 0) { @@ -99,57 +104,71 @@ const Proposals = ({ proposals }: { proposals: PartialProposal[] }) => { return (
{showDelegateModal && setShowDelegateModal(false)} />} -
-

- Proposals -

- {hasEnoughVotesToPropose ? ( -
-
- +
+
+ +
+ {tabs.map((tab, index) => ( + + ))}
- {hasNounBalance && ( -
- + {hasEnoughVotesToPropose ? ( +
+
+ +
+ + {hasNounBalance && ( +
+ +
+ )}
- )} -
- ) : ( -
- {!isMobile &&
{nullStateCopy()}
} -
- -
- {!isMobile && hasNounBalance && ( -
- + ) : ( +
+ {!isMobile &&
{nullStateCopy()}
} +
+ +
+ {!isMobile && hasNounBalance && ( +
+ +
+ )}
)} -
- )} + {/*
*/} + +
{isMobile &&
{nullStateCopy()}
} {isMobile && hasNounBalance && ( @@ -159,67 +178,107 @@ const Proposals = ({ proposals }: { proposals: PartialProposal[] }) => {
)} - {proposals?.length ? ( - proposals - .slice(0) - .reverse() - .map((p, i) => { - const isPropInStateToHaveCountDown = - p.status === ProposalState.PENDING || - p.status === ProposalState.ACTIVE || - p.status === ProposalState.QUEUED; - - const countdownPill = ( -
-
-
- - - {' '} - - {getCountdownCopy(p, currentBlock || 0, activeLocale)} - -
-
-
- ); - - return ( - -
- - {i18n.number(parseInt(p.id || '0'))}{' '} - {p.title} - - - {isPropInStateToHaveCountDown && ( -
{countdownPill}
- )} -
- -
-
+
+ {activeTab === 0 && ( + + {proposals?.length ? ( + proposals + .slice(0) + .reverse() + .map((p, i) => { + const isPropInStateToHaveCountDown = + p.status === ProposalState.PENDING || + p.status === ProposalState.ACTIVE || + p.status === ProposalState.QUEUED; - {isPropInStateToHaveCountDown && ( -
{countdownPill}
- )} -
- ); - }) - ) : ( - - - No proposals found - -

- Proposals submitted by community members will appear here. -

-
- )} + const countdownPill = ( +
+
+
+ + + {' '} + + {getCountdownCopy(p, currentBlock || 0, activeLocale)} + +
+
+
+ ); + + return ( + +
+ + + {i18n.number(parseInt(p.id || '0'))} + {' '} + {p.title} + + + {isPropInStateToHaveCountDown && ( +
{countdownPill}
+ )} +
+ +
+
+ + {isPropInStateToHaveCountDown && ( +
{countdownPill}
+ )} +
+ ); + }) + ) : ( + + + No proposals found + +

+ Proposals submitted by community members will appear here. +

+
+ )} + + )} + {activeTab === 1 && ( + + {proposals?.length ? ( + proposals + .slice(0) + .reverse() + .map((p, i) => { + return ( +
+ +
+ ); + }) + ) : ( + + + No candidates found + +

+ Candidates submitted by community members will appear here. +

+
+ )} + + )} +
); }; diff --git a/packages/nouns-webapp/src/hooks/useGetCountDownCopy.tsx b/packages/nouns-webapp/src/hooks/useGetCountDownCopy.tsx new file mode 100644 index 0000000000..1f65845bd5 --- /dev/null +++ b/packages/nouns-webapp/src/hooks/useGetCountDownCopy.tsx @@ -0,0 +1,63 @@ +import { Trans } from '@lingui/macro'; +import dayjs, { locale } from 'dayjs'; +import { useEffect, useState } from 'react'; +import { SupportedLocale, SUPPORTED_LOCALE_TO_DAYSJS_LOCALE } from '../i18n/locales'; +import { AVERAGE_BLOCK_TIME_IN_SECS } from '../utils/constants'; +import { PartialProposal } from '../wrappers/nounsDao'; +import en from 'dayjs/locale/en'; + +/** + * A function that takes a proposal and block number and returns the timestamp of the event + * @param proposal partial proposal object to retrieve the timestamp for + * @param currentBlock target block number to base the timestamp off of + * @returns string with the event timestamp + */ +export const useGetCountdownCopy = ( + proposal: PartialProposal, + currentBlock: number, + locale: SupportedLocale, +) => { + const timestamp = Date.now(); + const startDate = + proposal && timestamp && currentBlock + ? dayjs(timestamp).add( + AVERAGE_BLOCK_TIME_IN_SECS * (proposal.startBlock - currentBlock), + 'seconds', + ) + : undefined; + + const endDate = + proposal && timestamp && currentBlock + ? dayjs(timestamp).add( + AVERAGE_BLOCK_TIME_IN_SECS * (proposal.endBlock - currentBlock), + 'seconds', + ) + : undefined; + + const expiresDate = proposal && dayjs(proposal.eta).add(14, 'days'); + + const now = dayjs(); + + if (startDate?.isBefore(now) && endDate?.isAfter(now)) { + return ( + + Ends {endDate.locale(SUPPORTED_LOCALE_TO_DAYSJS_LOCALE[locale] || en).fromNow()} + + ); + } + if (endDate?.isBefore(now)) { + return ( + + Expires {expiresDate.locale(SUPPORTED_LOCALE_TO_DAYSJS_LOCALE[locale] || en).fromNow()} + + ); + } + return ( + + Starts{' '} + {dayjs(startDate) + .locale(SUPPORTED_LOCALE_TO_DAYSJS_LOCALE[locale] || en) + .fromNow()} + + ); +}; diff --git a/packages/nouns-webapp/src/pages/Governance/index.tsx b/packages/nouns-webapp/src/pages/Governance/index.tsx index 7b7a5ea844..4c2de417ee 100644 --- a/packages/nouns-webapp/src/pages/Governance/index.tsx +++ b/packages/nouns-webapp/src/pages/Governance/index.tsx @@ -22,64 +22,67 @@ const GovernancePage = () => { const nounPlural = Nouns; return ( -
- - - - Governance - -

- Nouns DAO -

-
-

- - Nouns govern Nouns DAO. Nouns can vote on - proposals or delegate their vote to a third party. A minimum of{' '} - - {nounsRequired} {threshold === 0 ? nounSingular : nounPlural} - {' '} - is required to submit proposals. - -

- - - - - - Treasury - - - - -

Ξ

-

- {treasuryBalance && - i18n.number(Number(Number(utils.formatEther(treasuryBalance)).toFixed(0)))} -

- - -

- {treasuryBalanceUSD && - i18n.number(Number(treasuryBalanceUSD.toFixed(0)), { - style: 'currency', - currency: 'USD', - })} -

- -
- - + <> +
+ + + + Governance + +

+ Nouns DAO +

+
+

- This treasury exists for Nouns DAO{' '} - participants to allocate resources for the long-term growth and prosperity of the - Nouns project. + Nouns govern Nouns DAO. Nouns can vote on + proposals or delegate their vote to a third party. A minimum of{' '} + + {nounsRequired} {threshold === 0 ? nounSingular : nounPlural} + {' '} + is required to submit proposals. - - - - -

+

+ + + + + + Treasury + + + + +

Ξ

+

+ {treasuryBalance && + i18n.number(Number(Number(utils.formatEther(treasuryBalance)).toFixed(0)))} +

+ + +

+ {treasuryBalanceUSD && + i18n.number(Number(treasuryBalanceUSD.toFixed(0)), { + style: 'currency', + currency: 'USD', + })} +

+ +
+ + + + This treasury exists for Nouns DAO{' '} + participants to allocate resources for the long-term growth and prosperity of the + Nouns project. + + +
+ +
+ + + ); }; export default GovernancePage; From 1e857e3d4222fbeef56cb5d30420afe254505570 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Wed, 12 Apr 2023 13:44:44 +0200 Subject: [PATCH 008/218] fix build --- .../foundry/NounsDAOLogicV3/NounsDAOLogicV3BaseTest.sol | 6 +++--- .../nouns-contracts/test/foundry/helpers/DeployUtils.sol | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/nouns-contracts/test/foundry/NounsDAOLogicV3/NounsDAOLogicV3BaseTest.sol b/packages/nouns-contracts/test/foundry/NounsDAOLogicV3/NounsDAOLogicV3BaseTest.sol index 8d34809991..54dff40ee4 100644 --- a/packages/nouns-contracts/test/foundry/NounsDAOLogicV3/NounsDAOLogicV3BaseTest.sol +++ b/packages/nouns-contracts/test/foundry/NounsDAOLogicV3/NounsDAOLogicV3BaseTest.sol @@ -79,10 +79,11 @@ abstract contract NounsDAOLogicV3BaseTest is Test, DeployUtils, SigUtils { dao = NounsDAOLogicV3( payable( new NounsDAOProxyV3( - NounsDAOProxyV3.ProxyParams(address(timelock), address(new NounsDAOLogicV3())), address(timelock), address(nounsToken), vetoer, + address(timelock), + address(new NounsDAOLogicV3()), VOTING_PERIOD, VOTING_DELAY, PROPOSAL_THRESHOLD, @@ -93,8 +94,7 @@ abstract contract NounsDAOLogicV3BaseTest is Test, DeployUtils, SigUtils { }), lastMinuteWindowInBlocks, objectionPeriodDurationInBlocks, - proposalUpdatablePeriodInBlocks, - voteSnapshotBlockSwitchProposalId + proposalUpdatablePeriodInBlocks ) ) ); diff --git a/packages/nouns-contracts/test/foundry/helpers/DeployUtils.sol b/packages/nouns-contracts/test/foundry/helpers/DeployUtils.sol index ca1339568a..bb56c0de0b 100644 --- a/packages/nouns-contracts/test/foundry/helpers/DeployUtils.sol +++ b/packages/nouns-contracts/test/foundry/helpers/DeployUtils.sol @@ -86,10 +86,11 @@ abstract contract DeployUtils is Test, DescriptorHelpers { return NounsDAOLogicV1( payable( new NounsDAOProxyV3( - NounsDAOProxyV3.ProxyParams(timelock, address(new NounsDAOLogicV3())), timelock, nounsToken, vetoer, + timelock, + address(new NounsDAOLogicV3()), VOTING_PERIOD, VOTING_DELAY, PROPOSAL_THRESHOLD, @@ -100,7 +101,6 @@ abstract contract DeployUtils is Test, DescriptorHelpers { }), 0, 0, - 0, 0 ) ) From 26102c492b0b822489be71133cf6ffbc3ce4f875 Mon Sep 17 00:00:00 2001 From: ripe <109935398+ripe0x@users.noreply.github.com> Date: Fri, 14 Apr 2023 15:42:34 -0700 Subject: [PATCH 009/218] add sidebar to candidates list --- .../src/components/Proposals/index.tsx | 66 +++++++++++++------ 1 file changed, 46 insertions(+), 20 deletions(-) diff --git a/packages/nouns-webapp/src/components/Proposals/index.tsx b/packages/nouns-webapp/src/components/Proposals/index.tsx index 6723492e09..73bd7fae30 100644 --- a/packages/nouns-webapp/src/components/Proposals/index.tsx +++ b/packages/nouns-webapp/src/components/Proposals/index.tsx @@ -1,5 +1,5 @@ import { PartialProposal, ProposalState, useProposalThreshold } from '../../wrappers/nounsDao'; -import { Alert, Button, Col } from 'react-bootstrap'; +import { Alert, Button, Col, Row } from 'react-bootstrap'; import ProposalStatus from '../ProposalStatus'; import classes from './Proposals.module.css'; import { useHistory } from 'react-router-dom'; @@ -21,6 +21,7 @@ import en from 'dayjs/locale/en'; import { AVERAGE_BLOCK_TIME_IN_SECS } from '../../utils/constants'; import Section from '../../layout/Section'; import CandidateCard from '../CandidateCard'; +import Link from '../Link'; dayjs.extend(relativeTime); @@ -255,27 +256,52 @@ const Proposals = ({ proposals }: { proposals: PartialProposal[] }) => { )} {activeTab === 1 && ( - {proposals?.length ? ( - proposals - .slice(0) - .reverse() - .map((p, i) => { - return ( -
- -
- ); - }) - ) : ( - - - No candidates found - + + + {proposals?.length ? ( + proposals + .slice(0) + .reverse() + .map((p, i) => { + return ( +
+ +
+ ); + }) + ) : ( + + + No candidates found + +

+ Candidates submitted by community members will appear here. +

+
+ )} + + +

+ + About Proposal Candidates + +

+ {/* TODO: add real copy */}

- Candidates submitted by community members will appear here. + + Nullam id dolor id nibh ultricies vehicula ut id elit. Cras justo odio, dapibus + ac facilisis in, egestas eget quam. +

-
- )} +

+ + Current threshold: + {threshold} Nouns + +

+ Create a candidate + + )} From 983a74fb52ff574a2e7bf9e39cafc9613579c59c Mon Sep 17 00:00:00 2001 From: ripe <109935398+ripe0x@users.noreply.github.com> Date: Fri, 14 Apr 2023 16:13:49 -0700 Subject: [PATCH 010/218] scaffold create-candidate page --- packages/nouns-webapp/src/App.tsx | 2 + .../CreateCandidateButton/index.tsx | 52 ++++ .../src/pages/CreateCandidate/index.tsx | 287 ++++++++++++++++++ .../CreateProposal/CreateProposal.module.css | 6 + 4 files changed, 347 insertions(+) create mode 100644 packages/nouns-webapp/src/components/CreateCandidateButton/index.tsx create mode 100644 packages/nouns-webapp/src/pages/CreateCandidate/index.tsx diff --git a/packages/nouns-webapp/src/App.tsx b/packages/nouns-webapp/src/App.tsx index 570934488a..ea910dbdda 100644 --- a/packages/nouns-webapp/src/App.tsx +++ b/packages/nouns-webapp/src/App.tsx @@ -24,6 +24,7 @@ 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'; function App() { const { account, chainId, library } = useEthers(); @@ -63,6 +64,7 @@ function App() { /> + diff --git a/packages/nouns-webapp/src/components/CreateCandidateButton/index.tsx b/packages/nouns-webapp/src/components/CreateCandidateButton/index.tsx new file mode 100644 index 0000000000..21f59d8910 --- /dev/null +++ b/packages/nouns-webapp/src/components/CreateCandidateButton/index.tsx @@ -0,0 +1,52 @@ +import { Button, Spinner } from 'react-bootstrap'; +import { Trans } from '@lingui/macro'; +import { i18n } from '@lingui/core'; + +const CreateCandidateButton = ({ + className, + isLoading, + proposalThreshold, + hasActiveOrPendingProposal, + hasEnoughVote, + isFormInvalid, + handleCreateProposal, +}: { + className?: string; + isLoading: boolean; + proposalThreshold?: number; + hasActiveOrPendingProposal: boolean; + hasEnoughVote: boolean; + isFormInvalid: boolean; + handleCreateProposal: () => void; +}) => { + const buttonText = () => { + if (hasActiveOrPendingProposal) { + return You already have an active or pending proposal; + } + // if (!hasEnoughVote) { + // if (proposalThreshold) { + // return ( + // + // You must have {i18n.number((proposalThreshold || 0) + 1)} votes to submit a proposal + // + // ); + // } + // return You don't have enough votes to submit a proposal; + // } + return Create proposal candidate; + }; + + return ( +
+ +
+ ); +}; +export default CreateCandidateButton; diff --git a/packages/nouns-webapp/src/pages/CreateCandidate/index.tsx b/packages/nouns-webapp/src/pages/CreateCandidate/index.tsx new file mode 100644 index 0000000000..8eba55f6fa --- /dev/null +++ b/packages/nouns-webapp/src/pages/CreateCandidate/index.tsx @@ -0,0 +1,287 @@ +import { Col, Alert, Button } from 'react-bootstrap'; +import Section from '../../layout/Section'; +import { + ProposalState, + ProposalTransaction, + useProposal, + useProposalCount, + useProposalThreshold, + usePropose, +} from '../../wrappers/nounsDao'; +import { useUserVotes } from '../../wrappers/nounToken'; +import classes from '../CreateProposal/CreateProposal.module.css'; +import { Link } from 'react-router-dom'; +import { useEthers } from '@usedapp/core'; +import { AlertModal, setAlertModal } from '../../state/slices/application'; +import ProposalEditor from '../../components/ProposalEditor'; +import CreateCandidateButton from '../../components/CreateCandidateButton'; +import ProposalTransactions from '../../components/ProposalTransactions'; +import { withStepProgress } from 'react-stepz'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useAppDispatch } from '../../hooks'; +import { Trans } from '@lingui/macro'; +import clsx from 'clsx'; +import navBarButtonClasses from '../../components/NavBarButton/NavBarButton.module.css'; +import ProposalActionModal from '../../components/ProposalActionsModal'; +import config from '../../config'; +import { useEthNeeded } from '../../utils/tokenBuyerContractUtils/tokenBuyer'; + +const CreateCandidatePage = () => { + const { account } = useEthers(); + const latestProposalId = useProposalCount(); + const latestProposal = useProposal(latestProposalId ?? 0); + const availableVotes = useUserVotes(); + const proposalThreshold = useProposalThreshold(); + + const { propose, proposeState } = usePropose(); + + const [proposalTransactions, setProposalTransactions] = useState([]); + const [titleValue, setTitleValue] = useState(''); + const [bodyValue, setBodyValue] = useState(''); + + const [totalUSDCPayment, setTotalUSDCPayment] = useState(0); + const [tokenBuyerTopUpEth, setTokenBuyerTopUpETH] = useState('0'); + const ethNeeded = useEthNeeded( + config.addresses.tokenBuyer ?? '', + totalUSDCPayment, + config.addresses.tokenBuyer === undefined || totalUSDCPayment === 0, + ); + + const handleAddProposalAction = useCallback( + (transactions: ProposalTransaction | ProposalTransaction[]) => { + const transactionsArray = Array.isArray(transactions) ? transactions : [transactions]; + + transactionsArray.forEach(transaction => { + if (!transaction.address.startsWith('0x')) { + transaction.address = `0x${transaction.address}`; + } + if (!transaction.calldata.startsWith('0x')) { + transaction.calldata = `0x${transaction.calldata}`; + } + + if (transaction.usdcValue) { + setTotalUSDCPayment(totalUSDCPayment + transaction.usdcValue); + } + }); + setProposalTransactions([...proposalTransactions, ...transactionsArray]); + + setShowTransactionFormModal(false); + }, + [proposalTransactions, totalUSDCPayment], + ); + + const handleRemoveProposalAction = useCallback( + (index: number) => { + setTotalUSDCPayment(totalUSDCPayment - (proposalTransactions[index].usdcValue ?? 0)); + setProposalTransactions(proposalTransactions.filter((_, i) => i !== index)); + }, + [proposalTransactions, totalUSDCPayment], + ); + + useEffect(() => { + if (ethNeeded !== undefined && ethNeeded !== tokenBuyerTopUpEth && totalUSDCPayment > 0) { + const hasTokenBuyterTopTop = + proposalTransactions.filter(txn => txn.address === config.addresses.tokenBuyer).length > 0; + + // Add a new top up txn if one isn't there already, else add to the existing one + if (parseInt(ethNeeded) > 0 && !hasTokenBuyterTopTop) { + handleAddProposalAction({ + address: config.addresses.tokenBuyer ?? '', + value: ethNeeded ?? '0', + calldata: '0x', + signature: '', + }); + } else { + if (parseInt(ethNeeded) > 0) { + const indexOfTokenBuyerTopUp = + proposalTransactions + .map((txn, index: number) => { + if (txn.address === config.addresses.tokenBuyer) { + return index; + } else { + return -1; + } + }) + .filter(n => n >= 0) ?? new Array(); + + const txns = proposalTransactions; + if (indexOfTokenBuyerTopUp.length > 0) { + txns[indexOfTokenBuyerTopUp[0]].value = ethNeeded; + setProposalTransactions(txns); + } + } + } + + setTokenBuyerTopUpETH(ethNeeded ?? '0'); + } + }, [ + ethNeeded, + handleAddProposalAction, + handleRemoveProposalAction, + proposalTransactions, + tokenBuyerTopUpEth, + totalUSDCPayment, + ]); + + const handleTitleInput = useCallback( + (title: string) => { + setTitleValue(title); + }, + [setTitleValue], + ); + + const handleBodyInput = useCallback( + (body: string) => { + setBodyValue(body); + }, + [setBodyValue], + ); + + const isFormInvalid = useMemo( + () => !proposalTransactions.length || titleValue === '' || bodyValue === '', + [proposalTransactions, titleValue, bodyValue], + ); + + const hasEnoughVote = Boolean( + availableVotes && proposalThreshold !== undefined && availableVotes > proposalThreshold, + ); + + const handleCreateProposal = async () => { + if (!proposalTransactions?.length) return; + + await propose( + proposalTransactions.map(({ address }) => address), // Targets + proposalTransactions.map(({ value }) => value ?? '0'), // Values + proposalTransactions.map(({ signature }) => signature), // Signatures + proposalTransactions.map(({ calldata }) => calldata), // Calldatas + `# ${titleValue}\n\n${bodyValue}`, // Description + ); + }; + + const [showTransactionFormModal, setShowTransactionFormModal] = useState(false); + const [isProposePending, setProposePending] = useState(false); + + const dispatch = useAppDispatch(); + const setModal = useCallback((modal: AlertModal) => dispatch(setAlertModal(modal)), [dispatch]); + + useEffect(() => { + switch (proposeState.status) { + case 'None': + setProposePending(false); + break; + case 'Mining': + setProposePending(true); + break; + case 'Success': + setModal({ + title: Success, + message: Proposal Created!, + show: true, + }); + setProposePending(false); + break; + case 'Fail': + setModal({ + title: Transaction Failed, + message: proposeState?.errorMessage || Please try again., + show: true, + }); + setProposePending(false); + break; + case 'Exception': + setModal({ + title: Error, + message: proposeState?.errorMessage || Please try again., + show: true, + }); + setProposePending(false); + break; + } + }, [proposeState, setModal]); + + return ( +
+ setShowTransactionFormModal(false)} + show={showTransactionFormModal} + onActionAdd={handleAddProposalAction} + /> + + +
+ + + +

+ Create Proposal Candidate +

+
+ + {/* TODO: add real copy */} + + Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum + massa justo sit amet risus. Vivamus sagittis lacus vel augue laoreet rutrum faucibus. + +
+
+ {/* TODO: fetch fee amount from contract */} + + + Submissions are free for Nouns voters. Non-voters can submit for a fee of 0.1 ETH. + + +
+
+ +
+ + {totalUSDCPayment > 0 && ( + + + Note + + :{' '} + + Because this proposal contains a USDC fund transfer action we've added an additional + ETH transaction to refill the TokenBuyer contract. This action allows to DAO to + continue to trustlessly acquire USDC to fund proposals like this. + + + )} + + + {/* TODO: fetch fee amount from contract */} +

{!hasEnoughVote && '0.1 eth fee upon submission'}

+ +
+ ); +}; + +export default withStepProgress(CreateCandidatePage); diff --git a/packages/nouns-webapp/src/pages/CreateProposal/CreateProposal.module.css b/packages/nouns-webapp/src/pages/CreateProposal/CreateProposal.module.css index 82df40f81d..3fcd8a7570 100644 --- a/packages/nouns-webapp/src/pages/CreateProposal/CreateProposal.module.css +++ b/packages/nouns-webapp/src/pages/CreateProposal/CreateProposal.module.css @@ -82,3 +82,9 @@ display: flex; align-items: center; } + +.feeNotice { + text-align: center; + font-size: 18px; + color: #6c757d; +} \ No newline at end of file From ca46ea83166634b180d0ae3626e2d683a59329ac Mon Sep 17 00:00:00 2001 From: ripe <109935398+ripe0x@users.noreply.github.com> Date: Fri, 14 Apr 2023 18:31:02 -0700 Subject: [PATCH 011/218] sponsor module scaffolding --- packages/nouns-webapp/src/App.tsx | 2 + .../CandidateSponsors.module.css | 84 +++ .../components/CandidateSponsors/index.tsx | 37 ++ .../src/components/ProposalHeader/index.tsx | 27 +- .../src/pages/Candidate/Candidate.module.css | 201 +++++++ .../src/pages/Candidate/index.tsx | 552 ++++++++++++++++++ 6 files changed, 897 insertions(+), 6 deletions(-) create mode 100644 packages/nouns-webapp/src/components/CandidateSponsors/CandidateSponsors.module.css create mode 100644 packages/nouns-webapp/src/components/CandidateSponsors/index.tsx create mode 100644 packages/nouns-webapp/src/pages/Candidate/Candidate.module.css create mode 100644 packages/nouns-webapp/src/pages/Candidate/index.tsx diff --git a/packages/nouns-webapp/src/App.tsx b/packages/nouns-webapp/src/App.tsx index ea910dbdda..523142448d 100644 --- a/packages/nouns-webapp/src/App.tsx +++ b/packages/nouns-webapp/src/App.tsx @@ -25,6 +25,7 @@ import { AvatarProvider } from '@davatar/react'; import dayjs from 'dayjs'; import DelegatePage from './pages/DelegatePage'; import CreateCandidatePage from './pages/CreateCandidate'; +import CandidatePage from './pages/Candidate'; function App() { const { account, chainId, library } = useEthers(); @@ -67,6 +68,7 @@ function App() { + 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..e0551c26ef --- /dev/null +++ b/packages/nouns-webapp/src/components/CandidateSponsors/CandidateSponsors.module.css @@ -0,0 +1,84 @@ +.wrapper { + border: 1px solid #e6e6e6; + padding: 15px; + border-radius: 12px; + text-align: center; + position: sticky; + top: 20px; + @media (max-width: 1200px) { + position: relative; + top: 0; + } +} +.sponsorsList { + text-align: left; + padding: 0; + gap: 16px; + display: flex; + flex-direction: column +} +.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 li.sponsor { + background-color: #FBFBFC; + border-width: 1px; +} + +.sponsorsList li.placeholder { + border-style: dashed; + border-width: 2px; + min-height: 40px; +} + +.details { + display: flex; + flex-direction: row; + justify-content: space-between; +} + +.sponsorName { + font-size: 16px; + font-weight: bold; + color: #000000; +} + +.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 { + margin-top: 10px; + margin-bottom: 10px; + padding: 10px; + border-radius: 8px; + background-color: #000; + border: 1px solid #e6e6e6; + font-size: 14px; + font-weight: bold; + color: #fff; + cursor: pointer; +} diff --git a/packages/nouns-webapp/src/components/CandidateSponsors/index.tsx b/packages/nouns-webapp/src/components/CandidateSponsors/index.tsx new file mode 100644 index 0000000000..46948fbc74 --- /dev/null +++ b/packages/nouns-webapp/src/components/CandidateSponsors/index.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import classes from './CandidateSponsors.module.css'; + +interface CandidateSponsorsProps { + slug: string; +} + +const CandidateSponsors: React.FC = props => { + const { slug } = props; + return ( +
+
+

+ 0/5 Sponsored Votes +

+

Proposal candidates must meet the required Nouns vote threshold.

+
+
    +
  • +
    +
    + {/* TODO: truncase long names */} +

    0x123

    +

    expires in 30 days

    +
    +

    2 votes

    +
    +
  • +
  • + {/* TODO: conditional button show based on nouns holder */} + +
+
+ ); +}; + +export default CandidateSponsors; diff --git a/packages/nouns-webapp/src/components/ProposalHeader/index.tsx b/packages/nouns-webapp/src/components/ProposalHeader/index.tsx index 5587aedaf9..f2a447d49d 100644 --- a/packages/nouns-webapp/src/components/ProposalHeader/index.tsx +++ b/packages/nouns-webapp/src/components/ProposalHeader/index.tsx @@ -23,6 +23,7 @@ interface ProposalHeaderProps { proposal: Proposal; isActiveForVoting?: boolean; isWalletConnected: boolean; + isCandidate?: boolean; submitButtonClickHandler: () => void; } @@ -108,18 +109,32 @@ const ProposalHeader: React.FC = props => { <>
+ {/* TODO: bleed left on wide. move above on mobile */}
-
- Proposal {i18n.number(parseInt(proposal.id || '0'))} -
-
- -
+ {props.isCandidate ? ( + <> +
+ Proposal Candide +
+ + ) : ( + <> +
+ Proposal {i18n.number(parseInt(proposal.id || '0'))} +
+
+ +
+ + )}
diff --git a/packages/nouns-webapp/src/pages/Candidate/Candidate.module.css b/packages/nouns-webapp/src/pages/Candidate/Candidate.module.css new file mode 100644 index 0000000000..0371e56547 --- /dev/null +++ b/packages/nouns-webapp/src/pages/Candidate/Candidate.module.css @@ -0,0 +1,201 @@ +.votePage a { + color: var(--brand-dark-red); +} + +.proposal { + margin-top: 1em; + background-color: white; +} + +.backArrow { + height: 1rem; +} + +.votingButton { + margin-top: 1rem; +} + +.voteCountCard { + margin-top: 1rem; +} + +.proposalId { + margin: 1rem 0; +} + +.voteCountCard p { + display: flex; + justify-content: space-between; +} + +.section { + word-wrap: break-word; + padding-top: 2rem; + margin-top: 2rem; +} + +.section h5 { + font-size: 1.7rem; + margin-top: 1rem; + font-family: 'Londrina Solid'; +} + +.voterIneligibleAlert { + margin: 1rem 0 0 0; +} + +.blockRestrictionAlert { + margin: 1rem 0 0 0; +} + +.wrapper { + margin-left: auto; + margin-right: auto; +} + +.transitionStateButton { + height: 50px; + border-radius: 8px; + font-family: 'PT Root UI'; + font-weight: bold; + font-size: 24px; + transition: all 0.125s ease-in-out; +} + +.transitionStateButton:hover { + opacity: 0.5; + cursor: pointer; +} + +.transitionStateButtonSection { + border-top: 0px; +} + +.destructiveTransitionStateButton { + height: 50px; + border-radius: 8px; + border-color: var(--brand-color-red); + font-family: 'PT Root UI'; + font-weight: bold; + font-size: 24px; + transition: all 0.125s ease-in-out; + background-color: var(--brand-color-red); +} + +.destructiveTransitionStateButton:hover { + background-color: var(--brand-color-red); + opacity: 0.5; + cursor: pointer; +} + +.destructiveTransitionStateButtonSection { + height: 50px; + border-radius: 8px; + font-family: 'PT Root UI'; + font-weight: bold; + font-size: 24px; + transition: all 0.125s ease-in-out; + background-color: var(--brand-color-red); +} + +.spinner { + margin-left: auto; + margin-right: auto; + color: var(--brand-gray-light-text); +} + +/* Info section stuff */ +.voteInfoCard { + margin-top: 1rem; + padding: 0.5rem; + border-radius: 12px; +} + +.voteMetadataRow { + display: flex; + justify-content: space-between; +} + +.voteMetadataRow h1 { + font-size: 20px; + color: var(--brand-gray-light-text); + font-family: 'Londrina Solid'; +} + +.voteMetadataRow span { + font-size: 14px; + font-family: 'PT Root UI'; + color: var(--brand-gray-light-text); +} + +.voteMetadataRow h3 { + font-size: 18px; + font-family: 'PT Root UI'; + font-weight: bold; +} + +.voteMetadataRowTitle { + margin-top: 0.5rem; + width: max-content; +} + +.voteMetadataTime { + min-width: fit-content; + text-align: right; +} + +.snapshotBlock { + text-align: right; +} + +.thresholdInfo { + text-align: right; +} + +.toggleDelegateVoteView { + margin-top: 0.1rem; + margin-bottom: 0.1rem; + opacity: 0.5; + font-size: 14px; + cursor: pointer; + transition: ease-in-out 125ms; + width: fit-content; + margin-left: 0.1rem; +} + +.toggleDelegateVoteView:hover { + text-decoration: underline; +} + +@media (max-width: 1200px) { + .toggleDelegateVoteView { + display: none; + } +} + +.delegateHover { + border-radius: 8px !important; + background-color: var(--brand-gray-dark-text) !important; + color: white; + opacity: 0.75 !important; + font-weight: 500; + transition: ease-in-out 125ms; +} + +.dqIcon { + opacity: 0.5; + margin-left: 0.25rem; + margin-bottom: 0.25rem; + height: 18px; + width: 18px; +} + +.cursorPointer { + cursor: pointer; +} + +.boldedLabel { + font-weight: 500; + color: var(--brand-gray-light-text); + margin-bottom: 0.5rem; +} diff --git a/packages/nouns-webapp/src/pages/Candidate/index.tsx b/packages/nouns-webapp/src/pages/Candidate/index.tsx new file mode 100644 index 0000000000..78f3657055 --- /dev/null +++ b/packages/nouns-webapp/src/pages/Candidate/index.tsx @@ -0,0 +1,552 @@ +import { Row, Col, Button, Card, Spinner } from 'react-bootstrap'; +import Section from '../../layout/Section'; +import { + ProposalState, + useCancelProposal, + useCurrentQuorum, + useExecuteProposal, + useProposal, + useQueueProposal, +} from '../../wrappers/nounsDao'; +import { useUserVotesAsOfBlock } from '../../wrappers/nounToken'; +import classes from './Candidate.module.css'; +import { RouteComponentProps } from 'react-router-dom'; +import { TransactionStatus, useBlockNumber, useEthers } from '@usedapp/core'; +import { AlertModal, setAlertModal } from '../../state/slices/application'; +import dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc'; +import timezone from 'dayjs/plugin/timezone'; +import advanced from 'dayjs/plugin/advancedFormat'; +import VoteModal from '../../components/VoteModal'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useAppDispatch, useAppSelector } from '../../hooks'; +import clsx from 'clsx'; +import ProposalHeader from '../../components/ProposalHeader'; +import ProposalContent from '../../components/ProposalContent'; +import VoteCard, { VoteCardVariant } from '../../components/VoteCard'; +import { useQuery } from '@apollo/client'; +import { + proposalVotesQuery, + delegateNounsAtBlockQuery, + ProposalVotes, + Delegates, + propUsingDynamicQuorum, +} from '../../wrappers/subgraph'; +import { getNounVotes } from '../../utils/getNounsVotes'; +import { Trans } from '@lingui/macro'; +import { i18n } from '@lingui/core'; +import { ReactNode } from 'react-markdown/lib/react-markdown'; +import { AVERAGE_BLOCK_TIME_IN_SECS } from '../../utils/constants'; +import { SearchIcon } from '@heroicons/react/solid'; +import ReactTooltip from 'react-tooltip'; +import DynamicQuorumInfoModal from '../../components/DynamicQuorumInfoModal'; +import config from '../../config'; +import ShortAddress from '../../components/ShortAddress'; +import StreamWithdrawModal from '../../components/StreamWithdrawModal'; +import { parseStreamCreationCallData } from '../../utils/streamingPaymentUtils/streamingPaymentUtils'; +import CandidateSponsors from '../../components/CandidateSponsors'; + +dayjs.extend(utc); +dayjs.extend(timezone); +dayjs.extend(advanced); + +const CandidatePage = ({ + match: { + params: { id }, + }, +}: RouteComponentProps<{ id: string }>) => { + const proposal = useProposal(id); + const { account } = useEthers(); + + const [showVoteModal, setShowVoteModal] = useState(false); + const [showDynamicQuorumInfoModal, setShowDynamicQuorumInfoModal] = useState(false); + // Toggle between Noun centric view and delegate view + const [isDelegateView, setIsDelegateView] = useState(false); + + const [isQueuePending, setQueuePending] = useState(false); + const [isExecutePending, setExecutePending] = useState(false); + const [isCancelPending, setCancelPending] = useState(false); + const [showStreamWithdrawModal, setShowStreamWithdrawModal] = useState(false); + const [streamWithdrawInfo, setStreamWithdrawInfo] = useState<{ + streamAddress: string; + startTime: number; + endTime: number; + streamAmount: number; + tokenAddress: string; + } | null>(null); + + const dispatch = useAppDispatch(); + const setModal = useCallback((modal: AlertModal) => dispatch(setAlertModal(modal)), [dispatch]); + const { + data: dqInfo, + loading: loadingDQInfo, + error: dqError, + } = useQuery(propUsingDynamicQuorum(id ?? '0')); + + const { queueProposal, queueProposalState } = useQueueProposal(); + const { executeProposal, executeProposalState } = useExecuteProposal(); + const { cancelProposal, cancelProposalState } = useCancelProposal(); + + // Get and format date from data + const timestamp = Date.now(); + const currentBlock = useBlockNumber(); + const startDate = + proposal && timestamp && currentBlock + ? dayjs(timestamp).add( + AVERAGE_BLOCK_TIME_IN_SECS * (proposal.startBlock - currentBlock), + 'seconds', + ) + : undefined; + + const endDate = + proposal && timestamp && currentBlock + ? dayjs(timestamp).add( + AVERAGE_BLOCK_TIME_IN_SECS * (proposal.endBlock - currentBlock), + 'seconds', + ) + : undefined; + const now = dayjs(); + + // Get total votes and format percentages for UI + const totalVotes = proposal + ? proposal.forCount + proposal.againstCount + proposal.abstainCount + : undefined; + const forPercentage = proposal && totalVotes ? (proposal.forCount * 100) / totalVotes : 0; + const againstPercentage = proposal && totalVotes ? (proposal.againstCount * 100) / totalVotes : 0; + const abstainPercentage = proposal && totalVotes ? (proposal.abstainCount * 100) / totalVotes : 0; + + // Only count available votes as of the proposal created block + const availableVotes = useUserVotesAsOfBlock(proposal?.createdBlock ?? undefined); + + const currentQuorum = useCurrentQuorum( + config.addresses.nounsDAOProxy, + proposal && proposal.id ? parseInt(proposal.id) : 0, + dqInfo && dqInfo.proposal ? dqInfo.proposal.quorumCoefficient === '0' : true, + ); + + const hasSucceeded = proposal?.status === ProposalState.SUCCEEDED; + const isInNonFinalState = [ + ProposalState.PENDING, + ProposalState.ACTIVE, + ProposalState.SUCCEEDED, + ProposalState.QUEUED, + ].includes(proposal?.status!); + const isCancellable = + isInNonFinalState && proposal?.proposer?.toLowerCase() === account?.toLowerCase(); + + const isAwaitingStateChange = () => { + if (hasSucceeded) { + return true; + } + if (proposal?.status === ProposalState.QUEUED) { + return new Date() >= (proposal?.eta ?? Number.MAX_SAFE_INTEGER); + } + return false; + }; + + const isAwaitingDestructiveStateChange = () => { + if (isCancellable) { + return true; + } + return false; + }; + + const startOrEndTimeCopy = () => { + if (startDate?.isBefore(now) && endDate?.isAfter(now)) { + return Ends; + } + if (endDate?.isBefore(now)) { + return Ended; + } + return Starts; + }; + + const startOrEndTimeTime = () => { + if (!startDate?.isBefore(now)) { + return startDate; + } + return endDate; + }; + + const moveStateButtonAction = hasSucceeded ? Queue : Execute; + const moveStateAction = (() => { + if (hasSucceeded) { + return () => { + if (proposal?.id) { + return queueProposal(proposal.id); + } + }; + } + return () => { + if (proposal?.id) { + return executeProposal(proposal.id); + } + }; + })(); + + const destructiveStateButtonAction = isCancellable ? Cancel : ''; + const destructiveStateAction = (() => { + if (isCancellable) { + return () => { + if (proposal?.id) { + return cancelProposal(proposal.id); + } + }; + } + })(); + + const onTransactionStateChange = useCallback( + ( + tx: TransactionStatus, + successMessage?: ReactNode, + setPending?: (isPending: boolean) => void, + getErrorMessage?: (error?: string) => ReactNode | undefined, + onFinalState?: () => void, + ) => { + switch (tx.status) { + case 'None': + setPending?.(false); + break; + case 'Mining': + setPending?.(true); + break; + case 'Success': + setModal({ + title: Success, + message: successMessage || Transaction Successful!, + show: true, + }); + setPending?.(false); + onFinalState?.(); + break; + case 'Fail': + setModal({ + title: Transaction Failed, + message: tx?.errorMessage || Please try again., + show: true, + }); + setPending?.(false); + onFinalState?.(); + break; + case 'Exception': + setModal({ + title: Error, + message: getErrorMessage?.(tx?.errorMessage) || Please try again., + show: true, + }); + setPending?.(false); + onFinalState?.(); + break; + } + }, + [setModal], + ); + + useEffect( + () => + onTransactionStateChange( + queueProposalState, + Proposal Queued!, + setQueuePending, + ), + [queueProposalState, onTransactionStateChange, setModal], + ); + + useEffect( + () => + onTransactionStateChange( + executeProposalState, + Proposal Executed!, + setExecutePending, + ), + [executeProposalState, onTransactionStateChange, setModal], + ); + + useEffect( + () => onTransactionStateChange(cancelProposalState, 'Proposal Canceled!', setCancelPending), + [cancelProposalState, onTransactionStateChange, setModal], + ); + + const activeAccount = useAppSelector(state => state.account.activeAccount); + const { + loading, + error, + data: voters, + } = useQuery(proposalVotesQuery(proposal?.id ?? '0'), { + skip: !proposal, + }); + + const voterIds = voters?.votes?.map(v => v.voter.id); + const { data: delegateSnapshot } = useQuery( + delegateNounsAtBlockQuery(voterIds ?? [], proposal?.createdBlock ?? 0), + { + skip: !voters?.votes?.length, + }, + ); + + const { delegates } = delegateSnapshot || {}; + const delegateToNounIds = delegates?.reduce>((acc, curr) => { + acc[curr.id] = curr?.nounsRepresented?.map(nr => nr.id) ?? []; + return acc; + }, {}); + + const data = voters?.votes?.map(v => ({ + delegate: v.voter.id, + supportDetailed: v.supportDetailed, + nounsRepresented: delegateToNounIds?.[v.voter.id] ?? [], + })); + + const [showToast, setShowToast] = useState(true); + useEffect(() => { + if (showToast) { + setTimeout(() => { + setShowToast(false); + }, 5000); + } + }, [showToast]); + + if (!proposal || loading || !data || loadingDQInfo || !dqInfo) { + return ( +
+ +
+ ); + } + + if (error || dqError) { + return Failed to fetch; + } + + const isWalletConnected = !(activeAccount === undefined); + const isActiveForVoting = startDate?.isBefore(now) && endDate?.isAfter(now); + + const forNouns = getNounVotes(data, 1); + const againstNouns = getNounVotes(data, 0); + const abstainNouns = getNounVotes(data, 2); + const isV2Prop = dqInfo.proposal.quorumCoefficient > 0; + + return ( +
+ + {proposal && ( + setShowVoteModal(true)} + /> + )} + + + {proposal.status === ProposalState.EXECUTED && + proposal.details + .filter(txn => txn?.functionSig.includes('createStream')) + .map(txn => { + const parsedCallData = parseStreamCreationCallData(txn.callData); + if (parsedCallData.recipient.toLowerCase() !== account?.toLowerCase()) { + return <>; + } + + return ( + + + Only visible to you + + + + + + ); + })} + + {(isAwaitingStateChange() || isAwaitingDestructiveStateChange()) && ( + + + {isAwaitingStateChange() && ( + + )} + + {isAwaitingDestructiveStateChange() && ( + + )} + + + )} + + + + {/*

setIsDelegateView(!isDelegateView)} + className={classes.toggleDelegateVoteView} + > + {isDelegateView ? ( + Switch to Noun view + ) : ( + Switch to delegate view + )} +

+ + + + + */} + + {/* TODO abstract this into a component */} + {/* + + + +
+
+

+ Threshold +

+
+ {isV2Prop && ( + { + return View Threshold Info; + }} + /> + )} +
setShowDynamicQuorumInfoModal(true && isV2Prop)} + className={clsx(classes.thresholdInfo, isV2Prop ? classes.cursorPointer : '')} + > + + {isV2Prop ? Current Threshold : Threshold} + +

+ + {isV2Prop ? i18n.number(currentQuorum ?? 0) : proposal.quorumVotes} votes + + {isV2Prop && } +

+
+
+
+
+ + + + +
+
+

{startOrEndTimeCopy()}

+
+
+ + {startOrEndTimeTime() && + i18n.date(new Date(startOrEndTimeTime()?.toISOString() || 0), { + hour: 'numeric', + minute: '2-digit', + timeZoneName: 'short', + })} + +

+ {startOrEndTimeTime() && + i18n.date(new Date(startOrEndTimeTime()?.toISOString() || 0), { + dateStyle: 'long', + })} +

+
+
+
+
+ + + + +
+
+

Snapshot

+
+
+ + Taken at block + +

{proposal.createdBlock}

+
+
+
+
+ +
*/} + + + + + + +
+
+ ); +}; + +export default CandidatePage; From 7427ca7d7d4558eaf92c00179afcab24956caef0 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Sun, 16 Apr 2023 16:03:45 +0200 Subject: [PATCH 012/218] move data contract abi to data folder --- .../governance/{ => data}/NounsDAOData.sol/NounsDAOData.json | 0 packages/nouns-contracts/src/index.ts | 2 ++ packages/nouns-contracts/tasks/deploy-local-dao-v3.ts | 2 +- packages/nouns-subgraph/subgraph.yaml.mustache | 2 +- 4 files changed, 4 insertions(+), 2 deletions(-) rename packages/nouns-contracts/abi/contracts/governance/{ => data}/NounsDAOData.sol/NounsDAOData.json (100%) diff --git a/packages/nouns-contracts/abi/contracts/governance/NounsDAOData.sol/NounsDAOData.json b/packages/nouns-contracts/abi/contracts/governance/data/NounsDAOData.sol/NounsDAOData.json similarity index 100% rename from packages/nouns-contracts/abi/contracts/governance/NounsDAOData.sol/NounsDAOData.json rename to packages/nouns-contracts/abi/contracts/governance/data/NounsDAOData.sol/NounsDAOData.json diff --git a/packages/nouns-contracts/src/index.ts b/packages/nouns-contracts/src/index.ts index b61825f225..09cc4119be 100644 --- a/packages/nouns-contracts/src/index.ts +++ b/packages/nouns-contracts/src/index.ts @@ -4,6 +4,7 @@ export { default as NounsDescriptorABI } from '../abi/contracts/NounsDescriptor. export { default as NounsSeederABI } from '../abi/contracts/NounsSeeder.sol/NounsSeeder.json'; export { default as NounsDAOABI } from '../abi/contracts/governance/NounsDAOLogicV1.sol/NounsDAOLogicV1.json'; export { default as NounsDAOV2ABI } from '../abi/contracts/governance/NounsDAOLogicV2.sol/NounsDAOLogicV2.json'; +export { default as NounsDAODataABI } from '../abi/contracts/governance/data/NounsDAOData.sol/NounsDAOData.json'; export { NounsToken__factory as NounsTokenFactory } from '../typechain/factories/contracts/NounsToken__factory'; export { NounsAuctionHouse__factory as NounsAuctionHouseFactory } from '../typechain/factories/contracts/NounsAuctionHouse__factory'; export { NounsDescriptor__factory as NounsDescriptorFactory } from '../typechain/factories/contracts/NounsDescriptor__factory'; @@ -11,3 +12,4 @@ export { NounsSeeder__factory as NounsSeederFactory } from '../typechain/factori export { NounsDAOLogicV1__factory as NounsDaoLogicV1Factory } from '../typechain/factories/contracts/governance/NounsDAOLogicV1__factory'; export { NounsDAOLogicV2__factory as NounsDaoLogicV2Factory } from '../typechain/factories/contracts/governance/NounsDAOLogicV2__factory'; export { NounsDAOLogicV3__factory as NounsDaoLogicV3Factory } from '../typechain/factories/contracts/governance/NounsDAOLogicV3__factory'; +export { NounsDAOData__factory as NounsDaoDataFactory } from '../typechain/factories/contracts/governance/data/NounsDAOData__factory'; diff --git a/packages/nouns-contracts/tasks/deploy-local-dao-v3.ts b/packages/nouns-contracts/tasks/deploy-local-dao-v3.ts index 37e614ad17..460e1ee3ba 100644 --- a/packages/nouns-contracts/tasks/deploy-local-dao-v3.ts +++ b/packages/nouns-contracts/tasks/deploy-local-dao-v3.ts @@ -1,5 +1,5 @@ import { default as NounsAuctionHouseABI } from '../abi/contracts/NounsAuctionHouse.sol/NounsAuctionHouse.json'; -import { default as NounsDaoDataABI } from '../abi/contracts/governance/NounsDAOData.sol/NounsDAOData.json'; +import { default as NounsDaoDataABI } from '../abi/contracts/governance/data/NounsDAOData.sol/NounsDAOData.json'; import { task, types } from 'hardhat/config'; import { Interface, parseUnits } from 'ethers/lib/utils'; import { Contract as EthersContract } from 'ethers'; diff --git a/packages/nouns-subgraph/subgraph.yaml.mustache b/packages/nouns-subgraph/subgraph.yaml.mustache index 6c467c3a56..034db89062 100644 --- a/packages/nouns-subgraph/subgraph.yaml.mustache +++ b/packages/nouns-subgraph/subgraph.yaml.mustache @@ -133,7 +133,7 @@ dataSources: - ProposalFeedback abis: - name: NounsDAOData - file: ../nouns-contracts/abi/contracts/governance/NounsDAOData.sol/NounsDAOData.json + file: ../nouns-contracts/abi/contracts/governance/data/NounsDAOData.sol/NounsDAOData.json eventHandlers: - event: ProposalCandidateCreated(indexed address,address[],uint256[],string[],bytes[],string,string,bytes32) handler: handleProposalCandidateCreated From 5f7f3484691c0ffe3e30a4c8a2910a05d19fc190 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Sun, 16 Apr 2023 16:04:32 +0200 Subject: [PATCH 013/218] add signatures backref to candidate object in subgraph --- packages/nouns-subgraph/schema.graphql | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/nouns-subgraph/schema.graphql b/packages/nouns-subgraph/schema.graphql index a4516a71fe..2daffe891b 100644 --- a/packages/nouns-subgraph/schema.graphql +++ b/packages/nouns-subgraph/schema.graphql @@ -480,6 +480,9 @@ type ProposalCandidateVersion @entity(immutable: true) { "The update message of this version, relevant when it's an update" updateMessage: String! + + "This version's signatures by signers" + versionSignatures: [ProposalCandidateSignature!]! @derivedFrom(field: "version") } type ProposalCandidateSignature @entity(immutable: true) { From b5ff46e867006c4ff34d15c98b11843386f3f09d Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Sun, 16 Apr 2023 16:06:01 +0200 Subject: [PATCH 014/218] poc for creating proposal candidates via data contract --- .../nouns-sdk/src/contract/addresses.json | 3 +- packages/nouns-sdk/src/contract/types.ts | 1 + packages/nouns-webapp/src/App.tsx | 6 + .../src/pages/CandidateProposalPage/index.tsx | 244 +++++++++++++++ .../src/pages/CandidateProposals/index.tsx | 28 ++ .../CreateCandidateProposalPage/index.tsx | 284 ++++++++++++++++++ .../nouns-webapp/src/wrappers/nounsData.ts | 27 ++ .../nouns-webapp/src/wrappers/subgraph.ts | 25 ++ 8 files changed, 617 insertions(+), 1 deletion(-) create mode 100644 packages/nouns-webapp/src/pages/CandidateProposalPage/index.tsx create mode 100644 packages/nouns-webapp/src/pages/CandidateProposals/index.tsx create mode 100644 packages/nouns-webapp/src/pages/CreateCandidateProposalPage/index.tsx create mode 100644 packages/nouns-webapp/src/wrappers/nounsData.ts diff --git a/packages/nouns-sdk/src/contract/addresses.json b/packages/nouns-sdk/src/contract/addresses.json index abf3ddea2c..550632ab1a 100644 --- a/packages/nouns-sdk/src/contract/addresses.json +++ b/packages/nouns-sdk/src/contract/addresses.json @@ -46,6 +46,7 @@ "nounsAuctionHouseProxyAdmin": "0x8a791620dd6260079bf849dc5567adc3f2fdc318", "nounsDaoExecutor": "0xb7f8bc63bbcad18155201308c8f3540b07f84f5e", "nounsDAOProxy": "0x9A9f2CCfdE556A7E9Ff0848998Aa4a0CFD8863AE", - "nounsDAOLogicV1": "0x959922be3caee4b8cd9a407cc3ac1c251c2007b1" + "nounsDAOLogicV1": "0x959922be3caee4b8cd9a407cc3ac1c251c2007b1", + "nounsDAOData": "0x59b670e9fA9D0A427751Af201D676719a970857b" } } 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-webapp/src/App.tsx b/packages/nouns-webapp/src/App.tsx index f58fb7baba..1c3cfc4209 100644 --- a/packages/nouns-webapp/src/App.tsx +++ b/packages/nouns-webapp/src/App.tsx @@ -28,6 +28,9 @@ import dayjs from 'dayjs'; import DelegatePage from './pages/DelegatePage'; import DraftProposals from './pages/DraftProposals'; import DraftProposalPage from './pages/DraftProposal'; +import CreateCandidateProposalPage from './pages/CreateCandidateProposalPage'; +import CandidateProposals from './pages/CandidateProposals'; +import CandidateProposalPage from './pages/CandidateProposalPage'; function App() { const { account, chainId, library } = useEthers(); @@ -68,6 +71,9 @@ function App() { + + + diff --git a/packages/nouns-webapp/src/pages/CandidateProposalPage/index.tsx b/packages/nouns-webapp/src/pages/CandidateProposalPage/index.tsx new file mode 100644 index 0000000000..58fc41bec9 --- /dev/null +++ b/packages/nouns-webapp/src/pages/CandidateProposalPage/index.tsx @@ -0,0 +1,244 @@ +import { JsonRpcSigner } from '@ethersproject/providers'; +import { Trans } from '@lingui/macro'; +import { useEthers } from '@usedapp/core'; +import { useCallback, useEffect, useState } from 'react'; +import { Button, Container } from 'react-bootstrap'; +import ReactMarkdown from 'react-markdown'; +import { RouteComponentProps } from 'react-router-dom'; +import remarkBreaks from 'remark-breaks'; +import config, { CHAIN_ID } from '../../config'; +import { useAppDispatch } from '../../hooks'; +import Section from '../../layout/Section'; +import { AlertModal, setAlertModal } from '../../state/slices/application'; +import { useProposeBySigs, useUpdateProposalBySigs } from '../../wrappers/nounsDao'; +import { + addSignature, + DraftProposal, + getDraftProposals, + ProposalContent, +} from '../CreateDraftProposal/DraftProposalsStorage'; +import { useCandidateProposal } 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' }, + ], +}; + +const CandidateProposalPage = ({ + match: { + params: { id }, + }, +}: RouteComponentProps<{ id: string }>) => { + const { library, chainId } = useEthers(); + const signer = library?.getSigner(); + + const [expiry, setExpiry] = useState(Math.round(Date.now() / 1000) + 60 * 60 * 24); + const [proposalIdToUpdate, setProposalIdToUpdate] = useState(''); + + const candidateProposal = useCandidateProposal(id); + + async function sign() { + if (!candidateProposal) return; + let signature; + if (proposalIdToUpdate) { + const value = { + proposer: candidateProposal.proposer, + targets: candidateProposal.targets, + values: candidateProposal.values, + signatures: candidateProposal.signatures, + calldatas: candidateProposal.calldatas, + description: candidateProposal.description, + expiry: expiry, + proposalId: proposalIdToUpdate, + }; + signature = await signer!._signTypedData(domain, updateProposalTypes, value); + } else { + const value = { + proposer: candidateProposal.proposer, + targets: candidateProposal.targets, + values: candidateProposal.values, + signatures: candidateProposal.signatures, + calldatas: candidateProposal.calldatas, + description: candidateProposal.description, + expiry: expiry, + }; + signature = await signer!._signTypedData(domain, createProposalTypes, value); + } + + // const updatedDraftProposal = addSignature( + // { + // signer: await signer!.getAddress(), + // signature: signature!, + // expiry: expiry}, + // proposalId); + // setDraftProposal(updatedDraftProposal); + } + + const [isProposePending, setProposePending] = useState(false); + + const dispatch = useAppDispatch(); + const setModal = useCallback((modal: AlertModal) => dispatch(setAlertModal(modal)), [dispatch]); + const { proposeBySigs, proposeBySigsState } = useProposeBySigs(); + const { updateProposalBySigs, updateProposalBySigState } = useUpdateProposalBySigs(); + + useEffect(() => { + switch (proposeBySigsState.status) { + case 'None': + setProposePending(false); + break; + case 'Mining': + setProposePending(true); + break; + case 'Success': + setModal({ + title: Success, + message: Proposal Created!, + show: true, + }); + setProposePending(false); + break; + case 'Fail': + setModal({ + title: Transaction Failed, + message: proposeBySigsState?.errorMessage || Please try again., + show: true, + }); + setProposePending(false); + break; + case 'Exception': + setModal({ + title: Error, + message: proposeBySigsState?.errorMessage || Please try again., + show: true, + }); + setProposePending(false); + break; + } + }, [proposeBySigsState, setModal]); + + useEffect(() => { + switch (updateProposalBySigState.status) { + case 'None': + setProposePending(false); + break; + case 'Mining': + setProposePending(true); + break; + case 'Success': + setModal({ + title: Success, + message: Proposal Updated!, + show: true, + }); + setProposePending(false); + break; + case 'Fail': + setModal({ + title: Transaction Failed, + message: updateProposalBySigState?.errorMessage || Please try again., + show: true, + }); + setProposePending(false); + break; + case 'Exception': + setModal({ + title: Error, + message: updateProposalBySigState?.errorMessage || Please try again., + show: true, + }); + setProposePending(false); + break; + } + }, [updateProposalBySigState, setModal]); + + async function proposeBySigsClicked() { + // await proposeBySigs( + // draftProposal?.signatures.map(s => [s.signature, s.signer, s.expiry]), + // draftProposal?.proposalContent.targets, + // draftProposal?.proposalContent.values, + // draftProposal?.proposalContent.signatures, + // draftProposal?.proposalContent.calldatas, + // draftProposal?.proposalContent.description, + // ); + } + async function updateProposalBySigsClicked() { + // const proposalId = Number.parseInt(proposalIdToUpdate); + // await updateProposalBySigs( + // proposalId, + // draftProposal?.signatures.map(s => [s.signature, s.signer, s.expiry]), + // draftProposal?.proposalContent.targets, + // draftProposal?.proposalContent.values, + // draftProposal?.proposalContent.signatures, + // draftProposal?.proposalContent.calldatas, + // draftProposal?.proposalContent.description, + // ) + } + + return ( +
+

Candidate Proposal {id}

+ {candidateProposal && ( + + )} +
{JSON.stringify(candidateProposal, null, 4)}
+ + + + + + + + + + +
+ ); +}; + +export default CandidateProposalPage; diff --git a/packages/nouns-webapp/src/pages/CandidateProposals/index.tsx b/packages/nouns-webapp/src/pages/CandidateProposals/index.tsx new file mode 100644 index 0000000000..9de9190e1d --- /dev/null +++ b/packages/nouns-webapp/src/pages/CandidateProposals/index.tsx @@ -0,0 +1,28 @@ +import { Link } from 'react-router-dom'; +import Section from '../../layout/Section'; +import { useCandidateProposals } from '../../wrappers/nounsData'; + +const CandidateProposals = () => { + const { loading, error, data } = useCandidateProposals(); + + let candidates: {} | null | undefined = []; + + if (!loading && !error) { + candidates = data['proposalCandidates'].map((c: any) => { + return ( +
+ {c.title} +
+ ); + }); + } + + return ( +
+
Proposal candidates
+
{candidates}
+
+ ); +}; + +export default CandidateProposals; diff --git a/packages/nouns-webapp/src/pages/CreateCandidateProposalPage/index.tsx b/packages/nouns-webapp/src/pages/CreateCandidateProposalPage/index.tsx new file mode 100644 index 0000000000..f8e5c45921 --- /dev/null +++ b/packages/nouns-webapp/src/pages/CreateCandidateProposalPage/index.tsx @@ -0,0 +1,284 @@ +import { Col, Alert, Button } from 'react-bootstrap'; +import Section from '../../layout/Section'; +import { + ProposalState, + ProposalTransaction, + useProposal, + useProposalCount, + useProposalThreshold, + usePropose, +} from '../../wrappers/nounsDao'; +import { useUserVotes } from '../../wrappers/nounToken'; +import classes from '../CreateProposal/CreateProposal.module.css'; +import { Link, useHistory } from 'react-router-dom'; +import { useEthers } from '@usedapp/core'; +import { AlertModal, setAlertModal } from '../../state/slices/application'; +import ProposalEditor from '../../components/ProposalEditor'; +import ProposalTransactions from '../../components/ProposalTransactions'; +import { withStepProgress } from 'react-stepz'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useAppDispatch } from '../../hooks'; +import { Trans } from '@lingui/macro'; +import clsx from 'clsx'; +import navBarButtonClasses from '../../components/NavBarButton/NavBarButton.module.css'; +import ProposalActionModal from '../../components/ProposalActionsModal'; +import config from '../../config'; +import { useEthNeeded } from '../../utils/tokenBuyerContractUtils/tokenBuyer'; +import { useCreateProposalCandidate } from '../../wrappers/nounsData'; + +const CreateDraftProposalPage = () => { + const history = useHistory(); + const { account } = useEthers(); + const latestProposalId = useProposalCount(); + const latestProposal = useProposal(latestProposalId ?? 0); + const availableVotes = useUserVotes(); + const proposalThreshold = useProposalThreshold(); + + const { createProposalCandidate, createProposalCandidateState } = useCreateProposalCandidate(); + + const [proposalTransactions, setProposalTransactions] = useState([]); + const [titleValue, setTitleValue] = useState(''); + const [bodyValue, setBodyValue] = useState(''); + + const [slug, setSlug] = useState(''); + + const [totalUSDCPayment, setTotalUSDCPayment] = useState(0); + const [tokenBuyerTopUpEth, setTokenBuyerTopUpETH] = useState('0'); + const ethNeeded = useEthNeeded(config.addresses.tokenBuyer ?? '', totalUSDCPayment); + + const handleAddProposalAction = useCallback( + (transaction: ProposalTransaction) => { + if (!transaction.address.startsWith('0x')) { + transaction.address = `0x${transaction.address}`; + } + if (!transaction.calldata.startsWith('0x')) { + transaction.calldata = `0x${transaction.calldata}`; + } + + if (transaction.usdcValue) { + setTotalUSDCPayment(totalUSDCPayment + transaction.usdcValue); + } + + setProposalTransactions([...proposalTransactions, transaction]); + setShowTransactionFormModal(false); + }, + [proposalTransactions, totalUSDCPayment], + ); + + const handleRemoveProposalAction = useCallback( + (index: number) => { + setTotalUSDCPayment(totalUSDCPayment - (proposalTransactions[index].usdcValue ?? 0)); + setProposalTransactions(proposalTransactions.filter((_, i) => i !== index)); + }, + [proposalTransactions, totalUSDCPayment], + ); + + useEffect(() => { + if (ethNeeded !== undefined && ethNeeded !== tokenBuyerTopUpEth) { + const hasTokenBuyterTopTop = + proposalTransactions.filter(txn => txn.address === config.addresses.tokenBuyer).length > 0; + + // Add a new top up txn if one isn't there already, else add to the existing one + if (parseInt(ethNeeded) > 0 && !hasTokenBuyterTopTop) { + handleAddProposalAction({ + address: config.addresses.tokenBuyer ?? '', + value: ethNeeded ?? '0', + calldata: '0x', + signature: '', + }); + } else { + if (parseInt(ethNeeded) > 0) { + const indexOfTokenBuyerTopUp = + proposalTransactions + .map((txn, index: number) => { + if (txn.address === config.addresses.tokenBuyer) { + return index; + } else { + return -1; + } + }) + .filter(n => n >= 0) ?? new Array(); + + const txns = proposalTransactions; + if (indexOfTokenBuyerTopUp.length > 0) { + txns[indexOfTokenBuyerTopUp[0]].value = ethNeeded; + setProposalTransactions(txns); + } + } + } + + setTokenBuyerTopUpETH(ethNeeded ?? '0'); + } + }, [ + ethNeeded, + handleAddProposalAction, + handleRemoveProposalAction, + proposalTransactions, + tokenBuyerTopUpEth, + ]); + + const handleTitleInput = useCallback( + (title: string) => { + setTitleValue(title); + }, + [setTitleValue], + ); + + const handleBodyInput = useCallback( + (body: string) => { + setBodyValue(body); + }, + [setBodyValue], + ); + + const isFormInvalid = useMemo( + () => !proposalTransactions.length || titleValue === '' || bodyValue === '', + [proposalTransactions, titleValue, bodyValue], + ); + + const hasEnoughVote = Boolean( + availableVotes && proposalThreshold !== undefined && availableVotes > proposalThreshold, + ); + + const handleCreateProposal = async () => { + const description = `# ${titleValue}\n\n${bodyValue}`; + + await createProposalCandidate( + proposalTransactions.map(({ address }) => address), // Targets + proposalTransactions.map(({ value }) => value ?? '0'), // Values + proposalTransactions.map(({ signature }) => signature), // Signatures + proposalTransactions.map(({ calldata }) => calldata), // Calldatas + `# ${titleValue}\n\n${bodyValue}`, // Description + slug, // Slug + ); + }; + + const [showTransactionFormModal, setShowTransactionFormModal] = useState(false); + const [isProposePending, setProposePending] = useState(false); + + const dispatch = useAppDispatch(); + const setModal = useCallback((modal: AlertModal) => dispatch(setAlertModal(modal)), [dispatch]); + + useEffect(() => { + switch (createProposalCandidateState.status) { + case 'None': + setProposePending(false); + break; + case 'Mining': + setProposePending(true); + break; + case 'Success': + setModal({ + title: Success, + message: Proposal Created!, + show: true, + }); + setProposePending(false); + break; + case 'Fail': + setModal({ + title: Transaction Failed, + message: createProposalCandidateState?.errorMessage || Please try again., + show: true, + }); + setProposePending(false); + break; + case 'Exception': + setModal({ + title: Error, + message: createProposalCandidateState?.errorMessage || Please try again., + show: true, + }); + setProposePending(false); + break; + } + }, [createProposalCandidateState, setModal]); + + return ( +
+ setShowTransactionFormModal(false)} + show={showTransactionFormModal} + onActionAdd={handleAddProposalAction} + /> + + +
+ + + +

+ Create Draft Proposal +

+
+ + + Tip + + :{' '} + + Add one or more proposal actions and describe your proposal for the community. The + proposal cannot be modified after submission, so please verify all information before + submitting. The voting period will begin after 2 days and last for 5 days. + +
+
+ + You MUST maintain enough voting power to meet the proposal threshold until your + proposal is executed. If you fail to do so, anyone can cancel your proposal. + +
+
+ +
+
+ +
+ + + {totalUSDCPayment > 0 && ( + + + Note + + :{' '} + + Because this proposal contains a USDC fund transfer action we've added an additional + ETH transaction to refill the TokenBuyer contract. This action allows to DAO to + continue to trustlessly acquire USDC to fund proposals like this. + + + )} + +
+ +
+ +
+ ); +}; + +export default withStepProgress(CreateDraftProposalPage); diff --git a/packages/nouns-webapp/src/wrappers/nounsData.ts b/packages/nouns-webapp/src/wrappers/nounsData.ts new file mode 100644 index 0000000000..c34b8ad5d9 --- /dev/null +++ b/packages/nouns-webapp/src/wrappers/nounsData.ts @@ -0,0 +1,27 @@ +import { Contract, utils } from 'ethers'; +import { NounsDAODataABI, NounsDaoDataFactory } from '@nouns/contracts'; +import { useContractFunction, useEthers } from '@usedapp/core'; +import config from '../config'; +import { candidateProposalQuery, candidateProposalsQuery } from './subgraph'; +import { useQuery } from '@apollo/client'; + +const abi = new utils.Interface(NounsDAODataABI); + +export const useCreateProposalCandidate = () => { + const nounsDAOData = new NounsDaoDataFactory().attach(config.addresses.nounsDAOData!); + + const { send: createProposalCandidate, state: createProposalCandidateState } = + useContractFunction(nounsDAOData, 'createProposalCandidate'); + + return { createProposalCandidate, createProposalCandidateState }; +}; + +export const useCandidateProposals = () => { + const { loading, data, error } = useQuery(candidateProposalsQuery()); + + return { loading, data, error }; +}; + +export const useCandidateProposal = (id: string) => { + return useQuery(candidateProposalQuery(id)).data?.proposalCandidate; +}; diff --git a/packages/nouns-webapp/src/wrappers/subgraph.ts b/packages/nouns-webapp/src/wrappers/subgraph.ts index ff591a050b..af62960350 100644 --- a/packages/nouns-webapp/src/wrappers/subgraph.ts +++ b/packages/nouns-webapp/src/wrappers/subgraph.ts @@ -100,6 +100,31 @@ export const partialProposalsQuery = (first = 1_000) => gql` } `; +export const candidateProposalsQuery = (first = 1_000) => gql` + { + proposalCandidates { + id + slug + title + } + } +`; + +export const candidateProposalQuery = (id: string) => gql` +{ + proposalCandidate(id: "${id}") { + id + title + description + proposer + targets + values + signatures + calldatas + } +} +`; + export const auctionQuery = (auctionId: number) => gql` { auction(id: ${auctionId}) { From 8051c27f5e067af58f49b696a2510af7afbcb2cf Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Tue, 18 Apr 2023 11:46:30 +0200 Subject: [PATCH 015/218] frontend: signing candidate and broadcasting to data contract --- .../src/pages/CandidateProposalPage/index.tsx | 107 ++++++++++++++---- .../src/pages/CandidateProposals/index.tsx | 4 +- .../nouns-webapp/src/wrappers/nounsData.ts | 11 ++ .../nouns-webapp/src/wrappers/subgraph.ts | 27 +++-- 4 files changed, 121 insertions(+), 28 deletions(-) diff --git a/packages/nouns-webapp/src/pages/CandidateProposalPage/index.tsx b/packages/nouns-webapp/src/pages/CandidateProposalPage/index.tsx index 58fc41bec9..425fe805b7 100644 --- a/packages/nouns-webapp/src/pages/CandidateProposalPage/index.tsx +++ b/packages/nouns-webapp/src/pages/CandidateProposalPage/index.tsx @@ -17,7 +17,8 @@ import { getDraftProposals, ProposalContent, } from '../CreateDraftProposal/DraftProposalsStorage'; -import { useCandidateProposal } from '../../wrappers/nounsData'; +import { useAddSignature, useCandidateProposal } from '../../wrappers/nounsData'; +import { ethers } from 'ethers'; const domain = { name: 'Nouns DAO', @@ -62,6 +63,38 @@ const CandidateProposalPage = ({ const [proposalIdToUpdate, setProposalIdToUpdate] = useState(''); const candidateProposal = useCandidateProposal(id); + const { addSignature, addSignatureState } = useAddSignature(); + + 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; @@ -69,11 +102,11 @@ const CandidateProposalPage = ({ if (proposalIdToUpdate) { const value = { proposer: candidateProposal.proposer, - targets: candidateProposal.targets, - values: candidateProposal.values, - signatures: candidateProposal.signatures, - calldatas: candidateProposal.calldatas, - description: candidateProposal.description, + targets: candidateProposal.latestVersion.targets, + values: candidateProposal.latestVersion.values, + signatures: candidateProposal.latestVersion.signatures, + calldatas: candidateProposal.latestVersion.calldatas, + description: candidateProposal.latestVersion.description, expiry: expiry, proposalId: proposalIdToUpdate, }; @@ -81,16 +114,44 @@ const CandidateProposalPage = ({ } else { const value = { proposer: candidateProposal.proposer, - targets: candidateProposal.targets, - values: candidateProposal.values, - signatures: candidateProposal.signatures, - calldatas: candidateProposal.calldatas, - description: candidateProposal.description, + targets: candidateProposal.latestVersion.targets, + values: candidateProposal.latestVersion.values, + signatures: candidateProposal.latestVersion.signatures, + calldatas: candidateProposal.latestVersion.calldatas, + description: candidateProposal.latestVersion.description, expiry: expiry, }; signature = await signer!._signTypedData(domain, createProposalTypes, value); } + const encodedProp = await calcProposalEncodeData( + candidateProposal.proposer, + candidateProposal.latestVersion.targets, + candidateProposal.latestVersion.values, + candidateProposal.latestVersion.signatures, + candidateProposal.latestVersion.calldatas, + candidateProposal.latestVersion.description, + ); + + console.log('>>>> encodedProp', encodedProp); + + console.log( + '>> addSignature', + signature, + expiry, + candidateProposal.proposer, + candidateProposal.slug, + encodedProp, + ); + + await addSignature( + signature, + expiry, + candidateProposal.proposer, + candidateProposal.slug, + encodedProp, + 'TODO reason', + ); // const updatedDraftProposal = addSignature( // { // signer: await signer!.getAddress(), @@ -178,14 +239,18 @@ const CandidateProposalPage = ({ }, [updateProposalBySigState, setModal]); async function proposeBySigsClicked() { - // await proposeBySigs( - // draftProposal?.signatures.map(s => [s.signature, s.signer, s.expiry]), - // draftProposal?.proposalContent.targets, - // draftProposal?.proposalContent.values, - // draftProposal?.proposalContent.signatures, - // draftProposal?.proposalContent.calldatas, - // draftProposal?.proposalContent.description, - // ); + await proposeBySigs( + candidateProposal?.latestVersion.versionSignatures.map((s: any) => [ + s.sig, + s.signer.id, + s.expirationTimestamp, + ]), + candidateProposal?.latestVersion.targets, + candidateProposal?.latestVersion.values, + candidateProposal?.latestVersion.signatures, + candidateProposal?.latestVersion.calldatas, + candidateProposal?.latestVersion.description, + ); } async function updateProposalBySigsClicked() { // const proposalId = Number.parseInt(proposalIdToUpdate); @@ -200,6 +265,8 @@ const CandidateProposalPage = ({ // ) } + console.log('>> addSignatureState', addSignatureState); + return (

Candidate Proposal {id}

@@ -226,7 +293,7 @@ const CandidateProposalPage = ({
+
+ {availableVotes ? ( + <>You have votes, no need to pay + ) : ( + <> + Cost to create candidate:{' '} + {createCandidateCost && ethers.utils.formatEther(createCandidateCost)} ETH + + )} +