governance active proposal voting (#117)

* governance wip

* governance store + helpers wip

* get voter weight

* on boarding realms component

* token list wip

* pyth oracle get

* adv fields

* hidden adv fields

* create proposal fcn + mangolana

* register market wip

* list market fixes wip

* translations

* register to register trustless + layout changes

* fix layout

* match switchboard oracle

* loaders

* validation

* style components

* change icon

* copy update

* translations keys

* fix translation

* fix warning create open book market

* fix

* fix transaltion

* fix

* fix

* fix pr

* fix onboarding translation

* fix token owner record error in console

* remove axios

* generic vsr idl

* fix pr

* remove mangolana

* small folder ref

* voting wip

* code fix

* menu

* fix

* additional prop in onboarding component

* fix translation

* fix transaltion

* voting fixes

* add basic styling

* fix merge

* fix files

* fix

* fix loaders

* fix

* fix loading voteRecord

* fix voter state without wallet

* fix

* translations

* fix translation

* dynamic imports

* copy updates, show vote weight and current vote

* fix

---------

Co-authored-by: saml33 <slam.uke@gmail.com>
This commit is contained in:
Adrian Brzeziński 2023-04-14 21:05:27 +02:00 committed by GitHub
parent 544b7077cc
commit 453b62fdd7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 1221 additions and 298 deletions

View File

@ -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}
/>
<MenuItem
active={pathname === '/governance/vote'}
collapsed={false}
icon={<ArchiveBoxArrowDownIcon className="h-5 w-5" />}
title={t('common:vote')}
pagePath="/governance/vote"
hideIconBg
showTooltip={false}
/>
<MenuItem
collapsed={false}
icon={<LightBulbIcon className="h-5 w-5" />}

View File

