Add add votes button and commands

This commit is contained in:
Dummy Tester 123 2021-03-01 13:19:55 -06:00
parent 0c95a9ab6c
commit c5ac500648
9 changed files with 517 additions and 20 deletions

View File

@ -0,0 +1,133 @@
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';
const { createTokenAccount } = actions;
const { sendTransaction } = contexts.Connection;
const { notify } = utils;
const { approve } = models;
export const mintVotingTokens = async (
connection: Connection,
wallet: any,
proposal: ParsedAccount<TimelockSet>,
signatoryAccount: PublicKey,
newVotingAccountOwner: PublicKey,
existingVoteAccount: PublicKey | undefined,
votingTokenAmount: number,
) => {
const PROGRAM_IDS = utils.programIds();
let signers: Account[] = [];
let instructions: TransactionInstruction[] = [];
const accountRentExempt = await connection.getMinimumBalanceForRentExemption(
AccountLayout.span,
);
if (!existingVoteAccount) {
existingVoteAccount = createTokenAccount(
instructions,
wallet.publicKey,
accountRentExempt,
proposal.info.votingMint,
newVotingAccountOwner,
signers,
);
notify({
message: LABELS.ADDING_NEW_VOTE_ACCOUNT,
description: LABELS.PLEASE_WAIT,
type: 'warn',
});
try {
let tx = await sendTransaction(
connection,
wallet,
instructions,
signers,
true,
);
notify({
message: LABELS.NEW_VOTED_ACCOUNT_ADDED,
type: 'success',
description: LABELS.TRANSACTION + ` ${tx}`,
});
} catch (ex) {
console.error(ex);
throw new Error();
}
signers = [];
instructions = [];
}
const [mintAuthority] = await PublicKey.findProgramAddress(
[PROGRAM_IDS.timelock.programAccountId.toBuffer()],
PROGRAM_IDS.timelock.programId,
);
const transferAuthority = approve(
instructions,
[],
signatoryAccount,
wallet.publicKey,
1,
);
signers.push(transferAuthority);
instructions.push(
mintVotingTokensInstruction(
proposal.pubkey,
existingVoteAccount,
proposal.info.votingMint,
signatoryAccount,
proposal.info.signatoryValidation,
transferAuthority.publicKey,
mintAuthority,
votingTokenAmount,
),
);
notify({
message: LABELS.ADDING_VOTES_TO_VOTER,
description: LABELS.PLEASE_WAIT,
type: 'warn',
});
try {
let tx = await sendTransaction(
connection,
wallet,
instructions,
signers,
true,
);
notify({
message: LABELS.VOTES_ADDED,
type: 'success',
description: LABELS.TRANSACTION + ` ${tx}`,
});
} catch (ex) {
console.error(ex);
throw new Error();
}
};

View File

