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] 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 {