From ee4ef42936cdddedd197666215191c03231cee5a Mon Sep 17 00:00:00 2001 From: Pileks Date: Mon, 19 Oct 2026 22:02:48 +0200 Subject: [PATCH 1/3] feat(scripts): add dao liquidation proposal template --- scripts/utils/adminApproval.ts | 12 +- scripts/utils/daoActions.ts | 663 +++++++++++++++++++++------- scripts/utils/futarchyProposal.ts | 20 +- scripts/utils/squads.ts | 216 ++++++++- scripts/v0.6/liquidationTemplate.ts | 101 +++++ 5 files changed, 833 insertions(+), 179 deletions(-) create mode 100644 scripts/v0.6/liquidationTemplate.ts 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 193bcf4a..47a7c28b 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,10 +16,14 @@ import { PERMISSIONLESS_ACCOUNT, } from "@metadaoproject/programs"; import { + AuthorityType, + createApproveInstruction, createAssociatedTokenAccountIdempotentInstruction, + createSetAuthorityInstruction, createTransferInstruction, getAccount, getAssociatedTokenAddressSync, + getMint, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID, } from "@solana/spl-token"; @@ -33,8 +38,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"); @@ -99,6 +110,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 @@ -130,19 +232,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, - ); - - const ammPosition = - await futarchy.futarchy.account.ammPosition.fetch(ammPositionPda); + return async (ctx) => { + const position = await findAmmPosition(ctx); + const { daoAccount, ammPositionAccount } = position; - const liquidityToWithdraw = ammPosition.liquidity + const liquidityToWithdraw = ammPositionAccount.liquidity .muln(fractionBps) .divn(10_000); if (liquidityToWithdraw.isZero()) { @@ -171,68 +265,136 @@ 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); + + 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), + }); +}; +// The Meteora DAMM v2 position the launchpad created for the DAO, which the +// vault owns through its position NFT, with the vault's accounts for the +// pool's tokens and the instructions making sure those exist +const findMeteoraPosition = async ({ + provider, + futarchy, + dao, + daoMultisigVault, + payer, +}: DaoActionContext) => { + const daoAccount = await futarchy.getDao(dao); + const cpAmm = new CpAmm(provider.connection); + + // Any launchpad version may have launched the DAO, so use whichever + // version's position exists + const candidates = LAUNCHPAD_PROGRAM_IDS.map((launchpadProgramId) => { + const [positionNftMint] = PublicKey.findProgramAddressSync( + [SEED_POSITION_NFT_MINT, daoAccount.baseMint.toBuffer()], + launchpadProgramId, + ); return { - instructions: [withdrawLiquidityIx], - setupInstructions: [ - createAssociatedTokenAccountIdempotentInstruction( - payer, - vaultBaseTokenAccount, - daoMultisigVault, - daoAccount.baseMint, - ), - createAssociatedTokenAccountIdempotentInstruction( - payer, - vaultQuoteTokenAccount, - daoMultisigVault, - daoAccount.quoteMint, - ), - ], + positionNftMint, + position: derivePositionAddress(positionNftMint), }; + }); + const positionStates = await cpAmm._program.account.position.fetchMultiple( + candidates.map((candidate) => candidate.position), + ); + const foundIndex = positionStates.findIndex((state) => state !== null); + if (foundIndex === -1) { + throw new Error("No launchpad-created Meteora position found for this DAO"); + } + const { positionNftMint, position } = candidates[foundIndex]; + const positionState = positionStates[foundIndex]!; + const positionNftAccount = derivePositionNftAccount(positionNftMint); + + // The vault signs as the position owner, so it must hold the position NFT + const positionNft = await getAccount( + provider.connection, + positionNftAccount, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + if (!positionNft.owner.equals(daoMultisigVault)) { + throw new Error( + `Position NFT is owned by ${positionNft.owner.toBase58()}, not the DAO's vault`, + ); + } + + const poolState = await cpAmm.fetchPoolState(positionState.pool); + 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:", positionState.pool.toBase58()); + console.log("Meteora position:", position.toBase58()); + console.log("Token A mint:", poolState.tokenAMint.toBase58()); + console.log("Token B mint:", poolState.tokenBMint.toBase58()); + + return { + cpAmm, + position, + positionState, + positionNftAccount, + poolState, + tokenAProgram, + tokenBProgram, + vaultTokenAAccount, + vaultTokenBAccount, + setupInstructions: [ + createAssociatedTokenAccountIdempotentInstruction( + payer, + vaultTokenAAccount, + daoMultisigVault, + poolState.tokenAMint, + tokenAProgram, + ), + createAssociatedTokenAccountIdempotentInstruction( + payer, + vaultTokenBAccount, + daoMultisigVault, + poolState.tokenBMint, + tokenBProgram, + ), + ], }; }; @@ -256,56 +418,25 @@ export const withdrawMeteoraLiquidity = ({ ); } - return async ({ provider, futarchy, dao, daoMultisigVault, payer }) => { - const daoAccount = await futarchy.getDao(dao); - const cpAmm = new CpAmm(provider.connection); - - // Any launchpad version may have launched the DAO, so use whichever - // version's position exists - const candidates = LAUNCHPAD_PROGRAM_IDS.map((launchpadProgramId) => { - const [positionNftMint] = PublicKey.findProgramAddressSync( - [SEED_POSITION_NFT_MINT, daoAccount.baseMint.toBuffer()], - launchpadProgramId, - ); - return { - positionNftMint, - position: derivePositionAddress(positionNftMint), - }; - }); - const positionStates = await cpAmm._program.account.position.fetchMultiple( - candidates.map((candidate) => candidate.position), - ); - const foundIndex = positionStates.findIndex((state) => state !== null); - if (foundIndex === -1) { - throw new Error( - "No launchpad-created Meteora position found for this DAO", - ); - } - const { positionNftMint, position } = candidates[foundIndex]; - const positionState = positionStates[foundIndex]!; - const positionNftAccount = derivePositionNftAccount(positionNftMint); - - // The vault signs the withdrawal as the position owner, so it must hold - // the position NFT - const positionNft = await getAccount( - provider.connection, + return async (ctx) => { + const { + cpAmm, + position, + positionState, positionNftAccount, - undefined, - TOKEN_2022_PROGRAM_ID, - ); - if (!positionNft.owner.equals(daoMultisigVault)) { - throw new Error( - `Position NFT is owned by ${positionNft.owner.toBase58()}, not the DAO's vault`, - ); - } + poolState, + tokenAProgram, + tokenBProgram, + vaultTokenAAccount, + vaultTokenBAccount, + setupInstructions, + } = await findMeteoraPosition(ctx); const liquidity = positionState.unlockedLiquidity; if (liquidity.isZero()) { throw new Error("The position has no unlocked liquidity to withdraw"); } - const poolState = await cpAmm.fetchPoolState(positionState.pool); - // Same math as the program's remove_all_liquidity const { outAmountA, outAmountB } = cpAmm.getWithdrawQuote({ liquidityDelta: liquidity, @@ -320,23 +451,6 @@ export const withdrawMeteoraLiquidity = ({ .muln(10_000 - slippageBps) .divn(10_000); - 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:", positionState.pool.toBase58()); - console.log("Meteora position:", position.toBase58()); console.log("Unlocked liquidity:", liquidity.toString()); console.log( "Vested liquidity (stays):", @@ -346,8 +460,6 @@ export const withdrawMeteoraLiquidity = ({ "Permanently locked liquidity (stays):", positionState.permanentLockedLiquidity.toString(), ); - console.log("Token A mint:", poolState.tokenAMint.toBase58()); - console.log("Token B mint:", poolState.tokenBMint.toBase58()); console.log("Expected token A out:", outAmountA.toString()); console.log("Expected token B out:", outAmountB.toString()); console.log("Min token A amount:", tokenAAmountThreshold.toString()); @@ -360,7 +472,7 @@ export const withdrawMeteoraLiquidity = ({ pool: positionState.pool, position, positionNftAccount, - owner: daoMultisigVault, + owner: ctx.daoMultisigVault, tokenAAccount: vaultTokenAAccount, tokenBAccount: vaultTokenBAccount, tokenAMint: poolState.tokenAMint, @@ -374,26 +486,56 @@ export const withdrawMeteoraLiquidity = ({ return { instructions: [removeAllLiquidityIx], - setupInstructions: [ - createAssociatedTokenAccountIdempotentInstruction( - payer, - vaultTokenAAccount, - daoMultisigVault, - poolState.tokenAMint, - tokenAProgram, - ), - createAssociatedTokenAccountIdempotentInstruction( - payer, - vaultTokenBAccount, - daoMultisigVault, - poolState.tokenBMint, - tokenBProgram, - ), - ], + setupInstructions, }; }; }; +// Claims the swap fees the Meteora DAMM v2 position the launchpad created for +// the DAO has earned into the vault's token accounts. Removing the liquidity +// leaves them on the position. +export const claimMeteoraFees = (): DaoActionBuilder => async (ctx) => { + const { + cpAmm, + position, + positionState, + positionNftAccount, + poolState, + tokenAProgram, + tokenBProgram, + vaultTokenAAccount, + vaultTokenBAccount, + setupInstructions, + } = await findMeteoraPosition(ctx); + + console.log("Unclaimed token A fees:", positionState.feeAPending.toString()); + console.log("Unclaimed token B fees:", positionState.feeBPending.toString()); + + const claimPositionFeeIx = await cpAmm._program.methods + .claimPositionFee() + .accountsPartial({ + poolAuthority: DAMM_V2_POOL_AUTHORITY, + pool: positionState.pool, + position, + positionNftAccount, + owner: ctx.daoMultisigVault, + tokenAAccount: vaultTokenAAccount, + tokenBAccount: vaultTokenBAccount, + tokenAMint: poolState.tokenAMint, + tokenBMint: poolState.tokenBMint, + tokenAVault: poolState.tokenAVault, + tokenBVault: poolState.tokenBVault, + tokenAProgram, + tokenBProgram, + }) + .instruction(); + + return { + instructions: [claimPositionFeeIx], + setupInstructions, + }; +}; + // Logs the text on-chain when the vault transaction executes, with the vault // as a verified signer export const memo = @@ -455,6 +597,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. @@ -495,11 +781,14 @@ 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 signed by the payer and + * sent in order before anything else - `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, @@ -544,27 +833,63 @@ 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", + ); + } + } + + const { blockhash } = await provider.connection.getLatestBlockhash(); + for (const transaction of setupTransactions) { + transaction.recentBlockhash = blockhash; + 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 ({ @@ -580,17 +905,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, })), }; @@ -598,7 +928,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 @@ -614,14 +944,15 @@ 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) { 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); @@ -693,5 +1024,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 974a93ec..e61d4f9b 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"; @@ -243,9 +244,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( @@ -277,7 +288,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 @@ -320,7 +331,8 @@ export const createFutarchyProposal = async ({ daoMultisig, daoMultisigVault, instructions, - setupTransaction, + setupTransactions, + lookupTable, requiresAdminExecution, } = await buildDaoActions({ provider, @@ -347,7 +359,7 @@ export const createFutarchyProposal = async ({ }); } - if (setupTransaction) { + for (const setupTransaction of setupTransactions) { setupTransaction.sign(payer); const setupSignature = await sendAndConfirm(provider, setupTransaction); @@ -386,6 +398,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..53e66f06 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,199 @@ 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(); + + const size = + 1 + 64 * message.header.numRequiredSignatures + message.serialize().length; + return size <= PACKET_DATA_SIZE; +}; + +// 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 +278,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 +328,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/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); +}); From 20a35a109030ce89bee83e43be11b43f6895db2e Mon Sep 17 00:00:00 2001 From: Pileks Date: Wed, 21 Oct 2026 20:29:11 +0200 Subject: [PATCH 2/3] fix(scripts): execute vault transactions with their lookup tables and a compute limit --- scripts/v0.6/executeGeneralProposal.ts | 58 +++++++++++++++++--------- 1 file changed, 38 insertions(+), 20 deletions(-) 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); }); From e31067d1c8fe2fcd67d65196bd7f2f4a17919b5b Mon Sep 17 00:00:00 2001 From: Pileks Date: Wed, 21 Oct 2026 22:55:48 +0200 Subject: [PATCH 3/3] fix(scripts): let any oversized proposal reach the lookup table and refresh setup blockhashes --- scripts/utils/daoActions.ts | 15 +++++++++------ scripts/utils/futarchyProposal.ts | 4 ++++ scripts/utils/squads.ts | 16 +++++++++++++--- 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/scripts/utils/daoActions.ts b/scripts/utils/daoActions.ts index 3fe7b229..6a96aecf 100644 --- a/scripts/utils/daoActions.ts +++ b/scripts/utils/daoActions.ts @@ -811,10 +811,11 @@ export const removeSpendingLimit = * 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 signed by the payer and - * sent in order before anything else - `lookupTable`, the table to compile - * the vault transaction with in that case, and `requiresAdminExecution`, set - * when any action needs the DAO proposal executed through + * 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 ({ @@ -898,9 +899,7 @@ export const buildDaoActions = async ({ } } - const { blockhash } = await provider.connection.getLatestBlockhash(); for (const transaction of setupTransactions) { - transaction.recentBlockhash = blockhash; transaction.feePayer = payer; } @@ -976,6 +975,10 @@ export const signAndSendDaoActionTransactions = async ({ 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); const setupSignature = await sendAndConfirm(provider, setupTransaction); diff --git a/scripts/utils/futarchyProposal.ts b/scripts/utils/futarchyProposal.ts index 20cff3da..8d9a89f1 100644 --- a/scripts/utils/futarchyProposal.ts +++ b/scripts/utils/futarchyProposal.ts @@ -364,6 +364,10 @@ export const createFutarchyProposal = async ({ } 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); diff --git a/scripts/utils/squads.ts b/scripts/utils/squads.ts index 53e66f06..4cda5036 100644 --- a/scripts/utils/squads.ts +++ b/scripts/utils/squads.ts @@ -119,9 +119,19 @@ export const squadsVaultTransactionFits = async ({ transaction.feePayer = payer; const message = transaction.compileMessage(); - const size = - 1 + 64 * message.header.numRequiredSignatures + message.serialize().length; - return size <= PACKET_DATA_SIZE; + 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