From 087b84a78aea50679d34a7977275e1428ab7fe5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Brzezin=CC=81ski?= Date: Sun, 18 Jan 2026 03:07:44 +0100 Subject: [PATCH 1/2] v3 reimburmsnet withdraw instruction --- hooks/useGovernanceAssets.ts | 5 + .../WithdrawFromVaults.tsx | 485 ++++++++++++++++++ pages/dao/[symbol]/proposal/new.tsx | 4 +- utils/uiTypes/proposalCreationTypes.ts | 3 +- 4 files changed, 495 insertions(+), 2 deletions(-) create mode 100644 pages/dao/[symbol]/proposal/components/instructions/ReimbursementProgram/WithdrawFromVaults.tsx diff --git a/hooks/useGovernanceAssets.ts b/hooks/useGovernanceAssets.ts index 1bb744ec1..407c38daa 100644 --- a/hooks/useGovernanceAssets.ts +++ b/hooks/useGovernanceAssets.ts @@ -878,6 +878,11 @@ export default function useGovernanceAssets() { isVisible: canUseAuthorityInstruction, packageId: PackageEnum.Distribution, }, + [Instructions.ReimbursementWithdraw]: { + name: 'Mango V3 Reimbursement: Withdraw from Vaults', + isVisible: canUseAuthorityInstruction, + packageId: PackageEnum.Distribution, + }, } const availablePackages: PackageType[] = Object.entries(packages) diff --git a/pages/dao/[symbol]/proposal/components/instructions/ReimbursementProgram/WithdrawFromVaults.tsx b/pages/dao/[symbol]/proposal/components/instructions/ReimbursementProgram/WithdrawFromVaults.tsx new file mode 100644 index 000000000..f9bb5ba36 --- /dev/null +++ b/pages/dao/[symbol]/proposal/components/instructions/ReimbursementProgram/WithdrawFromVaults.tsx @@ -0,0 +1,485 @@ +import { useCallback, useContext, useEffect, useMemo, useState } from 'react' +import * as yup from 'yup' +import { createHash } from 'crypto' +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 useLegacyConnectionContext from '@hooks/useLegacyConnectionContext' +import { PublicKey, TransactionInstruction } from '@solana/web3.js' +import { tryGetTokenAccount } from '@utils/tokens' +import Button from '@components/Button' +import { TOKEN_PROGRAM_ID } from '@solana/spl-token' +import { + createAssociatedTokenAccountIdempotentInstruction, + getAssociatedTokenAddressSync, +} from '@solana/spl-token-new' +import { validateInstruction } from '@utils/instructionTools' + +// Mango V3 Reimbursement Program ID +const REIMBURSEMENT_PROGRAM_ID = new PublicKey( + 'm3roABq4Ta3sGyFRLdY4LH1KN16zBtg586gJ3UxoBzb' +) + +// Default group address - can be changed in the form +const DEFAULT_GROUP = 'Hy4ZsZkVa1ZTVa2ghkKY3TsThYEK9MgaL8VPF569jsHP' + +interface WithdrawFromVaultsForm { + governedAccount: AssetAccount | null + groupAddress: string +} + +type VaultInfo = { + publicKey: PublicKey + mint: PublicKey + amount: bigint + tokenIndex: number + symbol?: string +} + +// Group account offsets (after 8-byte Anchor discriminator) +const GROUP_OFFSETS = { + GROUP_NUM: 8, + TABLE: 12, + CLAIM_TRANSFER_DESTINATION: 44, + AUTHORITY: 76, + VAULTS: 108, // 16 * 32 = 512 bytes + CLAIM_MINTS: 620, // 16 * 32 = 512 bytes + MINTS: 1132, // 16 * 32 = 512 bytes + REIMBURSEMENT_STARTED: 1644, + BUMP: 1645, + TESTING: 1646, +} + +// Create the Anchor instruction discriminator +function getInstructionDiscriminator(name: string): Buffer { + const hash = createHash('sha256') + .update(`global:${name}`) + .digest() + return hash.slice(0, 8) +} + +// Parse Group account data +function parseGroupAccount(data: Buffer) { + const vaults: PublicKey[] = [] + const mints: PublicKey[] = [] + + for (let i = 0; i < 16; i++) { + const vaultOffset = GROUP_OFFSETS.VAULTS + i * 32 + const mintOffset = GROUP_OFFSETS.MINTS + i * 32 + vaults.push(new PublicKey(data.slice(vaultOffset, vaultOffset + 32))) + mints.push(new PublicKey(data.slice(mintOffset, mintOffset + 32))) + } + + return { + groupNum: data.readUInt32LE(GROUP_OFFSETS.GROUP_NUM), + authority: new PublicKey( + data.slice(GROUP_OFFSETS.AUTHORITY, GROUP_OFFSETS.AUTHORITY + 32) + ), + vaults, + mints, + bump: data[GROUP_OFFSETS.BUMP], + } +} + +// Build withdraw_to_authority instruction +function buildWithdrawToAuthorityInstruction( + group: PublicKey, + vault: PublicKey, + authorityTokenAccount: PublicKey, + authority: PublicKey, + tokenIndex: number +): TransactionInstruction { + const discriminator = getInstructionDiscriminator('withdraw_to_authority') + + // token_index is usize (u64 on Solana) + const data = Buffer.alloc(16) + discriminator.copy(data, 0) + data.writeBigUInt64LE(BigInt(tokenIndex), 8) + + return new TransactionInstruction({ + programId: REIMBURSEMENT_PROGRAM_ID, + keys: [ + { pubkey: group, isSigner: false, isWritable: false }, + { pubkey: vault, isSigner: false, isWritable: true }, + { pubkey: authorityTokenAccount, isSigner: false, isWritable: true }, + { pubkey: authority, isSigner: true, isWritable: false }, + { pubkey: TOKEN_PROGRAM_ID, isSigner: false, isWritable: false }, + ], + data, + }) +} + +// Get the authority address based on account type +function getAuthorityAddress(account: AssetAccount | null): PublicKey | null { + if (!account) return null + + // For SOL accounts, use transferAddress (native treasury) + if (account.extensions.transferAddress) { + return account.extensions.transferAddress + } + + // For PROGRAM accounts, the authority is stored in extensions.program.authority + // This is the program's upgrade authority which can sign + if (account.type === AccountType.PROGRAM && account.extensions.program?.authority) { + return account.extensions.program.authority + } + + // For old-style governance, try native treasury + if (account.governance.nativeTreasuryAddress) { + return account.governance.nativeTreasuryAddress + } + + // Fallback to governed account + return account.governance.account.governedAccount || account.pubkey +} + +const WithdrawFromVaults = ({ + index, + governance, +}: { + index: number + governance: ProgramAccount | null +}) => { + const wallet = useWalletOnePointOh() + const { assetAccounts } = useGovernanceAssets() + // Include SOL, PROGRAM, and GENERIC accounts as possible authorities + const governanceAccounts = assetAccounts.filter( + (x) => + x.type === AccountType.SOL || + x.type === AccountType.PROGRAM || + x.type === AccountType.GENERIC + ) + const connection = useLegacyConnectionContext() + const shouldBeGoverned = !!(index !== 0 && governance) + + const [form, setForm] = useState({ + governedAccount: null, + groupAddress: DEFAULT_GROUP, + }) + const [vaults, setVaults] = useState([]) + const [selectedVaults, setSelectedVaults] = useState>(new Set()) + const [groupData, setGroupData] = useState | null>(null) + const [isLoading, setIsLoading] = useState(false) + const [formErrors, setFormErrors] = useState({}) + const { handleSetInstructions } = useContext(NewProposalContext) + + const schema = useMemo( + () => + yup.object().shape({ + governedAccount: yup + .object() + .nullable() + .required('Governance account is required'), + groupAddress: yup.string().required('Group address is required'), + }), + [] + ) + + const fetchGroupAndVaults = async () => { + if (!form.groupAddress) return + setIsLoading(true) + + try { + const groupPubkey = new PublicKey(form.groupAddress) + const groupAccountInfo = await connection.current.getAccountInfo( + groupPubkey + ) + + if (!groupAccountInfo) { + console.error('Group account not found') + setIsLoading(false) + return + } + + const parsed = parseGroupAccount(groupAccountInfo.data as Buffer) + setGroupData(parsed) + + // Fetch vault balances + const vaultInfos: VaultInfo[] = [] + for (let i = 0; i < 16; i++) { + const vault = parsed.vaults[i] + const mint = parsed.mints[i] + + // Skip empty vaults (default pubkey) + if (vault.equals(PublicKey.default)) continue + + try { + const tokenAccount = await tryGetTokenAccount( + connection.current, + vault + ) + + if (tokenAccount && Number(tokenAccount.account.amount) > 0) { + vaultInfos.push({ + publicKey: vault, + mint: mint, + amount: BigInt(tokenAccount.account.amount.toString()), + tokenIndex: i, + }) + } + } catch (e) { + console.log(`Vault ${i} error:`, e) + } + } + + setVaults(vaultInfos) + // Select all vaults by default - chunking handles tx size + setSelectedVaults(new Set(vaultInfos.map((v) => v.tokenIndex))) + } catch (e) { + console.error('Error fetching group:', e) + } + + setIsLoading(false) + } + + const toggleVaultSelection = (tokenIndex: number) => { + setSelectedVaults((prev) => { + const newSet = new Set(prev) + if (newSet.has(tokenIndex)) { + newSet.delete(tokenIndex) + } else { + newSet.add(tokenIndex) + } + return newSet + }) + } + + const selectAllVaults = () => { + setSelectedVaults(new Set(vaults.map((v) => v.tokenIndex))) + } + + const deselectAllVaults = () => { + setSelectedVaults(new Set()) + } + + const getInstruction = useCallback(async () => { + const isValid = await validateInstruction({ schema, form, setFormErrors }) + const serializedInstruction = '' + const additionalSerializedInstructions: string[] = [] + const prerequisiteInstructions: TransactionInstruction[] = [] + const mintsOfCurrentlyPushedAtaInstructions: string[] = [] + + const selectedVaultsList = vaults.filter((v) => + selectedVaults.has(v.tokenIndex) + ) + + const authority = getAuthorityAddress(form.governedAccount) + + if ( + isValid && + form.governedAccount?.governance?.account && + wallet?.publicKey && + selectedVaultsList.length > 0 && + groupData && + authority + ) { + const groupPubkey = new PublicKey(form.groupAddress) + + for (const vault of selectedVaultsList) { + // Get ATA address for the authority + const ataAddress = getAssociatedTokenAddressSync( + vault.mint, + authority, + true, // allowOwnerOffCurve + TOKEN_PROGRAM_ID + ) + + // Always add idempotent ATA creation - it's safe even if ATA exists + if (!mintsOfCurrentlyPushedAtaInstructions.includes(vault.mint.toBase58())) { + prerequisiteInstructions.push( + createAssociatedTokenAccountIdempotentInstruction( + wallet.publicKey, // payer + ataAddress, + authority, // owner + vault.mint, + TOKEN_PROGRAM_ID + ) + ) + mintsOfCurrentlyPushedAtaInstructions.push(vault.mint.toBase58()) + } + + // Build withdraw instruction + const ix = buildWithdrawToAuthorityInstruction( + groupPubkey, + vault.publicKey, + ataAddress, + authority, + vault.tokenIndex + ) + + additionalSerializedInstructions.push(serializeInstructionToBase64(ix)) + } + } + + const obj: UiInstruction = { + serializedInstruction, + isValid, + governance: form.governedAccount?.governance, + additionalSerializedInstructions, + prerequisiteInstructions, + chunkBy: 2, + } + return obj + }, [ + connection, + form, + groupData, + schema, + selectedVaults, + vaults, + wallet?.publicKey, + ]) + + 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, getInstruction, handleSetInstructions, index, selectedVaults, vaults]) + + const inputs: InstructionInput[] = [ + { + label: 'Governance', + initialValue: form.governedAccount, + name: 'governedAccount', + type: InstructionInputType.GOVERNED_ACCOUNT, + shouldBeGoverned: shouldBeGoverned as any, + governance: governance, + options: governanceAccounts, + }, + { + label: 'Reimbursement Group Address', + initialValue: form.groupAddress, + type: InstructionInputType.INPUT, + inputType: 'text', + name: 'groupAddress', + additionalComponent: ( +
+ +
+ ), + }, + ] + + const formatAmount = (amount: bigint, decimals = 6) => { + const divisor = BigInt(10 ** decimals) + const intPart = amount / divisor + const fracPart = amount % divisor + return `${intPart}.${fracPart.toString().padStart(decimals, '0')}` + } + + return ( + <> + {form && ( + <> + + {vaults.length > 0 && ( +
+
+ + Select vaults to withdraw ({selectedVaults.size}/{vaults.length} selected) + +
+ + +
+
+ {groupData && ( +
+
+ Group authority: + + {groupData.authority.toBase58()} + +
+
+ Selected: + + {getAuthorityAddress(form.governedAccount)?.toBase58() || 'None'} + +
+
+ (pubkey: {form.governedAccount?.pubkey?.toBase58()?.slice(0,8)}... | + native: {form.governedAccount?.governance?.nativeTreasuryAddress?.toBase58()?.slice(0,8)}...) +
+ {form.governedAccount && + !groupData.authority.equals( + getAuthorityAddress(form.governedAccount) || PublicKey.default + ) && ( +
+ ⚠️ Mismatch! Need governance with authority: {groupData.authority.toBase58()} +
+ )} +
+ )} +
+ Note: Instructions are chunked (2 per tx). Select vaults and create proposal. +
+
+ {vaults.map((vault) => ( + + ))} +
+
+ )} + {groupData && vaults.length === 0 && !isLoading && ( +
+ No vaults with balance found +
+ )} + + )} + + ) +} + +export default WithdrawFromVaults diff --git a/pages/dao/[symbol]/proposal/new.tsx b/pages/dao/[symbol]/proposal/new.tsx index 530ebc178..7fac89cd1 100644 --- a/pages/dao/[symbol]/proposal/new.tsx +++ b/pages/dao/[symbol]/proposal/new.tsx @@ -158,6 +158,7 @@ import SquadsV4RemoveMember from './components/instructions/Squads/SquadsV4Remov import CollectPoolFees from './components/instructions/Raydium/CollectPoolFees' import CollectVestedTokens from './components/instructions/Raydium/CollectVestedTokens' import RelinquishDaoVote from './components/instructions/RelinquishDaoVote' +import ReimbursementWithdraw from './components/instructions/ReimbursementProgram/WithdrawFromVaults' 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) @@ -639,7 +640,8 @@ const New = () => { [Instructions.SymmetryDeposit]: SymmetryDeposit, [Instructions.SymmetryWithdraw]: SymmetryWithdraw, [Instructions.CollectPoolFees]: CollectPoolFees , - [Instructions.CollectVestedTokens]: CollectVestedTokens + [Instructions.CollectVestedTokens]: CollectVestedTokens, + [Instructions.ReimbursementWithdraw]: ReimbursementWithdraw }), [governance?.pubkey?.toBase58()], ) diff --git a/utils/uiTypes/proposalCreationTypes.ts b/utils/uiTypes/proposalCreationTypes.ts index 36c4e1b5f..3c7b892dc 100644 --- a/utils/uiTypes/proposalCreationTypes.ts +++ b/utils/uiTypes/proposalCreationTypes.ts @@ -433,7 +433,8 @@ export enum Instructions { SymmetryWithdraw, TokenWithdrawFees, CollectPoolFees, - CollectVestedTokens + CollectVestedTokens, + ReimbursementWithdraw } export interface ComponentInstructionData { From 247e4f214c243a0ca754d8dccee0252482badd43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Brzezin=CC=81ski?= Date: Sun, 18 Jan 2026 18:47:28 +0100 Subject: [PATCH 2/2] v3 reim fixes --- .gitignore | 4 +- components/instructions/programs/mangoV4.tsx | 42 ++++ components/instructions/tools.tsx | 15 +- hooks/useGovernanceAssets.ts | 5 + .../Mango/MangoV4/WithdrawInsuranceFund.tsx | 210 ++++++++++++++++++ pages/dao/[symbol]/proposal/new.tsx | 2 + stores/useGovernanceAssetsStore.tsx | 39 +++- utils/uiTypes/proposalCreationTypes.ts | 1 + 8 files changed, 308 insertions(+), 10 deletions(-) create mode 100644 pages/dao/[symbol]/proposal/components/instructions/Mango/MangoV4/WithdrawInsuranceFund.tsx diff --git a/.gitignore b/.gitignore index 55713a92f..ce19a96c4 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,6 @@ yarn-error.log* # Sentry .sentryclirc -.vscode/settings.json \ No newline at end of file +.vscode/settings.json +.claude +.claude.local \ No newline at end of file diff --git a/components/instructions/programs/mangoV4.tsx b/components/instructions/programs/mangoV4.tsx index 20f70b57d..25eb3f764 100644 --- a/components/instructions/programs/mangoV4.tsx +++ b/components/instructions/programs/mangoV4.tsx @@ -1872,6 +1872,48 @@ const instructions = () => ({ } }, }, + 7395: { + name: 'Withdraw Insurance Fund', + accounts: [ + { name: 'Group' }, + { name: 'Admin' }, + { name: 'Insurance Vault' }, + { name: 'Destination' }, + { name: 'Token Program' }, + ], + getDataUI: async ( + connection: Connection, + data: Uint8Array, + accounts: AccountMetaData[], + ) => { + const args = await getDataObjectFlattened(connection, data) + const group = accounts[0].pubkey + const client = await getClient(connection) + const mangoGroup = await getGroupForClient(client, group) + const insuranceMint = mangoGroup.insuranceMint + const mintInfo = await tryGetMint(connection, insuranceMint) + const tokenInfo = tokenPriceService.getTokenInfo(insuranceMint.toBase58()) + + try { + return ( +
+
+ Amount:{' '} + {mintInfo?.account.decimals + ? formatNumber( + toUiDecimals(args.amount, mintInfo?.account.decimals), + ) + : args.amount}{' '} + {tokenInfo?.symbol || 'tokens'} +
+
+ ) + } catch (e) { + console.log(e) + return
{JSON.stringify(data)}
+ } + }, + }, }) export const MANGO_V4_INSTRUCTIONS = { diff --git a/components/instructions/tools.tsx b/components/instructions/tools.tsx index b1c6f2cf6..4704fac79 100644 --- a/components/instructions/tools.tsx +++ b/components/instructions/tools.tsx @@ -516,11 +516,24 @@ const MNGO_AUXILIARY_TOKEN_ACCOUNTS = [ owner: 'FRYXAjyVnvXja8chgdq47qL3CKoyBjUg4ro7M7QQn1aD', accounts: ['24frxVoDzo7bAimBU6rDhB1McxWNvzX9qddPMSv9VACZ'], }, - // + //v3 reimbursement - empty accounts array means include all token accounts from this owner + { + owner: 'D3SMRX2tBq5MYRuis3i4kbVt1fdQ5TkX1xfxhUpZ2pN3', + accounts: [], + }, +] + +// v3 reimbursement accounts - shared across Mango realms +const MNGO_V3_REIMBURSEMENT_ACCOUNTS = [ + { + owner: 'D3SMRX2tBq5MYRuis3i4kbVt1fdQ5TkX1xfxhUpZ2pN3', + accounts: [], + }, ] export const AUXILIARY_TOKEN_ACCOUNTS = { Mango: MNGO_AUXILIARY_TOKEN_ACCOUNTS, + 'Mango Developer Council v2': MNGO_V3_REIMBURSEMENT_ACCOUNTS, } export const HIDDEN_TREASURES = [...HIDDEN_MNGO_TREASURES] diff --git a/hooks/useGovernanceAssets.ts b/hooks/useGovernanceAssets.ts index 407c38daa..b7f92583c 100644 --- a/hooks/useGovernanceAssets.ts +++ b/hooks/useGovernanceAssets.ts @@ -565,6 +565,11 @@ export default function useGovernanceAssets() { packageId: PackageEnum.MangoMarketV4, isVisible: canUseAnyInstruction, }, + [Instructions.MangoV4WithdrawInsuranceFund]: { + name: 'Withdraw Insurance Fund', + packageId: PackageEnum.MangoMarketV4, + isVisible: canUseAnyInstruction, + }, [Instructions.MangoV4OpenBookEditMarket]: { name: 'Edit Openbook Market', packageId: PackageEnum.MangoMarketV4, diff --git a/pages/dao/[symbol]/proposal/components/instructions/Mango/MangoV4/WithdrawInsuranceFund.tsx b/pages/dao/[symbol]/proposal/components/instructions/Mango/MangoV4/WithdrawInsuranceFund.tsx new file mode 100644 index 000000000..52fbc0534 --- /dev/null +++ b/pages/dao/[symbol]/proposal/components/instructions/Mango/MangoV4/WithdrawInsuranceFund.tsx @@ -0,0 +1,210 @@ +/* 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 { NewProposalContext } from '../../../../new' +import useGovernanceAssets from '@hooks/useGovernanceAssets' +import { Governance } from '@solana/spl-governance' +import { ProgramAccount } from '@solana/spl-governance' +import { serializeInstructionToBase64 } from '@solana/spl-governance' +import { AccountType, AssetAccount } from '@utils/uiTypes/assets' +import InstructionForm, { InstructionInput } from '../../FormCreator' +import { InstructionInputType } from '../../inputInstructionType' +import UseMangoV4 from '../../../../../../../../hooks/useMangoV4' +import useWalletOnePointOh from '@hooks/useWalletOnePointOh' +import { + ASSOCIATED_TOKEN_PROGRAM_ID, + TOKEN_PROGRAM_ID, + Token, +} from '@solana/spl-token' +import { TransactionInstruction } from '@solana/web3.js' +import { useConnection } from '@solana/wallet-adapter-react' +import { BN } from '@coral-xyz/anchor' +import ProgramSelector from '@components/Mango/ProgramSelector' +import useProgramSelector from '@components/Mango/useProgramSelector' +import { tryGetMint } from '@utils/tokens' +import { toNative } from '@blockworks-foundation/mango-v4' + +interface WithdrawInsuranceFundForm { + governedAccount: AssetAccount | null + amount: number + holdupTime: number +} + +interface MintInfo { + decimals: number + symbol: string +} + +const WithdrawInsuranceFund = ({ + index, + governance, +}: { + index: number + governance: ProgramAccount | null +}) => { + const wallet = useWalletOnePointOh() + const programSelectorHook = useProgramSelector() + const { mangoClient, mangoGroup } = UseMangoV4( + programSelectorHook.program?.val, + programSelectorHook.program?.group, + ) + const { assetAccounts } = useGovernanceAssets() + const { connection } = useConnection() + const solAccounts = assetAccounts.filter( + (x) => + x.type === AccountType.SOL && + mangoGroup?.admin && + x.extensions.transferAddress?.equals(mangoGroup.admin), + ) + const shouldBeGoverned = !!(index !== 0 && governance) + const [mintInfo, setMintInfo] = useState(null) + const [form, setForm] = useState({ + governedAccount: null, + amount: 0, + holdupTime: 0, + }) + const [formErrors, setFormErrors] = useState({}) + const { handleSetInstructions } = useContext(NewProposalContext) + + useEffect(() => { + const getMintInfo = async () => { + if (mangoGroup) { + const mint = await tryGetMint(connection, mangoGroup.insuranceMint) + if (mint) { + setMintInfo({ + decimals: mint.account.decimals, + symbol: 'USDC', // Insurance fund is typically USDC + }) + } + } + } + getMintInfo() + }, [mangoGroup, connection]) + + 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 prerequisiteInstructions: TransactionInstruction[] = [] + if ( + isValid && + form.governedAccount?.governance?.account && + wallet?.publicKey && + mangoGroup && + mangoClient && + form.amount > 0 + ) { + const insuranceMint = mangoGroup.insuranceMint + + const ataAddress = await Token.getAssociatedTokenAddress( + ASSOCIATED_TOKEN_PROGRAM_ID, + TOKEN_PROGRAM_ID, + insuranceMint, + form.governedAccount.extensions.transferAddress!, + true, + ) + + const depositAccountInfo = await connection.getAccountInfo(ataAddress) + if (!depositAccountInfo) { + // generate the instruction for creating the ATA + prerequisiteInstructions.push( + Token.createAssociatedTokenAccountInstruction( + ASSOCIATED_TOKEN_PROGRAM_ID, + TOKEN_PROGRAM_ID, + insuranceMint, + ataAddress, + form.governedAccount.extensions.transferAddress!, + wallet.publicKey, + ), + ) + } + + const nativeAmount = toNative(form.amount, mintInfo?.decimals || 6) + + const ix = await mangoClient!.program.methods + .groupWithdrawInsuranceFund(nativeAmount) + .accounts({ + group: mangoGroup.publicKey, + admin: form.governedAccount.extensions.transferAddress, + insuranceVault: mangoGroup.insuranceVault, + destination: ataAddress, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .instruction() + + serializedInstruction = serializeInstructionToBase64(ix) + } + const obj: UiInstruction = { + prerequisiteInstructions, + serializedInstruction: serializedInstruction, + isValid, + governance: form.governedAccount?.governance, + customHoldUpTime: form.holdupTime, + } + return obj + } + + 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, mangoClient, mangoGroup, mintInfo]) + 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: `Amount (${mintInfo?.symbol || 'tokens'})`, + initialValue: form.amount, + type: InstructionInputType.INPUT, + inputType: 'number', + name: 'amount', + }, + { + label: 'Instruction hold up time (days)', + initialValue: form.holdupTime, + type: InstructionInputType.INPUT, + inputType: 'number', + name: 'holdupTime', + }, + ] + + return ( + <> + + {form && ( + + )} + + ) +} + +export default WithdrawInsuranceFund diff --git a/pages/dao/[symbol]/proposal/new.tsx b/pages/dao/[symbol]/proposal/new.tsx index 7fac89cd1..36b1aff0e 100644 --- a/pages/dao/[symbol]/proposal/new.tsx +++ b/pages/dao/[symbol]/proposal/new.tsx @@ -88,6 +88,7 @@ import PerpEdit from './components/instructions/Mango/MangoV4/PerpEdit' import GroupEdit from './components/instructions/Mango/MangoV4/GroupEdit' import AdminTokenWithdrawFees from './components/instructions/Mango/MangoV4/WithdrawTokenFees' import WithdrawPerpFees from './components/instructions/Mango/MangoV4/WithdrawPerpFees' +import WithdrawInsuranceFund from './components/instructions/Mango/MangoV4/WithdrawInsuranceFund' import OpenBookRegisterMarket from './components/instructions/Mango/MangoV4/OpenBookRegisterMarket' import OpenBookEditMarket from './components/instructions/Mango/MangoV4/OpenBookEditMarket' import PerpCreate from './components/instructions/Mango/MangoV4/PerpCreate' @@ -494,6 +495,7 @@ const New = () => { [Instructions.MangoV4GroupEdit]: GroupEdit, [Instructions.MangoV4AdminWithdrawTokenFees]: AdminTokenWithdrawFees, [Instructions.MangoV4WithdrawPerpFees]: WithdrawPerpFees, + [Instructions.MangoV4WithdrawInsuranceFund]: WithdrawInsuranceFund, [Instructions.IdlSetBuffer]: IdlSetBuffer, [Instructions.MangoV4OpenBookEditMarket]: OpenBookEditMarket, [Instructions.MangoV4IxGateSet]: IxGateSet, diff --git a/stores/useGovernanceAssetsStore.tsx b/stores/useGovernanceAssetsStore.tsx index 24b1fdab7..7defa4029 100644 --- a/stores/useGovernanceAssetsStore.tsx +++ b/stores/useGovernanceAssetsStore.tsx @@ -416,8 +416,12 @@ const getTokenAssetAccounts = async ( accounts.push(account) } } else if ( - [...Object.values(AUXILIARY_TOKEN_ACCOUNTS).flatMap((x) => x)].find((x) => - x.accounts.includes(tokenAccount.publicKey.toBase58()), + [...Object.values(AUXILIARY_TOKEN_ACCOUNTS).flatMap((x) => x)].find( + (x) => + x.accounts.includes(tokenAccount.publicKey.toBase58()) || + // Empty accounts array means include all token accounts from this owner + (x.accounts.length === 0 && + x.owner === tokenAccount.account.owner.toBase58()), ) ) { const mint = mintAccounts.find( @@ -425,13 +429,32 @@ const getTokenAssetAccounts = async ( ) if (mint && !isToken2022(tokenAccount.account)) { - const account = new AccountTypeAuxiliaryToken( - tokenAccount as TokenProgramAccount, - mint, - ) + // Try to find a governance whose native treasury matches the token owner + // This allows using auxiliary accounts in proposals + const matchingGov = govNativeSolAddress.find( + (x) => x.nativeSolAddress.toBase58() === tokenAccount.account.owner.toBase58(), + )?.governanceAcc + + if (matchingGov) { + // Create a proper token account with governance + const account = getTokenAccountObj( + matchingGov, + tokenAccount, + mintAccounts, + ) + if (account) { + accounts.push(account) + } + } else { + // Fallback to auxiliary token account (no governance) + const account = new AccountTypeAuxiliaryToken( + tokenAccount as TokenProgramAccount, + mint, + ) - if (account) { - accounts.push(account) + if (account) { + accounts.push(account) + } } } } diff --git a/utils/uiTypes/proposalCreationTypes.ts b/utils/uiTypes/proposalCreationTypes.ts index 3c7b892dc..ed2328f49 100644 --- a/utils/uiTypes/proposalCreationTypes.ts +++ b/utils/uiTypes/proposalCreationTypes.ts @@ -377,6 +377,7 @@ export enum Instructions { MangoV4TokenAddBank, MangoV4AdminWithdrawTokenFees, MangoV4WithdrawPerpFees, + MangoV4WithdrawInsuranceFund, MeanCreateAccount, MeanCreateStream, MeanFundAccount,