diff --git a/packages/governance/src/components/RealmDepositBadge/realmDepositBadge.tsx b/packages/governance/src/components/RealmDepositBadge/realmDepositBadge.tsx new file mode 100644 index 0000000..ba87d15 --- /dev/null +++ b/packages/governance/src/components/RealmDepositBadge/realmDepositBadge.tsx @@ -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 | undefined; + communityTokenOwnerRecord: ParsedAccount | undefined; +}) { + const communityMint = useMint( + communityTokenOwnerRecord?.info.governingTokenMint, + ); + + const councilMint = useMint(councilTokenOwnerRecord?.info.governingTokenMint); + + if (!councilTokenOwnerRecord && !communityTokenOwnerRecord) { + return null; + } + + return ( + <> + deposited + {communityTokenOwnerRecord && ( + + {`tokens: ${formatTokenAmount( + communityMint, + communityTokenOwnerRecord.info.governingTokenDepositAmount, + )}`} + + )} + {communityTokenOwnerRecord && councilTokenOwnerRecord && ', '} + {councilTokenOwnerRecord && ( + + {`council tokens: ${formatTokenAmount( + councilMint, + councilTokenOwnerRecord.info.governingTokenDepositAmount, + )}`} + + )} + + ); +} diff --git a/packages/governance/src/constants/labels.ts b/packages/governance/src/constants/labels.ts index 2a9f028..a29e714 100644 --- a/packages/governance/src/constants/labels.ts +++ b/packages/governance/src/constants/labels.ts @@ -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', diff --git a/packages/governance/src/hooks/apiHooks.ts b/packages/governance/src/hooks/apiHooks.ts index 1f43173..2f8c22c 100644 --- a/packages/governance/src/hooks/apiHooks.ts +++ b/packages/governance/src/hooks/apiHooks.ts @@ -86,6 +86,15 @@ export function useWalletTokenOwnerRecord( ); } +/// Returns all TokenOwnerRecords for the current wallet +export function useWalletTokenOwnerRecords() { + const { wallet } = useWallet(); + + return useGovernanceAccountsByFilter(TokenOwnerRecord, [ + pubkeyFilter(1 + 32 + 32, wallet?.publicKey), + ]); +} + export function useProposalAuthority(proposalOwner: PublicKey | undefined) { const { wallet, connected } = useWallet(); const tokenOwnerRecord = useTokenOwnerRecord(proposalOwner); diff --git a/packages/governance/src/hooks/useHasVotingTimeExpired.ts b/packages/governance/src/hooks/useHasVotingTimeExpired.ts index 8197592..68a4fd3 100644 --- a/packages/governance/src/hooks/useHasVotingTimeExpired.ts +++ b/packages/governance/src/hooks/useHasVotingTimeExpired.ts @@ -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, proposal: ParsedAccount, ) => { - 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, ); }; diff --git a/packages/governance/src/hooks/useIsBeyondSlot.ts b/packages/governance/src/hooks/useIsBeyondSlot.ts deleted file mode 100644 index e06014d..0000000 --- a/packages/governance/src/hooks/useIsBeyondSlot.ts +++ /dev/null @@ -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(); - - 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; -}; diff --git a/packages/governance/src/hooks/useIsBeyondTimestamp.ts b/packages/governance/src/hooks/useIsBeyondTimestamp.ts new file mode 100644 index 0000000..99e5d9e --- /dev/null +++ b/packages/governance/src/hooks/useIsBeyondTimestamp.ts @@ -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; +}; diff --git a/packages/governance/src/models/accounts.ts b/packages/governance/src/models/accounts.ts index da9351a..d8587e7 100644 --- a/packages/governance/src/models/accounts.ts +++ b/packages/governance/src/models/accounts.ts @@ -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; }) { diff --git a/packages/governance/src/models/serialisation.ts b/packages/governance/src/models/serialisation.ts index ed42eab..26a56e6 100644 --- a/packages/governance/src/models/serialisation.ts +++ b/packages/governance/src/models/serialisation.ts @@ -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([ fields: [ ['instruction', 'u8'], ['index', 'u16'], - ['holdUpTime', 'u64'], + ['holdUpTime', 'u32'], ['instructionData', InstructionData], ], }, @@ -290,8 +284,8 @@ export const GOVERNANCE_SCHEMA = new Map([ ['governedAccount', 'pubkey'], ['yesVoteThresholdPercentage', 'u8'], ['minTokensToCreateProposal', 'u16'], - ['minInstructionHoldUpTime', 'u64'], - ['maxVotingTime', 'u64'], + ['minInstructionHoldUpTime', 'u32'], + ['maxVotingTime', 'u32'], ], }, ], @@ -381,7 +375,7 @@ export const GOVERNANCE_SCHEMA = new Map([ fields: [ ['accountType', 'u8'], ['proposal', 'pubkey'], - ['holdUpTime', 'u64'], + ['holdUpTime', 'u32'], ['instruction', InstructionData], ['executedAt', { kind: 'option', type: 'u64' }], ], diff --git a/packages/governance/src/tools/forms.ts b/packages/governance/src/tools/forms.ts index 38d561a..f0ecef9 100644 --- a/packages/governance/src/tools/forms.ts +++ b/packages/governance/src/tools/forms.ts @@ -14,7 +14,3 @@ export const formDefaults = { ...formVerticalLayout, validateMessages: formValidateMessages, }; - -export const formSlotInputStyle = { - width: 250, -}; diff --git a/packages/governance/src/tools/text.ts b/packages/governance/src/tools/text.ts new file mode 100644 index 0000000..8c1cb0c --- /dev/null +++ b/packages/governance/src/tools/text.ts @@ -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(); +} diff --git a/packages/governance/src/views/governance/NewProposal.tsx b/packages/governance/src/views/governance/NewProposal.tsx index 593a5b0..502861b 100644 --- a/packages/governance/src/views/governance/NewProposal.tsx +++ b/packages/governance/src/views/governance/NewProposal.tsx @@ -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 }]} > - + - + ); diff --git a/packages/governance/src/views/home/HomeView.tsx b/packages/governance/src/views/home/HomeView.tsx index 6586e1a..3850d8a 100644 --- a/packages/governance/src/views/home/HomeView.tsx +++ b/packages/governance/src/views/home/HomeView.tsx @@ -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: ( - - ), - 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: ( + + ), + key: r.pubkey.toBase58(), + description: ( + + ), + }; + }); + }, [realms, tokenOwnerRecords]); return ( <> @@ -56,6 +81,7 @@ export const HomeView = () => { )} diff --git a/packages/governance/src/views/home/registerRealm.tsx b/packages/governance/src/views/home/registerRealm.tsx index 62ece15..263027a 100644 --- a/packages/governance/src/views/home/registerRealm.tsx +++ b/packages/governance/src/views/home/registerRealm.tsx @@ -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 }]} > - + - - {loading ? ( - - ) : isUrl ? ( - failed ? ( -