@ -3,7 +3,7 @@ import ListToken from './ListToken/ListToken'
const GovernancePage = () => {
return (
<div className="p-8 pb-20 md:pb-16 lg:p-10">
<div className="py-8 px-4 pb-20 sm:px-6 md:pb-16 lg:p-10">
<GovernancePageWrapper>
<ListToken />
</GovernancePageWrapper>

View File

@ -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(),

View File

@ -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({

View File

@ -0,0 +1,15 @@
import dynamic from 'next/dynamic'
import GovernancePageWrapper from '../GovernancePageWrapper'
const ListToken = dynamic(() => import('./ListToken'))
const ListTokenPage = () => {
return (
<div className="p-8 pb-20 md:pb-16 lg:p-10">
<GovernancePageWrapper>
<ListToken />
</GovernancePageWrapper>
</div>
)
}
export default ListTokenPage

View File

@ -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<Proposal>
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<VoteKind | undefined>(undefined)
const [voteRecordAddress, setVoteRecordAddress] = useState<PublicKey | null>(
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 ? (
<div
className="rounded-lg border border-th-bkg-3 p-4 md:p-6"
key={proposal.pubkey.toBase58()}
>
<div className="mb-6 flex flex-col md:flex-row md:items-start md:justify-between">
<div className="pr-6">
<h2 className="mb-2 text-lg md:text-xl">
<a
href={`https://dao.mango.markets/dao/MNGO/proposal/${proposal.pubkey.toBase58()}`}
>
<span className="mr-2">{proposal.account.name}</span>
<ArrowTopRightOnSquareIcon className="mb-1 inline-block h-4 w-4 flex-shrink-0" />
</a>
</h2>
<p className="mb-2 md:mb-0">{proposal.account.descriptionLink}</p>
</div>
<VoteCountdown
proposal={proposal.account}
governance={governance.account}
/>
</div>
<div>
{!isVoteCast ? (
<div className="flex space-x-4">
<Button
className="w-32"
onClick={() => vote(VoteKind.Approve)}
disabled={!canVote || processedVoteType !== ''}
secondary
>
<div className="flex items-center justify-center">
<HandThumbUpIcon className="mr-2 h-4 w-4" />
{processedVoteType === PROCESSED_VOTE_TYPE.APPROVE ? (
<Loading className="w-4"></Loading>
) : (
t('vote-yes')
)}
</div>
</Button>
<Button
className="w-32"
onClick={() => vote(VoteKind.Deny)}
disabled={!canVote || processedVoteType !== ''}
secondary
>
<div className="flex items-center justify-center">
<HandThumbDownIcon className="mr-2 h-4 w-4" />
{processedVoteType === PROCESSED_VOTE_TYPE.DENY ? (
<Loading className="w-4"></Loading>
) : (
t('vote-no')
)}
</div>
</Button>
</div>
) : (
<div className="flex flex-wrap items-center">
<Button
className="mr-4 flex w-40 items-center justify-center"
disabled={processedVoteType !== ''}
secondary
onClick={() => submitRelinquishVote()}
>
{processedVoteType === PROCESSED_VOTE_TYPE.RELINQUISH ? (
<Loading className="w-4"></Loading>
) : (
t('relinquish-vote')
)}
</Button>
{voteType !== undefined ? (
<div className="my-2 flex">
<p className="mr-2">{t('current-vote')}</p>
<span className="font-bold text-th-fgd-2">
{voteType === VoteKind.Approve ? (
<span className="flex items-center">
<HandThumbUpIcon className="mr-1.5 h-3 w-3" />
{t('yes')}
</span>
) : (
<span className="flex items-center">
<HandThumbDownIcon className="mr-1.5 h-3 w-3" />
{t('no')}
</span>
)}
</span>
</div>
) : null}
</div>
)}
</div>
{mangoMint && (
<div className="mt-6 flex w-full flex-col space-y-4 border-t border-th-bkg-3 pt-4 md:flex-row md:space-y-0 md:space-x-6">
<VoteResults communityMint={mangoMint} proposal={proposal.account} />
<QuorumProgress
proposal={proposal}
governance={governance}
communityMint={mangoMint}
/>
</div>
)}
</div>
) : null
}
export default ProposalCard

View File

@ -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<MintInfo | null>(null)
const [votingProposals, setVotingProposals] = useState<
ProgramAccount<Proposal>[]
>([])
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 (
<div>
<div className="mb-4 flex flex-wrap items-end justify-between">
<h1 className="mr-4">{t('active-proposals')}</h1>
<p className="whitespace-no-wrap mb-0.5 mt-2">
{t('your-votes')}{' '}
<span className="font-mono text-th-fgd-2">
{!loadingVoter
? fmtTokenAmount(voter.voteWeight, MANGO_MINT_DECIMALS)
: 0}
</span>
</p>
</div>
{loadingProposals || loadingRealm ? (
<div className="space-y-3">
<SheenLoader className="flex flex-1">
<div className={`h-56 w-full bg-th-bkg-2`} />
</SheenLoader>
<SheenLoader className="flex flex-1">
<div className={`h-56 w-full bg-th-bkg-2`} />
</SheenLoader>
</div>
) : (
<>
{!loadingVoter ? (
<OnBoarding minVotes={new BN(1000000)}></OnBoarding>
) : null}
<div className="space-y-3">
{votingProposals.length ? (
votingProposals.map(
(x) =>
mangoMint && (
<ProposalCard
key={x.pubkey.toBase58()}
proposal={x}
mangoMint={mangoMint}
></ProposalCard>
)
)
) : (
<div className="flex h-56 items-center justify-center rounded-lg border border-th-bkg-3 p-6">
<div className="flex flex-col items-center">
<NoSymbolIcon className="mb-1 h-6 w-6" />
<p>{t('no-active-proposals')}</p>
</div>
</div>
)}
</div>
</>
)}
</div>
)
}
export default Vote

View File

@ -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) ? (
<div className="text-fgd-3">{t('voting-ended')}</div>
) : (
<div className="text-fgd-1 flex w-40 items-center">
<div className="text-fgd-3 mr-1">{t('ends')}</div>
{countdown && countdown.days > 0 && (
<>
<div className="bg-bkg-1 rounded px-1 py-0.5">
{countdown.days}d
</div>
<span className="text-fgd-3 mx-0.5 font-bold">:</span>
</>
)}
<div className="bg-bkg-1 rounded px-1 py-0.5">{countdown.hours}h</div>
<span className="text-fgd-3 mx-0.5 font-bold">:</span>
<div className="bg-bkg-1 rounded px-1 py-0.5">
{countdown.minutes}m
</div>
{!countdown.days && (
<>
<span className="text-fgd-3 mx-0.5 font-bold">:</span>
<div className="bg-bkg-1 w-9 rounded px-1 py-0.5">
{countdown.seconds}s
</div>
</>
)}
</div>
)}
</>
)
}

View File

@ -0,0 +1,15 @@
import dynamic from 'next/dynamic'
import GovernancePageWrapper from '../GovernancePageWrapper'
const Vote = dynamic(() => import('./Vote'))
const VotePage = () => {
return (
<div className="py-8 px-4 pb-20 sm:px-6 md:pb-16 lg:p-10">
<GovernancePageWrapper>
<Vote />
</GovernancePageWrapper>
</div>
)
}
export default VotePage

View File

@ -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<Governance>
proposal: ProgramAccount<Proposal>
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 (
<div className="w-full rounded-md">
<div className="flex items-center">
<div className="w-full">
<div className="flex items-center">
<p className="text-fgd-2 mb-0 mr-1.5">{t('approval-q')}</p>
<Tooltip content={t('quorum-description')}>
<InformationCircleIcon className="text-fgd-2 h-5 w-5 cursor-help" />
</Tooltip>
</div>
{typeof yesVoteProgress !== 'undefined' && yesVoteProgress < 100 ? (
<p className="text-fgd-1 mb-0 font-bold">{`${(
yesVotesRequired ?? 0
).toLocaleString(undefined, {
maximumFractionDigits: 0,
})} ${(yesVoteProgress ?? 0) > 0 ? 'more' : ''} Yes vote${
(yesVotesRequired ?? 0) > 1 ? 's' : ''
} required`}</p>
) : (
<div className="flex items-center">
<CheckCircleIcon className="text-green mr-1.5 h-5 w-5 flex-shrink-0" />
<p className="text-fgd-1 mb-0 font-bold">
{t('required-approval-achieved')}
</p>
</div>
)}
</div>
</div>
<div className="mt-2.5 flex h-2 w-full flex-grow rounded bg-th-bkg-4">
<div
style={{
width: `${yesVoteProgress}%`,
}}
className={`${
(yesVoteProgress ?? 0) >= 100 ? 'bg-th-up' : 'bg-th-fgd-2'
} flex rounded`}
></div>
</div>
</div>
)
}
export default QuorumProgress

