diff --git a/scripts/utils/adminApproval.ts b/scripts/utils/adminApproval.ts index 0323e287..11abc5f7 100644 --- a/scripts/utils/adminApproval.ts +++ b/scripts/utils/adminApproval.ts @@ -2,13 +2,17 @@ import { AnchorProvider } from "@coral-xyz/anchor"; import * as multisig from "@sqds/multisig"; import BN from "bn.js"; import { + AddressLookupTableAccount, PublicKey, Transaction, TransactionInstruction, TransactionMessage, } from "@solana/web3.js"; import { FutarchyClient } from "@metadaoproject/programs/futarchy/v0.6"; -import { METADAO_MULTISIG_VAULT } from "@metadaoproject/programs"; +import { + METADAO_MULTISIG_VAULT, + PERMISSIONLESS_ACCOUNT, +} from "@metadaoproject/programs"; import { createSquadsVaultTxAndProposal, getSquadsPdasFromDao, @@ -28,6 +32,8 @@ const SEED_ENQUEUED_APPROVAL = Buffer.from("enqueued_approval"); * 1. `buildDaoTransaction()` builds the transaction that creates the squads * vault transaction + proposal holding `instructions` on the DAO's * multisig. Sign with the payer and PERMISSIONLESS_ACCOUNT (the creator). + * `lookupTable` is the table the instructions' accounts are loaded from, + * when they don't fit the vault transaction otherwise. * 2. `buildMetadaoTransaction()` (returned by `buildDaoTransaction`) builds * the transaction that creates the squads vault transaction + proposal on * the MetaDAO operational multisig enqueueing the futarchy admin approval @@ -52,12 +58,14 @@ export const buildAdminApprovalTransactions = async ({ futarchy, dao, instructions, + lookupTable, payer, }: { provider: AnchorProvider; futarchy: FutarchyClient; dao: PublicKey; instructions: TransactionInstruction[]; + lookupTable?: AddressLookupTableAccount; payer: PublicKey; }) => { const { multisigPda: daoMultisig, vaultPda: daoMultisigVault } = @@ -88,6 +96,8 @@ export const buildAdminApprovalTransactions = async ({ daoTransactionIndex, daoMessage, payer, + PERMISSIONLESS_ACCOUNT.publicKey, + lookupTable && [lookupTable], ); const [daoVaultTransactionPda] = multisig.getTransactionPda({ diff --git a/scripts/utils/daoActions.ts b/scripts/utils/daoActions.ts index d3900db6..6a96aecf 100644 --- a/scripts/utils/daoActions.ts +++ b/scripts/utils/daoActions.ts @@ -2,6 +2,7 @@ import { AnchorProvider } from "@coral-xyz/anchor"; import * as multisig from "@sqds/multisig"; import BN from "bn.js"; import { + AddressLookupTableAccount, Keypair, PublicKey, Transaction, @@ -15,9 +16,13 @@ import { PERMISSIONLESS_ACCOUNT, } from "@metadaoproject/programs"; import { + AuthorityType, + createApproveInstruction, createAssociatedTokenAccountIdempotentInstruction, + createSetAuthorityInstruction, createTransferInstruction, getAssociatedTokenAddressSync, + getMint, TOKEN_PROGRAM_ID, } from "@solana/spl-token"; import { createMemoInstruction } from "@solana/spl-memo"; @@ -30,8 +35,14 @@ import { FutarchyClient, UpdateDaoParams, } from "@metadaoproject/programs/futarchy/v0.6"; +import { MintGovernorClient } from "@metadaoproject/programs/mint_governor"; import { buildAdminApprovalTransactions } from "./adminApproval.js"; -import { getSquadsPdasFromDao, probeSquadsVaultTransaction } from "./squads.js"; +import { + createLookupTable, + getSquadsPdasFromDao, + probeSquadsVaultTransaction, + squadsVaultTransactionFits, +} from "./squads.js"; import { sendAndConfirm, sendWithRetries } from "./transactions.js"; const SEED_AMM_POSITION = Buffer.from("amm_position"); @@ -94,6 +105,97 @@ export const updateDao = ], }); +// The vault's position in the DAO's AMM - the DAO's protocol-owned liquidity +const findAmmPosition = async ({ + futarchy, + dao, + daoMultisigVault, +}: DaoActionContext) => { + const daoAccount = await futarchy.getDao(dao); + const [ammPosition] = PublicKey.findProgramAddressSync( + [SEED_AMM_POSITION, dao.toBuffer(), daoMultisigVault.toBuffer()], + futarchy.futarchy.programId, + ); + const ammPositionAccount = + await futarchy.futarchy.account.ammPosition.fetch(ammPosition); + + console.log("AMM position:", ammPosition.toBase58()); + console.log("Position liquidity:", ammPositionAccount.liquidity.toString()); + + return { daoAccount, ammPosition, ammPositionAccount }; +}; + +// Withdraws `liquidityToWithdraw` of the vault's AMM position into the +// vault's token accounts, which the setup instructions make sure exist +const withdrawLiquidityAction = async ( + { futarchy, dao, daoMultisigVault, payer }: DaoActionContext, + { daoAccount, ammPosition }: Awaited>, + { + liquidityToWithdraw, + minBaseAmount, + minQuoteAmount, + }: { + liquidityToWithdraw: BN; + minBaseAmount: BN; + minQuoteAmount: BN; + }, +): Promise => { + const vaultBaseTokenAccount = getAssociatedTokenAddressSync( + daoAccount.baseMint, + daoMultisigVault, + true, + ); + const vaultQuoteTokenAccount = getAssociatedTokenAddressSync( + daoAccount.quoteMint, + daoMultisigVault, + true, + ); + + const withdrawLiquidityIx = await futarchy.futarchy.methods + .withdrawLiquidity({ + liquidityToWithdraw, + minBaseAmount, + minQuoteAmount, + }) + .accounts({ + dao, + positionAuthority: daoMultisigVault, + liquidityProviderBaseAccount: vaultBaseTokenAccount, + liquidityProviderQuoteAccount: vaultQuoteTokenAccount, + ammBaseVault: getAssociatedTokenAddressSync( + daoAccount.baseMint, + dao, + true, + ), + ammQuoteVault: getAssociatedTokenAddressSync( + daoAccount.quoteMint, + dao, + true, + ), + ammPosition, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .instruction(); + + return { + instructions: [withdrawLiquidityIx], + setupInstructions: [ + createAssociatedTokenAccountIdempotentInstruction( + payer, + vaultBaseTokenAccount, + daoMultisigVault, + daoAccount.baseMint, + ), + createAssociatedTokenAccountIdempotentInstruction( + payer, + vaultQuoteTokenAccount, + daoMultisigVault, + daoAccount.quoteMint, + ), + ], + }; +}; + // Withdraws a fraction of the vault's AMM position into the vault's token // accounts. The min amounts are set `slippageBps` below what the withdrawn // liquidity is worth right now, so reserve changes between now and execution @@ -125,19 +227,11 @@ export const withdrawLiquidity = ({ ); } - return async ({ futarchy, dao, daoMultisigVault, payer }) => { - const daoAccount = await futarchy.getDao(dao); - - // The DAO's protocol-owned liquidity position is held by its squads vault - const [ammPositionPda] = PublicKey.findProgramAddressSync( - [SEED_AMM_POSITION, dao.toBuffer(), daoMultisigVault.toBuffer()], - futarchy.futarchy.programId, - ); + return async (ctx) => { + const position = await findAmmPosition(ctx); + const { daoAccount, ammPositionAccount } = position; - const ammPosition = - await futarchy.futarchy.account.ammPosition.fetch(ammPositionPda); - - const liquidityToWithdraw = ammPosition.liquidity + const liquidityToWithdraw = ammPositionAccount.liquidity .muln(fractionBps) .divn(10_000); if (liquidityToWithdraw.isZero()) { @@ -166,69 +260,38 @@ export const withdrawLiquidity = ({ .muln(10_000 - slippageBps) .divn(10_000); - console.log("AMM position:", ammPositionPda.toBase58()); - console.log("Position liquidity:", ammPosition.liquidity.toString()); console.log("Liquidity to withdraw:", liquidityToWithdraw.toString()); console.log("Expected base out:", baseWithdrawable.toString()); console.log("Expected quote out:", quoteWithdrawable.toString()); console.log("Min base amount:", minBaseAmount.toString()); console.log("Min quote amount:", minQuoteAmount.toString()); - const vaultBaseTokenAccount = getAssociatedTokenAddressSync( - daoAccount.baseMint, - daoMultisigVault, - true, - ); - const vaultQuoteTokenAccount = getAssociatedTokenAddressSync( - daoAccount.quoteMint, - daoMultisigVault, - true, - ); + return withdrawLiquidityAction(ctx, position, { + liquidityToWithdraw, + minBaseAmount, + minQuoteAmount, + }); + }; +}; - const withdrawLiquidityIx = await futarchy.futarchy.methods - .withdrawLiquidity({ - liquidityToWithdraw, - minBaseAmount, - minQuoteAmount, - }) - .accounts({ - dao, - positionAuthority: daoMultisigVault, - liquidityProviderBaseAccount: vaultBaseTokenAccount, - liquidityProviderQuoteAccount: vaultQuoteTokenAccount, - ammBaseVault: getAssociatedTokenAddressSync( - daoAccount.baseMint, - dao, - true, - ), - ammQuoteVault: getAssociatedTokenAddressSync( - daoAccount.quoteMint, - dao, - true, - ), - ammPosition: ammPositionPda, - tokenProgram: TOKEN_PROGRAM_ID, - }) - .instruction(); +// Withdraws the vault's whole AMM position into the vault's token accounts, +// taking whatever it's worth at execution: nothing is computed from the +// reserves and the min amounts are zero, so a price move during the vote +// can't fail the withdrawal. For liquidating the DAO, where the vault ends +// up holding its share of the reserves either way. +export const withdrawAllLiquidity = (): DaoActionBuilder => async (ctx) => { + const position = await findAmmPosition(ctx); - return { - instructions: [withdrawLiquidityIx], - setupInstructions: [ - createAssociatedTokenAccountIdempotentInstruction( - payer, - vaultBaseTokenAccount, - daoMultisigVault, - daoAccount.baseMint, - ), - createAssociatedTokenAccountIdempotentInstruction( - payer, - vaultQuoteTokenAccount, - daoMultisigVault, - daoAccount.quoteMint, - ), - ], - }; - }; + const liquidityToWithdraw = position.ammPositionAccount.liquidity; + if (liquidityToWithdraw.isZero()) { + throw new Error("The position has no liquidity to withdraw"); + } + + return withdrawLiquidityAction(ctx, position, { + liquidityToWithdraw, + minBaseAmount: new BN(0), + minQuoteAmount: new BN(0), + }); }; // Finds the Meteora DAMM v2 pool the launchpad created for the DAO's mints, @@ -260,6 +323,93 @@ const findLaunchpadMeteoraPool = async ( return existing[0]; }; +// The vault's positions in a Meteora DAMM v2 pool - the one the launchpad +// created for the DAO's mints, unless `pool` is given - with the vault's +// accounts for the pool's tokens and the instructions making sure those +// exist. The vault signs as the position owner, so only the positions whose +// NFT it holds count. +const findMeteoraPositions = async ( + { provider, futarchy, dao, daoMultisigVault, payer }: DaoActionContext, + pool?: PublicKey, +) => { + const cpAmm = new CpAmm(provider.connection); + const meteoraPool = + pool ?? (await findLaunchpadMeteoraPool(cpAmm, futarchy, dao)); + const poolState = await cpAmm.fetchPoolState(meteoraPool); + + const positions = await cpAmm.getUserPositionByPool( + meteoraPool, + daoMultisigVault, + ); + if (positions.length === 0) { + throw new Error( + `The DAO's vault holds no position in Meteora pool ${meteoraPool.toBase58()}`, + ); + } + + const tokenAProgram = getTokenProgram(poolState.tokenAFlag); + const tokenBProgram = getTokenProgram(poolState.tokenBFlag); + const vaultTokenAAccount = getAssociatedTokenAddressSync( + poolState.tokenAMint, + daoMultisigVault, + true, + tokenAProgram, + ); + const vaultTokenBAccount = getAssociatedTokenAddressSync( + poolState.tokenBMint, + daoMultisigVault, + true, + tokenBProgram, + ); + + console.log("Meteora pool:", meteoraPool.toBase58()); + console.log("Token A mint:", poolState.tokenAMint.toBase58()); + console.log("Token B mint:", poolState.tokenBMint.toBase58()); + + // The accounts every instruction on one of the positions takes + const positionAccounts = ( + position: PublicKey, + positionNftAccount: PublicKey, + ) => ({ + poolAuthority: DAMM_V2_POOL_AUTHORITY, + pool: meteoraPool, + position, + positionNftAccount, + owner: daoMultisigVault, + tokenAAccount: vaultTokenAAccount, + tokenBAccount: vaultTokenBAccount, + tokenAMint: poolState.tokenAMint, + tokenBMint: poolState.tokenBMint, + tokenAVault: poolState.tokenAVault, + tokenBVault: poolState.tokenBVault, + tokenAProgram, + tokenBProgram, + }); + + return { + cpAmm, + poolState, + positions, + positionAccounts, + setupInstructions: [ + createAssociatedTokenAccountIdempotentInstruction( + payer, + vaultTokenAAccount, + daoMultisigVault, + poolState.tokenAMint, + tokenAProgram, + ), + createAssociatedTokenAccountIdempotentInstruction( + payer, + vaultTokenBAccount, + daoMultisigVault, + poolState.tokenBMint, + tokenBProgram, + ), + ], + }; +}; + // Withdraws the vault's liquidity from a Meteora DAMM v2 pool - the one the // launchpad created for the DAO's mints, unless `pool` is given - into the // vault's token accounts: `fractionBps` of the unlocked liquidity of every @@ -298,42 +448,9 @@ export const withdrawMeteoraLiquidity = ({ ); } - return async ({ provider, futarchy, dao, daoMultisigVault, payer }) => { - const cpAmm = new CpAmm(provider.connection); - const meteoraPool = - pool ?? (await findLaunchpadMeteoraPool(cpAmm, futarchy, dao)); - const poolState = await cpAmm.fetchPoolState(meteoraPool); - - // The vault signs the withdrawals as the position owner, so only the - // positions whose NFT it holds count - const positions = await cpAmm.getUserPositionByPool( - meteoraPool, - daoMultisigVault, - ); - if (positions.length === 0) { - throw new Error( - `The DAO's vault holds no position in Meteora pool ${meteoraPool.toBase58()}`, - ); - } - - const tokenAProgram = getTokenProgram(poolState.tokenAFlag); - const tokenBProgram = getTokenProgram(poolState.tokenBFlag); - const vaultTokenAAccount = getAssociatedTokenAddressSync( - poolState.tokenAMint, - daoMultisigVault, - true, - tokenAProgram, - ); - const vaultTokenBAccount = getAssociatedTokenAddressSync( - poolState.tokenBMint, - daoMultisigVault, - true, - tokenBProgram, - ); - - console.log("Meteora pool:", meteoraPool.toBase58()); - console.log("Token A mint:", poolState.tokenAMint.toBase58()); - console.log("Token B mint:", poolState.tokenBMint.toBase58()); + return async (ctx) => { + const { cpAmm, poolState, positions, positionAccounts, setupInstructions } = + await findMeteoraPositions(ctx, pool); const instructions: TransactionInstruction[] = []; for (const { position, positionNftAccount, positionState } of positions) { @@ -381,21 +498,7 @@ export const withdrawMeteoraLiquidity = ({ console.log(" Min token A amount:", tokenAAmountThreshold.toString()); console.log(" Min token B amount:", tokenBAmountThreshold.toString()); - const accounts = { - poolAuthority: DAMM_V2_POOL_AUTHORITY, - pool: meteoraPool, - position, - positionNftAccount, - owner: daoMultisigVault, - tokenAAccount: vaultTokenAAccount, - tokenBAccount: vaultTokenBAccount, - tokenAMint: poolState.tokenAMint, - tokenBMint: poolState.tokenBMint, - tokenAVault: poolState.tokenAVault, - tokenBVault: poolState.tokenBVault, - tokenAProgram, - tokenBProgram, - }; + const accounts = positionAccounts(position, positionNftAccount); // A full withdrawal is sized by the program at execution, so liquidity // that unlocks before then is withdrawn too @@ -422,28 +525,44 @@ export const withdrawMeteoraLiquidity = ({ ); } - return { - instructions, - setupInstructions: [ - createAssociatedTokenAccountIdempotentInstruction( - payer, - vaultTokenAAccount, - daoMultisigVault, - poolState.tokenAMint, - tokenAProgram, - ), - createAssociatedTokenAccountIdempotentInstruction( - payer, - vaultTokenBAccount, - daoMultisigVault, - poolState.tokenBMint, - tokenBProgram, - ), - ], - }; + return { instructions, setupInstructions }; }; }; +// Claims the swap fees the vault's positions in a Meteora DAMM v2 pool - the +// one the launchpad created for the DAO's mints, unless `pool` is given - have +// earned into the vault's token accounts. Whatever is pending when the vault +// transaction executes is claimed; removing liquidity leaves the fees on the +// position. +export const claimMeteoraFees = + ({ pool }: { pool?: PublicKey } = {}): DaoActionBuilder => + async (ctx) => { + const { cpAmm, positions, positionAccounts, setupInstructions } = + await findMeteoraPositions(ctx, pool); + + const instructions: TransactionInstruction[] = []; + for (const { position, positionNftAccount, positionState } of positions) { + console.log("Meteora position:", position.toBase58()); + console.log( + " Unclaimed token A fees:", + positionState.feeAPending.toString(), + ); + console.log( + " Unclaimed token B fees:", + positionState.feeBPending.toString(), + ); + + instructions.push( + await cpAmm._program.methods + .claimPositionFee() + .accountsPartial(positionAccounts(position, positionNftAccount)) + .instruction(), + ); + } + + return { instructions, setupInstructions }; + }; + // Logs the text on-chain when the vault transaction executes, with the vault // as a verified signer export const memo = @@ -505,6 +624,150 @@ export const transferToken = }; }; +// Gives up the base mint's mint authority for good. Launches up to v0.7 left +// it with the vault; v0.8 launches left it with a mint governor the vault +// administers, which hands it back to the vault first. +export const revokeMintAuthority = + (): DaoActionBuilder => + async ({ provider, futarchy, dao, daoMultisigVault }) => { + const daoAccount = await futarchy.getDao(dao); + const mint = await getMint(provider.connection, daoAccount.baseMint); + if (!mint.mintAuthority) { + throw new Error("The base mint has no mint authority"); + } + + console.log("Base mint:", daoAccount.baseMint.toBase58()); + console.log("Mint authority:", mint.mintAuthority.toBase58()); + + const instructions: TransactionInstruction[] = []; + if (!mint.mintAuthority.equals(daoMultisigVault)) { + const mintGovernorClient = MintGovernorClient.createClient({ provider }); + const mintAuthorityAccount = await provider.connection.getAccountInfo( + mint.mintAuthority, + ); + const mintGovernor = mintAuthorityAccount?.owner.equals( + mintGovernorClient.programId, + ) + ? await mintGovernorClient.deserializeMintGovernor(mintAuthorityAccount) + : null; + if (!mintGovernor?.admin.equals(daoMultisigVault)) { + throw new Error( + `Mint authority ${mint.mintAuthority.toBase58()} is neither the DAO's vault nor a mint governor it administers`, + ); + } + + instructions.push( + await mintGovernorClient + .reclaimAuthorityIx({ + mintGovernor: mint.mintAuthority, + mint: daoAccount.baseMint, + admin: daoMultisigVault, + newAuthority: daoMultisigVault, + }) + .instruction(), + ); + } + + instructions.push( + createSetAuthorityInstruction( + daoAccount.baseMint, + daoMultisigVault, + AuthorityType.MintTokens, + null, + ), + ); + + return { instructions }; + }; + +const U64_MAX = 2n ** 64n - 1n; + +// Approves `delegate` to transfer and burn any amount from the vault's +// associated token account for `mint`. The vault stays the owner and can +// revoke the delegate again. +export const approveDelegate = + ({ + mint, + delegate, + }: { + mint: PublicKey; + delegate: PublicKey; + }): DaoActionBuilder => + async ({ daoMultisigVault, payer }) => { + const vaultTokenAccount = getAssociatedTokenAddressSync( + mint, + daoMultisigVault, + true, + ); + + console.log( + `Delegating the vault's ${mint.toBase58()} account ${vaultTokenAccount.toBase58()} to ${delegate.toBase58()}`, + ); + + return { + instructions: [ + createApproveInstruction( + vaultTokenAccount, + delegate, + daoMultisigVault, + U64_MAX, + ), + ], + setupInstructions: [ + createAssociatedTokenAccountIdempotentInstruction( + payer, + vaultTokenAccount, + daoMultisigVault, + mint, + ), + ], + }; + }; + +// Hands the vault's associated token account for `mint` over to `newOwner` +// for good: only the new owner can move what's in it or pass it on, and +// since the account keeps its address the vault can't have an associated +// token account for the mint again. Classic SPL Token accounts only, as +// Token-2022 associated accounts have immutable owners. +export const transferTokenAccountOwnership = + ({ + mint, + newOwner, + }: { + mint: PublicKey; + newOwner: PublicKey; + }): DaoActionBuilder => + async ({ daoMultisigVault, payer }) => { + const vaultTokenAccount = getAssociatedTokenAddressSync( + mint, + daoMultisigVault, + true, + ); + + console.log( + `Handing the vault's ${mint.toBase58()} account ${vaultTokenAccount.toBase58()} over to ${newOwner.toBase58()}`, + ); + + return { + instructions: [ + createSetAuthorityInstruction( + vaultTokenAccount, + daoMultisigVault, + AuthorityType.AccountOwner, + newOwner, + ), + ], + setupInstructions: [ + createAssociatedTokenAccountIdempotentInstruction( + payer, + vaultTokenAccount, + daoMultisigVault, + mint, + ), + ], + }; + }; + // Removes the DAO's spending limit, returning its rent to the vault. The DAO // is its multisig's config authority, so the DAO signs this rather than the // vault, which makes the proposal admin-execute only. @@ -545,11 +808,15 @@ export const removeSpendingLimit = /** * Runs the action builders against the DAO's squads accounts. Returns the - * instructions the DAO's vault should execute, `setupTransaction` - a - * payer-funded transaction with the actions' setup instructions (null if - * none), to be signed by the payer and sent before anything else - and - * `requiresAdminExecution`, set when any action needs the DAO proposal - * executed through admin_execute_multisig_proposal. + * instructions the DAO's vault should execute, `setupTransactions` - + * payer-funded transactions with the actions' setup instructions and, when + * the instructions don't fit a squads transaction otherwise, the ones + * creating a lookup table of their accounts, to be sent in order before + * anything else, each given a fresh blockhash and signed by the payer right + * before it's sent - `lookupTable`, the table to compile the vault + * transaction with in that case, and `requiresAdminExecution`, set when any + * action needs the DAO proposal executed through + * admin_execute_multisig_proposal. */ export const buildDaoActions = async ({ provider, @@ -594,27 +861,61 @@ export const buildDaoActions = async ({ (action) => action.requiresAdminExecution, ); - let setupTransaction: Transaction | null = null; + const setupTransactions: Transaction[] = []; if (setupInstructions.length > 0) { - setupTransaction = new Transaction().add(...setupInstructions); - setupTransaction.recentBlockhash = ( - await provider.connection.getLatestBlockhash() - ).blockhash; - setupTransaction.feePayer = payer; + setupTransactions.push(new Transaction().add(...setupInstructions)); + } + + // A vault transaction carries its whole message as instruction data, so + // instructions touching many accounts only fit once the accounts are + // loaded from a lookup table, created and frozen up front + let lookupTable: AddressLookupTableAccount | undefined; + const fits = (addressLookupTableAccounts?: AddressLookupTableAccount[]) => + squadsVaultTransactionFits({ + squadsMultisig: daoMultisig, + vault: daoMultisigVault, + instructions, + payer, + addressLookupTableAccounts, + }); + if (!(await fits())) { + const created = await createLookupTable({ + connection: provider.connection, + payer, + instructions, + }); + lookupTable = created.lookupTable; + setupTransactions.push(...created.transactions); + + console.log("Lookup table:", lookupTable.key.toBase58()); + console.log( + `The instructions don't fit a squads transaction as is - ${lookupTable.state.addresses.length} of their accounts are loaded from the lookup table`, + ); + + if (!(await fits([lookupTable]))) { + throw new Error( + "The instructions don't fit a squads transaction even with a lookup table - split them across proposals", + ); + } + } + + for (const transaction of setupTransactions) { + transaction.feePayer = payer; } return { daoMultisig, daoMultisigVault, instructions, - setupTransaction, + setupTransactions, + lookupTable, requiresAdminExecution, }; }; /** * Runs the action builders and routes their instructions through the admin - * approval system. Returns buildDaoActions' `setupTransaction` and + * approval system. Returns buildDaoActions' `setupTransactions` and * `requiresAdminExecution` on top of buildAdminApprovalTransactions' result. */ export const buildDaoActionTransactions = async ({ @@ -630,17 +931,22 @@ export const buildDaoActionTransactions = async ({ payer: PublicKey; actions: DaoActionBuilder[]; }) => { - const { instructions, setupTransaction, requiresAdminExecution } = - await buildDaoActions({ provider, futarchy, dao, payer, actions }); + const { + instructions, + setupTransactions, + lookupTable, + requiresAdminExecution, + } = await buildDaoActions({ provider, futarchy, dao, payer, actions }); return { - setupTransaction, + setupTransactions, requiresAdminExecution, ...(await buildAdminApprovalTransactions({ provider, futarchy, dao, instructions, + lookupTable, payer, })), }; @@ -648,7 +954,7 @@ export const buildDaoActionTransactions = async ({ /** * Signs and sends the transactions built by buildDaoActionTransactions in - * order (setup if any, DAO multisig, ops multisig), logging the created + * order (setup ones if any, DAO multisig, ops multisig), logging the created * squads transactions and proposals along the way. Each squads transaction * is built right before it's sent, so its multisig's transaction index is * read as late as possible, and goes through sendWithRetries, which keeps @@ -664,14 +970,19 @@ export const signAndSendDaoActionTransactions = async ({ payer: Keypair; transactions: Awaited>; }) => { - const { setupTransaction, requiresAdminExecution, buildDaoTransaction } = + const { setupTransactions, requiresAdminExecution, buildDaoTransaction } = transactions; - let setupSignature: string | null = null; - if (setupTransaction) { + const setupSignatures: string[] = []; + for (const setupTransaction of setupTransactions) { + // Fetched per transaction, as confirming the earlier ones can outlast it + setupTransaction.recentBlockhash = ( + await provider.connection.getLatestBlockhash() + ).blockhash; setupTransaction.sign(payer); - setupSignature = await sendAndConfirm(provider, setupTransaction); + const setupSignature = await sendAndConfirm(provider, setupTransaction); + setupSignatures.push(setupSignature); console.log("Setup transaction sent!"); console.log("Transaction signature:", setupSignature); @@ -743,5 +1054,5 @@ export const signAndSendDaoActionTransactions = async ({ ); } - return { setupSignature, daoSignature, metadaoSignature }; + return { setupSignatures, daoSignature, metadaoSignature }; }; diff --git a/scripts/utils/futarchyProposal.ts b/scripts/utils/futarchyProposal.ts index 97097da0..8d9a89f1 100644 --- a/scripts/utils/futarchyProposal.ts +++ b/scripts/utils/futarchyProposal.ts @@ -20,6 +20,7 @@ import { compareVaultTransactionInstructions, createSquadsVaultTxAndProposal, getSquadsPdasFromDao, + loadVaultTransactionAddresses, probeSquadsVaultTransaction, } from "./squads.js"; import { sendAndConfirm, sendWithRetries } from "./transactions.js"; @@ -247,9 +248,19 @@ const resumeFutarchyProposal = async ({ provider.connection, squadsVaultTransaction, ); + const loadedAddresses = await loadVaultTransactionAddresses( + provider.connection, + vaultTransaction.message, + ); + if (!loadedAddresses) { + throw new Error( + `Squads proposal ${squadsProposal.toBase58()} loads accounts from a lookup table that doesn't exist - it isn't the proposal these actions created. Check resumeSquadsProposal against the failed run's log.`, + ); + } const comparison = compareVaultTransactionInstructions( vaultTransaction.message, instructions, + loadedAddresses, ); if (comparison.kind === "different") { throw new Error( @@ -281,7 +292,7 @@ const resumeFutarchyProposal = async ({ /** * Runs the action builders and puts their instructions up for a futarchy - * vote: sends the payer-funded setup transaction (if any), creates the squads + * vote: sends the payer-funded setup transactions (if any), creates the squads * vault transaction + proposal holding the instructions on the DAO's * multisig, then initializes the futarchy proposal in draft state. Stake base * tokens to the proposal - or have the team sponsor it - and launch it to @@ -324,7 +335,8 @@ export const createFutarchyProposal = async ({ daoMultisig, daoMultisigVault, instructions, - setupTransaction, + setupTransactions, + lookupTable, requiresAdminExecution, } = await buildDaoActions({ provider, @@ -351,7 +363,11 @@ export const createFutarchyProposal = async ({ }); } - if (setupTransaction) { + for (const setupTransaction of setupTransactions) { + // Fetched per transaction, as confirming the earlier ones can outlast it + setupTransaction.recentBlockhash = ( + await provider.connection.getLatestBlockhash() + ).blockhash; setupTransaction.sign(payer); const setupSignature = await sendAndConfirm(provider, setupTransaction); @@ -390,6 +406,8 @@ export const createFutarchyProposal = async ({ transactionIndex, transactionMessage, payer.publicKey, + PERMISSIONLESS_ACCOUNT.publicKey, + lookupTable && [lookupTable], ); const [squadsVaultTransaction] = multisig.getTransactionPda({ diff --git a/scripts/utils/squads.ts b/scripts/utils/squads.ts index 4556d393..4cda5036 100644 --- a/scripts/utils/squads.ts +++ b/scripts/utils/squads.ts @@ -1,7 +1,11 @@ import { PERMISSIONLESS_ACCOUNT } from "@metadaoproject/programs"; import { + AddressLookupTableAccount, + AddressLookupTableProgram, Connection, + PACKET_DATA_SIZE, PublicKey, + Transaction, TransactionInstruction, TransactionMessage, } from "@solana/web3.js"; @@ -45,6 +49,7 @@ export const createSquadsVaultTxAndProposal = async ( transactionMessage: TransactionMessage, payer: PublicKey, creator: PublicKey = PERMISSIONLESS_ACCOUNT.publicKey, + addressLookupTableAccounts?: AddressLookupTableAccount[], ) => { const vaultTxCreateIx = multisig.instructions.vaultTransactionCreate({ multisigPda: squadsMultisig, @@ -54,6 +59,7 @@ export const createSquadsVaultTxAndProposal = async ( vaultIndex: 0, ephemeralSigners: 0, transactionMessage, + addressLookupTableAccounts, }); const proposalCreateIx = multisig.instructions.proposalCreate({ @@ -70,12 +76,209 @@ export const createSquadsVaultTxAndProposal = async ( }; }; +/** + * Whether the transaction creating a squads vault transaction + proposal + * holding `instructions` fits in a Solana transaction. The vault transaction + * carries its whole message as instruction data, so instructions touching + * many accounts only fit once the accounts are loaded from a lookup table + * (see createLookupTable). + */ +export const squadsVaultTransactionFits = async ({ + squadsMultisig, + vault, + instructions, + payer, + creator, + addressLookupTableAccounts, +}: { + squadsMultisig: PublicKey; + vault: PublicKey; + instructions: TransactionInstruction[]; + payer: PublicKey; + creator?: PublicKey; + addressLookupTableAccounts?: AddressLookupTableAccount[]; +}) => { + // Only the size matters here, so the index and blockhashes are placeholders + const transactionMessage = new TransactionMessage({ + payerKey: vault, + recentBlockhash: PublicKey.default.toBase58(), + instructions, + }); + const { vaultTxCreateIx, proposalCreateIx } = + await createSquadsVaultTxAndProposal( + squadsMultisig, + 0n, + transactionMessage, + payer, + creator, + addressLookupTableAccounts, + ); + + const transaction = new Transaction().add(vaultTxCreateIx, proposalCreateIx); + transaction.recentBlockhash = PublicKey.default.toBase58(); + transaction.feePayer = payer; + const message = transaction.compileMessage(); + + try { + const size = + 1 + + 64 * message.header.numRequiredSignatures + + message.serialize().length; + return size <= PACKET_DATA_SIZE; + } catch (error) { + // Serializing throws once the instructions alone outgrow the size limit + if (error instanceof RangeError) { + return false; + } + throw error; + } +}; + +// Keeps the transaction creating and extending a lookup table within the +// transaction size limit +const LOOKUP_TABLE_ADDRESSES_PER_TRANSACTION = 20; + +/** + * Builds the transactions creating a lookup table of the accounts + * `instructions` touch and freezing it, so that a squads vault transaction + * loading its accounts from the table can never resolve to other accounts. + * Signers and the invoked programs stay in the message, so they're left out. + * Returns the table as the vault transaction should be compiled with, ahead + * of the transactions being sent - in order, signed and paid for by `payer`. + */ +export const createLookupTable = async ({ + connection, + payer, + instructions, +}: { + connection: Connection; + payer: PublicKey; + instructions: TransactionInstruction[]; +}) => { + const staysInMessage = (pubkey: PublicKey) => + instructions.some( + (instruction) => + instruction.programId.equals(pubkey) || + instruction.keys.some( + (key) => key.isSigner && key.pubkey.equals(pubkey), + ), + ); + const addresses = [ + ...new Map( + instructions + .flatMap((instruction) => instruction.keys.map((key) => key.pubkey)) + .map((pubkey) => [pubkey.toBase58(), pubkey] as const), + ).values(), + ].filter((pubkey) => !staysInMessage(pubkey)); + if (addresses.length === 0) { + throw new Error( + "The instructions have no accounts a lookup table could hold", + ); + } + + // The table's address derives from a recent slot, which the program checks + // against the slot hashes it keeps; a finalized slot is surely among them + const recentSlot = await connection.getSlot("finalized"); + const [createIx, lookupTable] = AddressLookupTableProgram.createLookupTable({ + authority: payer, + payer, + recentSlot, + }); + + const transactions: Transaction[] = []; + for ( + let i = 0; + i < addresses.length; + i += LOOKUP_TABLE_ADDRESSES_PER_TRANSACTION + ) { + const transaction = new Transaction(); + if (i === 0) { + transaction.add(createIx); + } + transaction.add( + AddressLookupTableProgram.extendLookupTable({ + lookupTable, + authority: payer, + payer, + addresses: addresses.slice( + i, + i + LOOKUP_TABLE_ADDRESSES_PER_TRANSACTION, + ), + }), + ); + if (i + LOOKUP_TABLE_ADDRESSES_PER_TRANSACTION >= addresses.length) { + transaction.add( + AddressLookupTableProgram.freezeLookupTable({ + lookupTable, + authority: payer, + }), + ); + } + transactions.push(transaction); + } + + return { + lookupTable: new AddressLookupTableAccount({ + key: lookupTable, + state: { + deactivationSlot: 2n ** 64n - 1n, + lastExtendedSlot: 0, + lastExtendedSlotStartIndex: 0, + authority: undefined, + addresses, + }, + }), + transactions, + }; +}; + +/** + * The addresses a vault transaction's message loads from its lookup tables, + * in the order its instructions index them after the static account keys: + * every table's writable addresses, then every table's readonly ones. Null + * when one of the tables doesn't exist. + */ +export const loadVaultTransactionAddresses = async ( + connection: Connection, + message: multisig.generated.VaultTransactionMessage, +): Promise => { + const tables: { + lookup: multisig.generated.MultisigMessageAddressTableLookup; + addresses: PublicKey[]; + }[] = []; + for (const lookup of message.addressTableLookups) { + const { value } = await connection.getAddressLookupTable( + lookup.accountKey, + { commitment: "confirmed" }, + ); + if (!value) { + return null; + } + tables.push({ lookup, addresses: value.state.addresses }); + } + + const loaded = ( + indexes: ( + lookup: multisig.generated.MultisigMessageAddressTableLookup, + ) => Uint8Array, + ) => + tables.flatMap(({ lookup, addresses }) => + Array.from(indexes(lookup)).map((index) => addresses[index]), + ); + return [ + ...loaded((lookup) => lookup.writableIndexes), + ...loaded((lookup) => lookup.readonlyIndexes), + ]; +}; + /** * How a vault transaction's message compares to `instructions`: `exact` when * it holds the same programs, accounts (in order) and data; `data` when only * the data of some instructions (`differing`, by index) differs - the same * actions built against other state; `different` otherwise: other programs - * or accounts, another number of instructions, or lookup tables in use. + * or accounts, or another number of instructions. `loadedAddresses` are the + * accounts the message loads from lookup tables, if any (see + * loadVaultTransactionAddresses). */ export type InstructionsComparison = | { kind: "exact" } @@ -85,24 +288,21 @@ export type InstructionsComparison = export const compareVaultTransactionInstructions = ( message: multisig.generated.VaultTransactionMessage, instructions: TransactionInstruction[], + loadedAddresses: PublicKey[] = [], ): InstructionsComparison => { - if ( - message.addressTableLookups.length > 0 || - message.instructions.length !== instructions.length - ) { + if (message.instructions.length !== instructions.length) { return { kind: "different" }; } + const accountKeys = [...message.accountKeys, ...loadedAddresses]; const differing: number[] = []; for (const [i, compiled] of message.instructions.entries()) { const instruction = instructions[i]; const accounts = Array.from(compiled.accountIndexes).map( - (index) => message.accountKeys[index], + (index) => accountKeys[index], ); const sameTarget = - message.accountKeys[compiled.programIdIndex]?.equals( - instruction.programId, - ) && + accountKeys[compiled.programIdIndex]?.equals(instruction.programId) && accounts.length === instruction.keys.length && accounts.every((account, j) => account?.equals(instruction.keys[j].pubkey), @@ -138,9 +338,17 @@ export const probeSquadsVaultTransaction = async ( } const [vaultTransaction] = multisig.accounts.VaultTransaction.fromAccountInfo(accountInfo); + const loadedAddresses = await loadVaultTransactionAddresses( + connection, + vaultTransaction.message, + ); + if (!loadedAddresses) { + return "taken"; + } return compareVaultTransactionInstructions( vaultTransaction.message, instructions, + loadedAddresses, ).kind === "exact" ? "landed" : "taken"; diff --git a/scripts/v0.6/executeGeneralProposal.ts b/scripts/v0.6/executeGeneralProposal.ts index 1abd1028..27720dca 100644 --- a/scripts/v0.6/executeGeneralProposal.ts +++ b/scripts/v0.6/executeGeneralProposal.ts @@ -1,4 +1,9 @@ -import { PublicKey, Transaction } from "@solana/web3.js"; +import { + ComputeBudgetProgram, + PublicKey, + TransactionMessage, + VersionedTransaction, +} from "@solana/web3.js"; import * as multisig from "@sqds/multisig"; import * as anchor from "@coral-xyz/anchor"; import { FutarchyClient } from "@metadaoproject/programs/futarchy/v0.6"; @@ -22,28 +27,41 @@ async function main() { const dao = await futarchy.getDao(proposal.dao); const multisigPda = dao.squadsMultisig; - const vaultTxExecuteIx = multisig.instructions.vaultTransactionExecute({ - connection: provider.connection, - multisigPda, - transactionIndex: BigInt(proposalAccount.transactionIndex.toString()), - member: PERMISSIONLESS_ACCOUNT.publicKey, - }); - - // Add both instructions to create the proposal - const vaultTxExecuteIxResolved = await vaultTxExecuteIx; - const tx = new Transaction().add(vaultTxExecuteIxResolved.instruction); - tx.recentBlockhash = ( - await provider.connection.getLatestBlockhash() - ).blockhash; - tx.feePayer = payer.publicKey; + // Resolves the accounts the vault transaction loads from lookup tables, if any + const { instruction, lookupTableAccounts } = + await multisig.instructions.vaultTransactionExecute({ + connection: provider.connection, + multisigPda, + transactionIndex: BigInt(proposalAccount.transactionIndex.toString()), + member: PERMISSIONLESS_ACCOUNT.publicKey, + }); + + const message = new TransactionMessage({ + payerKey: payer.publicKey, + recentBlockhash: (await provider.connection.getLatestBlockhash()).blockhash, + instructions: [ + // The vault transaction may run several programs' worth of work + ComputeBudgetProgram.setComputeUnitLimit({ units: 1_400_000 }), + instruction, + ], + }).compileToV0Message(lookupTableAccounts); + const transaction = new VersionedTransaction(message); // Sign with both accounts - tx.sign(payer, PERMISSIONLESS_ACCOUNT); + transaction.sign([payer, PERMISSIONLESS_ACCOUNT]); - const txHash = await provider.connection.sendRawTransaction(tx.serialize()); - await provider.connection.confirmTransaction(txHash, "confirmed"); + const txHash = await provider.connection.sendTransaction(transaction); + const status = await provider.connection.confirmTransaction( + txHash, + "confirmed", + ); + if (status.value.err) { + throw new Error( + `Transaction ${txHash} failed: ${JSON.stringify(status.value.err)}`, + ); + } - console.log("USDC transfer proposal executed successfully!"); + console.log("Proposal executed successfully!"); console.log("Transaction hash:", txHash); console.log( "Proposal index", @@ -52,6 +70,6 @@ async function main() { } main().catch((error) => { - console.error("Error creating transfer:", error); + console.error("Error executing proposal:", error); process.exit(1); }); diff --git a/scripts/v0.6/liquidationTemplate.ts b/scripts/v0.6/liquidationTemplate.ts new file mode 100644 index 00000000..872d546e --- /dev/null +++ b/scripts/v0.6/liquidationTemplate.ts @@ -0,0 +1,101 @@ +import * as anchor from "@coral-xyz/anchor"; +import { PublicKey } from "@solana/web3.js"; +import { FutarchyClient } from "@metadaoproject/programs/futarchy/v0.6"; +import { METADAO_MULTISIG_VAULT } from "@metadaoproject/programs"; +import { + claimMeteoraFees, + memo, + revokeMintAuthority, + transferTokenAccountOwnership, + withdrawAllLiquidity, + withdrawMeteoraLiquidity, +} from "../utils/daoActions.js"; +import { createFutarchyProposal } from "../utils/futarchyProposal.js"; + +// Template for putting a DAO's liquidation up for a futarchy vote. Once the +// proposal passes and executes, everything the DAO had in its futarchy AMM +// and Meteora positions sits in the vault's base token and USDC accounts, +// the base mint can't be minted anymore and those two accounts belong to the +// estate handler for good, to burn the DAO's own tokens and distribute the +// USDC to holders without another vote. The DAO can't take them back. The +// vault's instructions execute as one squads transaction, so if any of them +// fails none of it happens. +// +// Copy it, set the constants, and run it. The proposal is created in draft +// state - stake base tokens to it, or have the team sponsor it with +// sponsorProposal.ts, then launch it. Note that no further proposal can be +// launched once the AMM is empty. +// +// If a run fails after "Squads transaction and proposal created!", don't +// re-run it as is - that creates a second squads proposal with the same +// instructions. Set RESUME_SQUADS_PROPOSAL to the logged squads proposal, +// keep the constants as they were (the script checks the proposal holds the +// actions), and re-run to finish initializing the futarchy proposal for it. + +/////////////// +// Constants // +/////////////// + +// The DAO to liquidate +const DAO = new PublicKey("DAO_ADDRESS"); + +// Who owns the vault's base token and USDC accounts once the proposal +// executes - the MetaDAO operational multisig's vault by default +const ESTATE_HANDLER = METADAO_MULTISIG_VAULT; + +// How far below its current worth the Meteora withdrawal may come out at +// execution. The vote takes days and the whole transaction fails if the +// withdrawal falls below its minimum, so leave room for the price to move. +const METEORA_SLIPPAGE_BPS = 10_000; + +// Logged on-chain when the vault transaction executes +const MEMO = "Liquidation: ..."; + +// The squads proposal of a run that failed after creating it, to finish +// initializing the futarchy proposal for. Leave null to create a new proposal. +const RESUME_SQUADS_PROPOSAL: PublicKey | null = null; + +//////////////// +// Operations // +//////////////// + +const provider = anchor.AnchorProvider.env(); + +// Pays for the setup instructions and the rent of the squads and futarchy +// proposal accounts +const payer = provider.wallet["payer"]; + +const futarchy = FutarchyClient.createClient({ provider }); + +async function main() { + const daoAccount = await futarchy.getDao(DAO); + + await createFutarchyProposal({ + provider, + futarchy, + dao: DAO, + payer, + resumeSquadsProposal: RESUME_SQUADS_PROPOSAL ?? undefined, + actions: [ + withdrawAllLiquidity(), + withdrawMeteoraLiquidity({ slippageBps: METEORA_SLIPPAGE_BPS }), + claimMeteoraFees(), + revokeMintAuthority(), + // Last, once everything is in the vault's token accounts + transferTokenAccountOwnership({ + mint: daoAccount.baseMint, + newOwner: ESTATE_HANDLER, + }), + transferTokenAccountOwnership({ + mint: daoAccount.quoteMint, + newOwner: ESTATE_HANDLER, + }), + // memo(MEMO), + ], + }); +} + +main().catch((error) => { + console.error("Error creating liquidation proposal:", error); + process.exit(1); +});