diff --git a/components/SideNav.tsx b/components/SideNav.tsx index 235a81a4..77303d26 100644 --- a/components/SideNav.tsx +++ b/components/SideNav.tsx @@ -15,6 +15,7 @@ import { BanknotesIcon, NewspaperIcon, PlusCircleIcon, + ArchiveBoxArrowDownIcon, } from '@heroicons/react/20/solid' import { useRouter } from 'next/router' import { useTranslation } from 'next-i18next' @@ -151,6 +152,15 @@ const SideNav = ({ collapsed }: { collapsed: boolean }) => { hideIconBg showTooltip={false} /> + } + title={t('common:vote')} + pagePath="/governance/vote" + hideIconBg + showTooltip={false} + /> } diff --git a/components/governance/GovernancePage.tsx b/components/governance/GovernancePage.tsx index e7fee628..127fe2a3 100644 --- a/components/governance/GovernancePage.tsx +++ b/components/governance/GovernancePage.tsx @@ -3,7 +3,7 @@ import ListToken from './ListToken/ListToken' const GovernancePage = () => { return ( -
+
diff --git a/components/governance/GovernancePageWrapper.tsx b/components/governance/GovernancePageWrapper.tsx index d3fdc3b4..671e19b4 100644 --- a/components/governance/GovernancePageWrapper.tsx +++ b/components/governance/GovernancePageWrapper.tsx @@ -10,7 +10,8 @@ const GovernancePageWrapper = ({ children }: { children: ReactNode }) => { const connectionContext = GovernanceStore((s) => s.connectionContext) const initRealm = GovernanceStore((s) => s.initRealm) const vsrClient = GovernanceStore((s) => s.vsrClient) - const fetchVoterWeight = GovernanceStore((s) => s.fetchVoterWeight) + const fetchVoter = GovernanceStore((s) => s.fetchVoter) + const resetVoter = GovernanceStore((s) => s.resetVoter) const realm = GovernanceStore((s) => s.realm) const connection = mangoStore((s) => s.connection) @@ -32,7 +33,9 @@ const GovernancePageWrapper = ({ children }: { children: ReactNode }) => { connectionContext?.endpoint && vsrClient?.program.programId.toBase58() ) { - fetchVoterWeight(publicKey, vsrClient, connectionContext) + fetchVoter(publicKey, vsrClient, connectionContext) + } else { + resetVoter() } }, [ publicKey?.toBase58(), diff --git a/components/governance/ListToken/ListToken.tsx b/components/governance/ListToken/ListToken.tsx index 2e7665e0..d950a113 100644 --- a/components/governance/ListToken/ListToken.tsx +++ b/components/governance/ListToken/ListToken.tsx @@ -89,7 +89,7 @@ const ListToken = () => { const loadingRealm = GovernanceStore((s) => s.loadingRealm) const loadingVoter = GovernanceStore((s) => s.loadingVoter) const proposals = GovernanceStore((s) => s.proposals) - const fetchVoterWeight = GovernanceStore((s) => s.fetchVoterWeight) + const fetchVoter = GovernanceStore((s) => s.fetchVoter) const connectionContext = GovernanceStore((s) => s.connectionContext) const { t } = useTranslation(['governance']) @@ -364,7 +364,7 @@ const ListToken = () => { return } if (!wallet?.publicKey || !vsrClient || !connectionContext) return - await fetchVoterWeight(wallet.publicKey, vsrClient, connectionContext) + await fetchVoter(wallet.publicKey, vsrClient, connectionContext) if (voter.voteWeight.cmp(minVoterWeight) === -1) { notify({ diff --git a/components/governance/ListToken/ListTokenPage.tsx b/components/governance/ListToken/ListTokenPage.tsx new file mode 100644 index 00000000..840c12bf --- /dev/null +++ b/components/governance/ListToken/ListTokenPage.tsx @@ -0,0 +1,15 @@ +import dynamic from 'next/dynamic' +import GovernancePageWrapper from '../GovernancePageWrapper' + +const ListToken = dynamic(() => import('./ListToken')) + +const ListTokenPage = () => { + return ( +
+ + + +
+ ) +} +export default ListTokenPage diff --git a/components/governance/Vote/ProposalCard.tsx b/components/governance/Vote/ProposalCard.tsx new file mode 100644 index 00000000..2c154374 --- /dev/null +++ b/components/governance/Vote/ProposalCard.tsx @@ -0,0 +1,267 @@ +import { + ProgramAccount, + Proposal, + VoteKind, + VoteRecord, + getGovernanceAccount, + getVoteRecordAddress, +} from '@solana/spl-governance' +import { VoteCountdown } from './VoteCountdown' +import { MintInfo } from '@solana/spl-token' +import VoteResults from './VoteResult' +import QuorumProgress from './VoteProgress' +import GovernanceStore from '@store/governanceStore' +import Button from '@components/shared/Button' +import { + ArrowTopRightOnSquareIcon, + HandThumbDownIcon, + HandThumbUpIcon, +} from '@heroicons/react/20/solid' +import { BN } from '@project-serum/anchor' +import { useEffect, useState } from 'react' +import { MANGO_GOVERNANCE_PROGRAM } from 'utils/governance/constants' +import mangoStore from '@store/mangoStore' +import { castVote } from 'utils/governance/instructions/castVote' +import { useWallet } from '@solana/wallet-adapter-react' +import { relinquishVote } from 'utils/governance/instructions/relinquishVote' +import { PublicKey } from '@solana/web3.js' +import { notify } from 'utils/notifications' +import Loading from '@components/shared/Loading' +import { useTranslation } from 'next-i18next' + +enum PROCESSED_VOTE_TYPE { + APPROVE, + DENY, + RELINQUISH, +} + +const ProposalCard = ({ + proposal, + mangoMint, +}: { + proposal: ProgramAccount + mangoMint: MintInfo +}) => { + const { t } = useTranslation('governance') + const connection = mangoStore((s) => s.connection) + const client = mangoStore((s) => s.client) + const governances = GovernanceStore((s) => s.governances) + const wallet = useWallet() + const voter = GovernanceStore((s) => s.voter) + const vsrClient = GovernanceStore((s) => s.vsrClient) + const updateProposals = GovernanceStore((s) => s.updateProposals) + + const [processedVoteType, setProcessedVoteType] = useState< + PROCESSED_VOTE_TYPE | '' + >('') + const [voteType, setVoteType] = useState(undefined) + + const [voteRecordAddress, setVoteRecordAddress] = useState( + null + ) + const [isVoteCast, setIsVoteCast] = useState(false) + + const governance = + governances && governances[proposal.account.governance.toBase58()] + const canVote = voter.voteWeight.cmp(new BN(1)) !== -1 + + //Approve 0, deny 1 + const vote = async (voteType: VoteKind) => { + setProcessedVoteType( + voteType === VoteKind.Approve + ? PROCESSED_VOTE_TYPE.APPROVE + : PROCESSED_VOTE_TYPE.DENY + ) + try { + await castVote( + connection, + wallet, + proposal, + voter.tokenOwnerRecord!, + voteType, + vsrClient!, + client + ) + await updateProposals(proposal.pubkey) + } catch (e) { + notify({ + title: 'Error', + description: `${e}`, + type: 'error', + }) + } + + setProcessedVoteType('') + } + + const submitRelinquishVote = async () => { + setProcessedVoteType(PROCESSED_VOTE_TYPE.RELINQUISH) + try { + await relinquishVote( + connection, + wallet, + proposal, + voter.tokenOwnerRecord!, + client, + voteRecordAddress! + ) + await updateProposals(proposal.pubkey) + } catch (e) { + notify({ + title: 'Error', + description: `${e}`, + type: 'error', + }) + } + setProcessedVoteType('') + } + + useEffect(() => { + const handleGetVoteRecord = async () => { + setIsVoteCast(false) + try { + await getGovernanceAccount(connection, voteRecordAddress!, VoteRecord) + setIsVoteCast(true) + // eslint-disable-next-line no-empty + } catch (e) {} + } + if (voteRecordAddress?.toBase58()) { + handleGetVoteRecord() + } else { + setIsVoteCast(false) + } + }, [voteRecordAddress, proposal.pubkey.toBase58()]) + + useEffect(() => { + const handleGetVoteRecordAddress = async () => { + const voteRecordAddress = await getVoteRecordAddress( + MANGO_GOVERNANCE_PROGRAM, + proposal.pubkey, + voter.tokenOwnerRecord!.pubkey! + ) + setVoteRecordAddress(voteRecordAddress) + try { + const governanceAccount = await getGovernanceAccount( + connection, + voteRecordAddress, + VoteRecord + ) + setIsVoteCast(true) + setVoteType(governanceAccount.account.vote?.voteType) + } catch (e) { + setIsVoteCast(false) + } + } + if (voter.tokenOwnerRecord?.pubkey.toBase58()) { + handleGetVoteRecordAddress() + } else { + setVoteRecordAddress(null) + } + }, [proposal.pubkey.toBase58(), voter.tokenOwnerRecord?.pubkey.toBase58()]) + + return governance ? ( +
+
+
+

+ + {proposal.account.name} + + +

+

{proposal.account.descriptionLink}

+
+ +
+
+ {!isVoteCast ? ( +
+ + +
+ ) : ( +
+ + {voteType !== undefined ? ( +
+

{t('current-vote')}

+ + {voteType === VoteKind.Approve ? ( + + + {t('yes')} + + ) : ( + + + {t('no')} + + )} + +
+ ) : null} +
+ )} +
+ {mangoMint && ( +
+ + +
+ )} +
+ ) : null +} + +export default ProposalCard diff --git a/components/governance/Vote/Vote.tsx b/components/governance/Vote/Vote.tsx new file mode 100644 index 00000000..16c0fa2b --- /dev/null +++ b/components/governance/Vote/Vote.tsx @@ -0,0 +1,117 @@ +import { ProgramAccount, Proposal, ProposalState } from '@solana/spl-governance' +import GovernanceStore from '@store/governanceStore' +import mangoStore from '@store/mangoStore' +import { useEffect, useState } from 'react' +import { isInCoolOffTime } from 'utils/governance/proposals' +import { MintInfo } from '@solana/spl-token' +import { MANGO_MINT } from 'utils/constants' +import { PublicKey } from '@solana/web3.js' +import dynamic from 'next/dynamic' +import { fmtTokenAmount, tryGetMint } from 'utils/governance/tools' +import { BN } from '@project-serum/anchor' +import { MANGO_MINT_DECIMALS } from 'utils/governance/constants' +import { useTranslation } from 'next-i18next' +import SheenLoader from '@components/shared/SheenLoader' +import { NoSymbolIcon } from '@heroicons/react/20/solid' + +const ProposalCard = dynamic(() => import('./ProposalCard')) +const OnBoarding = dynamic(() => import('../OnBoarding')) + +const Vote = () => { + const { t } = useTranslation('governance') + const connection = mangoStore((s) => s.connection) + const governances = GovernanceStore((s) => s.governances) + const proposals = GovernanceStore((s) => s.proposals) + const loadingProposals = GovernanceStore((s) => s.loadingProposals) + const voter = GovernanceStore((s) => s.voter) + const loadingVoter = GovernanceStore((s) => s.loadingVoter) + const loadingRealm = GovernanceStore((s) => s.loadingRealm) + + const [mangoMint, setMangoMint] = useState(null) + const [votingProposals, setVotingProposals] = useState< + ProgramAccount[] + >([]) + + useEffect(() => { + if (proposals) { + const activeProposals = Object.values(proposals).filter((x) => { + const governance = + governances && governances[x.account.governance.toBase58()] + const votingEnded = + governance && x.account.getTimeToVoteEnd(governance.account) < 0 + + const coolOff = isInCoolOffTime(x.account, governance?.account) + + return ( + !coolOff && !votingEnded && x.account.state === ProposalState.Voting + ) + }) + setVotingProposals(activeProposals) + } else { + setVotingProposals([]) + } + }, [governances, proposals]) + + useEffect(() => { + const handleGetMangoMint = async () => { + const mangoMint = await tryGetMint(connection, new PublicKey(MANGO_MINT)) + setMangoMint(mangoMint!.account) + } + handleGetMangoMint() + }, []) + + return ( +
+
+

{t('active-proposals')}

+

+ {t('your-votes')}{' '} + + {!loadingVoter + ? fmtTokenAmount(voter.voteWeight, MANGO_MINT_DECIMALS) + : 0} + +

+
+ {loadingProposals || loadingRealm ? ( +
+ +
+ + +
+ +
+ ) : ( + <> + {!loadingVoter ? ( + + ) : null} +
+ {votingProposals.length ? ( + votingProposals.map( + (x) => + mangoMint && ( + + ) + ) + ) : ( +
+
+ +

{t('no-active-proposals')}

+
+
+ )} +
+ + )} +
+ ) +} + +export default Vote diff --git a/components/governance/Vote/VoteCountdown.tsx b/components/governance/Vote/VoteCountdown.tsx new file mode 100644 index 00000000..acba7ac9 --- /dev/null +++ b/components/governance/Vote/VoteCountdown.tsx @@ -0,0 +1,115 @@ +import React, { useEffect, useState } from 'react' +import { Governance, Proposal } from '@solana/spl-governance' +import dayjs from 'dayjs' +import { useTranslation } from 'next-i18next' + +interface CountdownState { + days: number + hours: number + minutes: number + seconds: number +} + +const ZeroCountdown: CountdownState = { + days: 0, + hours: 0, + minutes: 0, + seconds: 0, +} + +const isZeroCountdown = (state: CountdownState) => + state.days === 0 && + state.hours === 0 && + state.minutes === 0 && + state.seconds === 0 + +export function VoteCountdown({ + proposal, + governance, +}: { + proposal: Proposal + governance: Governance +}) { + const { t } = useTranslation(['governance']) + + const [countdown, setCountdown] = useState(ZeroCountdown) + + useEffect(() => { + if (proposal.isVoteFinalized()) { + setCountdown(ZeroCountdown) + return + } + + const getTimeToVoteEnd = () => { + const now = dayjs().unix() + + let timeToVoteEnd = proposal.isPreVotingState() + ? governance.config.maxVotingTime + : (proposal.votingAt?.toNumber() ?? 0) + + governance.config.maxVotingTime - + now + + if (timeToVoteEnd <= 0) { + return ZeroCountdown + } + + const days = Math.floor(timeToVoteEnd / 86400) + timeToVoteEnd -= days * 86400 + + const hours = Math.floor(timeToVoteEnd / 3600) % 24 + timeToVoteEnd -= hours * 3600 + + const minutes = Math.floor(timeToVoteEnd / 60) % 60 + timeToVoteEnd -= minutes * 60 + + const seconds = Math.floor(timeToVoteEnd % 60) + + return { days, hours, minutes, seconds } + } + + const updateCountdown = () => { + const newState = getTimeToVoteEnd() + setCountdown(newState) + } + + const interval = setInterval(() => { + updateCountdown() + }, 1000) + + updateCountdown() + return () => clearInterval(interval) + }, [proposal, governance]) + + return ( + <> + {isZeroCountdown(countdown) ? ( +
{t('voting-ended')}
+ ) : ( +
+
{t('ends')}
+ {countdown && countdown.days > 0 && ( + <> +
+ {countdown.days}d +
+ : + + )} +
{countdown.hours}h
+ : +
+ {countdown.minutes}m +
+ {!countdown.days && ( + <> + : +
+ {countdown.seconds}s +
+ + )} +
+ )} + + ) +} diff --git a/components/governance/Vote/VotePage.tsx b/components/governance/Vote/VotePage.tsx new file mode 100644 index 00000000..e802fde6 --- /dev/null +++ b/components/governance/Vote/VotePage.tsx @@ -0,0 +1,15 @@ +import dynamic from 'next/dynamic' +import GovernancePageWrapper from '../GovernancePageWrapper' + +const Vote = dynamic(() => import('./Vote')) + +const VotePage = () => { + return ( +
+ + + +
+ ) +} +export default VotePage diff --git a/components/governance/Vote/VoteProgress.tsx b/components/governance/Vote/VoteProgress.tsx new file mode 100644 index 00000000..93c655cf --- /dev/null +++ b/components/governance/Vote/VoteProgress.tsx @@ -0,0 +1,95 @@ +import Tooltip from '@components/shared/Tooltip' +import { + CheckCircleIcon, + InformationCircleIcon, +} from '@heroicons/react/20/solid' +import { Governance, ProgramAccount, Proposal } from '@solana/spl-governance' +import { MintInfo } from '@solana/spl-token' +import GovernanceStore from '@store/governanceStore' +import { useTranslation } from 'next-i18next' +import { getMintMaxVoteWeight } from 'utils/governance/proposals' +import { fmtTokenAmount } from 'utils/governance/tools' + +type Props = { + governance: ProgramAccount + proposal: ProgramAccount + communityMint: MintInfo +} + +const QuorumProgress = ({ governance, proposal, communityMint }: Props) => { + const { t } = useTranslation(['governance']) + + const realm = GovernanceStore((s) => s.realm) + + const voteThresholdPct = + governance.account.config.communityVoteThreshold.value || 0 + + const maxVoteWeight = + realm && + getMintMaxVoteWeight( + communityMint, + realm.account.config.communityMintMaxVoteWeightSource + ) + + const minimumYesVotes = + fmtTokenAmount(maxVoteWeight!, communityMint.decimals) * + (voteThresholdPct / 100) + + const yesVoteCount = fmtTokenAmount( + proposal.account.getYesVoteCount(), + communityMint.decimals + ) + + const rawYesVotesRequired = minimumYesVotes - yesVoteCount + const votesRequiredInRange = rawYesVotesRequired < 0 ? 0 : rawYesVotesRequired + const yesVoteProgress = votesRequiredInRange + ? 100 - (votesRequiredInRange / minimumYesVotes) * 100 + : 100 + const yesVotesRequired = + communityMint.decimals == 0 + ? Math.ceil(votesRequiredInRange) + : votesRequiredInRange + + return ( +
+
+
+
+

{t('approval-q')}

+ + + +
+ {typeof yesVoteProgress !== 'undefined' && yesVoteProgress < 100 ? ( +

{`${( + yesVotesRequired ?? 0 + ).toLocaleString(undefined, { + maximumFractionDigits: 0, + })} ${(yesVoteProgress ?? 0) > 0 ? 'more' : ''} Yes vote${ + (yesVotesRequired ?? 0) > 1 ? 's' : '' + } required`}

+ ) : ( +
+ +

+ {t('required-approval-achieved')} +

+
+ )} +
+
+
+
= 100 ? 'bg-th-up' : 'bg-th-fgd-2' + } flex rounded`} + >
+
+
+ ) +} + +export default QuorumProgress diff --git a/components/governance/Vote/VoteResult.tsx b/components/governance/Vote/VoteResult.tsx new file mode 100644 index 00000000..6e795878 --- /dev/null +++ b/components/governance/Vote/VoteResult.tsx @@ -0,0 +1,67 @@ +import { Proposal } from '@solana/spl-governance' +import VoteResultsBar from './VoteResultBar' +import { fmtTokenAmount } from 'utils/governance/tools' +import { MintInfo } from '@solana/spl-token' +import { useTranslation } from 'next-i18next' + +type VoteResultsProps = { + proposal: Proposal + communityMint: MintInfo +} + +const VoteResults = ({ proposal, communityMint }: VoteResultsProps) => { + const { t } = useTranslation(['governance']) + + const yesVoteCount = fmtTokenAmount( + proposal.getYesVoteCount(), + communityMint.decimals + ) + const noVoteCount = fmtTokenAmount( + proposal.getNoVoteCount(), + communityMint.decimals + ) + const totalVoteCount = yesVoteCount + noVoteCount + const getRelativeVoteCount = (voteCount: number) => + totalVoteCount === 0 ? 0 : (voteCount / totalVoteCount) * 100 + const relativeYesVotes = getRelativeVoteCount(yesVoteCount) + const relativeNoVotes = getRelativeVoteCount(noVoteCount) + + return ( +
+ {proposal ? ( +
+
+
+

{t('yes-votes')}

+

+ {(yesVoteCount ?? 0).toLocaleString()} + + {relativeYesVotes?.toFixed(1)}% + +

+
+
+

{t('no-votes')}

+

+ {(noVoteCount ?? 0).toLocaleString()} + + {relativeNoVotes?.toFixed(1)}% + +

+
+
+ +
+ ) : ( + <> +
+ + )} +
+ ) +} + +export default VoteResults diff --git a/components/governance/Vote/VoteResultBar.tsx b/components/governance/Vote/VoteResultBar.tsx new file mode 100644 index 00000000..8fbede2f --- /dev/null +++ b/components/governance/Vote/VoteResultBar.tsx @@ -0,0 +1,42 @@ +type VoteResultsBarProps = { + approveVotePercentage: number + denyVotePercentage: number +} + +const VoteResultsBar = ({ + approveVotePercentage = 0, + denyVotePercentage = 0, +}: VoteResultsBarProps) => { + return ( + <> +
+
2 || approveVotePercentage < 0.01 + ? approveVotePercentage + : 2 + }%`, + }} + className={`flex rounded-l bg-th-up ${ + denyVotePercentage < 0.01 && 'rounded' + }`} + >
+
2 || denyVotePercentage < 0.01 + ? denyVotePercentage + : 2 + }%`, + }} + className={`flex rounded-r bg-th-down ${ + approveVotePercentage < 0.01 && 'rounded' + }`} + >
+
+ + ) +} + +export default VoteResultsBar diff --git a/components/mobile/BottomBar.tsx b/components/mobile/BottomBar.tsx index 2d8566f5..845e6b01 100644 --- a/components/mobile/BottomBar.tsx +++ b/components/mobile/BottomBar.tsx @@ -146,6 +146,11 @@ const MoreMenuPanel = ({ path="/governance/listToken" icon={} /> + } + /> import('@components/governance/ListToken/ListTokenPage') +) export async function getStaticProps({ locale }: { locale: string }) { return { @@ -17,7 +21,7 @@ export async function getStaticProps({ locale }: { locale: string }) { } const Governance: NextPage = () => { - return + return } export default Governance diff --git a/pages/governance/vote.tsx b/pages/governance/vote.tsx new file mode 100644 index 00000000..32fd7030 --- /dev/null +++ b/pages/governance/vote.tsx @@ -0,0 +1,25 @@ +import type { NextPage } from 'next' +import { serverSideTranslations } from 'next-i18next/serverSideTranslations' +import dynamic from 'next/dynamic' + +const VotePage = dynamic(() => import('@components/governance/Vote/VotePage')) + +export async function getStaticProps({ locale }: { locale: string }) { + return { + props: { + ...(await serverSideTranslations(locale, [ + 'governance', + 'search', + 'common', + 'onboarding', + 'profile', + ])), + }, + } +} + +const ListToken: NextPage = () => { + return +} + +export default ListToken diff --git a/public/locales/en/common.json b/public/locales/en/common.json index e732f01c..1accc710 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -167,6 +167,7 @@ "wallet-disconnected": "Disconnected from wallet", "withdraw": "Withdraw", "withdraw-amount": "Withdraw Amount", - "list-token": "List Token" + "list-token": "List Token", + "vote": "Vote" } \ No newline at end of file diff --git a/public/locales/en/governance.json b/public/locales/en/governance.json index 40f107d8..72795295 100644 --- a/public/locales/en/governance.json +++ b/public/locales/en/governance.json @@ -3,7 +3,7 @@ "cancel": "Cancel", "connect-wallet": "Connect Wallet", "on-boarding-title": "Looks like currently connected wallet doesn't have any MNGO deposited inside realms", - "on-boarding-description": "Before you continue. Deposit {{amount}} MNGO into", + "on-boarding-description": "Before you continue. Deposit at least {{amount}} MNGO into", "on-boarding-deposit-info": "Your MNGO will be locked for the duration of the proposal.", "tokens-deposited": "Tokens Deposited", "new-listing": "New Token Listing", @@ -54,5 +54,21 @@ "market-name": "Market Name", "proposal-title": "Proposal Title", "proposal-des": "Proposal Description", - "error-proposal-creation": "Error on proposal creation or no confirmation of transactions" + "error-proposal-creation": "Error on proposal creation or no confirmation of transactions", + "vote-yes": "Vote Yes", + "vote-no": "Vote No", + "relinquish-vote": "Relinquish Vote", + "voting-ended": "Voting ended", + "ends": "Ends", + "approval-q": "Approval Quorum", + "required-approval-achieved": "Required approval achieved", + "no-votes": "No Votes", + "yes-votes": "Yes Votes", + "quorum-description": "Proposals must reach a minimum number of 'Yes' votes before they are eligible to pass. If the minimum is reached but there are more 'No' votes when voting ends the proposal will fail.", + "active-proposals": "Active Proposals", + "no-active-proposals": "No active proposals", + "your-votes": "Your Votes:", + "yes": "Yes", + "no": "No", + "current-vote": "Current Vote:" } \ No newline at end of file diff --git a/public/locales/es/common.json b/public/locales/es/common.json index e732f01c..1accc710 100644 --- a/public/locales/es/common.json +++ b/public/locales/es/common.json @@ -167,6 +167,7 @@ "wallet-disconnected": "Disconnected from wallet", "withdraw": "Withdraw", "withdraw-amount": "Withdraw Amount", - "list-token": "List Token" + "list-token": "List Token", + "vote": "Vote" } \ No newline at end of file diff --git a/public/locales/es/governance.json b/public/locales/es/governance.json index 40f107d8..72795295 100644 --- a/public/locales/es/governance.json +++ b/public/locales/es/governance.json @@ -3,7 +3,7 @@ "cancel": "Cancel", "connect-wallet": "Connect Wallet", "on-boarding-title": "Looks like currently connected wallet doesn't have any MNGO deposited inside realms", - "on-boarding-description": "Before you continue. Deposit {{amount}} MNGO into", + "on-boarding-description": "Before you continue. Deposit at least {{amount}} MNGO into", "on-boarding-deposit-info": "Your MNGO will be locked for the duration of the proposal.", "tokens-deposited": "Tokens Deposited", "new-listing": "New Token Listing", @@ -54,5 +54,21 @@ "market-name": "Market Name", "proposal-title": "Proposal Title", "proposal-des": "Proposal Description", - "error-proposal-creation": "Error on proposal creation or no confirmation of transactions" + "error-proposal-creation": "Error on proposal creation or no confirmation of transactions", + "vote-yes": "Vote Yes", + "vote-no": "Vote No", + "relinquish-vote": "Relinquish Vote", + "voting-ended": "Voting ended", + "ends": "Ends", + "approval-q": "Approval Quorum", + "required-approval-achieved": "Required approval achieved", + "no-votes": "No Votes", + "yes-votes": "Yes Votes", + "quorum-description": "Proposals must reach a minimum number of 'Yes' votes before they are eligible to pass. If the minimum is reached but there are more 'No' votes when voting ends the proposal will fail.", + "active-proposals": "Active Proposals", + "no-active-proposals": "No active proposals", + "your-votes": "Your Votes:", + "yes": "Yes", + "no": "No", + "current-vote": "Current Vote:" } \ No newline at end of file diff --git a/public/locales/ru/common.json b/public/locales/ru/common.json index e732f01c..1accc710 100644 --- a/public/locales/ru/common.json +++ b/public/locales/ru/common.json @@ -167,6 +167,7 @@ "wallet-disconnected": "Disconnected from wallet", "withdraw": "Withdraw", "withdraw-amount": "Withdraw Amount", - "list-token": "List Token" + "list-token": "List Token", + "vote": "Vote" } \ No newline at end of file diff --git a/public/locales/ru/governance.json b/public/locales/ru/governance.json index 40f107d8..72795295 100644 --- a/public/locales/ru/governance.json +++ b/public/locales/ru/governance.json @@ -3,7 +3,7 @@ "cancel": "Cancel", "connect-wallet": "Connect Wallet", "on-boarding-title": "Looks like currently connected wallet doesn't have any MNGO deposited inside realms", - "on-boarding-description": "Before you continue. Deposit {{amount}} MNGO into", + "on-boarding-description": "Before you continue. Deposit at least {{amount}} MNGO into", "on-boarding-deposit-info": "Your MNGO will be locked for the duration of the proposal.", "tokens-deposited": "Tokens Deposited", "new-listing": "New Token Listing", @@ -54,5 +54,21 @@ "market-name": "Market Name", "proposal-title": "Proposal Title", "proposal-des": "Proposal Description", - "error-proposal-creation": "Error on proposal creation or no confirmation of transactions" + "error-proposal-creation": "Error on proposal creation or no confirmation of transactions", + "vote-yes": "Vote Yes", + "vote-no": "Vote No", + "relinquish-vote": "Relinquish Vote", + "voting-ended": "Voting ended", + "ends": "Ends", + "approval-q": "Approval Quorum", + "required-approval-achieved": "Required approval achieved", + "no-votes": "No Votes", + "yes-votes": "Yes Votes", + "quorum-description": "Proposals must reach a minimum number of 'Yes' votes before they are eligible to pass. If the minimum is reached but there are more 'No' votes when voting ends the proposal will fail.", + "active-proposals": "Active Proposals", + "no-active-proposals": "No active proposals", + "your-votes": "Your Votes:", + "yes": "Yes", + "no": "No", + "current-vote": "Current Vote:" } \ No newline at end of file diff --git a/public/locales/zh/common.json b/public/locales/zh/common.json index e732f01c..1accc710 100644 --- a/public/locales/zh/common.json +++ b/public/locales/zh/common.json @@ -167,6 +167,7 @@ "wallet-disconnected": "Disconnected from wallet", "withdraw": "Withdraw", "withdraw-amount": "Withdraw Amount", - "list-token": "List Token" + "list-token": "List Token", + "vote": "Vote" } \ No newline at end of file diff --git a/public/locales/zh/governance.json b/public/locales/zh/governance.json index 40f107d8..72795295 100644 --- a/public/locales/zh/governance.json +++ b/public/locales/zh/governance.json @@ -3,7 +3,7 @@ "cancel": "Cancel", "connect-wallet": "Connect Wallet", "on-boarding-title": "Looks like currently connected wallet doesn't have any MNGO deposited inside realms", - "on-boarding-description": "Before you continue. Deposit {{amount}} MNGO into", + "on-boarding-description": "Before you continue. Deposit at least {{amount}} MNGO into", "on-boarding-deposit-info": "Your MNGO will be locked for the duration of the proposal.", "tokens-deposited": "Tokens Deposited", "new-listing": "New Token Listing", @@ -54,5 +54,21 @@ "market-name": "Market Name", "proposal-title": "Proposal Title", "proposal-des": "Proposal Description", - "error-proposal-creation": "Error on proposal creation or no confirmation of transactions" + "error-proposal-creation": "Error on proposal creation or no confirmation of transactions", + "vote-yes": "Vote Yes", + "vote-no": "Vote No", + "relinquish-vote": "Relinquish Vote", + "voting-ended": "Voting ended", + "ends": "Ends", + "approval-q": "Approval Quorum", + "required-approval-achieved": "Required approval achieved", + "no-votes": "No Votes", + "yes-votes": "Yes Votes", + "quorum-description": "Proposals must reach a minimum number of 'Yes' votes before they are eligible to pass. If the minimum is reached but there are more 'No' votes when voting ends the proposal will fail.", + "active-proposals": "Active Proposals", + "no-active-proposals": "No active proposals", + "your-votes": "Your Votes:", + "yes": "Yes", + "no": "No", + "current-vote": "Current Vote:" } \ No newline at end of file diff --git a/public/locales/zh_tw/common.json b/public/locales/zh_tw/common.json index d69975fd..801ab217 100644 --- a/public/locales/zh_tw/common.json +++ b/public/locales/zh_tw/common.json @@ -1,99 +1,4 @@ { - "404-description": "或者從來不存在...", - "404-heading": "此頁被清算了", - "accept-terms": "接受條款", - "accept-terms-desc": "繼續,即表示您接受Mango", - "account": "帳戶", - "account-balance": "帳戶餘額", - "account-closed": "關戶成功👋", - "account-name": "帳戶標籤", - "account-name-desc": "以標籤來整理帳戶", - "account-settings": "帳戶設定", - "account-update-failed": "更新帳戶出錯", - "account-update-success": "更新帳戶成功", - "account-value": "帳戶價值", - "accounts": "帳戶", - "actions": "動作", - "add-new-account": "開戶", - "agree-and-continue": "同意並繼續", - "all": "全部", - "amount": "數量", - "amount-owed": "欠款", - "asset-liability-weight": "資產/債務權重", - "asset-liability-weight-desc": "資產權重在賬戶健康計算中對質押品價值進行扣減。資產權重越低,資產對質押品的影響越小。債務權重恰恰相反(在健康計算中增加債務價值)。", - "asset-weight": "資產權重", - "asset-weight-desc": "資產權重在賬戶健康計算中對質押品價值進行扣減。資產權重越低,資產對質押品的影響越小。", - "available": "可用", - "available-balance": "可用餘額", - "bal": "餘額", - "balance": "餘額", - "balances": "餘額", - "borrow": "借貨", - "borrow-amount": "借貸數量", - "borrow-fee": "借貸費用", - "borrow-funds": "進行借貸", - "borrow-rate": "借貸APR", - "buy": "買", - "cancel": "取消", - "chart-unavailable": "無法顯示圖表", - "clear-all": "清除全部", - "close": "關閉", - "close-account": "關戶", - "close-account-desc": "你確定嗎? 關戶就無法恢復", - "closing-account": "正在關閉帳戶...", - "collateral-value": "質押品價值", - "connect": "連接", - "connect-balances": "連接而查看資產餘額", - "connect-helper": "連接來開始", - "copy-address": "拷貝地址", - "copy-address-success": "地址被拷貝: {{pk}}", - "country-not-allowed": "你的國家{{country}}不允許使用Mango", - "country-not-allowed-tooltip": "你正在使用MangoDAO提供的開源介面。由於監管的不確定性因此處於謀些地區的人的行動會受到限制。", - "create-account": "開戶", - "creating-account": "正在開戶...", - "cumulative-interest-value": "總累積利息", - "daily-volume": "24小時交易量", - "date": "日期", - "date-from": "從", - "date-to": "至", - "delegate": "委託", - "delegate-account": "委託帳戶", - "delegate-account-info": "帳戶委託給 {{address}}", - "delegate-desc": "以Mango帳戶委託給別的錢包地址", - "delegate-placeholder": "輸入受委錢包地執", - "delete": "刪除", - "deposit": "存款", - "deposit-amount": "存款數量", - "deposit-more-sol": "您的SOL錢包餘額太低。請多存入以支付交易", - "deposit-rate": "存款APR", - "disconnect": "斷開連接", - "documentation": "文檔", - "edit": "編輯", - "edit-account": "編輯帳戶標籤", - "edit-profile-image": "切換頭像", - "explorer": "瀏覽器", - "fee": "費用", - "fees": "費用", - "free-collateral": "可用的質押品", - "funding": "資金費", - "get-started": "開始", - "governance": "治理", - "health": "健康度", - "health-impact": "健康影響", - "health-tooltip": "此在您進行交易之前預測您賬戶的健康狀況。第一個值是您當前的帳戶健康狀況,第二個值是您的預測帳戶健康狀況。", - "history": "紀錄", - "insufficient-sol": "Solana需要0.04454 SOL租金才能創建Mango賬戶。您關閉帳戶時租金將被退還。", - "interest-earned": "獲取利息", - "interest-earned-paid": "獲取利息", - "leaderboard": "排行榜", - "learn": "學", - "leverage": "槓桿", - "liability-weight": "債務權重", - "liquidity": "流動性", - "list-token": "創造市場", - "loading": "加載中", - "loan-origination-fee": "借貸費用", - "loan-origination-fee-tooltip": "執行借貸費用是{{fee}}。", "mango": "Mango", "mango-stats": "Mango統計", "market": "市場", @@ -167,5 +72,7 @@ "wallet-balance": "錢包餘額", "wallet-disconnected": "已斷開錢包連接", "withdraw": "取款", - "withdraw-amount": "取款額" -} \ No newline at end of file + "withdraw-amount": "取款額", + "list-token": "列表代币", + "vote": "投票" +} diff --git a/public/locales/zh_tw/governance.json b/public/locales/zh_tw/governance.json index 40f107d8..72795295 100644 --- a/public/locales/zh_tw/governance.json +++ b/public/locales/zh_tw/governance.json @@ -3,7 +3,7 @@ "cancel": "Cancel", "connect-wallet": "Connect Wallet", "on-boarding-title": "Looks like currently connected wallet doesn't have any MNGO deposited inside realms", - "on-boarding-description": "Before you continue. Deposit {{amount}} MNGO into", + "on-boarding-description": "Before you continue. Deposit at least {{amount}} MNGO into", "on-boarding-deposit-info": "Your MNGO will be locked for the duration of the proposal.", "tokens-deposited": "Tokens Deposited", "new-listing": "New Token Listing", @@ -54,5 +54,21 @@ "market-name": "Market Name", "proposal-title": "Proposal Title", "proposal-des": "Proposal Description", - "error-proposal-creation": "Error on proposal creation or no confirmation of transactions" + "error-proposal-creation": "Error on proposal creation or no confirmation of transactions", + "vote-yes": "Vote Yes", + "vote-no": "Vote No", + "relinquish-vote": "Relinquish Vote", + "voting-ended": "Voting ended", + "ends": "Ends", + "approval-q": "Approval Quorum", + "required-approval-achieved": "Required approval achieved", + "no-votes": "No Votes", + "yes-votes": "Yes Votes", + "quorum-description": "Proposals must reach a minimum number of 'Yes' votes before they are eligible to pass. If the minimum is reached but there are more 'No' votes when voting ends the proposal will fail.", + "active-proposals": "Active Proposals", + "no-active-proposals": "No active proposals", + "your-votes": "Your Votes:", + "yes": "Yes", + "no": "No", + "current-vote": "Current Vote:" } \ No newline at end of file diff --git a/store/governanceStore.ts b/store/governanceStore.ts index 65b0d393..05cd56c6 100644 --- a/store/governanceStore.ts +++ b/store/governanceStore.ts @@ -1,5 +1,7 @@ import { AnchorProvider, BN } from '@project-serum/anchor' import { + getAllProposals, + getProposal, getTokenOwnerRecord, getTokenOwnerRecordAddress, Governance, @@ -17,8 +19,8 @@ import { } from 'utils/governance/constants' import { getDeposits } from 'utils/governance/fetch/deposits' import { + accountsToPubkeyMap, fetchGovernances, - fetchProposals, fetchRealm, } from 'utils/governance/tools' import { ConnectionContext, EndpointTypes } from 'utils/governance/types' @@ -37,19 +39,21 @@ type IGovernanceStore = { vsrClient: VsrClient | null loadingRealm: boolean loadingVoter: boolean + loadingProposals: boolean voter: { voteWeight: BN - wallet: PublicKey tokenOwnerRecord: ProgramAccount | undefined | null } set: (x: (x: IGovernanceStore) => void) => void initConnection: (connection: Connection) => void initRealm: (connectionContext: ConnectionContext) => void - fetchVoterWeight: ( + fetchVoter: ( wallet: PublicKey, vsrClient: VsrClient, connectionContext: ConnectionContext ) => void + resetVoter: () => void + updateProposals: (proposalPk: PublicKey) => void } const GovernanceStore = create((set, get) => ({ @@ -60,13 +64,13 @@ const GovernanceStore = create((set, get) => ({ vsrClient: null, loadingRealm: false, loadingVoter: false, + loadingProposals: false, voter: { voteWeight: new BN(0), - wallet: PublicKey.default, tokenOwnerRecord: null, }, set: (fn) => set(produce(fn)), - fetchVoterWeight: async ( + fetchVoter: async ( wallet: PublicKey, vsrClient: VsrClient, connectionContext: ConnectionContext @@ -99,11 +103,17 @@ const GovernanceStore = create((set, get) => ({ }) set((state) => { state.voter.voteWeight = votingPower - state.voter.wallet = wallet state.voter.tokenOwnerRecord = tokenOwnerRecord state.loadingVoter = false }) }, + resetVoter: () => { + const set = get().set + set((state) => { + state.voter.voteWeight = new BN(0) + state.voter.tokenOwnerRecord = null + }) + }, initConnection: async (connection) => { const set = get().set const connectionContext = { @@ -141,18 +151,40 @@ const GovernanceStore = create((set, get) => ({ realmId: MANGO_REALM_PK, }), ]) - const proposals = await fetchProposals({ - connectionContext: connectionContext, - programId: MANGO_GOVERNANCE_PROGRAM, - governances: Object.keys(governances).map((x) => new PublicKey(x)), - }) set((state) => { + state.loadingProposals = true + }) + const proposals = await getAllProposals( + connectionContext.current, + MANGO_GOVERNANCE_PROGRAM, + MANGO_REALM_PK + ) + const proposalsObj = accountsToPubkeyMap(proposals.flatMap((p) => p)) + set((state) => { + state.loadingProposals = false state.realm = realm state.governances = governances - state.proposals = proposals + state.proposals = proposalsObj state.loadingRealm = false }) }, + updateProposals: async (proposalPk: PublicKey) => { + const state = get() + const set = get().set + set((state) => { + state.loadingProposals = true + }) + const proposal = await getProposal( + state.connectionContext!.current!, + proposalPk + ) + const newProposals = { ...state.proposals } + newProposals[proposal.pubkey.toBase58()] = proposal + set((state) => { + state.proposals = newProposals + state.loadingProposals = false + }) + }, })) export default GovernanceStore diff --git a/utils/governance/fetch/deposits.ts b/utils/governance/fetch/deposits.ts index c73030b9..40d74dd7 100644 --- a/utils/governance/fetch/deposits.ts +++ b/utils/governance/fetch/deposits.ts @@ -1,4 +1,4 @@ -import { MintInfo } from '@blockworks-foundation/mango-v4' +import { MintInfo } from '@solana/spl-token' import { BN, EventParser } from '@coral-xyz/anchor' import { Connection, PublicKey, Transaction } from '@solana/web3.js' import { diff --git a/utils/governance/fetch/getProposals.ts b/utils/governance/fetch/getProposals.ts deleted file mode 100644 index ff876100..00000000 --- a/utils/governance/fetch/getProposals.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { bs58 } from '@project-serum/anchor/dist/cjs/utils/bytes' -import { - deserializeBorsh, - getGovernanceSchemaForAccount, - GovernanceAccountType, - ProgramAccount, - Proposal, -} from '@solana/spl-governance' -import { PublicKey } from '@solana/web3.js' -import { ConnectionContext } from '../types' - -export const getProposals = async ( - pubkeys: PublicKey[], - connection: ConnectionContext, - programId: PublicKey -) => { - const proposalsRaw = await fetch(connection.endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify([ - ...pubkeys.map((x) => { - return getProposalsFilter( - programId, - connection, - bs58.encode(Uint8Array.from([GovernanceAccountType.ProposalV1])), - x - ) - }), - ...pubkeys.map((x) => { - return getProposalsFilter( - programId, - connection, - bs58.encode(Uint8Array.from([GovernanceAccountType.ProposalV2])), - x - ) - }), - ]), - }) - - const accounts: ProgramAccount[] = [] - const proposalsData = await proposalsRaw.json() - - const rawAccounts = proposalsData - ? // eslint-disable-next-line @typescript-eslint/no-explicit-any - proposalsData.flatMap((x: any) => x.result) - : [] - for (const rawAccount of rawAccounts) { - try { - const getSchema = getGovernanceSchemaForAccount - const data = Buffer.from(rawAccount.account.data[0], 'base64') - const accountType = data[0] - const account: ProgramAccount = { - pubkey: new PublicKey(rawAccount.pubkey), - account: deserializeBorsh(getSchema(accountType), Proposal, data), - owner: new PublicKey(rawAccount.account.owner), - } - - accounts.push(account) - } catch (ex) { - console.info(`Can't deserialize @ ${rawAccount.pubkey}, ${ex}.`) - } - } - const acc: ProgramAccount[][] = [] - const reducedAccounts = accounts.reduce((acc, current) => { - const exsitingIdx = acc.findIndex((x) => - x.find( - (x) => - x.account.governance.toBase58() === - current.account.governance.toBase58() - ) - ) - if (exsitingIdx > -1) { - acc[exsitingIdx].push(current) - } else { - acc.push([current]) - } - return acc - }, acc) - return reducedAccounts -} - -const getProposalsFilter = ( - programId: PublicKey, - connection: ConnectionContext, - memcmpBytes: string, - pk: PublicKey -) => { - return { - jsonrpc: '2.0', - id: 1, - method: 'getProgramAccounts', - params: [ - programId.toBase58(), - { - commitment: connection.current.commitment, - encoding: 'base64', - filters: [ - { - memcmp: { - offset: 0, // number of bytes - bytes: memcmpBytes, // base58 encoded string - }, - }, - { - memcmp: { - offset: 1, - bytes: pk.toBase58(), - }, - }, - ], - }, - ], - } -} diff --git a/utils/governance/instructions/castVote.ts b/utils/governance/instructions/castVote.ts new file mode 100644 index 00000000..eddb4b94 --- /dev/null +++ b/utils/governance/instructions/castVote.ts @@ -0,0 +1,127 @@ +import { Connection, Keypair, TransactionInstruction } from '@solana/web3.js' +import { + ChatMessageBody, + getGovernanceProgramVersion, + GOVERNANCE_CHAT_PROGRAM_ID, + Proposal, + TokenOwnerRecord, + VoteChoice, + VoteKind, + withPostChatMessage, +} from '@solana/spl-governance' +import { ProgramAccount } from '@solana/spl-governance' + +import { Vote } from '@solana/spl-governance' + +import { withCastVote } from '@solana/spl-governance' +import { VsrClient } from '../voteStakeRegistryClient' +import { MANGO_GOVERNANCE_PROGRAM, MANGO_REALM_PK } from '../constants' +import { updateVoterWeightRecord } from './updateVoteWeightRecord' +import { WalletContextState } from '@solana/wallet-adapter-react' +import { MangoClient } from '@blockworks-foundation/mango-v4' +import { notify } from 'utils/notifications' + +export async function castVote( + connection: Connection, + wallet: WalletContextState, + proposal: ProgramAccount, + tokenOwnerRecord: ProgramAccount, + voteKind: VoteKind, + vsrClient: VsrClient, + mangoClient: MangoClient, + message?: ChatMessageBody | undefined +) { + const signers: Keypair[] = [] + const instructions: TransactionInstruction[] = [] + + const walletPubkey = wallet.publicKey! + const governanceAuthority = walletPubkey + const payer = walletPubkey + const programVersion = await getGovernanceProgramVersion( + connection, + MANGO_GOVERNANCE_PROGRAM + ) + + const { updateVoterWeightRecordIx, voterWeightPk } = + await updateVoterWeightRecord(vsrClient, walletPubkey) + instructions.push(updateVoterWeightRecordIx) + + // It is not clear that defining these extraneous fields, `deny` and `veto`, is actually necessary. + // See: https://discord.com/channels/910194960941338677/910630743510777926/1044741454175674378 + const vote = + voteKind === VoteKind.Approve + ? new Vote({ + voteType: VoteKind.Approve, + approveChoices: [new VoteChoice({ rank: 0, weightPercentage: 100 })], + deny: undefined, + veto: undefined, + }) + : voteKind === VoteKind.Deny + ? new Vote({ + voteType: VoteKind.Deny, + approveChoices: undefined, + deny: true, + veto: undefined, + }) + : voteKind == VoteKind.Veto + ? new Vote({ + voteType: VoteKind.Veto, + veto: true, + deny: undefined, + approveChoices: undefined, + }) + : new Vote({ + voteType: VoteKind.Abstain, + veto: undefined, + deny: undefined, + approveChoices: undefined, + }) + + const tokenMint = proposal.account.governingTokenMint + + await withCastVote( + instructions, + MANGO_GOVERNANCE_PROGRAM, + programVersion, + MANGO_REALM_PK, + proposal.account.governance, + proposal.pubkey, + proposal.account.tokenOwnerRecord, + tokenOwnerRecord.pubkey, + governanceAuthority, + tokenMint, + vote, + payer, + voterWeightPk + ) + + if (message) { + const { updateVoterWeightRecordIx, voterWeightPk } = + await updateVoterWeightRecord(vsrClient, walletPubkey) + instructions.push(updateVoterWeightRecordIx) + + await withPostChatMessage( + instructions, + signers, + GOVERNANCE_CHAT_PROGRAM_ID, + MANGO_GOVERNANCE_PROGRAM, + MANGO_REALM_PK, + proposal.account.governance, + proposal.pubkey, + tokenOwnerRecord.pubkey, + governanceAuthority, + payer, + undefined, + message, + voterWeightPk + ) + } + + const tx = await mangoClient.sendAndConfirmTransaction(instructions) + notify({ + title: 'Transaction confirmed', + type: 'success', + txid: tx, + noSound: true, + }) +} diff --git a/utils/governance/instructions/createProposal.ts b/utils/governance/instructions/createProposal.ts index b1a3e19b..3a3b1c08 100644 --- a/utils/governance/instructions/createProposal.ts +++ b/utils/governance/instructions/createProposal.ts @@ -4,7 +4,6 @@ import { getSignatoryRecordAddress, ProgramAccount, serializeInstructionToBase64, - SYSTEM_PROGRAM_ID, TokenOwnerRecord, VoteType, WalletSigner, @@ -22,12 +21,8 @@ import { import { chunk } from 'lodash' import { MANGO_MINT } from 'utils/constants' import { MANGO_GOVERNANCE_PROGRAM, MANGO_REALM_PK } from '../constants' -import { DEFAULT_VSR_ID, VsrClient } from '../voteStakeRegistryClient' -import { - getRegistrarPDA, - getVoterPDA, - getVoterWeightPDA, -} from '../accounts/vsrAccounts' +import { VsrClient } from '../voteStakeRegistryClient' +import { updateVoterWeightRecord } from './updateVoteWeightRecord' export const createProposal = async ( connection: Connection, @@ -57,27 +52,8 @@ export const createProposal = async ( const options = ['Approve'] const useDenyOption = true - //will run only if plugin is connected with realm - const { registrar } = await getRegistrarPDA( - MANGO_REALM_PK, - new PublicKey(MANGO_MINT), - DEFAULT_VSR_ID - ) - const { voter } = await getVoterPDA(registrar, walletPk, DEFAULT_VSR_ID) - const { voterWeightPk } = await getVoterWeightPDA( - registrar, - walletPk, - DEFAULT_VSR_ID - ) - const updateVoterWeightRecordIx = await client.program.methods - .updateVoterWeightRecord() - .accounts({ - registrar, - voter, - voterWeightRecord: voterWeightPk, - systemProgram: SYSTEM_PROGRAM_ID, - }) - .instruction() + const { updateVoterWeightRecordIx, voterWeightPk } = + await updateVoterWeightRecord(client, walletPk) instructions.push(updateVoterWeightRecordIx) const proposalAddress = await withCreateProposal( diff --git a/utils/governance/instructions/relinquishVote.ts b/utils/governance/instructions/relinquishVote.ts new file mode 100644 index 00000000..95a0ae42 --- /dev/null +++ b/utils/governance/instructions/relinquishVote.ts @@ -0,0 +1,52 @@ +import { Connection, PublicKey, TransactionInstruction } from '@solana/web3.js' +import { + getGovernanceProgramVersion, + Proposal, + TokenOwnerRecord, + withRelinquishVote, +} from '@solana/spl-governance' +import { ProgramAccount } from '@solana/spl-governance' +import { MANGO_GOVERNANCE_PROGRAM, MANGO_REALM_PK } from '../constants' +import { WalletContextState } from '@solana/wallet-adapter-react' +import { MangoClient } from '@blockworks-foundation/mango-v4' +import { notify } from 'utils/notifications' + +export async function relinquishVote( + connection: Connection, + wallet: WalletContextState, + proposal: ProgramAccount, + tokenOwnerRecord: ProgramAccount, + mangoClient: MangoClient, + voteRecord: PublicKey +) { + const instructions: TransactionInstruction[] = [] + const governanceAuthority = wallet.publicKey! + const beneficiary = wallet.publicKey! + + const programVersion = await getGovernanceProgramVersion( + connection, + MANGO_GOVERNANCE_PROGRAM + ) + + await withRelinquishVote( + instructions, + MANGO_GOVERNANCE_PROGRAM, + programVersion, + MANGO_REALM_PK, + proposal.account.governance, + proposal.pubkey, + tokenOwnerRecord.pubkey, + proposal.account.governingTokenMint, + voteRecord, + governanceAuthority, + beneficiary + ) + + const tx = await mangoClient.sendAndConfirmTransaction(instructions) + notify({ + title: 'Transaction confirmed', + type: 'success', + txid: tx, + noSound: true, + }) +} diff --git a/utils/governance/instructions/updateVoteWeightRecord.ts b/utils/governance/instructions/updateVoteWeightRecord.ts new file mode 100644 index 00000000..12508e71 --- /dev/null +++ b/utils/governance/instructions/updateVoteWeightRecord.ts @@ -0,0 +1,36 @@ +import { PublicKey } from '@solana/web3.js' +import { MANGO_MINT, MANGO_REALM_PK } from '../constants' +import { DEFAULT_VSR_ID, VsrClient } from '../voteStakeRegistryClient' +import { + getRegistrarPDA, + getVoterPDA, + getVoterWeightPDA, +} from '../accounts/vsrAccounts' +import { SYSTEM_PROGRAM_ID } from '@solana/spl-governance' + +export const updateVoterWeightRecord = async ( + client: VsrClient, + walletPk: PublicKey +) => { + const { registrar } = await getRegistrarPDA( + MANGO_REALM_PK, + new PublicKey(MANGO_MINT), + DEFAULT_VSR_ID + ) + const { voter } = await getVoterPDA(registrar, walletPk, DEFAULT_VSR_ID) + const { voterWeightPk } = await getVoterWeightPDA( + registrar, + walletPk, + DEFAULT_VSR_ID + ) + const updateVoterWeightRecordIx = await client!.program.methods + .updateVoterWeightRecord() + .accounts({ + registrar, + voter, + voterWeightRecord: voterWeightPk, + systemProgram: SYSTEM_PROGRAM_ID, + }) + .instruction() + return { updateVoterWeightRecordIx, voterWeightPk } +} diff --git a/utils/governance/proposals.ts b/utils/governance/proposals.ts new file mode 100644 index 00000000..23251292 --- /dev/null +++ b/utils/governance/proposals.ts @@ -0,0 +1,67 @@ +import { BN } from '@project-serum/anchor' +import { + Governance, + MintMaxVoteWeightSource, + MintMaxVoteWeightSourceType, + Proposal, + ProposalState, +} from '@solana/spl-governance' +import BigNumber from 'bignumber.js' +import dayjs from 'dayjs' +import { MintInfo } from '@solana/spl-token' + +export const isInCoolOffTime = ( + proposal: Proposal | undefined, + governance: Governance | undefined +) => { + const mainVotingEndedAt = proposal?.signingOffAt + ?.addn(governance?.config.maxVotingTime || 0) + .toNumber() + + const votingCoolOffTime = governance?.config.votingCoolOffTime || 0 + const canFinalizeAt = mainVotingEndedAt + ? mainVotingEndedAt + votingCoolOffTime + : mainVotingEndedAt + + const endOfProposalAndCoolOffTime = canFinalizeAt + ? dayjs(1000 * canFinalizeAt!) + : undefined + + const isInCoolOffTime = endOfProposalAndCoolOffTime + ? dayjs().isBefore(endOfProposalAndCoolOffTime) && + mainVotingEndedAt && + dayjs().isAfter(mainVotingEndedAt * 1000) + : undefined + + return !!isInCoolOffTime && proposal!.state !== ProposalState.Defeated +} + +/** Returns max VoteWeight for given mint and max source */ +export function getMintMaxVoteWeight( + mint: MintInfo, + maxVoteWeightSource: MintMaxVoteWeightSource +) { + if (maxVoteWeightSource.type === MintMaxVoteWeightSourceType.SupplyFraction) { + const supplyFraction = maxVoteWeightSource.getSupplyFraction() + + const maxVoteWeight = new BigNumber(supplyFraction.toString()) + .multipliedBy(mint.supply.toString()) + .shiftedBy(-MintMaxVoteWeightSource.SUPPLY_FRACTION_DECIMALS) + + return new BN(maxVoteWeight.dp(0, BigNumber.ROUND_DOWN).toString()) + } else { + // absolute value + return maxVoteWeightSource.value + } +} + +export const calculatePct = (c = new BN(0), total?: BN) => { + if (total?.isZero()) { + return 0 + } + + return new BN(100) + .mul(c) + .div(total ?? new BN(1)) + .toNumber() +} diff --git a/utils/governance/tools.ts b/utils/governance/tools.ts index bb90a219..8e7e0e13 100644 --- a/utils/governance/tools.ts +++ b/utils/governance/tools.ts @@ -1,4 +1,3 @@ -import { MintInfo } from '@blockworks-foundation/mango-v4' import { getGovernanceAccounts, getRealm, @@ -7,10 +6,8 @@ import { pubkeyFilter, } from '@solana/spl-governance' import { Connection, PublicKey } from '@solana/web3.js' -import { getProposals } from './fetch/getProposals' -import { ConnectionContext } from './types' import { TokenProgramAccount } from './accounts/vsrAccounts' -import { u64, MintLayout } from '@solana/spl-token' +import { u64, MintLayout, MintInfo } from '@solana/spl-token' import BN from 'bn.js' export async function fetchRealm({ @@ -43,25 +40,6 @@ export async function fetchGovernances({ return governancesMap } -export async function fetchProposals({ - connectionContext, - programId, - governances, -}: { - connectionContext: ConnectionContext - programId: PublicKey - governances: PublicKey[] -}) { - const proposalsByGovernance = await getProposals( - governances, - connectionContext, - programId - ) - - const proposals = accountsToPubkeyMap(proposalsByGovernance.flatMap((p) => p)) - return proposals -} - export function accountsToPubkeyMap(accounts: ProgramAccount[]) { return arrayToRecord(accounts, (a) => a.pubkey.toBase58()) }