From c1ed6a6b8caad161d990a3aa77a11e0ffa4e2dae Mon Sep 17 00:00:00 2001 From: agrippa Date: Wed, 8 Nov 2023 20:23:17 -0500 Subject: [PATCH] Delegator rework for vanilla + ux adjustment (#1912) --- .gitignore | 2 + .vscode/settings.json | 12 -- actions/castVote.ts | 169 +++++++++++++++--- .../GovernancePower/GovernancePowerCard.tsx | 27 +-- components/SelectPrimaryDelegators.tsx | 85 +++++---- components/VoteCommentModal.tsx | 31 ++-- components/VotePanel/CastMultiVoteButtons.tsx | 85 ++++----- components/VotePanel/CastVoteButtons.tsx | 54 +----- components/VotePanel/VetoButtons.tsx | 8 +- components/VotePanel/useCanVote.ts | 107 +++++++++++ constants/flags.ts | 14 ++ constants/plugins.ts | 18 +- hooks/queries/governancePower.ts | 25 +-- hooks/queries/proposal.ts | 12 +- hooks/useSubmitVote.ts | 69 ++++++- hooks/useUserOrDelegator.ts | 1 + 16 files changed, 489 insertions(+), 230 deletions(-) delete mode 100644 .vscode/settings.json create mode 100644 components/VotePanel/useCanVote.ts diff --git a/.gitignore b/.gitignore index 97a8637876..55713a92f9 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,5 @@ yarn-error.log* # Sentry .sentryclirc + +.vscode/settings.json \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 92a022a112..0000000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "typescript.tsdk": "node_modules/typescript/lib", - "cSpell.words": [ - "Addin", - "blockworks", - "lamports", - "solana", - "VERCEL", - "WSOL" - ], - "editor.formatOnSave": true -} diff --git a/actions/castVote.ts b/actions/castVote.ts index 8fa5cefa49..85ee947962 100644 --- a/actions/castVote.ts +++ b/actions/castVote.ts @@ -1,7 +1,7 @@ import { + Connection, Keypair, PublicKey, - Transaction, TransactionInstruction, } from '@solana/web3.js' import { @@ -14,6 +14,7 @@ import { VoteKind, VoteType, withPostChatMessage, + withCreateTokenOwnerRecord, } from '@solana/spl-governance' import { ProgramAccount } from '@solana/spl-governance' import { RpcContext } from '@solana/spl-governance' @@ -28,11 +29,15 @@ import { SequenceType, txBatchesToInstructionSetWithSigners, } from '@utils/sendTransactions' -import { sendTransaction } from '@utils/send' import { calcCostOfNftVote, checkHasEnoughSolToVote } from '@tools/nftVoteCalc' import useNftProposalStore from 'NftVotePlugin/NftProposalStore' import { HeliumVsrClient } from 'HeliumVotePlugin/sdk/client' import { NftVoterClient } from '@utils/uiTypes/NftVoterClient' +import { fetchRealmByPubkey } from '@hooks/queries/realm' +import { fetchProposalByPubkeyQuery } from '@hooks/queries/proposal' +import { findPluginName } from '@hooks/queries/governancePower' +import { DELEGATOR_BATCH_VOTE_SUPPORT_BY_PLUGIN } from '@constants/flags' +import { fetchTokenOwnerRecordByPubkey } from '@hooks/queries/tokenOwnerRecord' import { fetchProgramVersion } from '@hooks/queries/useProgramVersionQuery' const getVetoTokenMint = ( @@ -50,6 +55,86 @@ const getVetoTokenMint = ( return vetoTokenMint } +const createDelegatorVote = async ({ + connection, + realmPk, + proposalPk, + tokenOwnerRecordPk, + userPk, + vote, +}: { + connection: Connection + realmPk: PublicKey + proposalPk: PublicKey + tokenOwnerRecordPk: PublicKey + userPk: PublicKey + vote: Vote +}) => { + // + const realm = (await fetchRealmByPubkey(connection, realmPk)).result + if (!realm) throw new Error() + const proposal = (await fetchProposalByPubkeyQuery(connection, proposalPk)) + .result + if (!proposal) throw new Error() + + const programVersion = await fetchProgramVersion(connection, realm.owner) + + const castVoteIxs: TransactionInstruction[] = [] + await withCastVote( + castVoteIxs, + realm.owner, + programVersion, + realm.pubkey, + proposal.account.governance, + proposal.pubkey, + proposal.account.tokenOwnerRecord, + tokenOwnerRecordPk, + userPk, + proposal.account.governingTokenMint, + vote, + userPk + //plugin?.voterWeightPk, + //plugin?.maxVoterWeightRecord + ) + return castVoteIxs +} + +const createTokenOwnerRecordIfNeeded = async ({ + connection, + realmPk, + tokenOwnerRecordPk, + payer, + governingTokenMint, +}: { + connection: Connection + realmPk: PublicKey + tokenOwnerRecordPk: PublicKey + payer: PublicKey + governingTokenMint: PublicKey +}) => { + const realm = await fetchRealmByPubkey(connection, realmPk) + if (!realm.result) throw new Error() + const version = await fetchProgramVersion(connection, realm.result.owner) + + const tokenOwnerRecord = await fetchTokenOwnerRecordByPubkey( + connection, + tokenOwnerRecordPk + ) + if (tokenOwnerRecord.result) return [] + // create token owner record + const ixs: TransactionInstruction[] = [] + await withCreateTokenOwnerRecord( + ixs, + realm.result.owner, + version, + realmPk, + payer, + governingTokenMint, + payer + ) + return ixs +} + export async function castVote( { connection, wallet, programId, walletPubkey }: RpcContext, realm: ProgramAccount, @@ -60,9 +145,9 @@ export async function castVote( votingPlugin?: VotingClient, runAfterConfirmation?: (() => void) | null, voteWeights?: number[], - _additionalTokenOwnerRecords?: [] + additionalTokenOwnerRecords?: PublicKey[] ) { - const signers: Keypair[] = [] + const chatMessageSigners: Keypair[] = [] const createCastNftVoteTicketIxs: TransactionInstruction[] = [] const createPostMessageTicketIxs: TransactionInstruction[] = [] @@ -81,7 +166,6 @@ export async function castVote( tokenOwnerRecord, createCastNftVoteTicketIxs ) - console.log('PLUGIN IXS', pluginCastVoteIxs) const isMulti = proposal.account.voteType !== VoteType.SINGLE_CHOICE && @@ -153,6 +237,25 @@ export async function castVote( plugin?.maxVoterWeightRecord ) + const delegatorCastVoteAtoms = + additionalTokenOwnerRecords && + DELEGATOR_BATCH_VOTE_SUPPORT_BY_PLUGIN[ + findPluginName(votingPlugin?.client?.program.programId) + ] + ? await Promise.all( + additionalTokenOwnerRecords.map((tokenOwnerRecordPk) => + createDelegatorVote({ + connection, + realmPk: realm.pubkey, + proposalPk: proposal.pubkey, + tokenOwnerRecordPk, + userPk: walletPubkey, + vote, + }) + ) + ) + : [] + const pluginPostMessageIxs: TransactionInstruction[] = [] const postMessageIxs: TransactionInstruction[] = [] if (message) { @@ -165,7 +268,7 @@ export async function castVote( await withPostChatMessage( postMessageIxs, - signers, + chatMessageSigners, GOVERNANCE_CHAT_PROGRAM_ID, programId, realm.pubkey, @@ -182,22 +285,48 @@ export async function castVote( const isNftVoter = votingPlugin?.client instanceof NftVoterClient const isHeliumVoter = votingPlugin?.client instanceof HeliumVsrClient + const tokenOwnerRecordIxs = await createTokenOwnerRecordIfNeeded({ + connection, + realmPk: realm.pubkey, + tokenOwnerRecordPk: tokenOwnerRecord, + payer, + governingTokenMint: tokenMint, + }) if (!isNftVoter && !isHeliumVoter) { - const transaction = new Transaction() - transaction.add( - ...[ - ...pluginCastVoteIxs, - ...castVoteIxs, - ...pluginPostMessageIxs, - ...postMessageIxs, - ] + const batch1 = [ + ...tokenOwnerRecordIxs, + ...pluginCastVoteIxs, + ...castVoteIxs, + ...pluginPostMessageIxs, + ...postMessageIxs, + ] + // chunk size chosen conservatively. "Atoms" refers to atomic clusters of instructions (namely, updatevoterweight? + vote) + const delegatorBatches = chunks(delegatorCastVoteAtoms, 2).map((x) => + x.flat() ) + const actions = [batch1, ...delegatorBatches].map((ixs) => ({ + instructionsSet: ixs.map((ix) => ({ + transactionInstruction: ix, + signers: chatMessageSigners.filter((kp) => + ix.keys.find((key) => key.isSigner && key.pubkey.equals(kp.publicKey)) + ), + })), + sequenceType: SequenceType.Parallel, + })) - await sendTransaction({ transaction, wallet, connection, signers }) - if (runAfterConfirmation) { - runAfterConfirmation() - } + await sendTransactionsV3({ + connection, + wallet, + transactionInstructions: actions, + callbacks: { + afterAllTxConfirmed: () => { + if (runAfterConfirmation) { + runAfterConfirmation() + } + }, + }, + }) } // we need to chunk instructions @@ -217,7 +346,7 @@ export async function castVote( return { instructionsSet: txBatchesToInstructionSetWithSigners( txBatch, - message ? [[], signers] : [], + message ? [[], chatMessageSigners] : [], // seeing signer related bugs when posting chat? This is likely culprit batchIdx ), sequenceType: SequenceType.Sequential, @@ -276,7 +405,7 @@ export async function castVote( return { instructionsSet: txBatchesToInstructionSetWithSigners( txBatch, - message ? [[], signers] : [], + message ? [[], chatMessageSigners] : [], // seeing signer related bugs when posting chat? This is likely culprit batchIdx ), sequenceType: SequenceType.Sequential, diff --git a/components/GovernancePower/GovernancePowerCard.tsx b/components/GovernancePower/GovernancePowerCard.tsx index 1aa087f9ff..1465eceebf 100644 --- a/components/GovernancePower/GovernancePowerCard.tsx +++ b/components/GovernancePower/GovernancePowerCard.tsx @@ -1,19 +1,12 @@ import { ChevronRightIcon } from '@heroicons/react/solid' -import { - determineVotingPowerType, - useGovernancePowerAsync, -} from '@hooks/queries/governancePower' +import { useGovernancePowerAsync } from '@hooks/queries/governancePower' import { useRealmConfigQuery } from '@hooks/queries/realmConfig' -import useSelectedRealmPubkey from '@hooks/selectedRealm/useSelectedRealmPubkey' import useQueryContext from '@hooks/useQueryContext' import useWalletOnePointOh from '@hooks/useWalletOnePointOh' import { GoverningTokenType } from '@solana/spl-governance' -import { useConnection } from '@solana/wallet-adapter-react' import Link from 'next/link' import { useRouter } from 'next/router' -import { useAsync } from 'react-async-hook' import GovernancePowerForRole from './GovernancePowerForRole' -import { Deposit } from './Vanilla/Deposit' const GovernancePowerTitle = () => { const { symbol } = useRouter().query @@ -36,7 +29,7 @@ const GovernancePowerTitle = () => { ) } - +/* // TODO: refactor deposit components to their own generic DepositForRole component const VanillaDeposit = ({ role }: { role: 'community' | 'council' }) => { const { connection } = useConnection() @@ -49,7 +42,7 @@ const VanillaDeposit = ({ role }: { role: 'community' | 'council' }) => { }, [connection, realmPk, role]) return kind === 'vanilla' ? : <> -} +} */ const GovernancePowerCard = () => { const connected = useWalletOnePointOh()?.connected ?? false @@ -59,12 +52,6 @@ const GovernancePowerCard = () => { const bothLoading = communityPower.loading && councilPower.loading - const bothZero = - communityPower.result !== undefined && - councilPower.result !== undefined && - communityPower.result.isZero() && - councilPower.result.isZero() - const realmConfig = useRealmConfigQuery().data?.result return ( @@ -79,14 +66,6 @@ const GovernancePowerCard = () => {
- ) : bothZero ? ( -
- You do not have any governance power in this dao -
- - -
-
) : (
{realmConfig?.account.communityTokenConfig.tokenType === diff --git a/components/SelectPrimaryDelegators.tsx b/components/SelectPrimaryDelegators.tsx index 89e4bbb077..b30f259482 100644 --- a/components/SelectPrimaryDelegators.tsx +++ b/components/SelectPrimaryDelegators.tsx @@ -9,7 +9,8 @@ import { ProgramAccount, TokenOwnerRecord } from '@solana/spl-governance' import { capitalize } from '@utils/helpers' import { ProfileName } from './Profile/ProfileName' -const YOUR_WALLET_VALUE = 'Your wallet' +const YOUR_WALLET_VALUE = 'Yourself + all delegators' +const JUST_YOUR_WALLET = 'Yourself only' const SelectPrimaryDelegators = () => { const wallet = useWalletOnePointOh() @@ -49,42 +50,42 @@ const SelectPrimaryDelegators = () => { setCouncilDelegator, } = useSelectedDelegatorStore() - const handleCouncilSelect = (councilTokenRecord: string | undefined) => { + const handleCouncilSelect = (councilWalletPk: string | undefined) => { setCouncilDelegator( - councilTokenRecord !== undefined - ? new PublicKey(councilTokenRecord) - : undefined + councilWalletPk !== undefined ? new PublicKey(councilWalletPk) : undefined ) } - const handleCommunitySelect = (communityPubKey?: string) => { + const handleCommunitySelect = (communityWalletPk: string | undefined) => { setCommunityDelegator( - communityPubKey ? new PublicKey(communityPubKey) : undefined + communityWalletPk ? new PublicKey(communityWalletPk) : undefined ) } return ( <> - {walletId && + {((walletId && communityTorsDelegatedToUser && - communityTorsDelegatedToUser.length > 0 && ( - - )} - {walletId && + communityTorsDelegatedToUser.length > 0) || + communityDelegator) && ( + + )} + {((walletId && councilTorsDelegatedToUser && - councilTorsDelegatedToUser.length > 0 && ( - - )} + councilTorsDelegatedToUser.length > 0) || + councilDelegator) && ( + + )} ) } @@ -103,6 +104,8 @@ function PrimaryDelegatorSelect({ kind: 'community' | 'council' tors: ProgramAccount[] }) { + const wallet = useWalletOnePointOh() + const walletPk = wallet?.publicKey ?? undefined return (
@@ -115,15 +118,19 @@ function PrimaryDelegatorSelect({ onChange={handleSelect} componentLabel={ selectedDelegator ? ( -
- -
-
+ walletPk && selectedDelegator.equals(walletPk) ? ( + JUST_YOUR_WALLET + ) : ( +
+ +
+
+ ) ) : ( YOUR_WALLET_VALUE ) @@ -132,6 +139,16 @@ function PrimaryDelegatorSelect({ {YOUR_WALLET_VALUE} + {walletPk ? ( + + {JUST_YOUR_WALLET} + + ) : ( + <> + )} {tors.map((delegatedTor) => ( void isOpen: boolean vote: VoteKind - voterTokenRecord: ProgramAccount isMulti?: number[] } @@ -30,7 +27,6 @@ const VoteCommentModal: FunctionComponent = ({ onClose, isOpen, vote, - voterTokenRecord, isMulti, }) => { const [comment, setComment] = useState('') @@ -41,9 +37,8 @@ const VoteCommentModal: FunctionComponent = ({ const handleSubmit = async () => { await submitVote({ vote, - voterTokenRecord, comment, - voteWeights: isMulti + voteWeights: isMulti, }) onClose() @@ -78,16 +73,20 @@ const VoteCommentModal: FunctionComponent = ({ onClick={handleSubmit} >
- {!submitting && - isMulti ? "" : - (vote === VoteKind.Approve ? ( - - ) : vote === VoteKind.Deny ? ( - - ) : ( - - ))} - {submitting ? : Vote {isMulti ? "" : voteString}} + {!submitting && isMulti ? ( + '' + ) : vote === VoteKind.Approve ? ( + + ) : vote === VoteKind.Deny ? ( + + ) : ( + + )} + {submitting ? ( + + ) : ( + Vote {isMulti ? '' : voteString} + )}
diff --git a/components/VotePanel/CastMultiVoteButtons.tsx b/components/VotePanel/CastMultiVoteButtons.tsx index 3d73af57fd..8fd6511780 100644 --- a/components/VotePanel/CastMultiVoteButtons.tsx +++ b/components/VotePanel/CastMultiVoteButtons.tsx @@ -1,35 +1,32 @@ import { Proposal, VoteKind } from '@solana/spl-governance' -import { CheckCircleIcon } from "@heroicons/react/solid"; +import { CheckCircleIcon } from '@heroicons/react/solid' import { useState } from 'react' import Button, { SecondaryButton } from '../Button' import VoteCommentModal from '../VoteCommentModal' -import { - useIsVoting, - useVoterTokenRecord, - useVotingPop, -} from './hooks' +import { useIsVoting, useVotingPop } from './hooks' import { useProposalVoteRecordQuery } from '@hooks/queries/voteRecord' import { useSubmitVote } from '@hooks/useSubmitVote' import { useSelectedRealmInfo } from '@hooks/selectedRealm/useSelectedRealmRegistryEntry' -import { useCanVote } from './CastVoteButtons' +import { useCanVote } from './useCanVote' -export const CastMultiVoteButtons = ({proposal} : {proposal: Proposal}) => { +export const CastMultiVoteButtons = ({ proposal }: { proposal: Proposal }) => { const [showVoteModal, setShowVoteModal] = useState(false) const [vote, setVote] = useState<'yes' | 'no' | null>(null) const realmInfo = useSelectedRealmInfo() const allowDiscussion = realmInfo?.allowDiscussion ?? true const { submitting, submitVote } = useSubmitVote() const votingPop = useVotingPop() - const voterTokenRecord = useVoterTokenRecord() const [canVote, tooltipContent] = useCanVote() const { data: ownVoteRecord } = useProposalVoteRecordQuery('electoral') - const [selectedOptions, setSelectedOptions] = useState([]); - const [optionStatus, setOptionStatus] = useState(new Array(proposal.options.length).fill(false)); + const [selectedOptions, setSelectedOptions] = useState([]) + const [optionStatus, setOptionStatus] = useState( + new Array(proposal.options.length).fill(false) + ) const isVoteCast = !!ownVoteRecord?.found const isVoting = useIsVoting() - const nota = "$$_NOTA_$$"; - const last = proposal.options.length - 1; + const nota = '$$_NOTA_$$' + const last = proposal.options.length - 1 const handleVote = async (vote: 'yes' | 'no') => { setVote(vote) @@ -39,51 +36,50 @@ export const CastMultiVoteButtons = ({proposal} : {proposal: Proposal}) => { } else { await submitVote({ vote: vote === 'yes' ? VoteKind.Approve : VoteKind.Deny, - voterTokenRecord: voterTokenRecord!, - voteWeights: selectedOptions + voteWeights: selectedOptions, }) } } const handleOption = (index: number) => { - let options = [...selectedOptions]; - let status = [...optionStatus]; - const isNota = proposal.options[last].label === nota; + let options = [...selectedOptions] + let status = [...optionStatus] + const isNota = proposal.options[last].label === nota - const selected = status[index]; + const selected = status[index] if (selected) { - options = options.filter(option => option !== index); + options = options.filter((option) => option !== index) status[index] = false } else { if (isNota) { if (index === last) { // if nota is clicked, unselect all other options - status = status.map(() => false); - status[index] = true; - options = [index]; + status = status.map(() => false) + status[index] = true + options = [index] } else { // remove nota from the selected if any other option is clicked - status[last] = false; - options = options.filter(option => option !== last); + status[last] = false + options = options.filter((option) => option !== last) if (!options.includes(index)) { options.push(index) } - status[index] = true; + status[index] = true } } else { if (!options.includes(index)) { options.push(index) } - status[index] = true; + status[index] = true } } - setSelectedOptions(options); - setOptionStatus(status); + setSelectedOptions(options) + setOptionStatus(status) } - return (isVoting && !isVoteCast) ? ( + return isVoting && !isVoteCast ? (

Cast your {votingPop} vote

@@ -99,29 +95,35 @@ export const CastMultiVoteButtons = ({proposal} : {proposal: Proposal}) => { handleOption(index)} disabled={!canVote || submitting} isLoading={submitting} > - {optionStatus[index] && } - {option.label === nota && index === last ? "None of the Above" : option.label} + {optionStatus[index] && ( + + )} + {option.label === nota && index === last + ? 'None of the Above' + : option.label}
- )} - )} + ) + })}
Note: You can select one or more options
) : null -} \ No newline at end of file +} diff --git a/components/VotePanel/CastVoteButtons.tsx b/components/VotePanel/CastVoteButtons.tsx index 5ade48dafb..97b45d944e 100644 --- a/components/VotePanel/CastVoteButtons.tsx +++ b/components/VotePanel/CastVoteButtons.tsx @@ -3,58 +3,11 @@ import { useState } from 'react' import { ThumbUpIcon, ThumbDownIcon } from '@heroicons/react/solid' import Button from '../Button' import VoteCommentModal from '../VoteCommentModal' -import { - useIsInCoolOffTime, - useIsVoting, - useVoterTokenRecord, - useVotingPop, -} from './hooks' -import { VotingClientType } from '@utils/uiTypes/VotePlugin' -import useVotePluginsClientStore from 'stores/useVotePluginsClientStore' -import useWalletOnePointOh from '@hooks/useWalletOnePointOh' +import { useIsInCoolOffTime, useIsVoting, useVotingPop } from './hooks' import { useProposalVoteRecordQuery } from '@hooks/queries/voteRecord' import { useSubmitVote } from '@hooks/useSubmitVote' import { useSelectedRealmInfo } from '@hooks/selectedRealm/useSelectedRealmRegistryEntry' -import { useGovernancePowerAsync } from '@hooks/queries/governancePower' - -export const useCanVote = () => { - const client = useVotePluginsClientStore( - (s) => s.state.currentRealmVotingClient - ) - const votingPop = useVotingPop() - const { result: govPower } = useGovernancePowerAsync(votingPop) - const wallet = useWalletOnePointOh() - const connected = !!wallet?.connected - - const { data: ownVoteRecord } = useProposalVoteRecordQuery('electoral') - const voterTokenRecord = useVoterTokenRecord() - - const isVoteCast = !!ownVoteRecord?.found - - const hasMinAmountToVote = voterTokenRecord && govPower?.gtn(0) - - const canVote = - connected && - !( - client.clientType === VotingClientType.NftVoterClient && !voterTokenRecord - ) && - !( - client.clientType === VotingClientType.HeliumVsrClient && - !voterTokenRecord - ) && - !isVoteCast && - hasMinAmountToVote - - const voteTooltipContent = !connected - ? 'You need to connect your wallet to be able to vote' - : client.clientType === VotingClientType.NftVoterClient && !voterTokenRecord - ? 'You must join the Realm to be able to vote' - : !hasMinAmountToVote - ? 'You don’t have governance power to vote in this dao' - : '' - - return [canVote, voteTooltipContent] as const -} +import { useCanVote } from './useCanVote' export const CastVoteButtons = () => { const [showVoteModal, setShowVoteModal] = useState(false) @@ -63,7 +16,6 @@ export const CastVoteButtons = () => { const allowDiscussion = realmInfo?.allowDiscussion ?? true const { submitting, submitVote } = useSubmitVote() const votingPop = useVotingPop() - const voterTokenRecord = useVoterTokenRecord() const [canVote, tooltipContent] = useCanVote() const { data: ownVoteRecord } = useProposalVoteRecordQuery('electoral') @@ -79,7 +31,6 @@ export const CastVoteButtons = () => { } else { await submitVote({ vote: vote === 'yes' ? VoteKind.Approve : VoteKind.Deny, - voterTokenRecord: voterTokenRecord!, }) } } @@ -131,7 +82,6 @@ export const CastVoteButtons = () => { isOpen={showVoteModal} onClose={() => setShowVoteModal(false)} vote={vote === 'yes' ? VoteKind.Approve : VoteKind.Deny} - voterTokenRecord={voterTokenRecord!} /> ) : null}
diff --git a/components/VotePanel/VetoButtons.tsx b/components/VotePanel/VetoButtons.tsx index 597f6c6683..334feabf40 100644 --- a/components/VotePanel/VetoButtons.tsx +++ b/components/VotePanel/VetoButtons.tsx @@ -71,7 +71,6 @@ const VetoButtons = () => { const vetoingPop = useVetoingPop() const canVeto = useCanVeto() const [openModal, setOpenModal] = useState(false) - const voterTokenRecord = useUserVetoTokenRecord() const { data: userVetoRecord } = useProposalVoteRecordQuery('veto') const { submitting, submitVote } = useSubmitVote() @@ -81,15 +80,11 @@ const VetoButtons = () => { } else { submitVote({ vote: VoteKind.Veto, - voterTokenRecord: voterTokenRecord!, }) } } - return vetoable && - vetoingPop && - voterTokenRecord && - !userVetoRecord?.found ? ( + return vetoable && vetoingPop && !userVetoRecord?.found ? ( <>
@@ -116,7 +111,6 @@ const VetoButtons = () => { setOpenModal(false)} isOpen={openModal} - voterTokenRecord={voterTokenRecord} vote={VoteKind.Veto} /> ) : null} diff --git a/components/VotePanel/useCanVote.ts b/components/VotePanel/useCanVote.ts new file mode 100644 index 0000000000..077491b323 --- /dev/null +++ b/components/VotePanel/useCanVote.ts @@ -0,0 +1,107 @@ +import { useVoterTokenRecord, useVotingPop } from './hooks' +import { VotingClientType } from '@utils/uiTypes/VotePlugin' +import useVotePluginsClientStore from 'stores/useVotePluginsClientStore' +import useWalletOnePointOh from '@hooks/useWalletOnePointOh' +import { useProposalVoteRecordQuery } from '@hooks/queries/voteRecord' +import { + determineVotingPowerType, + useGovernancePowerAsync, +} from '@hooks/queries/governancePower' + +import { useConnection } from '@solana/wallet-adapter-react' +import { useAsync } from 'react-async-hook' +import { useSelectedDelegatorStore } from 'stores/useSelectedDelegatorStore' + +import { DELEGATOR_BATCH_VOTE_SUPPORT_BY_PLUGIN } from '@constants/flags' +import useSelectedRealmPubkey from '@hooks/selectedRealm/useSelectedRealmPubkey' +import { useRealmQuery } from '@hooks/queries/realm' +import { useTokenOwnerRecordsDelegatedToUser } from '@hooks/queries/tokenOwnerRecord' + +const useHasAnyVotingPower = (role: 'community' | 'council' | undefined) => { + const realmPk = useSelectedRealmPubkey() + const realm = useRealmQuery().data?.result + + const { connection } = useConnection() + + const relevantMint = + role && role === 'community' + ? realm?.account.communityMint + : realm?.account.config.councilMint + + const { result: personalAmount } = useGovernancePowerAsync(role) + + const { result: plugin } = useAsync( + async () => + role && realmPk && determineVotingPowerType(connection, realmPk, role), + [connection, realmPk, role] + ) + + // DELEGATOR VOTING --------------------------------------------------------------- + + const batchVoteSupported = + plugin && DELEGATOR_BATCH_VOTE_SUPPORT_BY_PLUGIN[plugin] + // If the user is selecting a specific delegator, we want to just use that and not count the other delegators + const selectedDelegator = useSelectedDelegatorStore((s) => + role === 'community' ? s.communityDelegator : s.councilDelegator + ) + const torsDelegatedToUser = useTokenOwnerRecordsDelegatedToUser() + const relevantDelegators = selectedDelegator + ? undefined + : relevantMint && + torsDelegatedToUser?.filter((x) => + x.account.governingTokenMint.equals(relevantMint) + ) + + //--------------------------------------------------------------------------------- + // notably, this is ignoring whether the delegators actually have voting power, but it's not a big deal + const canBatchVote = + relevantDelegators === undefined || batchVoteSupported === undefined + ? undefined + : batchVoteSupported && relevantDelegators?.length !== 0 + + // technically, if you have a TOR you can vote even if there's no power. But that doesnt seem user friendly. + const canPersonallyVote = + personalAmount === undefined ? undefined : personalAmount.isZero() === false + + const canVote = canBatchVote || canPersonallyVote + + return canVote +} + +export const useCanVote = () => { + const client = useVotePluginsClientStore( + (s) => s.state.currentRealmVotingClient + ) + const votingPop = useVotingPop() + const wallet = useWalletOnePointOh() + const connected = !!wallet?.connected + + const { data: ownVoteRecord } = useProposalVoteRecordQuery('electoral') + const voterTokenRecord = useVoterTokenRecord() + + const isVoteCast = !!ownVoteRecord?.found + + const hasMinAmountToVote = useHasAnyVotingPower(votingPop) + + const canVote = + connected && + !( + client.clientType === VotingClientType.NftVoterClient && !voterTokenRecord + ) && + !( + client.clientType === VotingClientType.HeliumVsrClient && + !voterTokenRecord + ) && + !isVoteCast && + hasMinAmountToVote + + const voteTooltipContent = !connected + ? 'You need to connect your wallet to be able to vote' + : client.clientType === VotingClientType.NftVoterClient && !voterTokenRecord + ? 'You must join the Realm to be able to vote' + : !hasMinAmountToVote + ? 'You don’t have governance power to vote in this dao' + : '' + + return [canVote, voteTooltipContent] as const +} diff --git a/constants/flags.ts b/constants/flags.ts index 548a5d1b9c..a928eb4260 100644 --- a/constants/flags.ts +++ b/constants/flags.ts @@ -1,3 +1,17 @@ +import { findPluginName } from './plugins' + export const SUPPORT_CNFTS = true export const ON_NFT_VOTER_V2 = false export const SHOW_DELEGATORS_LIST = false + +export const DELEGATOR_BATCH_VOTE_SUPPORT_BY_PLUGIN: Record< + ReturnType, + boolean +> = { + vanilla: true, + VSR: false, + HeliumVSR: false, + gateway: false, + NFT: false, + unknown: false, +} diff --git a/constants/plugins.ts b/constants/plugins.ts index 79726ddc90..daee165085 100644 --- a/constants/plugins.ts +++ b/constants/plugins.ts @@ -1,4 +1,5 @@ -import * as heliumVsrSdk from '@helium/voter-stake-registry-sdk' +import { PROGRAM_ID as HELIUM_VSR_PROGRAM_ID } from '@helium/voter-stake-registry-sdk' +import { PublicKey } from '@solana/web3.js' import { DEFAULT_NFT_VOTER_PLUGIN } from '@tools/constants' export const VSR_PLUGIN_PKS: string[] = [ @@ -11,7 +12,7 @@ export const VSR_PLUGIN_PKS: string[] = [ ] export const HELIUM_VSR_PLUGINS_PKS: string[] = [ - heliumVsrSdk.PROGRAM_ID.toBase58(), + HELIUM_VSR_PROGRAM_ID.toBase58(), ] export const NFT_PLUGINS_PKS: string[] = [ @@ -24,3 +25,16 @@ export const GATEWAY_PLUGINS_PKS: string[] = [ 'Ggatr3wgDLySEwA2qEjt1oiw4BUzp5yMLJyz21919dq6', 'GgathUhdrCWRHowoRKACjgWhYHfxCEdBi5ViqYN6HVxk', // v2, supporting composition ] + +export const findPluginName = (programId: PublicKey | undefined) => + programId === undefined + ? ('vanilla' as const) + : VSR_PLUGIN_PKS.includes(programId.toString()) + ? ('VSR' as const) + : HELIUM_VSR_PLUGINS_PKS.includes(programId.toString()) + ? 'HeliumVSR' + : NFT_PLUGINS_PKS.includes(programId.toString()) + ? 'NFT' + : GATEWAY_PLUGINS_PKS.includes(programId.toString()) + ? 'gateway' + : 'unknown' diff --git a/hooks/queries/governancePower.ts b/hooks/queries/governancePower.ts index 51cd17dbb2..f6c6a340a3 100644 --- a/hooks/queries/governancePower.ts +++ b/hooks/queries/governancePower.ts @@ -102,6 +102,19 @@ export const getNftGovpower = async ( return power } +export const findPluginName = (programId: PublicKey | undefined) => + programId === undefined + ? ('vanilla' as const) + : VSR_PLUGIN_PKS.includes(programId.toString()) + ? ('VSR' as const) + : HELIUM_VSR_PLUGINS_PKS.includes(programId.toString()) + ? 'HeliumVSR' + : NFT_PLUGINS_PKS.includes(programId.toString()) + ? 'NFT' + : GATEWAY_PLUGINS_PKS.includes(programId.toString()) + ? 'gateway' + : 'unknown' + export const determineVotingPowerType = async ( connection: Connection, realmPk: PublicKey, @@ -116,17 +129,7 @@ export const determineVotingPowerType = async ( ? config.result?.account.communityTokenConfig.voterWeightAddin : config.result?.account.councilTokenConfig.voterWeightAddin - return programId === undefined - ? ('vanilla' as const) - : VSR_PLUGIN_PKS.includes(programId.toString()) - ? ('VSR' as const) - : HELIUM_VSR_PLUGINS_PKS.includes(programId.toString()) - ? 'HeliumVSR' - : NFT_PLUGINS_PKS.includes(programId.toString()) - ? 'NFT' - : GATEWAY_PLUGINS_PKS.includes(programId.toString()) - ? 'gateway' - : 'unknown' + return findPluginName(programId) } export const useGovernancePowerAsync = ( diff --git a/hooks/queries/proposal.ts b/hooks/queries/proposal.ts index 9ff61adf8a..1539785074 100644 --- a/hooks/queries/proposal.ts +++ b/hooks/queries/proposal.ts @@ -1,4 +1,4 @@ -import { PublicKey } from '@solana/web3.js' +import { Connection, PublicKey } from '@solana/web3.js' import { useQuery } from '@tanstack/react-query' import asFindable from '@utils/queries/asFindable' import { @@ -28,6 +28,15 @@ export const proposalQueryKeys = { ], } +export const fetchProposalByPubkeyQuery = ( + connection: Connection, + pubkey: PublicKey +) => + queryClient.fetchQuery({ + queryKey: proposalQueryKeys.byPubkey(connection.rpcEndpoint, pubkey), + queryFn: () => asFindable(getProposal)(connection, pubkey), + }) + export const useProposalByPubkeyQuery = (pubkey: PublicKey | undefined) => { const connection = useLegacyConnectionContext() @@ -42,7 +51,6 @@ export const useProposalByPubkeyQuery = (pubkey: PublicKey | undefined) => { }, enabled, }) - return query } diff --git a/hooks/useSubmitVote.ts b/hooks/useSubmitVote.ts index 10a14f962e..16cafd9afa 100644 --- a/hooks/useSubmitVote.ts +++ b/hooks/useSubmitVote.ts @@ -9,10 +9,10 @@ import { ProgramAccount, Proposal, RpcContext, - TokenOwnerRecord, Vote, VoteChoice, VoteKind, + getTokenOwnerRecordAddress, withCastVote, } from '@solana/spl-governance' import { getProgramVersionForRealm } from '@models/registry/api' @@ -30,6 +30,8 @@ import { TransactionInstruction } from '@solana/web3.js' import useProgramVersion from './useProgramVersion' import useVotingTokenOwnerRecords from './useVotingTokenOwnerRecords' import { useMemo } from 'react' +import { useTokenOwnerRecordsDelegatedToUser } from './queries/tokenOwnerRecord' +import { useSelectedDelegatorStore } from 'stores/useSelectedDelegatorStore' export const useSubmitVote = () => { const wallet = useWalletOnePointOh() @@ -49,20 +51,29 @@ export const useSubmitVote = () => { config?.account.communityTokenConfig.voterWeightAddin?.toBase58() ) + const selectedCommunityDelegator = useSelectedDelegatorStore( + (s) => s.communityDelegator + ) + const selectedCouncilDelegator = useSelectedDelegatorStore( + (s) => s.councilDelegator + ) + const delegators = useTokenOwnerRecordsDelegatedToUser() + const { error, loading, execute } = useAsyncCallback( async ({ vote, - voterTokenRecord, comment, voteWeights, }: { vote: VoteKind - voterTokenRecord: ProgramAccount comment?: string voteWeights?: number[] }) => { + if (!proposal) throw new Error() + if (!realm) throw new Error() + const rpcContext = new RpcContext( - proposal!.owner, + proposal.owner, getProgramVersionForRealm(realmInfo!), wallet!, connection.current, @@ -82,17 +93,59 @@ export const useSubmitVote = () => { ) } + const relevantMint = + vote !== VoteKind.Veto + ? // if its not a veto, business as usual + proposal.account.governingTokenMint + : // if it is a veto, the vetoing mint is the opposite of the governing mint + realm.account.communityMint.equals( + proposal.account.governingTokenMint + ) + ? realm.account.config.councilMint + : realm.account.communityMint + if (relevantMint === undefined) throw new Error() + + const role = relevantMint.equals(realm.account.communityMint) + ? 'community' + : 'council' + + const relevantSelectedDelegator = + role === 'community' + ? selectedCommunityDelegator + : selectedCouncilDelegator + + const actingAsWalletPk = + relevantSelectedDelegator ?? wallet?.publicKey ?? undefined + if (!actingAsWalletPk) throw new Error() + + const tokenOwnerRecordPk = await getTokenOwnerRecordAddress( + realm.owner, + realm.pubkey, + relevantMint, + actingAsWalletPk + ) + + const relevantDelegators = + // if the user is manually selecting a delegator, don't auto-vote for the rest of the delegators + // ("delegator" is a slight misnomer here since you can select yourself, so that you dont vote with your other delegators) + relevantSelectedDelegator !== undefined + ? [] + : delegators + ?.filter((x) => x.account.governingTokenMint.equals(relevantMint)) + .map((x) => x.pubkey) + try { await castVote( rpcContext, - realm!, - proposal!, - voterTokenRecord.pubkey, + realm, + proposal, + tokenOwnerRecordPk, vote, msg, client, confirmationCallback, - voteWeights + voteWeights, + relevantDelegators ) queryClient.invalidateQueries({ queryKey: proposalQueryKeys.all(connection.current.rpcEndpoint), diff --git a/hooks/useUserOrDelegator.ts b/hooks/useUserOrDelegator.ts index e64a6d3173..b5ad8d325b 100644 --- a/hooks/useUserOrDelegator.ts +++ b/hooks/useUserOrDelegator.ts @@ -1,6 +1,7 @@ import { useSelectedDelegatorStore } from 'stores/useSelectedDelegatorStore' import useWalletOnePointOh from './useWalletOnePointOh' +/** @deprecated it's very suspicious this only cares about community delegator and shud prob not be used / shud take role as input */ export default function () { const wallet = useWalletOnePointOh() const selectedCommunityDelegator = useSelectedDelegatorStore(