diff --git a/hooks/useGovernanceAssets.ts b/hooks/useGovernanceAssets.ts index 8ad15b3352..0bae4ff8cc 100644 --- a/hooks/useGovernanceAssets.ts +++ b/hooks/useGovernanceAssets.ts @@ -190,6 +190,10 @@ export default function useGovernanceAssets() { name: 'Solend', image: '/img/solend.png', }, + [PackageEnum.Squads]: { + name: 'Squads', + image: '/img/squads.png', + }, [PackageEnum.Switchboard]: { name: 'Switchboard', image: '/img/switchboard.png', @@ -748,7 +752,28 @@ export default function useGovernanceAssets() { name: 'Withdraw Funds', packageId: PackageEnum.Solend, }, - + /* + ███████ ██████ ██ ██ █████ ██████ ███████ + ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + ███████ ██ ██ ██ ██ ███████ ██ ██ ███████ + ██ ██ ▄▄ ██ ██ ██ ██ ██ ██ ██ ██ + ███████ ██████ ██████ ██ ██ ██████ ███████ + */ + [Instructions.SquadsMeshAddMember]: { + name: 'Mesh Add Member', + packageId: PackageEnum.Squads, + isVisible: true, + }, + [Instructions.SquadsMeshChangeThresholdMember]: { + name: 'Mesh Change Threshold', + packageId: PackageEnum.Squads, + isVisible: true, + }, + [Instructions.SquadsMeshRemoveMember]: { + name: 'Mesh Remove Member', + packageId: PackageEnum.Squads, + isVisible: true, + }, /* ███████ ██ ██ ██ ████████ ██████ ██ ██ ██████ ██████ █████ ██████ ██████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ diff --git a/package.json b/package.json index f28069eaf1..93e00bd954 100644 --- a/package.json +++ b/package.json @@ -110,6 +110,7 @@ "@solana/web3.js": "1.78.2", "@solendprotocol/solend-sdk": "0.5.5", "@sqds/iframe-adapter": "1.0.16", + "@sqds/mesh": "1.0.6", "@switchboard-xyz/sbv2-lite": "0.2.4", "@switchboard-xyz/solana.js": "3.1.5", "@tailwindcss/forms": "0.5.3", diff --git a/pages/dao/[symbol]/proposal/components/instructions/Squads/MeshAddMember.tsx b/pages/dao/[symbol]/proposal/components/instructions/Squads/MeshAddMember.tsx new file mode 100644 index 0000000000..fe7f444466 --- /dev/null +++ b/pages/dao/[symbol]/proposal/components/instructions/Squads/MeshAddMember.tsx @@ -0,0 +1,149 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { useContext, useEffect, useState } from 'react' +import * as yup from 'yup' +import { isFormValid, validatePubkey } from '@utils/formValidation' +import { UiInstruction } from '@utils/uiTypes/proposalCreationTypes' +import useGovernanceAssets from '@hooks/useGovernanceAssets' +import { Governance } from '@solana/spl-governance' +import { ProgramAccount } from '@solana/spl-governance' +import { serializeInstructionToBase64 } from '@solana/spl-governance' +import { MESH_PROGRAM_ID, MeshEditMemberForm } from './common' +import useWalletOnePointOh from '@hooks/useWalletOnePointOh' +import InstructionForm, { InstructionInput } from '../FormCreator' +import { InstructionInputType } from '../inputInstructionType' +import { NewProposalContext } from '../../../new' +import Squads from '@sqds/mesh' +import useLegacyConnectionContext from '@hooks/useLegacyConnectionContext' +import { PublicKey } from '@solana/web3.js' +import { Wallet } from '@coral-xyz/anchor' + +const MeshAddMember = ({ + index, + governance, +}: { + index: number + governance: ProgramAccount | null +}) => { + const wallet = useWalletOnePointOh() + const connection = useLegacyConnectionContext() + const { assetAccounts } = useGovernanceAssets() + const shouldBeGoverned = !!(index !== 0 && governance) + const [form, setForm] = useState({ + governedAccount: null, + vault: '', + member: '', + }) + 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() + + if ( + isValid && + form.governedAccount?.governance?.account && + wallet?.publicKey + ) { + const squads = new Squads({ + connection: connection.current, + wallet: {} as Wallet, + multisigProgramId: MESH_PROGRAM_ID, + }) + const instruction = await squads.buildAddMember( + new PublicKey(form.vault), + form.governedAccount.governance.pubkey, + new PublicKey(form.member) + ) + return { + serializedInstruction: serializeInstructionToBase64(instruction), + isValid, + governance: form.governedAccount?.governance, + chunkBy: 1, + } + } else { + return { + serializedInstruction: '', + isValid, + governance: form.governedAccount?.governance, + chunkBy: 1, + } + } + } + + 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'), + vault: yup + .string() + .required('Vault is required') + .test('is-vault-valid', 'Invalid Vault Account', function (val: string) { + return val ? validatePubkey(val) : true + }), + member: yup + .string() + .required('Member is required') + .test( + 'is-member-valid', + 'Invalid Member Account', + function (val: string) { + return val ? validatePubkey(val) : true + } + ), + }) + const inputs: InstructionInput[] = [ + { + label: 'Governance', + initialValue: form.governedAccount, + name: 'governedAccount', + type: InstructionInputType.GOVERNED_ACCOUNT, + shouldBeGoverned: shouldBeGoverned as any, + governance: governance, + options: assetAccounts, + }, + { + label: 'Vault', + initialValue: form.vault, + type: InstructionInputType.INPUT, + inputType: 'text', + name: 'vault', + }, + { + label: 'Member', + initialValue: form.member, + type: InstructionInputType.INPUT, + inputType: 'text', + name: 'member', + }, + ] + + return ( + <> + {form && ( + + )} + + ) +} + +export default MeshAddMember diff --git a/pages/dao/[symbol]/proposal/components/instructions/Squads/MeshChangeThresholdMember.tsx b/pages/dao/[symbol]/proposal/components/instructions/Squads/MeshChangeThresholdMember.tsx new file mode 100644 index 0000000000..e4dbb5d739 --- /dev/null +++ b/pages/dao/[symbol]/proposal/components/instructions/Squads/MeshChangeThresholdMember.tsx @@ -0,0 +1,156 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { useContext, useEffect, useState } from 'react' +import * as yup from 'yup' +import { isFormValid, validatePubkey } from '@utils/formValidation' +import { UiInstruction } from '@utils/uiTypes/proposalCreationTypes' +import useGovernanceAssets from '@hooks/useGovernanceAssets' +import { Governance } from '@solana/spl-governance' +import { ProgramAccount } from '@solana/spl-governance' +import { serializeInstructionToBase64 } from '@solana/spl-governance' +import { MESH_PROGRAM_ID } from './common' +import useWalletOnePointOh from '@hooks/useWalletOnePointOh' +import InstructionForm, { InstructionInput } from '../FormCreator' +import { InstructionInputType } from '../inputInstructionType' +import { NewProposalContext } from '../../../new' +import Squads from '@sqds/mesh' +import useLegacyConnectionContext from '@hooks/useLegacyConnectionContext' +import { PublicKey } from '@solana/web3.js' +import { Wallet } from '@coral-xyz/anchor' +import { AssetAccount } from '@utils/uiTypes/assets' + +export interface MeshChangeThresholdMemberForm { + governedAccount: AssetAccount | null + vault: string + newThreshold: number +} + +const MeshChangeThresholdMember = ({ + index, + governance, +}: { + index: number + governance: ProgramAccount | null +}) => { + const wallet = useWalletOnePointOh() + const connection = useLegacyConnectionContext() + const { assetAccounts } = useGovernanceAssets() + const shouldBeGoverned = !!(index !== 0 && governance) + const [form, setForm] = useState({ + governedAccount: null, + vault: '', + newThreshold: 0, + }) + 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() + + if ( + isValid && + form.governedAccount?.governance?.account && + wallet?.publicKey + ) { + const squads = new Squads({ + connection: connection.current, + wallet: {} as Wallet, + multisigProgramId: MESH_PROGRAM_ID, + }) + const instruction = await squads.buildChangeThresholdMember( + new PublicKey(form.vault), + form.governedAccount.governance.pubkey, + form.newThreshold + ) + return { + serializedInstruction: serializeInstructionToBase64(instruction), + isValid, + governance: form.governedAccount?.governance, + chunkBy: 1, + } + } else { + return { + serializedInstruction: '', + isValid, + governance: form.governedAccount?.governance, + chunkBy: 1, + } + } + } + + 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'), + vault: yup + .string() + .required('Vault is required') + .test('is-vault-valid', 'Invalid Vault Account', function (val: string) { + return val ? validatePubkey(val) : true + }), + newThreshold: yup + .number() + .required('New threshold is required') + .test( + 'is-threshold-valid', + "New threshold can't be 0", + function (val: number) { + return val > 0 + } + ), + }) + const inputs: InstructionInput[] = [ + { + label: 'Governance', + initialValue: form.governedAccount, + name: 'governedAccount', + type: InstructionInputType.GOVERNED_ACCOUNT, + shouldBeGoverned: shouldBeGoverned as any, + governance: governance, + options: assetAccounts, + }, + { + label: 'Vault', + initialValue: form.vault, + type: InstructionInputType.INPUT, + inputType: 'text', + name: 'vault', + }, + { + label: 'New threshold', + initialValue: form.newThreshold, + type: InstructionInputType.INPUT, + inputType: 'number', + name: 'newThreshold', + }, + ] + + return ( + <> + {form && ( + + )} + + ) +} + +export default MeshChangeThresholdMember diff --git a/pages/dao/[symbol]/proposal/components/instructions/Squads/MeshRemoveMember.tsx b/pages/dao/[symbol]/proposal/components/instructions/Squads/MeshRemoveMember.tsx new file mode 100644 index 0000000000..e858b7d020 --- /dev/null +++ b/pages/dao/[symbol]/proposal/components/instructions/Squads/MeshRemoveMember.tsx @@ -0,0 +1,149 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { useContext, useEffect, useState } from 'react' +import * as yup from 'yup' +import { isFormValid, validatePubkey } from '@utils/formValidation' +import { UiInstruction } from '@utils/uiTypes/proposalCreationTypes' +import useGovernanceAssets from '@hooks/useGovernanceAssets' +import { Governance } from '@solana/spl-governance' +import { ProgramAccount } from '@solana/spl-governance' +import { serializeInstructionToBase64 } from '@solana/spl-governance' +import { MESH_PROGRAM_ID, MeshEditMemberForm } from './common' +import useWalletOnePointOh from '@hooks/useWalletOnePointOh' +import InstructionForm, { InstructionInput } from '../FormCreator' +import { InstructionInputType } from '../inputInstructionType' +import { NewProposalContext } from '../../../new' +import Squads from '@sqds/mesh' +import useLegacyConnectionContext from '@hooks/useLegacyConnectionContext' +import { PublicKey } from '@solana/web3.js' +import { Wallet } from '@coral-xyz/anchor' + +const MeshRemoveMember = ({ + index, + governance, +}: { + index: number + governance: ProgramAccount | null +}) => { + const wallet = useWalletOnePointOh() + const connection = useLegacyConnectionContext() + const { assetAccounts } = useGovernanceAssets() + const shouldBeGoverned = !!(index !== 0 && governance) + const [form, setForm] = useState({ + governedAccount: null, + vault: '', + member: '', + }) + 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() + + if ( + isValid && + form.governedAccount?.governance?.account && + wallet?.publicKey + ) { + const squads = new Squads({ + connection: connection.current, + wallet: {} as Wallet, + multisigProgramId: MESH_PROGRAM_ID, + }) + const instruction = await squads.buildRemoveMember( + new PublicKey(form.vault), + form.governedAccount.governance.pubkey, + new PublicKey(form.member) + ) + return { + serializedInstruction: serializeInstructionToBase64(instruction), + isValid, + governance: form.governedAccount?.governance, + chunkBy: 1, + } + } else { + return { + serializedInstruction: '', + isValid, + governance: form.governedAccount?.governance, + chunkBy: 1, + } + } + } + + 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'), + vault: yup + .string() + .required('Vault is required') + .test('is-vault-valid', 'Invalid Vault Account', function (val: string) { + return val ? validatePubkey(val) : true + }), + member: yup + .string() + .required('Member is required') + .test( + 'is-member-valid', + 'Invalid Member Account', + function (val: string) { + return val ? validatePubkey(val) : true + } + ), + }) + const inputs: InstructionInput[] = [ + { + label: 'Governance', + initialValue: form.governedAccount, + name: 'governedAccount', + type: InstructionInputType.GOVERNED_ACCOUNT, + shouldBeGoverned: shouldBeGoverned as any, + governance: governance, + options: assetAccounts, + }, + { + label: 'Vault', + initialValue: form.vault, + type: InstructionInputType.INPUT, + inputType: 'text', + name: 'vault', + }, + { + label: 'Member', + initialValue: form.member, + type: InstructionInputType.INPUT, + inputType: 'text', + name: 'member', + }, + ] + + return ( + <> + {form && ( + + )} + + ) +} + +export default MeshRemoveMember diff --git a/pages/dao/[symbol]/proposal/components/instructions/Squads/common.ts b/pages/dao/[symbol]/proposal/components/instructions/Squads/common.ts new file mode 100644 index 0000000000..5ea0b04468 --- /dev/null +++ b/pages/dao/[symbol]/proposal/components/instructions/Squads/common.ts @@ -0,0 +1,12 @@ +import { PublicKey } from '@solana/web3.js' +import { AssetAccount } from '@utils/uiTypes/assets' + +export const MESH_PROGRAM_ID = new PublicKey( + 'SMPLVC8MxZ5Bf5EfF7PaMiTCxoBAcmkbM2vkrvMK8ho' +) + +export interface MeshEditMemberForm { + governedAccount: AssetAccount | null + vault: string + member: string +} diff --git a/pages/dao/[symbol]/proposal/new.tsx b/pages/dao/[symbol]/proposal/new.tsx index 7e8b16bdf7..edf45aba39 100644 --- a/pages/dao/[symbol]/proposal/new.tsx +++ b/pages/dao/[symbol]/proposal/new.tsx @@ -144,6 +144,9 @@ 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' +import MeshRemoveMember from './components/instructions/Squads/MeshRemoveMember' +import MeshAddMember from './components/instructions/Squads/MeshAddMember' +import MeshChangeThresholdMember from './components/instructions/Squads/MeshChangeThresholdMember' 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) @@ -514,6 +517,9 @@ const New = () => { [Instructions.MeanWithdrawFromAccount]: MeanWithdrawFromAccount, [Instructions.MeanCreateStream]: MeanCreateStream, [Instructions.MeanTransferStream]: MeanTransferStream, + [Instructions.SquadsMeshRemoveMember]: MeshRemoveMember, + [Instructions.SquadsMeshAddMember]: MeshAddMember, + [Instructions.SquadsMeshChangeThresholdMember]: MeshChangeThresholdMember, [Instructions.CreateSolendObligationAccount]: CreateObligationAccount, [Instructions.InitSolendObligationAccount]: InitObligationAccount, [Instructions.DepositReserveLiquidityAndObligationCollateral]: DepositReserveLiquidityAndObligationCollateral, diff --git a/public/img/squads.png b/public/img/squads.png new file mode 100644 index 0000000000..25ffdf2c7a Binary files /dev/null and b/public/img/squads.png differ diff --git a/utils/uiTypes/proposalCreationTypes.ts b/utils/uiTypes/proposalCreationTypes.ts index 4de7c00e00..19bc624361 100644 --- a/utils/uiTypes/proposalCreationTypes.ts +++ b/utils/uiTypes/proposalCreationTypes.ts @@ -21,6 +21,7 @@ export enum PackageEnum { NftPlugin, MangoMarketV4, MeanFinance, + Squads, PsyFinance, Serum, Solend, @@ -400,6 +401,9 @@ export enum Instructions { SerumInitUser, SerumUpdateGovConfigAuthority, SerumUpdateGovConfigParams, + SquadsMeshAddMember, + SquadsMeshChangeThresholdMember, + SquadsMeshRemoveMember, StakeValidator, SwitchboardFundOracle, WithdrawFromOracle, diff --git a/yarn.lock b/yarn.lock index 00711f2417..00819bd0df 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4866,7 +4866,7 @@ "@wallet-standard/app" "^1.0.1" "@wallet-standard/base" "^1.0.1" -"@solana/web3.js@1.56.0", "@solana/web3.js@1.78.2", "@solana/web3.js@^1.17.0", "@solana/web3.js@^1.21.0", "@solana/web3.js@^1.22.0", "@solana/web3.js@^1.30.2", "@solana/web3.js@^1.31.0", "@solana/web3.js@^1.32.0", "@solana/web3.js@^1.35.1", "@solana/web3.js@^1.36.0", "@solana/web3.js@^1.37.1", "@solana/web3.js@^1.43.4", "@solana/web3.js@^1.44.3", "@solana/web3.js@^1.50.1", "@solana/web3.js@^1.56.2", "@solana/web3.js@^1.59.1", "@solana/web3.js@^1.63.0", "@solana/web3.js@^1.63.1", "@solana/web3.js@^1.66.2", "@solana/web3.js@^1.68.0", "@solana/web3.js@^1.73.0", "@solana/web3.js@^1.73.2", "@solana/web3.js@^1.77.3", "@solana/web3.js@^1.78.2", "@solana/web3.js@^1.78.3", "@solana/web3.js@^1.87.5", "@solana/web3.js@^1.87.6", "@solana/web3.js@^1.88.0": +"@solana/web3.js@1.56.0", "@solana/web3.js@1.78.2", "@solana/web3.js@^1.17.0", "@solana/web3.js@^1.21.0", "@solana/web3.js@^1.22.0", "@solana/web3.js@^1.30.2", "@solana/web3.js@^1.31.0", "@solana/web3.js@^1.32.0", "@solana/web3.js@^1.35.1", "@solana/web3.js@^1.36.0", "@solana/web3.js@^1.37.1", "@solana/web3.js@^1.43.4", "@solana/web3.js@^1.44.3", "@solana/web3.js@^1.50.1", "@solana/web3.js@^1.53.0", "@solana/web3.js@^1.56.2", "@solana/web3.js@^1.59.1", "@solana/web3.js@^1.63.0", "@solana/web3.js@^1.63.1", "@solana/web3.js@^1.66.2", "@solana/web3.js@^1.68.0", "@solana/web3.js@^1.73.0", "@solana/web3.js@^1.73.2", "@solana/web3.js@^1.77.3", "@solana/web3.js@^1.78.2", "@solana/web3.js@^1.78.3", "@solana/web3.js@^1.87.5", "@solana/web3.js@^1.87.6", "@solana/web3.js@^1.88.0": version "1.78.2" resolved "https://registry.yarnpkg.com/@solana/web3.js/-/web3.js-1.78.2.tgz#ae976ce2f793077aa104ab455f9f75be86a3e2a4" integrity sha512-oF+TmBZCt3eAEl4Meu3GO2p6G8wdyoKgXgTKzQpIUIhpMGA/dVQzyMFpKjCgoTU1Kx+/UF3gXUdsZOxQukGbvQ== @@ -4923,6 +4923,15 @@ bs58 "^5.0.0" encoding "^0.1.13" +"@sqds/mesh@1.0.6": + version "1.0.6" + resolved "https://registry.yarnpkg.com/@sqds/mesh/-/mesh-1.0.6.tgz#b3538a04b3232645d61db3ac7377511b5e742e63" + integrity sha512-z+x1GjixJm8K3uPwaDebTsssU3B71zJzRCkywmtz2ZZoMvoz9w/C4nY+v7v6Wg/9OTbfSDgcX/Hoo/FlphkWvg== + dependencies: + "@project-serum/anchor" "^0.25.0" + "@solana/web3.js" "^1.53.0" + bn.js "^5.2.1" + "@stablelib/aead@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@stablelib/aead/-/aead-1.0.1.tgz#c4b1106df9c23d1b867eb9b276d8f42d5fc4c0c3"