Governance: Change slots to timestamps (#123)

* feat: use timestamps instead of slots

* feat: show amount of deposited tokens
This commit is contained in:
Sebastian Bor 2021-06-30 12:36:24 +01:00 committed by GitHub
parent e60ce1b307
commit 7f60b40d7a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 233 additions and 139 deletions

View File

@ -0,0 +1,47 @@
import React from 'react';
import { contexts, ParsedAccount } from '@oyster/common';
import { TokenOwnerRecord } from '../../models/accounts';
import { formatTokenAmount } from '../../tools/text';
const { useMint } = contexts.Accounts;
export function RealmDepositBadge({
councilTokenOwnerRecord,
communityTokenOwnerRecord,
}: {
councilTokenOwnerRecord: ParsedAccount<TokenOwnerRecord> | undefined;
communityTokenOwnerRecord: ParsedAccount<TokenOwnerRecord> | undefined;
}) {
const communityMint = useMint(
communityTokenOwnerRecord?.info.governingTokenMint,
);
const councilMint = useMint(councilTokenOwnerRecord?.info.governingTokenMint);
if (!councilTokenOwnerRecord && !communityTokenOwnerRecord) {
return null;
}
return (
<>
<span>deposited </span>
{communityTokenOwnerRecord && (
<span>
{`tokens: ${formatTokenAmount(
communityMint,
communityTokenOwnerRecord.info.governingTokenDepositAmount,
)}`}
</span>
)}
{communityTokenOwnerRecord && councilTokenOwnerRecord && ', '}
{councilTokenOwnerRecord && (
<span>
{`council tokens: ${formatTokenAmount(
councilMint,
councilTokenOwnerRecord.info.governingTokenDepositAmount,
)}`}
</span>
)}
</>
);
}

View File

@ -121,8 +121,8 @@ export const LABELS = {
TOKEN_ACCOUNT_ADDRESS: 'token account address',
MIN_TOKENS_TO_CREATE_PROPOSAL: 'min tokens to create proposal',
MIN_INSTRUCTION_HOLD_UP_TIME: 'min instruction hold up time (slots)',
MAX_VOTING_TIME: 'max voting time (slots)',
MIN_INSTRUCTION_HOLD_UP_TIME_DAYS: 'min instruction hold up time (days)',
MAX_VOTING_TIME_DAYS: 'max voting time (days)',
UPGRADE_AUTHORITY: 'upgrade authority',
MINT_AUTHORITY: 'mint authority',
@ -148,7 +148,7 @@ export const LABELS = {
MINIMUM_SLOT_WAITING_PERIOD: 'Minimum slots between proposal and vote',
SELECT_CONFIG: 'Select Governed Program',
CONFIG: 'Governed Program',
GIST_PLACEHOLDER: 'Github Gist link',
GIST_PLACEHOLDER: 'Github Gist link (optional)',
NAME: 'Name',
PUBLIC_KEY: 'Public Key',
@ -158,7 +158,7 @@ export const LABELS = {
' Please note that during voting, if you withdraw your tokens, your vote will not count towards the voting total. You must wait for the vote to complete in order for your withdrawal to not affect the voting.',
SLOT_MUST_BE_NUMERIC: 'Slot can only be numeric',
SLOT_MUST_BE_GREATER_THAN: 'Slot must be greater than or equal to ',
HOLD_UP_TIME: 'hold up time',
HOLD_UP_TIME_DAYS: 'hold up time (days)',
MIN_SLOT_MUST_BE_NUMERIC: 'Minimum Slot Waiting Period can only be numeric',
TIME_LIMIT_MUST_BE_NUMERIC: 'Time Limit can only be numeric',

View File

@ -86,6 +86,15 @@ export function useWalletTokenOwnerRecord(
);
}
/// Returns all TokenOwnerRecords for the current wallet
export function useWalletTokenOwnerRecords() {
const { wallet } = useWallet();
return useGovernanceAccountsByFilter<TokenOwnerRecord>(TokenOwnerRecord, [
pubkeyFilter(1 + 32 + 32, wallet?.publicKey),
]);
}
export function useProposalAuthority(proposalOwner: PublicKey | undefined) {
const { wallet, connected } = useWallet();
const tokenOwnerRecord = useTokenOwnerRecord(proposalOwner);

View File

@ -1,15 +1,14 @@
import { ParsedAccount } from '@oyster/common';
import { Governance, Proposal } from '../models/accounts';
import { useIsBeyondSlot } from './useIsBeyondSlot';
import { useIsBeyondTimestamp } from './useIsBeyondTimestamp';
export const useHasVotingTimeExpired = (
governance: ParsedAccount<Governance>,
proposal: ParsedAccount<Proposal>,
) => {
return useIsBeyondSlot(
return useIsBeyondTimestamp(
proposal.info.votingAt
? proposal.info.votingAt.toNumber() +
governance.info.config.maxVotingTime.toNumber()
? proposal.info.votingAt.toNumber() + governance.info.config.maxVotingTime
: undefined,
);
};

View File

@ -1,41 +0,0 @@
import { useConnection } from '@oyster/common';
import { useEffect, useState } from 'react';
export const useIsBeyondSlot = (slot: number | undefined) => {
const connection = useConnection();
const [isBeyondSlot, setIsBeyondSlot] = useState<boolean | undefined>();
useEffect(() => {
if (!slot) {
return;
}
const sub = (async () => {
const currentSlot = await connection.getSlot();
if (currentSlot > slot) {
setIsBeyondSlot(true);
return;
}
setIsBeyondSlot(false);
const id = setInterval(() => {
connection.getSlot().then(currentSlot => {
if (currentSlot > slot) {
setIsBeyondSlot(true);
clearInterval(id!);
}
});
}, 5000); // TODO: How to estimate the slot distance to avoid uneccesery checks?
return id;
})();
return () => {
sub.then(id => id && clearInterval(id));
};
}, [connection, slot]);
return isBeyondSlot;
};

View File

@ -0,0 +1,43 @@
import { useConnection } from '@oyster/common';
import moment from 'moment';
import { useEffect, useState } from 'react';
export const useIsBeyondTimestamp = (timestamp: number | undefined) => {
const connection = useConnection();
const [isBeyondTimestamp, setIsBeyondTimestamp] = useState<
boolean | undefined
>();
useEffect(() => {
if (!timestamp) {
return;
}
const sub = (async () => {
const now = moment().unix();
if (now > timestamp) {
setIsBeyondTimestamp(true);
return;
}
setIsBeyondTimestamp(false);
const id = setInterval(() => {
const now = moment().unix();
if (now > timestamp) {
setIsBeyondTimestamp(true);
clearInterval(id!);
}
}, 5000); // TODO: Use actual timestamp to calculate the interval
return id;
})();
return () => {
sub.then(id => id && clearInterval(id));
};
}, [connection, timestamp]);
return isBeyondTimestamp;
};

View File

@ -83,16 +83,16 @@ export class GovernanceConfig {
governedAccount: PublicKey;
yesVoteThresholdPercentage: number;
minTokensToCreateProposal: number;
minInstructionHoldUpTime: BN;
maxVotingTime: BN;
minInstructionHoldUpTime: number;
maxVotingTime: number;
constructor(args: {
realm: PublicKey;
governedAccount: PublicKey;
yesVoteThresholdPercentage: number;
minTokensToCreateProposal: number;
minInstructionHoldUpTime: BN;
maxVotingTime: BN;
minInstructionHoldUpTime: number;
maxVotingTime: number;
}) {
this.realm = args.realm;
this.governedAccount = args.governedAccount;
@ -391,13 +391,13 @@ export class InstructionData {
export class ProposalInstruction {
accountType = GovernanceAccountType.ProposalInstruction;
proposal: PublicKey;
holdUpTime: BN;
holdUpTime: number;
instruction: InstructionData;
executedAt: BN | null;
constructor(args: {
proposal: PublicKey;
holdUpTime: BN;
holdUpTime: number;
instruction: InstructionData;
executedAt: BN | null;
}) {

View File

@ -40,12 +40,6 @@ import {
} from './accounts';
import { serialize } from 'borsh';
// TODO: Review the limits. Most likely they are leftovers from the legacy version
export const MAX_PROPOSAL_DESCRIPTION_LENGTH = 200;
export const MAX_PROPOSAL_NAME_LENGTH = 32;
export const MAX_REALM_NAME_LENGTH = 32;
export const MAX_INSTRUCTION_BASE64_LENGTH = 450;
// Temp. workaround to support u16.
(BinaryReader.prototype as any).readU16 = function () {
const reader = (this as unknown) as BinaryReader;
@ -217,7 +211,7 @@ export const GOVERNANCE_SCHEMA = new Map<any, any>([
fields: [
['instruction', 'u8'],
['index', 'u16'],
['holdUpTime', 'u64'],
['holdUpTime', 'u32'],
['instructionData', InstructionData],
],
},
@ -290,8 +284,8 @@ export const GOVERNANCE_SCHEMA = new Map<any, any>([
['governedAccount', 'pubkey'],
['yesVoteThresholdPercentage', 'u8'],
['minTokensToCreateProposal', 'u16'],
['minInstructionHoldUpTime', 'u64'],
['maxVotingTime', 'u64'],
['minInstructionHoldUpTime', 'u32'],
['maxVotingTime', 'u32'],
],
},
],
@ -381,7 +375,7 @@ export const GOVERNANCE_SCHEMA = new Map<any, any>([
fields: [
['accountType', 'u8'],
['proposal', 'pubkey'],
['holdUpTime', 'u64'],
['holdUpTime', 'u32'],
['instruction', InstructionData],
['executedAt', { kind: 'option', type: 'u64' }],
],

View File

@ -14,7 +14,3 @@ export const formDefaults = {
...formVerticalLayout,
validateMessages: formValidateMessages,
};
export const formSlotInputStyle = {
width: 250,
};

View File

@ -0,0 +1,9 @@
import { MintInfo } from '@solana/spl-token';
import BN from 'bn.js';
import { BigNumber } from 'bignumber.js';
export function formatTokenAmount(mint: MintInfo | undefined, amount: BN) {
return mint
? new BigNumber(amount.toString()).shiftedBy(-mint.decimals).toFormat()
: amount.toString();
}

View File

@ -2,10 +2,7 @@ import React, { useState } from 'react';
import { ButtonProps, Radio } from 'antd';
import { Form, Input } from 'antd';
import { PublicKey } from '@solana/web3.js';
import {
MAX_PROPOSAL_DESCRIPTION_LENGTH,
MAX_PROPOSAL_NAME_LENGTH,
} from '../../models/serialisation';
import { LABELS } from '../../constants';
import { contexts, ParsedAccount } from '@oyster/common';
import { createProposal } from '../../actions/createProposal';
@ -81,7 +78,7 @@ export function AddNewProposal({
governance.info.config.realm,
governance.pubkey,
values.name,
values.descriptionLink,
values.descriptionLink ?? '',
governingTokenMint,
proposalIndex,
);
@ -136,17 +133,14 @@ export function AddNewProposal({
label={LABELS.NAME_LABEL}
rules={[{ required: true }]}
>
<Input maxLength={MAX_PROPOSAL_NAME_LENGTH} />
<Input />
</Form.Item>
<Form.Item
name="descriptionLink"
label={LABELS.DESCRIPTION_LABEL}
rules={[{ required: true }]}
rules={[{ required: false }]}
>
<Input
maxLength={MAX_PROPOSAL_DESCRIPTION_LENGTH}
placeholder={LABELS.GIST_PLACEHOLDER}
/>
<Input placeholder={LABELS.GIST_PLACEHOLDER} />
</Form.Item>
</ModalFormAction>
);

View File

@ -10,26 +10,51 @@ import { RegisterRealm } from './registerRealm';
import { LABELS } from '../../constants';
import { RealmBadge } from '../../components/RealmBadge/realmBadge';
import { useWalletTokenOwnerRecords } from '../../hooks/apiHooks';
import { RealmDepositBadge } from '../../components/RealmDepositBadge/realmDepositBadge';
export const HomeView = () => {
const history = useHistory();
const realms = useRealms();
const tokenOwnerRecords = useWalletTokenOwnerRecords();
const realmItems = useMemo(() => {
return realms
.sort((r1, r2) => r1.info.name.localeCompare(r2.info.name))
.map(r => ({
href: '/realm/' + r.pubkey.toBase58(),
title: r.info.name,
badge: (
<RealmBadge
communityMint={r.info.communityMint}
councilMint={r.info.councilMint}
></RealmBadge>
),
key: r.pubkey.toBase58(),
}));
}, [realms]);
.map(r => {
const communityTokenOwnerRecord = tokenOwnerRecords.find(
tor =>
tor.info.governingTokenMint.toBase58() ===
r.info.communityMint.toBase58(),
);
const councilTokenOwnerRecord =
r.info.councilMint &&
tokenOwnerRecords.find(
tor =>
tor.info.governingTokenMint.toBase58() ===
r.info.councilMint!.toBase58(),
);
return {
href: '/realm/' + r.pubkey.toBase58(),
title: r.info.name,
badge: (
<RealmBadge
communityMint={r.info.communityMint}
councilMint={r.info.councilMint}
></RealmBadge>
),
key: r.pubkey.toBase58(),
description: (
<RealmDepositBadge
communityTokenOwnerRecord={communityTokenOwnerRecord}
councilTokenOwnerRecord={councilTokenOwnerRecord}
></RealmDepositBadge>
),
};
});
}, [realms, tokenOwnerRecords]);
return (
<>
@ -56,6 +81,7 @@ export const HomeView = () => {
<List.Item.Meta
avatar={item.badge}
title={item.title}
description={item.description}
></List.Item.Meta>
</List.Item>
)}

View File

@ -2,7 +2,7 @@ import React, { useState } from 'react';
import { ButtonProps, Switch } from 'antd';
import { Form, Input } from 'antd';
import { PublicKey } from '@solana/web3.js';
import { MAX_REALM_NAME_LENGTH } from '../../models/serialisation';
import { LABELS } from '../../constants';
import { contexts } from '@oyster/common';
import { Redirect } from 'react-router';
@ -65,7 +65,7 @@ export function RegisterRealm({ buttonProps }: { buttonProps: ButtonProps }) {
label={LABELS.NAME_LABEL}
rules={[{ required: true }]}
>
<Input maxLength={MAX_REALM_NAME_LENGTH} />
<Input />
</Form.Item>
<MintFormItem

View File

@ -399,33 +399,35 @@ function InnerProposalView({
<Row>
<Col span={24}>
<Tabs
defaultActiveKey="1"
defaultActiveKey="description"
size="large"
style={{ marginBottom: 32 }}
>
<TabPane tab="Description" key="1">
{loading ? (
<Spin />
) : isUrl ? (
failed ? (
<p>
{LABELS.DESCRIPTION}:{' '}
<a
href={proposal.info.descriptionLink}
target="_blank"
rel="noopener noreferrer"
>
{msg ? msg : LABELS.NO_LOAD}
</a>
</p>
{proposal.info.descriptionLink && (
<TabPane tab="Description" key="description">
{loading ? (
<Spin />
) : isUrl ? (
failed ? (
<p>
{LABELS.DESCRIPTION}:{' '}
<a
href={proposal.info.descriptionLink}
target="_blank"
rel="noopener noreferrer"
>
{msg ? msg : LABELS.NO_LOAD}
</a>
</p>
) : (
<ReactMarkdown children={content} />
)
) : (
<ReactMarkdown children={content} />
)
) : (
content
)}
</TabPane>
<TabPane tab={LABELS.INSTRUCTIONS} key="2">
content
)}
</TabPane>
)}
<TabPane tab={LABELS.INSTRUCTIONS} key="instructions">
<Row
gutter={[
{ xs: 8, sm: 16, md: 24, lg: 32 },

View File

@ -76,7 +76,7 @@ export function InstructionCard({
<>
<p>{`${LABELS.INSTRUCTION}: ${instructionDetails.dataBase64}`}</p>
<p>
{LABELS.HOLD_UP_TIME}: {instruction.info.holdUpTime.toNumber()}
{LABELS.HOLD_UP_TIME_DAYS}: {instruction.info.holdUpTime}
</p>
</>
}

View File

@ -17,10 +17,7 @@ import { useState } from 'react';
import { AccountFormItem } from '../../../components/AccountFormItem/accountFormItem';
import { Governance } from '../../../models/accounts';
import { createUpgradeInstruction } from '../../../models/sdkInstructions';
import {
MAX_INSTRUCTION_BASE64_LENGTH,
serializeInstructionToBase64,
} from '../../../models/serialisation';
import { serializeInstructionToBase64 } from '../../../models/serialisation';
import { formDefaults, formVerticalLayout } from '../../../tools/forms';
export default function InstructionInput({
@ -56,7 +53,6 @@ export default function InstructionInput({
<Input.TextArea
value={instruction}
onChange={e => updateInstruction(e.target.value)}
maxLength={MAX_INSTRUCTION_BASE64_LENGTH}
placeholder={`base64 encoded serialized Solana Instruction`}
/>
</Col>

View File

@ -68,18 +68,17 @@ export function NewInstructionCard({
name="control-hooks"
onFinish={onFinish}
initialValues={{
holdUpTime:
governance.info.config.minInstructionHoldUpTime.toNumber(),
holdUpTime: governance.info.config.minInstructionHoldUpTime,
}}
>
<Form.Item
name="holdUpTime"
label={LABELS.HOLD_UP_TIME}
label={LABELS.HOLD_UP_TIME_DAYS}
rules={[{ required: true }]}
>
<InputNumber
maxLength={64}
min={governance.info.config.minInstructionHoldUpTime.toNumber()}
min={governance.info.config.minInstructionHoldUpTime}
/>
</Form.Item>

View File

@ -1,8 +1,11 @@
import { Col, List, Row } from 'antd';
import { Col, List, Row, Typography } from 'antd';
import React, { useMemo } from 'react';
import { useRealm } from '../../contexts/GovernanceContext';
import { useGovernancesByRealm } from '../../hooks/apiHooks';
import {
useGovernancesByRealm,
useWalletTokenOwnerRecord,
} from '../../hooks/apiHooks';
import './style.less'; // Don't remove this line, it will break dark mode if you do due to weird transpiling conditions
import { Background } from '../../components/Background';
@ -16,6 +19,9 @@ import { WithdrawGoverningTokens } from './WithdrawGoverningTokens';
import { RealmBadge } from '../../components/RealmBadge/realmBadge';
import { GovernanceBadge } from '../../components/GovernanceBadge/governanceBadge';
import AccountDescription from './accountDescription';
import { RealmDepositBadge } from '../../components/RealmDepositBadge/realmDepositBadge';
const { Text } = Typography;
export const RealmView = () => {
const history = useHistory();
@ -24,6 +30,16 @@ export const RealmView = () => {
const realm = useRealm(realmKey);
const governances = useGovernancesByRealm(realmKey);
const communityTokenOwnerRecord = useWalletTokenOwnerRecord(
realm?.pubkey,
realm?.info.communityMint,
);
const councilTokenOwnerRecord = useWalletTokenOwnerRecord(
realm?.pubkey,
realm?.info.councilMint,
);
const governanceItems = useMemo(() => {
return governances
.sort((g1, g2) =>
@ -54,8 +70,14 @@ export const RealmView = () => {
councilMint={realm?.info.councilMint}
></RealmBadge>
<Col>
<Col style={{ textAlign: 'left', marginLeft: 8 }}>
<h1>{realm?.info.name}</h1>
<Text type="secondary">
<RealmDepositBadge
communityTokenOwnerRecord={communityTokenOwnerRecord}
councilTokenOwnerRecord={councilTokenOwnerRecord}
></RealmDepositBadge>
</Text>
</Col>
</Row>
</Col>

View File

@ -10,11 +10,10 @@ import { Redirect } from 'react-router';
import { GovernanceType } from '../../models/enums';
import { registerGovernance } from '../../actions/registerGovernance';
import { GovernanceConfig } from '../../models/accounts';
import BN from 'bn.js';
import { useKeyParam } from '../../hooks/useKeyParam';
import { ModalFormAction } from '../../components/ModalFormAction/modalFormAction';
import { formSlotInputStyle } from '../../tools/forms';
import { AccountFormItem } from '../../components/AccountFormItem/accountFormItem';
const { useWallet } = contexts.Wallet;
@ -45,8 +44,8 @@ export function RegisterGovernance({
governedAccount: new PublicKey(values.governedAccountAddress),
yesVoteThresholdPercentage: values.yesVoteThresholdPercentage,
minTokensToCreateProposal: values.minTokensToCreateProposal,
minInstructionHoldUpTime: new BN(values.minInstructionHoldUpTime),
maxVotingTime: new BN(values.maxVotingTime),
minInstructionHoldUpTime: values.minInstructionHoldUpTime * 86400,
maxVotingTime: values.maxVotingTime * 86400,
});
return await registerGovernance(
connection,
@ -137,20 +136,20 @@ export function RegisterGovernance({
<Form.Item
name="minInstructionHoldUpTime"
label={LABELS.MIN_INSTRUCTION_HOLD_UP_TIME}
label={LABELS.MIN_INSTRUCTION_HOLD_UP_TIME_DAYS}
rules={[{ required: true }]}
initialValue={1}
>
<InputNumber min={1} style={formSlotInputStyle} />
<InputNumber min={0} />
</Form.Item>
<Form.Item
name="maxVotingTime"
label={LABELS.MAX_VOTING_TIME}
label={LABELS.MAX_VOTING_TIME_DAYS}
rules={[{ required: true }]}
initialValue={1000000}
initialValue={3}
>
<InputNumber min={1} style={formSlotInputStyle} />
<InputNumber min={1} />
</Form.Item>
<Form.Item
name="yesVoteThresholdPercentage"