diff --git a/packages/proposals/src/actions/vote.ts b/packages/proposals/src/actions/vote.ts new file mode 100644 index 0000000..8496160 --- /dev/null +++ b/packages/proposals/src/actions/vote.ts @@ -0,0 +1,87 @@ +import { + Account, + Connection, + PublicKey, + TransactionInstruction, +} from '@solana/web3.js'; +import { + contexts, + utils, + models, + ParsedAccount, + actions, +} from '@oyster/common'; + +import { TimelockSet } from '../models/timelock'; +import { AccountLayout } from '@solana/spl-token'; +import { mintVotingTokensInstruction } from '../models/mintVotingTokens'; +import { LABELS } from '../constants'; +import { voteInstruction } from '../models/vote'; +const { createTokenAccount } = actions; +const { sendTransaction } = contexts.Connection; +const { notify } = utils; +const { approve } = models; + +export const vote = async ( + connection: Connection, + wallet: any, + proposal: ParsedAccount, + votingAccount: PublicKey, + votingTokenAmount: number, +) => { + const PROGRAM_IDS = utils.programIds(); + + let signers: Account[] = []; + let instructions: TransactionInstruction[] = []; + + const [mintAuthority] = await PublicKey.findProgramAddress( + [PROGRAM_IDS.timelock.programAccountId.toBuffer()], + PROGRAM_IDS.timelock.programId, + ); + + const transferAuthority = approve( + instructions, + [], + votingAccount, + wallet.publicKey, + votingTokenAmount, + ); + + signers.push(transferAuthority); + + instructions.push( + voteInstruction( + proposal.pubkey, + votingAccount, + proposal.info.votingMint, + transferAuthority.publicKey, + mintAuthority, + votingTokenAmount, + ), + ); + + notify({ + message: LABELS.BURNING_VOTES, + description: LABELS.PLEASE_WAIT, + type: 'warn', + }); + + try { + let tx = await sendTransaction( + connection, + wallet, + instructions, + signers, + true, + ); + + notify({ + message: LABELS.VOTES_BURNED, + type: 'success', + description: LABELS.TRANSACTION + ` ${tx}`, + }); + } catch (ex) { + console.error(ex); + throw new Error(); + } +}; diff --git a/packages/proposals/src/components/Proposal/StateBadge.tsx b/packages/proposals/src/components/Proposal/StateBadge.tsx index ac7e7da..2c27009 100644 --- a/packages/proposals/src/components/Proposal/StateBadge.tsx +++ b/packages/proposals/src/components/Proposal/StateBadge.tsx @@ -17,7 +17,10 @@ export function StateBadgeRibbon({ const status = proposal.info.state.status; let color = STATE_COLOR[status]; return ( - + {children} ); diff --git a/packages/proposals/src/components/Proposal/Vote.tsx b/packages/proposals/src/components/Proposal/Vote.tsx new file mode 100644 index 0000000..8a3af9c --- /dev/null +++ b/packages/proposals/src/components/Proposal/Vote.tsx @@ -0,0 +1,72 @@ +import { ParsedAccount } from '@oyster/common'; +import { Button, Col, Modal, Row, Slider } from 'antd'; +import React, { useState } from 'react'; +import { TimelockSet } from '../../models/timelock'; +import { LABELS } from '../../constants'; +import { vote } from '../../actions/vote'; +import { utils, contexts, hooks } from '@oyster/common'; +import { ExclamationCircleOutlined } from '@ant-design/icons'; + +const { useWallet } = contexts.Wallet; +const { useConnection } = contexts.Connection; +const { useAccountByMint } = hooks; + +const { confirm } = Modal; +export function Vote({ proposal }: { proposal: ParsedAccount }) { + const wallet = useWallet(); + const connection = useConnection(); + const voteAccount = useAccountByMint(proposal.info.votingMint); + const [tokenAmount, setTokenAmount] = useState(1); + return ( + + ); +} diff --git a/packages/proposals/src/constants/labels.ts b/packages/proposals/src/constants/labels.ts index 88524f4..fde64af 100644 --- a/packages/proposals/src/constants/labels.ts +++ b/packages/proposals/src/constants/labels.ts @@ -52,4 +52,7 @@ export const LABELS = { BULK: 'Bulk', SINGLE: 'Single', ADD_VOTES: 'Add Votes', + BURNING_VOTES: 'Burning your votes...', + VOTES_BURNED: 'Votes burned', + VOTE: 'Vote', }; diff --git a/packages/proposals/src/models/timelock.ts b/packages/proposals/src/models/timelock.ts index 5c14d45..cbd9bff 100644 --- a/packages/proposals/src/models/timelock.ts +++ b/packages/proposals/src/models/timelock.ts @@ -15,6 +15,7 @@ export enum TimelockInstruction { RemoveSigner = 3, AddCustomSingleSignerTransaction = 4, Sign = 8, + Vote = 9, MintVotingTokens = 10, } diff --git a/packages/proposals/src/models/vote.ts b/packages/proposals/src/models/vote.ts new file mode 100644 index 0000000..e260ba5 --- /dev/null +++ b/packages/proposals/src/models/vote.ts @@ -0,0 +1,64 @@ +import { PublicKey, TransactionInstruction } from '@solana/web3.js'; +import { utils } from '@oyster/common'; +import * as Layout from '../utils/layout'; + +import * as BufferLayout from 'buffer-layout'; +import { TimelockInstruction } from './timelock'; +import BN from 'bn.js'; + +/// [Requires Voting tokens] +/// Burns voting tokens, indicating you approve of running this set of transactions. If you tip the consensus, +/// then the transactions begin to be run at their time slots. +/// +/// 0. `[writable]` Timelock set account. +/// 1. `[writable]` Voting account. +/// 2. `[writable]` Voting mint account. +/// 3. `[]` Transfer authority +/// 4. `[]` Timelock program mint authority +/// 5. `[]` Timelock program account pub key. +/// 6. `[]` Token program account. +export const voteInstruction = ( + timelockSetAccount: PublicKey, + votingAccount: PublicKey, + votingMint: PublicKey, + transferAuthority: PublicKey, + mintAuthority: PublicKey, + votingTokenAmount: number, +): TransactionInstruction => { + const PROGRAM_IDS = utils.programIds(); + + const dataLayout = BufferLayout.struct([ + BufferLayout.u8('instruction'), + Layout.uint64('votingTokenAmount'), + ]); + + const data = Buffer.alloc(dataLayout.span); + + dataLayout.encode( + { + instruction: TimelockInstruction.Vote, + votingTokenAmount: new BN(votingTokenAmount), + }, + data, + ); + + const keys = [ + { pubkey: timelockSetAccount, isSigner: false, isWritable: true }, + { pubkey: votingAccount, isSigner: false, isWritable: true }, + { pubkey: votingMint, isSigner: false, isWritable: true }, + { pubkey: transferAuthority, isSigner: true, isWritable: false }, + { pubkey: mintAuthority, isSigner: false, isWritable: false }, + { + pubkey: PROGRAM_IDS.timelock.programAccountId, + isSigner: false, + isWritable: false, + }, + { pubkey: PROGRAM_IDS.token, isSigner: false, isWritable: false }, + ]; + + return new TransactionInstruction({ + keys, + programId: PROGRAM_IDS.timelock.programId, + data, + }); +}; diff --git a/packages/proposals/src/views/proposal/index.tsx b/packages/proposals/src/views/proposal/index.tsx index bad1b64..4be4f06 100644 --- a/packages/proposals/src/views/proposal/index.tsx +++ b/packages/proposals/src/views/proposal/index.tsx @@ -20,6 +20,7 @@ import { NewInstructionCard } from '../../components/Proposal/NewInstructionCard import SignButton from '../../components/Proposal/SignButton'; import AddSigners from '../../components/Proposal/AddSigners'; import AddVotes from '../../components/Proposal/AddVotes'; +import { Vote } from '../../components/Proposal/Vote'; export const urlRegex = /[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/; const { useMint } = contexts.Accounts; const { useAccountByMint } = hooks; @@ -60,6 +61,8 @@ function InnerProposalView({ }) { const sigAccount = useAccountByMint(proposal.info.signatoryMint); const adminAccount = useAccountByMint(proposal.info.adminMint); + const voteAccount = useAccountByMint(proposal.info.votingMint); + const instructionsForProposal: ParsedAccount[] = proposal.info.state.timelockTransactions .map(k => instructions[k.toBase58()]) .filter(k => k); @@ -198,6 +201,11 @@ function InnerProposalView({ proposal.info.state.status === TimelockStateStatus.Draft && ( )} + {voteAccount && + voteAccount.info.amount.toNumber() > 0 && + proposal.info.state.status === TimelockStateStatus.Voting && ( + + )} @@ -222,14 +230,15 @@ function InnerProposalView({ /> ))} - {instructionsForProposal.length < INSTRUCTION_LIMIT && ( - - - - )} + {instructionsForProposal.length < INSTRUCTION_LIMIT && + proposal.info.state.status === TimelockStateStatus.Draft && ( + + + + )} @@ -245,7 +254,7 @@ function getVotesRequired(proposal: ParsedAccount): number { proposal.info.config.consensusAlgorithm === ConsensusAlgorithm.SuperMajority ) { return Math.ceil( - proposal.info.state.totalVotingTokensMinted.toNumber() * 0.5, + proposal.info.state.totalVotingTokensMinted.toNumber() * 0.66, ); } else if ( proposal.info.config.consensusAlgorithm === ConsensusAlgorithm.FullConsensus