@ -38,7 +38,6 @@ export default function AddSigners({
failedSigners: string;
}) => {
const signers = values.signers.split(',').map(s => s.trim());
setSaving(true);
if (!adminAccount) {
notify({
message: LABELS.ADMIN_ACCOUNT_NOT_DEFINED,
@ -46,13 +45,15 @@ export default function AddSigners({
});
return;
}
if (signers.length == 0 || (signers.length == 1 && !signers[0])) {
if (!signers.find(s => s)) {
notify({
message: LABELS.ENTER_AT_LEAST_ONE_PUB_KEY,
type: 'error',
});
return;
}
setSaving(true);
const failedSignersHold: string[] = [];
@ -79,6 +80,7 @@ export default function AddSigners({
setSaving(false);
setSavePerc(0);
setIsModalVisible(failedSignersHold.length > 0);
if (failedSignersHold.length === 0) form.resetFields();
};
return (

View File

@ -0,0 +1,246 @@
import { ParsedAccount } from '@oyster/common';
import { Button, Modal, Input, Form, Progress, InputNumber, Radio } from 'antd';
import React, { useState } from 'react';
import { TimelockSet } from '../../models/timelock';
import { utils, contexts, hooks } from '@oyster/common';
import { PublicKey } from '@solana/web3.js';
import { LABELS } from '../../constants';
import { mintVotingTokens } from '../../actions/mintVotingTokens';
const { notify } = utils;
const { TextArea } = Input;
const { useWallet } = contexts.Wallet;
const { useConnection } = contexts.Connection;
const { useAccountByMint } = hooks;
const { deserializeAccount } = contexts.Accounts;
const layout = {
labelCol: { span: 5 },
wrapperCol: { span: 19 },
};
export default function AddVotes({
proposal,
}: {
proposal: ParsedAccount<TimelockSet>;
}) {
const PROGRAM_IDS = utils.programIds();
const wallet = useWallet();
const connection = useConnection();
const sigAccount = useAccountByMint(proposal.info.signatoryMint);
const [saving, setSaving] = useState(false);
const [isModalVisible, setIsModalVisible] = useState(false);
const [bulkModeVisible, setBulkModeVisible] = useState(false);
const [savePerc, setSavePerc] = useState(0);
const [failedVoters, setFailedVoters] = useState<any>([]);
const [form] = Form.useForm();
const onSubmit = async (values: {
voters: string;
failedVoters: string;
singleVoter: string;
singleVoteCount: number;
}) => {
const { singleVoter, singleVoteCount } = values;
const votersAndCounts = values.voters
? values.voters.split(',').map(s => s.trim())
: [];
const voters: any[] = [];
votersAndCounts.forEach((value: string, index: number) => {
if (index % 2 == 0) voters.push([value, 0]);
else voters[voters.length - 1][1] = parseInt(value);
});
console.log('Voters', votersAndCounts);
if (singleVoter) voters.push([singleVoter, singleVoteCount]);
if (!sigAccount) {
notify({
message: LABELS.SIG_ACCOUNT_NOT_DEFINED,
type: 'error',
});
return;
}
if (!voters.find(v => v[0])) {
notify({
message: LABELS.ENTER_AT_LEAST_ONE_PUB_KEY,
type: 'error',
});
return;
}
setSaving(true);
if (voters.find(v => v[1] === 0)) {
notify({
message: LABELS.CANT_GIVE_ZERO_VOTES,
type: 'error',
});
setSaving(false);
return;
}
const failedVotersHold: any[] = [];
for (let i = 0; i < voters.length; i++) {
try {
const tokenAccounts = await connection.getTokenAccountsByOwner(
new PublicKey(voters[i][0]),
{
programId: PROGRAM_IDS.token,
},
);
const specificToThisMint = tokenAccounts.value.find(
a =>
deserializeAccount(a.account.data).mint.toBase58() ===
proposal.info.votingMint.toBase58(),
);
await mintVotingTokens(
connection,
wallet.wallet,
proposal,
sigAccount.pubkey,
new PublicKey(voters[i][0]),
specificToThisMint?.pubkey,
voters[i][1],
);
setSavePerc(Math.round(100 * ((i + 1) / voters.length)));
} catch (e) {
console.error(e);
failedVotersHold.push(voters[i]);
notify({
message: voters[i][0] + LABELS.PUB_KEY_FAILED,
type: 'error',
});
}
}
setFailedVoters(failedVotersHold);
setSaving(false);
setSavePerc(0);
setIsModalVisible(failedVotersHold.length > 0);
if (failedVotersHold.length === 0) form.resetFields();
};
return (
<>
{sigAccount ? (
<Button
onClick={() => {
setIsModalVisible(true);
}}
>
{LABELS.ADD_VOTES}
</Button>
) : null}
<Modal
title={LABELS.ADD_VOTES}
visible={isModalVisible}
destroyOnClose={true}
onOk={form.submit}
zIndex={10000}
onCancel={() => {
if (!saving) setIsModalVisible(false);
}}
>
<Form
className={'voters-form'}
{...layout}
form={form}
onFinish={onSubmit}
name="control-hooks"
>
{!saving && (
<>
<Form.Item
label={LABELS.VOTE_MODE}
name="voteMode"
initialValue={LABELS.SINGLE}
rules={[{ required: false }]}
>
<Radio.Group
value={layout}
onChange={e =>
setBulkModeVisible(e.target.value === LABELS.BULK)
}
>
<Radio.Button value={LABELS.BULK}>{LABELS.BULK}</Radio.Button>
<Radio.Button value={LABELS.SINGLE}>
{LABELS.SINGLE}
</Radio.Button>
</Radio.Group>
</Form.Item>
{!bulkModeVisible && (
<>
<Form.Item
name="singleVoter"
label={LABELS.SINGLE_VOTER}
rules={[{ required: false }]}
>
<Input placeholder={LABELS.SINGLE_KEY} />
</Form.Item>
<Form.Item
name="singleVoteCount"
label={LABELS.VOTE_COUNT}
initialValue={0}
rules={[{ required: false }]}
>
<InputNumber />
</Form.Item>
</>
)}
{bulkModeVisible && (
<Form.Item
name="voters"
label={LABELS.BULK_VOTERS}
rules={[{ required: false }]}
>
<TextArea
id="voters"
placeholder={LABELS.COMMA_SEPARATED_KEYS_AND_VOTES}
/>
</Form.Item>
)}
</>
)}
</Form>
{saving && <Progress percent={savePerc} status="active" />}
{!saving && failedVoters.length > 0 && (
<div
style={{
flex: 1,
flexDirection: 'column',
justifyContent: 'space-evenly',
alignItems: 'stretch',
display: 'flex',
}}
>
<Button
onClick={() => {
navigator.clipboard.writeText(failedVoters.join(','));
notify({
message: LABELS.FAILED_SIGNERS_COPIED_TO_CLIPBOARD,
type: 'success',
});
}}
>
{LABELS.COPY_FAILED_ADDRESSES_TO_CLIPBOARD}
</Button>
<br />
<Button
onClick={() => {
form.setFieldsValue({
voters: failedVoters.join(','),
});
notify({
message: LABELS.FAILED_SIGNERS_COPIED_TO_INPUT,
type: 'success',
});
}}
>
{LABELS.COPY_FAILED_ADDRESSES_TO_INPUT}
</Button>
</div>
)}
</Modal>
</>
);
}

View File

@ -1,7 +1,11 @@
import { ParsedAccount } from '@oyster/common';
import { Badge, Tag } from 'antd';
import React from 'react';
import { STATE_COLOR, TimelockSet } from '../../models/timelock';
import {
STATE_COLOR,
TimelockSet,
TimelockStateStatus,
} from '../../models/timelock';
export function StateBadgeRibbon({
proposal,
@ -26,5 +30,5 @@ export function StateBadge({
}) {
const status = proposal.info.state.status;
let color = STATE_COLOR[status];
return <Tag color={color}>{status}</Tag>;
return <Tag color={color}>{TimelockStateStatus[status]}</Tag>;
}

View File

@ -28,10 +28,28 @@ export const LABELS = {
SIGNERS: 'Signers',
ADD_SIGNERS: 'Add Signers',
ADMIN_ACCOUNT_NOT_DEFINED: 'Admin account is not defined',
SIG_ACCOUNT_NOT_DEFINED: 'Signature account is not defined',
ENTER_AT_LEAST_ONE_PUB_KEY: 'Please enter at least one pub key.',
PUB_KEY_FAILED:
" Pub key failed. Please check your inspector tab for more information. We'll continue onward and add this to a list for you to re-upload in a later save.",
ADD: 'Add',
REMOVE: 'Remove',
ADDING_OR_REMOVING: 'Type',
ADDING_VOTES_TO_VOTER: 'Adding votes to voter',
PLEASE_WAIT: 'Please wait...',
VOTES_ADDED: 'Votes added.',
NEW_VOTED_ACCOUNT_ADDED: 'New vote account added.',
ADDING_NEW_VOTE_ACCOUNT: 'Adding new vote account...',
TRANSACTION: 'Transaction - ',
CANT_GIVE_ZERO_VOTES: "Can't give zero votes to a user!",
BULK_VOTERS: 'Voters',
COMMA_SEPARATED_KEYS_AND_VOTES:
'base58 pubkey, vote count, base58 pubkey, vote count, ...',
SINGLE_VOTER: 'Single Voter',
VOTE_COUNT: 'Vote Amount',
SINGLE_KEY: 'base58 pubkey',
VOTE_MODE: 'Vote Mode',
BULK: 'Bulk',
SINGLE: 'Single',
ADD_VOTES: 'Add Votes',
};

View File

@ -63,7 +63,7 @@ export const addCustomSingleSignerTransactionInstruction = (
dataLayout.encode(
{
instruction: TimelockInstruction.addCustomSingleSignerTransaction,
instruction: TimelockInstruction.AddCustomSingleSignerTransaction,
slot: new BN(slot),
instructions: instructionAsBytes,
position: position,

View File

@ -0,0 +1,69 @@
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 Signatory token]
/// Mints voting tokens for a destination account to be used during the voting process.
///
/// 0. `[writable]` Timelock set account.
/// 1. `[writable]` Initialized Voting account.
/// 2. `[writable]` Voting mint account.
/// 3. `[writable]` Signatory account
/// 4. `[writable]` Signatory validation account.
/// 5. `[]` Transfer authority
/// 6. `[]` Timelock program mint authority
/// 7. `[]` Timelock program account pub key.
/// 8. `[]` Token program account.
export const mintVotingTokensInstruction = (
timelockSetAccount: PublicKey,
votingAccount: PublicKey,
votingMint: PublicKey,
signatoryAccount: PublicKey,
signatoryValidationAccount: 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.MintVotingTokens,
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: signatoryAccount, isSigner: false, isWritable: true },
{ pubkey: signatoryValidationAccount, 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,
});
};

View File

@ -13,8 +13,9 @@ export enum TimelockInstruction {
InitTimelockSet = 1,
AddSigner = 2,
RemoveSigner = 3,
addCustomSingleSignerTransaction = 4,
AddCustomSingleSignerTransaction = 4,
Sign = 8,
MintVotingTokens = 10,
}
export interface TimelockConfig {
@ -55,11 +56,11 @@ export enum TimelockStateStatus {
}
export const STATE_COLOR: Record<string, string> = {
Draft: 'orange',
Voting: 'blue',
Executing: 'green',
Completed: 'purple',
Deleted: 'gray',
[TimelockStateStatus.Draft]: 'orange',
[TimelockStateStatus.Voting]: 'blue',
[TimelockStateStatus.Executing]: 'green',
[TimelockStateStatus.Completed]: 'purple',
[TimelockStateStatus.Deleted]: 'gray',
};
export interface TimelockState {
@ -154,7 +155,7 @@ export const TimelockSetParser = (
adminValidation: data.adminValidation,
votingValidation: data.votingValidation,
state: {
status: TimelockStateStatus[data.timelockStateStatus],
status: data.timelockStateStatus,
totalVotingTokensMinted: data.totalVotingTokensMinted,
totalSigningTokensMinted: data.totalSigningTokensMinted,
descLink: utils.fromUTF8Array(data.descLink).replaceAll('\u0000', ''),

View File

@ -6,6 +6,7 @@ import {
ConsensusAlgorithm,
INSTRUCTION_LIMIT,
TimelockSet,
TimelockStateStatus,
TimelockTransaction,
} from '../../models/timelock';
import { useParams } from 'react-router-dom';
@ -18,6 +19,7 @@ import { InstructionCard } from '../../components/Proposal/InstructionCard';
import { NewInstructionCard } from '../../components/Proposal/NewInstructionCard';
import SignButton from '../../components/Proposal/SignButton';
import AddSigners from '../../components/Proposal/AddSigners';
import AddVotes from '../../components/Proposal/AddVotes';
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;
@ -162,12 +164,16 @@ function InnerProposalView({
: 'vertical'
}
>
{adminAccount && adminAccount.info.amount.toNumber() === 1 && (
<AddSigners proposal={proposal} />
)}
{sigAccount && sigAccount.info.amount.toNumber() === 1 && (
<SignButton proposal={proposal} />
)}
{adminAccount &&
adminAccount.info.amount.toNumber() === 1 &&
proposal.info.state.status === TimelockStateStatus.Draft && (
<AddSigners proposal={proposal} />
)}
{sigAccount &&
sigAccount.info.amount.toNumber() === 1 &&
proposal.info.state.status === TimelockStateStatus.Draft && (
<SignButton proposal={proposal} />
)}
</Space>
</Col>
<Col span={8}>
@ -179,6 +185,20 @@ function InnerProposalView({
}
suffix={`/ ${proposal.info.state.totalVotingTokensMinted}`}
/>
<Space
style={{ marginTop: '10px' }}
direction={
breakpoint.lg || breakpoint.xl || breakpoint.xxl
? 'horizontal'
: 'vertical'
}
>
{sigAccount &&
sigAccount.info.amount.toNumber() === 1 &&
proposal.info.state.status === TimelockStateStatus.Draft && (
<AddVotes proposal={proposal} />
)}
</Space>
</Col>
<Col span={8}>
<Statistic
@ -218,11 +238,15 @@ function InnerProposalView({
function getVotesRequired(proposal: ParsedAccount<TimelockSet>): number {
if (proposal.info.config.consensusAlgorithm === ConsensusAlgorithm.Majority) {
return proposal.info.state.totalVotingTokensMinted.toNumber() * 0.5;
return Math.ceil(
proposal.info.state.totalVotingTokensMinted.toNumber() * 0.5,
);
} else if (
proposal.info.config.consensusAlgorithm === ConsensusAlgorithm.SuperMajority
) {
return proposal.info.state.totalVotingTokensMinted.toNumber() * 0.6;
return Math.ceil(
proposal.info.state.totalVotingTokensMinted.toNumber() * 0.5,
);
} else if (
proposal.info.config.consensusAlgorithm === ConsensusAlgorithm.FullConsensus
) {