diff --git a/hooks/useGovernanceAssets.ts b/hooks/useGovernanceAssets.ts index b2126797a2..dc3e80dcb5 100644 --- a/hooks/useGovernanceAssets.ts +++ b/hooks/useGovernanceAssets.ts @@ -755,6 +755,11 @@ export default function useGovernanceAssets() { isVisible: canUseAuthorityInstruction, packageId: PackageEnum.Distribution, }, + [Instructions.DistributionFillVaults]: { + name: 'Fill vaults', + isVisible: canUseAuthorityInstruction, + packageId: PackageEnum.Distribution, + }, } const availablePackages: PackageType[] = Object.entries(packages) diff --git a/pages/dao/[symbol]/proposal/components/instructions/DistrubtionProgram/FillVaults.tsx b/pages/dao/[symbol]/proposal/components/instructions/DistrubtionProgram/FillVaults.tsx new file mode 100644 index 0000000000..31862bf248 --- /dev/null +++ b/pages/dao/[symbol]/proposal/components/instructions/DistrubtionProgram/FillVaults.tsx @@ -0,0 +1,298 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { useContext, useEffect, useState } from 'react' +import * as yup from 'yup' +import { isFormValid } from '@utils/formValidation' +import { UiInstruction } from '@utils/uiTypes/proposalCreationTypes' +import useGovernanceAssets from '@hooks/useGovernanceAssets' +import { + Governance, + serializeInstructionToBase64, +} from '@solana/spl-governance' +import { ProgramAccount } from '@solana/spl-governance' +import { AccountType, AssetAccount } from '@utils/uiTypes/assets' +import useWalletOnePointOh from '@hooks/useWalletOnePointOh' +import { NewProposalContext } from '../../../new' +import InstructionForm, { InstructionInput } from '../FormCreator' +import { InstructionInputType } from '../inputInstructionType' +import { + Distribution, + MangoMintsRedemptionClient, +} from '@blockworks-foundation/mango-mints-redemption' +import { AnchorProvider } from '@coral-xyz/anchor' +import useLegacyConnectionContext from '@hooks/useLegacyConnectionContext' +import EmptyWallet from '@utils/Mango/listingTools' +import { Keypair, PublicKey, TransactionInstruction } from '@solana/web3.js' +import { tryGetTokenAccount } from '@utils/tokens' +import Button from '@components/Button' +import { TOKEN_PROGRAM_ID, Token, u64 } from '@solana/spl-token' +import Input from '@components/inputs/Input' +import { parseMintNaturalAmountFromDecimal } from '@tools/sdk/units' + +interface FillVaultsForm { + governedAccount: AssetAccount | null + distributionNumber: number +} + +type Vault = { + publicKey: PublicKey + amount: bigint + mintIndex: number + mint: PublicKey +} + +type Transfer = { + from: PublicKey + to: PublicKey + amount: string + decimals: number + mintIndex: number +} + +const FillVaults = ({ + index, + governance, +}: { + index: number + governance: ProgramAccount | null +}) => { + const wallet = useWalletOnePointOh() + const { assetAccounts } = useGovernanceAssets() + const solAccounts = assetAccounts.filter((x) => x.type === AccountType.SOL) + const connection = useLegacyConnectionContext() + const shouldBeGoverned = !!(index !== 0 && governance) + const [form, setForm] = useState({ + governedAccount: null, + distributionNumber: 0, + }) + const [transfers, setTransfers] = useState([]) + const [client, setClient] = useState() + const [distribution, setDistribution] = useState() + const [vaults, setVaults] = useState<{ [pubkey: string]: Vault }>() + const [formErrors, setFormErrors] = useState({}) + const { handleSetInstructions } = useContext(NewProposalContext) + const validateInstruction = async (): Promise => { + const { isValid, validationErrors } = await isFormValid(schema, form) + setFormErrors(validationErrors) + return isValid + } + async function getInstruction(): Promise { + const isValid = await validateInstruction() + let serializedInstruction = '' + const additionalSerializedInstructions: string[] = [] + const prerequisiteInstructions: TransactionInstruction[] = [] + if ( + isValid && + form.governedAccount?.governance?.account && + wallet?.publicKey && + vaults + ) { + for (const t of transfers) { + const mintAmount = parseMintNaturalAmountFromDecimal( + t.amount, + t.decimals + ) + const transferIx = Token.createTransferInstruction( + TOKEN_PROGRAM_ID, + t.from, + t.to, + form.governedAccount.extensions.transferAddress!, + [], + new u64(mintAmount.toString()) + ) + additionalSerializedInstructions.push( + serializeInstructionToBase64(transferIx!) + ) + } + serializedInstruction = '' + } + const obj: UiInstruction = { + additionalSerializedInstructions, + prerequisiteInstructions, + serializedInstruction: serializedInstruction, + isValid, + governance: form.governedAccount?.governance, + customHoldUpTime: form.distributionNumber, + } + return obj + } + const handleSelectDistribution = async (number: number) => { + const distribution = await client?.loadDistribution(number) + setDistribution(distribution) + } + const fetchVaults = async () => { + if (!client || !distribution) return + const v: any = {} + for (let i = 0; i < distribution.metadata!.mints.length; i++) { + const mint = distribution.metadata!.mints[i] + const vaultAddress = distribution.findVaultAddress( + new PublicKey(mint.address) + ) + try { + const tokenAccount = await tryGetTokenAccount( + connection.current, + vaultAddress + ) + + v[vaultAddress.toString()] = { + publicKey: vaultAddress, + amount: tokenAccount?.account.amount, + mint: tokenAccount?.account.mint, + mintIndex: i, + } + } catch { + v[vaultAddress.toString()] = { amount: -1, mintIndex: i } + } + } + setVaults(v) + } + useEffect(() => { + if (distribution) { + fetchVaults() + } + }, [distribution]) + useEffect(() => { + const client = new MangoMintsRedemptionClient( + new AnchorProvider( + connection.current, + new EmptyWallet(Keypair.generate()), + { skipPreflight: true } + ) + ) + setClient(client) + }, []) + useEffect(() => { + if (vaults && form.governedAccount) { + const trans = Object.values(vaults).map((v) => { + const from = assetAccounts.find( + (assetAccount) => + assetAccount.isToken && + assetAccount.extensions.mint?.publicKey.equals(v.mint) && + assetAccount.extensions.token?.account.owner.equals( + form.governedAccount!.extensions.transferAddress! + ) + ) + if (!from) { + return undefined + } + return { + from: from!.pubkey, + to: v.publicKey, + amount: '', + decimals: from!.extensions.mint!.account.decimals, + mintIndex: v.mintIndex, + } + }) + setTransfers(trans.filter((x) => x) as Transfer[]) + } else { + setTransfers([]) + } + }, [vaults]) + + useEffect(() => { + handleSetInstructions( + { governedAccount: form.governedAccount?.governance, getInstruction }, + index + ) + // eslint-disable-next-line react-hooks/exhaustive-deps -- TODO please fix, it can cause difficult bugs. You might wanna check out https://bobbyhadz.com/blog/react-hooks-exhaustive-deps for info. -@asktree + }, [form]) + const schema = yup.object().shape({ + governedAccount: yup + .object() + .nullable() + .required('Program governed account is required'), + }) + const inputs: InstructionInput[] = [ + { + label: 'Governance', + initialValue: form.governedAccount, + name: 'governedAccount', + type: InstructionInputType.GOVERNED_ACCOUNT, + shouldBeGoverned: shouldBeGoverned as any, + governance: governance, + options: solAccounts, + }, + { + label: 'Distribution Number', + initialValue: form.distributionNumber, + type: InstructionInputType.INPUT, + additionalComponent: ( +
+ +
+ ), + inputType: 'number', + name: 'distributionNumber', + }, + ] + + return ( + <> + {form && ( + <> + + {distribution && vaults && ( +
+ + Vaults to fill + + +
+ {transfers + ? transfers.map((t, idx) => { + return ( +
+

{t.to.toBase58()}

{' '} +

+ { + distribution.metadata!.mints[t.mintIndex] + .properties?.name + } +

{' '} + + { + const newTrans = transfers.map( + (x, innerIdex) => { + if (innerIdex === idx) { + return { + ...x, + amount: e.target.value, + } + } + return x + } + ) + setTransfers(newTrans) + }} + type="text" + > + +
+ ) + }) + : 'Loading...'} +
+
+
+ )} + + )} + + ) +} + +export default FillVaults diff --git a/pages/dao/[symbol]/proposal/new.tsx b/pages/dao/[symbol]/proposal/new.tsx index 51a1c03dc2..42fd0fbc88 100644 --- a/pages/dao/[symbol]/proposal/new.tsx +++ b/pages/dao/[symbol]/proposal/new.tsx @@ -136,6 +136,7 @@ import DualGso from './components/instructions/Dual/DualGso' import DualGsoWithdraw from './components/instructions/Dual/DualGsoWithdraw' import MultiChoiceForm from '../../../../components/MultiChoiceForm' import CloseVaults from './components/instructions/DistrubtionProgram/CloseVaults' +import FillVaults from './components/instructions/DistrubtionProgram/FillVaults' const TITLE_LENGTH_LIMIT = 130 // the true length limit is either at the tx size level, and maybe also the total account size level (I can't remember) @@ -494,6 +495,7 @@ const New = () => { [Instructions.DualFinanceVoteDeposit]: DualVoteDeposit, [Instructions.DualFinanceVote]: DualVote, [Instructions.DistributionCloseVaults]: CloseVaults, + [Instructions.DistributionFillVaults]: FillVaults, [Instructions.MeanCreateAccount]: MeanCreateAccount, [Instructions.MeanFundAccount]: MeanFundAccount, [Instructions.MeanWithdrawFromAccount]: MeanWithdrawFromAccount, diff --git a/utils/uiTypes/proposalCreationTypes.ts b/utils/uiTypes/proposalCreationTypes.ts index 79c4824d03..b73d59cf25 100644 --- a/utils/uiTypes/proposalCreationTypes.ts +++ b/utils/uiTypes/proposalCreationTypes.ts @@ -342,6 +342,7 @@ export enum Instructions { DualFinanceVoteDeposit, DualFinanceVote, DistributionCloseVaults, + DistributionFillVaults, DelegateStake, ForesightAddMarketListToCategory, ForesightInitCategory,