View File

@ -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 (
<div className="flex w-full items-center space-x-4">
{proposal ? (
<div className={`w-full rounded-md`}>
<div className="flex">
<div className="w-1/2">
<p>{t('yes-votes')}</p>
<p className={`hero-text font-bold text-th-fgd-1`}>
{(yesVoteCount ?? 0).toLocaleString()}
<span className="ml-1 text-xs font-normal text-th-fgd-3">
{relativeYesVotes?.toFixed(1)}%
</span>
</p>
</div>
<div className="w-1/2 text-right">
<p>{t('no-votes')}</p>
<p className={`hero-text font-bold text-th-fgd-1`}>
{(noVoteCount ?? 0).toLocaleString()}
<span className="ml-1 text-xs font-normal text-th-fgd-3">
{relativeNoVotes?.toFixed(1)}%
</span>
</p>
</div>
</div>
<VoteResultsBar
approveVotePercentage={relativeYesVotes!}
denyVotePercentage={relativeNoVotes!}
/>
</div>
) : (
<>
<div className="h-12 w-full animate-pulse rounded bg-th-bkg-3" />
</>
)}
</div>
)
}
export default VoteResults

View File

@ -0,0 +1,42 @@
type VoteResultsBarProps = {
approveVotePercentage: number
denyVotePercentage: number
}
const VoteResultsBar = ({
approveVotePercentage = 0,
denyVotePercentage = 0,
}: VoteResultsBarProps) => {
return (
<>
<div className="mt-2.5 flex h-2 w-full flex-grow rounded bg-th-bkg-4">
<div
style={{
width: `${
approveVotePercentage > 2 || approveVotePercentage < 0.01
? approveVotePercentage
: 2
}%`,
}}
className={`flex rounded-l bg-th-up ${
denyVotePercentage < 0.01 && 'rounded'
}`}
></div>
<div
style={{
width: `${
denyVotePercentage > 2 || denyVotePercentage < 0.01
? denyVotePercentage
: 2
}%`,
}}
className={`flex rounded-r bg-th-down ${
approveVotePercentage < 0.01 && 'rounded'
}`}
></div>
</div>
</>
)
}
export default VoteResultsBar

View File

@ -146,6 +146,11 @@ const MoreMenuPanel = ({
path="/governance/listToken"
icon={<PlusCircleIcon className="h-5 w-5" />}
/>
<MoreMenuItem
title={t('common:vote')}
path="/governance/vote"
icon={<PlusCircleIcon className="h-5 w-5" />}
/>
<MoreMenuItem
title={t('learn')}
path="https://docs.mango.markets/"

View File

@ -1,6 +1,10 @@
import GovernancePage from '@components/governance/GovernancePage'
import type { NextPage } from 'next'
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
import dynamic from 'next/dynamic'
const ListTokenPage = dynamic(
() => 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 <GovernancePage />
return <ListTokenPage />
}
export default Governance

25
pages/governance/vote.tsx Normal file
View File

@ -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 <VotePage />
}
export default ListToken

View File

@ -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"
}

View File

@ -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:"
}

View File

@ -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"
}

View File

@ -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:"
}

View File

@ -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"
}

View File

@ -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:"
}

View File

@ -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"
}

View File

@ -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:"
}

View File

@ -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": "取款額"
}
"withdraw-amount": "取款額",
"list-token": "列表代币",
"vote": "投票"
}

View File

@ -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:"
}

View File

@ -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<TokenOwnerRecord> | 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<IGovernanceStore>((set, get) => ({
@ -60,13 +64,13 @@ const GovernanceStore = create<IGovernanceStore>((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<IGovernanceStore>((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<IGovernanceStore>((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

View File

@ -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 {

View File

@ -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<Proposal>[] = []
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<Proposal> = {
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<Proposal>[][] = []
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(),
},
},
],
},
],
}
}

View File

@ -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<Proposal>,
tokenOwnerRecord: ProgramAccount<TokenOwnerRecord>,
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,
})
}

View File

@ -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(

View File

@ -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<Proposal>,
tokenOwnerRecord: ProgramAccount<TokenOwnerRecord>,
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,
})
}

View File

@ -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 }
}

View File

@ -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()
}

View File

@ -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<T>(accounts: ProgramAccount<T>[]) {
return arrayToRecord(accounts, (a) => a.pubkey.toBase58())
}