- {LABELS.DESCRIPTION}:{' '} - - {msg ? msg : LABELS.NO_LOAD} - -

+ {proposal.info.descriptionLink && ( + + {loading ? ( + + ) : isUrl ? ( + failed ? ( +

+ {LABELS.DESCRIPTION}:{' '} + + {msg ? msg : LABELS.NO_LOAD} + +

+ ) : ( + + ) ) : ( - - ) - ) : ( - content - )} -
- + content + )} + + )} +

{`${LABELS.INSTRUCTION}: ${instructionDetails.dataBase64}`}

- {LABELS.HOLD_UP_TIME}: {instruction.info.holdUpTime.toNumber()} + {LABELS.HOLD_UP_TIME_DAYS}: {instruction.info.holdUpTime}

} diff --git a/packages/governance/src/views/proposal/components/InstructionInput.tsx b/packages/governance/src/views/proposal/components/InstructionInput.tsx index 9228a37..2afbcc8 100644 --- a/packages/governance/src/views/proposal/components/InstructionInput.tsx +++ b/packages/governance/src/views/proposal/components/InstructionInput.tsx @@ -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({ updateInstruction(e.target.value)} - maxLength={MAX_INSTRUCTION_BASE64_LENGTH} placeholder={`base64 encoded serialized Solana Instruction`} /> diff --git a/packages/governance/src/views/proposal/components/NewInstructionCard.tsx b/packages/governance/src/views/proposal/components/NewInstructionCard.tsx index d7d35d8..6e47e38 100644 --- a/packages/governance/src/views/proposal/components/NewInstructionCard.tsx +++ b/packages/governance/src/views/proposal/components/NewInstructionCard.tsx @@ -68,18 +68,17 @@ export function NewInstructionCard({ name="control-hooks" onFinish={onFinish} initialValues={{ - holdUpTime: - governance.info.config.minInstructionHoldUpTime.toNumber(), + holdUpTime: governance.info.config.minInstructionHoldUpTime, }} > diff --git a/packages/governance/src/views/realm/RealmView.tsx b/packages/governance/src/views/realm/RealmView.tsx index d9c354e..ed788eb 100644 --- a/packages/governance/src/views/realm/RealmView.tsx +++ b/packages/governance/src/views/realm/RealmView.tsx @@ -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} > - +

{realm?.info.name}

+ + +
diff --git a/packages/governance/src/views/realm/registerGovernance.tsx b/packages/governance/src/views/realm/registerGovernance.tsx index c1afe42..6ab660a 100644 --- a/packages/governance/src/views/realm/registerGovernance.tsx +++ b/packages/governance/src/views/realm/registerGovernance.tsx @@ -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({ - + - +