Skip to content

Commit

Permalink
Parcl staking (#2385)
Browse files Browse the repository at this point in the history
  • Loading branch information
DMJ16 authored Jul 20, 2024
1 parent 2f20757 commit c4449d6
Show file tree
Hide file tree
Showing 15 changed files with 582 additions and 14 deletions.
98 changes: 98 additions & 0 deletions ParclVotePlugin/ParclVoterWeightPluginClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import {Client} from "@solana/governance-program-library";
import {PublicKey, TransactionInstruction} from "@solana/web3.js";
import BN from "bn.js";
import {StakeAccount, StakeConnection} from "@parcl-oss/staking";
import {Provider, Wallet} from "@coral-xyz/anchor";
import {VoterWeightAction} from "@solana/spl-governance";
import {convertVoterWeightActionToType} from "../VoterWeightPlugins/lib/utils";
import queryClient from "@hooks/queries/queryClient";

// A wrapper for the StakeConnection from @parcl-oss/staking, that implements the generic plugin client interface
export class ParclVoterWeightPluginClient extends Client<any> {
readonly requiresInputVoterWeight = false;
// The parcl plugin does not have a registrar account
async getRegistrarAccount(): Promise<null> {
return null;
}

async getMaxVoterWeightRecordPDA() {
const maxVoterWeightPk = (await this.client.program.methods.updateMaxVoterWeight().pubkeys()).maxVoterRecord

if (!maxVoterWeightPk) return null;

return {
maxVoterWeightPk,
maxVoterWeightRecordBump: 0 // This is wrong for Parcl - but it doesn't matter as it is not used
}
}

async getVoterWeightRecordPDA(realm: PublicKey, mint: PublicKey, voter: PublicKey) {
const { voterWeightAccount } = await this.getUpdateVoterWeightPks([], voter, VoterWeightAction.CastVote, PublicKey.default);

return {
voterWeightPk: voterWeightAccount,
voterWeightRecordBump: 0 // This is wrong for Parcl - but it doesn't matter as it is not used
};
}

// NO-OP Parcl records are created through the Parcl dApp.
async createVoterWeightRecord(): Promise<TransactionInstruction | null> {
return null;
}

// NO-OP
async createMaxVoterWeightRecord(): Promise<TransactionInstruction | null> {
return null;
}

private async getStakeAccount(voter: PublicKey): Promise<StakeAccount> {
return queryClient.fetchQuery({
queryKey: ['parcl getStakeAccount', voter],
queryFn: () => this.client.getMainAccount(voter),
})
}

private async getUpdateVoterWeightPks(instructions: TransactionInstruction[], voter: PublicKey, action: VoterWeightAction, target?: PublicKey) {
const stakeAccount = await this.getStakeAccount(voter)

if (!stakeAccount) throw new Error("Stake account not found for voter " + voter.toString());
return this.client.withUpdateVoterWeight(
instructions,
stakeAccount,
{ [convertVoterWeightActionToType(action)]: {} } as any,
target
);
}

async updateVoterWeightRecord(voter: PublicKey, realm: PublicKey, mint: PublicKey, action: VoterWeightAction, inputRecordCallback?: () => Promise<PublicKey>, target?: PublicKey) {
const instructions: TransactionInstruction[] = [];
await this.getUpdateVoterWeightPks(instructions, voter, action, target);

return { pre: instructions };
}
// NO-OP
async updateMaxVoterWeightRecord(): Promise<TransactionInstruction | null> {
return null;
}
async calculateVoterWeight(voter: PublicKey): Promise<BN | null> {
const stakeAccount = await this.getStakeAccount(voter)

if (stakeAccount) {
return stakeAccount.getVoterWeight(await this.client.getTime()).toBN()
} else {
return new BN(0)
}
}
constructor(program: typeof StakeConnection.prototype.program, private client: StakeConnection, devnet:boolean) {
super(program, devnet);
}

static async connect(provider: Provider, devnet = false, wallet: Wallet): Promise<ParclVoterWeightPluginClient> {
const parclClient = await StakeConnection.connect(
provider.connection,
wallet
)

return new ParclVoterWeightPluginClient(parclClient.program, parclClient, devnet);
}
}
163 changes: 163 additions & 0 deletions ParclVotePlugin/components/ParclAccountDetails.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { MintInfo } from '@solana/spl-token'
import BN from 'bn.js'
import { GoverningTokenType } from '@solana/spl-governance'
import { fmtMintAmount } from '@tools/sdk/units'
import { useEffect } from 'react'
import useWalletOnePointOh from '@hooks/useWalletOnePointOh'
import { useUserCommunityTokenOwnerRecord } from '@hooks/queries/tokenOwnerRecord'
import { useRealmConfigQuery } from '@hooks/queries/realmConfig'
import { useRealmCommunityMintInfoQuery, useRealmCouncilMintInfoQuery } from '@hooks/queries/mintInfo'
import ParclVotingPower from './ParclVotingPower'
import { useUserTokenAccountsQuery } from '@hooks/queries/tokenAccount'
import { useRealmQuery } from '@hooks/queries/realm'
import { PublicKey } from '@solana/web3.js'
import VanillaWithdrawTokensButton from '@components/TokenBalance/VanillaWithdrawTokensButton'
import { Deposit } from '@components/GovernancePower/Power/Vanilla/Deposit'

export const PARCL_INSTRUCTIONS =
'You can deposit PRCL tokens at https://app.parcl.co/staking'

const TokenDeposit = ({
mintInfo,
mintAddress,
inAccountDetails,
setHasGovPower,
role
}: {
mintInfo: MintInfo | undefined,
mintAddress: PublicKey,
inAccountDetails?: boolean,
role: 'council' | 'community',
setHasGovPower?: (hasGovPower: boolean) => void
}) => {
const wallet = useWalletOnePointOh()
const { data: tokenAccounts } = useUserTokenAccountsQuery()
const connected = !!wallet?.connected

const ownTokenRecord = useUserCommunityTokenOwnerRecord().data?.result
const config = useRealmConfigQuery().data?.result

const relevantTokenConfig = role === "community"
? config?.account.communityTokenConfig
: config?.account.councilTokenConfig;

const isMembership =
relevantTokenConfig?.tokenType === GoverningTokenType.Membership
const isDormant =
relevantTokenConfig?.tokenType === GoverningTokenType.Dormant;

const depositTokenRecord = ownTokenRecord
const depositTokenAccount = tokenAccounts?.find((a) =>
a.account.mint.equals(mintAddress)
);

const hasTokensInWallet =
depositTokenAccount && depositTokenAccount.account.amount.gt(new BN(0))

const hasTokensDeposited =
depositTokenRecord &&
depositTokenRecord.account.governingTokenDepositAmount.gt(new BN(0))

const availableTokens =
depositTokenRecord && mintInfo
? fmtMintAmount(
mintInfo,
depositTokenRecord.account.governingTokenDepositAmount
)
: '0'

useEffect(() => {
if (availableTokens != '0' || hasTokensDeposited || hasTokensInWallet) {
if (setHasGovPower) setHasGovPower(true)
}
}, [availableTokens, hasTokensDeposited, hasTokensInWallet, setHasGovPower])

const canShowAvailableTokensMessage = hasTokensInWallet && connected
const tokensToShow =
hasTokensInWallet && depositTokenAccount
? fmtMintAmount(mintInfo, depositTokenAccount.account.amount)
: hasTokensInWallet
? availableTokens
: 0

// Do not show deposits for mints with zero supply because nobody can deposit anyway
if (!mintInfo || mintInfo.supply.isZero()) {
return null
}

return (
<div className="w-full">
{(availableTokens != '0' || inAccountDetails) && (
<div className="flex items-center space-x-4">
<ParclVotingPower className="w-full" role={role} />
</div>
)}
<div
className={`my-4 opacity-70 text-xs ${
canShowAvailableTokensMessage ? 'block' : 'hidden'
}`}
>
You have {tokensToShow} {hasTokensDeposited ? `more ` : ``} tokens
available to deposit.
</div>
{
role === "community"
? <div className={`my-4 opacity-70 text-xs`}>{PARCL_INSTRUCTIONS}</div>
: null
}
{
!isDormant
? <Deposit role="council" />
: null
}
<div className="flex flex-col mt-6 space-y-4 sm:flex-row sm:space-x-4 sm:space-y-0">
{!isMembership && // Membership tokens can't be withdrawn (that is their whole point, actually)
!isDormant &&
inAccountDetails && (
<VanillaWithdrawTokensButton
role={
'council'
}
/>
)}
</div>
</div>
)
}

const ParclAccountDetails = () => {
const realm = useRealmQuery().data?.result
const communityMint = useRealmCommunityMintInfoQuery().data?.result
const councilMint = useRealmCouncilMintInfoQuery().data?.result;
const wallet = useWalletOnePointOh()
const connected = !!wallet?.connected
const councilMintAddress = realm?.account.config.councilMint;
const hasLoaded = communityMint && councilMint && realm && councilMintAddress;

return (
<>
{hasLoaded ? (
<div className={`${`flex flex-col w-full`}`}>
{!connected ? (
<div className={'text-xs text-white/50 mt-8'}>
Connect your wallet to see governance power
</div>
) :
(
<>
<TokenDeposit mintInfo={communityMint} mintAddress={realm.account.communityMint} role={"community"} inAccountDetails={true} />
<TokenDeposit mintInfo={councilMint} mintAddress={councilMintAddress} role={"council"} inAccountDetails={true} />
</>
)}
</div>
) : (
<>
<div className="h-12 mb-4 rounded-lg animate-pulse bg-bkg-3" />
<div className="h-10 rounded-lg animate-pulse bg-bkg-3" />
</>
)}
</>
)
}

export default ParclAccountDetails
113 changes: 113 additions & 0 deletions ParclVotePlugin/components/ParclVotingPower.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { BigNumber } from 'bignumber.js'
import { useMemo } from 'react'
import { useRealmQuery } from '@hooks/queries/realm'
import { useMintInfoByPubkeyQuery } from '@hooks/queries/mintInfo'
import { useConnection } from '@solana/wallet-adapter-react'
import { getParclGovPower } from '@hooks/queries/governancePower'
import { useAsync } from 'react-async-hook'
import BN from 'bn.js'
import { getMintMetadata } from '@components/instructions/programs/splToken'
import VotingPowerPct from '@components/ProposalVotingPower/VotingPowerPct'
import clsx from 'clsx'
import { useRealmConfigQuery } from '@hooks/queries/realmConfig'
import { GoverningTokenType } from '@solana/spl-governance'
import useParclScalingFactor from '@hooks/parcl/useScalingFactor'
import useWalletOnePointOh from '@hooks/useWalletOnePointOh'
import { useUserTokenAccountsQuery } from '@hooks/queries/tokenAccount'

interface Props {
className?: string
role: 'community' | 'council'
hideIfZero?: boolean
children?: React.ReactNode
}

export default function ParclVotingPower({
role,
hideIfZero,
children,
...props
}: Props) {
const realm = useRealmQuery().data?.result
const realmConfig = useRealmConfigQuery().data?.result
const { data: tokenAccounts } = useUserTokenAccountsQuery()
const wallet = useWalletOnePointOh()

const { connection } = useConnection()

const relevantMint =
role === 'community'
? realm?.account.communityMint
: realm?.account.config.councilMint

const mintInfo = useMintInfoByPubkeyQuery(relevantMint).data?.result

const { result: personalAmount } = useAsync(
async () =>
wallet?.publicKey && role === 'community'
? getParclGovPower(connection, wallet.publicKey)
: tokenAccounts?.find(
(a) => relevantMint && a.account.mint.equals(relevantMint)
)?.account.amount as BN,
[connection, wallet, role, relevantMint, tokenAccounts]
)

const parclScalingFactor = useParclScalingFactor() ?? 1;

const totalAmount = personalAmount ?? new BN(0)

const formattedTotal = useMemo(
() =>
mintInfo && totalAmount !== undefined
? new BigNumber(totalAmount.toString())
.multipliedBy(
role === 'community'
? parclScalingFactor
: 1
)
.shiftedBy(-mintInfo.decimals)
.integerValue()
.toString()
: undefined,
[totalAmount, mintInfo]

Check warning on line 72 in ParclVotePlugin/components/ParclVotingPower.tsx

View workflow job for this annotation

GitHub Actions / Run Tests

React Hook useMemo has missing dependencies: 'parclScalingFactor' and 'role'. Either include them or remove the dependency array
)

const tokenName =
getMintMetadata(relevantMint)?.name ?? realm?.account.name ?? ''

const disabled =
realmConfig?.account.councilTokenConfig.tokenType ===
GoverningTokenType.Dormant

return (
<div
className={clsx(
props.className,
hideIfZero && totalAmount.isZero() && 'hidden',
disabled && 'hidden'
)}
>
<div className={'p-3 rounded-md bg-bkg-1'}>
<div className="text-fgd-3 text-xs">
{tokenName}
{role === 'council' ? ' Council' : ''} Votes
</div>
<div className="flex items-center justify-between mt-1">
<div className=" flex flex-row gap-x-2">
<div className="text-xl font-bold text-fgd-1 hero-text">
{formattedTotal ?? 0}
</div>
</div>

{mintInfo && (
<VotingPowerPct
amount={new BigNumber(totalAmount.toString())}
total={new BigNumber(mintInfo.supply.toString())}
/>
)}
</div>
</div>
{children}
</div>
)
}
3 changes: 3 additions & 0 deletions VoterWeightPlugins/clients/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {

import { Provider, Wallet } from '@coral-xyz/anchor'
import { PythVoterWeightPluginClient } from './PythVoterWeightPluginClient'
import { ParclVoterWeightPluginClient } from 'ParclVotePlugin/ParclVoterWeightPluginClient'
import { PublicKey } from '@solana/web3.js'
import { VsrClient } from '../../VoteStakeRegistry/sdk/client'
import { NftVoterClient } from '@utils/uiTypes/NftVoterClient'
Expand Down Expand Up @@ -46,6 +47,8 @@ export const loadClient = (
return DriftVoterClient.connect(provider, programId)
case 'token_haver':
return TokenHaverClient.connect(provider, programId)
case 'parcl':
return ParclVoterWeightPluginClient.connect(provider, undefined, signer)
default:
return UnrecognisedVoterWeightPluginClient.connect(provider, programId)
}
Expand Down
Loading

0 comments on commit c4449d6

Please sign in to comment.