From ccdf371a440ff882029c93a793d59b565b141fe0 Mon Sep 17 00:00:00 2001 From: Pileks Date: Fri, 31 Jul 2026 16:46:52 +0200 Subject: [PATCH 1/7] add composable dao action scripts for the admin approval flow --- scripts/utils/adminApproval.ts | 185 ++++++++++++++++ scripts/utils/daoActions.ts | 374 ++++++++++++++++++++++++++++++++ scripts/utils/squads.ts | 5 +- scripts/v0.6/enqueueTemplate.ts | 65 ++++++ 4 files changed, 627 insertions(+), 2 deletions(-) create mode 100644 scripts/utils/adminApproval.ts create mode 100644 scripts/utils/daoActions.ts create mode 100644 scripts/v0.6/enqueueTemplate.ts diff --git a/scripts/utils/adminApproval.ts b/scripts/utils/adminApproval.ts new file mode 100644 index 00000000..e005b8da --- /dev/null +++ b/scripts/utils/adminApproval.ts @@ -0,0 +1,185 @@ +import { AnchorProvider } from "@coral-xyz/anchor"; +import * as multisig from "@sqds/multisig"; +import BN from "bn.js"; +import { + PublicKey, + Transaction, + TransactionInstruction, + TransactionMessage, +} from "@solana/web3.js"; +import { FutarchyClient } from "@metadaoproject/programs/futarchy/v0.6"; +import { METADAO_MULTISIG_VAULT } from "@metadaoproject/programs"; +import { + createSquadsVaultTxAndProposal, + getSquadsPdasFromDao, +} from "./squads.js"; + +// MetaDAO operational multisig - its vault 0 (METADAO_MULTISIG_VAULT) is the futarchy admin +export const METADAO_MULTISIG = new PublicKey( + "8N3Tvc6B1wEVKVC6iD4s6eyaCNqX2ovj2xze2q3Q9DWH", +); + +const SEED_ENQUEUED_APPROVAL = Buffer.from("enqueued_approval"); + +/** + * Routes a set of instructions the DAO's squads vault should execute through + * the admin approval system, by building two transactions: + * + * 1. `daoTransaction` creates the squads vault transaction + proposal holding + * `instructions` on the DAO's multisig. Sign with the payer and + * PERMISSIONLESS_ACCOUNT (the creator). + * 2. `metadaoTransaction` creates the squads vault transaction + proposal on + * the MetaDAO operational multisig that enqueues the futarchy admin + * approval of the DAO proposal. Sign with the payer, which MUST be a + * member of the operational multisig with permission to propose + * transactions. + * + * Once the operational multisig approves + executes its transaction, the DAO + * proposal can be approved + executed permissionlessly via + * executeMultisigProposalApproval. + */ +export const buildAdminApprovalTransactions = async ({ + provider, + futarchy, + dao, + instructions, + payer, +}: { + provider: AnchorProvider; + futarchy: FutarchyClient; + dao: PublicKey; + instructions: TransactionInstruction[]; + payer: PublicKey; +}) => { + const { multisigPda: daoMultisig, vaultPda: daoMultisigVault } = + await getSquadsPdasFromDao(dao); + + // === DAO multisig: vault transaction with the DAO's instructions === + + const daoMultisigAccount = + await multisig.accounts.Multisig.fromAccountAddress( + provider.connection, + daoMultisig, + ); + + const daoTransactionIndex = + BigInt(daoMultisigAccount.transactionIndex.toString()) + 1n; + + const daoMessage = new TransactionMessage({ + payerKey: daoMultisigVault, + recentBlockhash: (await provider.connection.getLatestBlockhash()).blockhash, + instructions, + }); + + const { + vaultTxCreateIx: daoVaultTxCreateIx, + proposalCreateIx: daoProposalCreateIx, + } = await createSquadsVaultTxAndProposal( + daoMultisig, + daoTransactionIndex, + daoMessage, + payer, + ); + + const [daoVaultTransactionPda] = multisig.getTransactionPda({ + multisigPda: daoMultisig, + index: daoTransactionIndex, + }); + + const [daoProposalPda] = multisig.getProposalPda({ + multisigPda: daoMultisig, + transactionIndex: daoTransactionIndex, + }); + + const daoTransaction = new Transaction().add( + daoVaultTxCreateIx, + daoProposalCreateIx, + ); + daoTransaction.recentBlockhash = ( + await provider.connection.getLatestBlockhash() + ).blockhash; + daoTransaction.feePayer = payer; + + // === Ops multisig: vault transaction enqueueing the admin approval === + + const metadaoMultisigAccount = + await multisig.accounts.Multisig.fromAccountAddress( + provider.connection, + METADAO_MULTISIG, + ); + + const metadaoTransactionIndex = + BigInt(metadaoMultisigAccount.transactionIndex.toString()) + 1n; + + const [enqueuedApprovalPda] = PublicKey.findProgramAddressSync( + [ + SEED_ENQUEUED_APPROVAL, + dao.toBuffer(), + new BN(daoTransactionIndex.toString()).toArrayLike(Buffer, "le", 8), + ], + futarchy.futarchy.programId, + ); + + // The vault signs as the futarchy admin and pays rent for the enqueued + // approval account, so it needs to hold a small amount of SOL + const enqueueApprovalIx = await futarchy.futarchy.methods + .adminEnqueueMultisigProposalApproval({ + transactionIndex: new BN(daoTransactionIndex.toString()), + }) + .accounts({ + dao, + admin: METADAO_MULTISIG_VAULT, + squadsMultisig: daoMultisig, + squadsMultisigProposal: daoProposalPda, + enqueuedApproval: enqueuedApprovalPda, + }) + .instruction(); + + const enqueueApprovalMessage = new TransactionMessage({ + payerKey: METADAO_MULTISIG_VAULT, + recentBlockhash: (await provider.connection.getLatestBlockhash()).blockhash, + instructions: [enqueueApprovalIx], + }); + + const { + vaultTxCreateIx: enqueueApprovalVaultTxCreateIx, + proposalCreateIx: enqueueApprovalProposalCreateIx, + } = await createSquadsVaultTxAndProposal( + METADAO_MULTISIG, + metadaoTransactionIndex, + enqueueApprovalMessage, + payer, + payer, + ); + + const [metadaoVaultTransactionPda] = multisig.getTransactionPda({ + multisigPda: METADAO_MULTISIG, + index: metadaoTransactionIndex, + }); + + const [metadaoProposalPda] = multisig.getProposalPda({ + multisigPda: METADAO_MULTISIG, + transactionIndex: metadaoTransactionIndex, + }); + + const metadaoTransaction = new Transaction().add( + enqueueApprovalVaultTxCreateIx, + enqueueApprovalProposalCreateIx, + ); + metadaoTransaction.recentBlockhash = ( + await provider.connection.getLatestBlockhash() + ).blockhash; + metadaoTransaction.feePayer = payer; + + return { + daoTransaction, + daoTransactionIndex, + daoVaultTransactionPda, + daoProposalPda, + metadaoTransaction, + metadaoTransactionIndex, + metadaoVaultTransactionPda, + metadaoProposalPda, + enqueuedApprovalPda, + }; +}; diff --git a/scripts/utils/daoActions.ts b/scripts/utils/daoActions.ts new file mode 100644 index 00000000..27d4feca --- /dev/null +++ b/scripts/utils/daoActions.ts @@ -0,0 +1,374 @@ +import { AnchorProvider } from "@coral-xyz/anchor"; +import BN from "bn.js"; +import { + Keypair, + PublicKey, + Transaction, + TransactionInstruction, +} from "@solana/web3.js"; +import { PERMISSIONLESS_ACCOUNT } from "@metadaoproject/programs"; +import { + createAssociatedTokenAccountIdempotentInstruction, + createTransferInstruction, + getAssociatedTokenAddressSync, + TOKEN_PROGRAM_ID, +} from "@solana/spl-token"; +import { + FutarchyClient, + UpdateDaoParams, +} from "@metadaoproject/programs/futarchy/v0.6"; +import { buildAdminApprovalTransactions } from "./adminApproval.js"; +import { getSquadsPdasFromDao } from "./squads.js"; + +const SEED_AMM_POSITION = Buffer.from("amm_position"); + +export type DaoActionContext = { + provider: AnchorProvider; + futarchy: FutarchyClient; + dao: PublicKey; + daoMultisig: PublicKey; + // The signer of the vault transaction's inner instructions + daoMultisigVault: PublicKey; + payer: PublicKey; +}; + +export type DaoAction = { + // Executed by the DAO's squads vault inside the vault transaction + instructions: TransactionInstruction[]; + // Payer-funded instructions sent up front, so the vault transaction can't + // fail at execution time (e.g. creating token accounts) + setupInstructions?: TransactionInstruction[]; +}; + +export type DaoActionBuilder = (ctx: DaoActionContext) => Promise; + +const EMPTY_UPDATE_DAO_PARAMS: UpdateDaoParams = { + passThresholdBps: null, + secondsPerProposal: null, + twapInitialObservation: null, + twapMaxObservationChangePerUpdate: null, + twapStartDelaySeconds: null, + minQuoteFutarchicLiquidity: null, + minBaseFutarchicLiquidity: null, + baseToStake: null, + teamSponsoredPassThresholdBps: null, + teamAddress: null, + isOptimisticGovernanceEnabled: null, +}; + +// Updates the given DAO config fields, leaving the omitted ones unchanged +export const updateDao = + (params: Partial): DaoActionBuilder => + async ({ futarchy, dao }) => ({ + instructions: [ + await futarchy + .updateDaoIx({ dao, params: { ...EMPTY_UPDATE_DAO_PARAMS, ...params } }) + .instruction(), + ], + }); + +// 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 +// beyond that tolerance fail the withdrawal instead of silently accepting a +// worse outcome. +export const withdrawLiquidity = + ({ + fractionBps, + slippageBps, + }: { + fractionBps: number; + slippageBps: number; + }): DaoActionBuilder => + async ({ provider, 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); + + const liquidityToWithdraw = ammPosition.liquidity + .muln(fractionBps) + .divn(10_000); + + const spotPool = daoAccount.amm.state.spot; + if (!spotPool) { + throw new Error("DAO AMM is not in spot state"); + } + + // Same math as the program's get_base_and_quote_withdrawable + const baseWithdrawable = liquidityToWithdraw + .mul(spotPool.spot.baseReserves) + .div(daoAccount.amm.totalLiquidity); + const quoteWithdrawable = liquidityToWithdraw + .mul(spotPool.spot.quoteReserves) + .div(daoAccount.amm.totalLiquidity); + + const minBaseAmount = baseWithdrawable + .muln(10_000 - slippageBps) + .divn(10_000); + const minQuoteAmount = quoteWithdrawable + .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, + ); + + 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(); + + return { + instructions: [withdrawLiquidityIx], + setupInstructions: [ + createAssociatedTokenAccountIdempotentInstruction( + payer, + vaultBaseTokenAccount, + daoMultisigVault, + daoAccount.baseMint, + ), + createAssociatedTokenAccountIdempotentInstruction( + payer, + vaultQuoteTokenAccount, + daoMultisigVault, + daoAccount.quoteMint, + ), + ], + }; + }; + +// Transfers tokens from the vault's associated token account to the recipient +export const transferToken = + ({ + mint, + recipient, + amount, + }: { + mint: PublicKey; + recipient: PublicKey; + amount: BN; + }): DaoActionBuilder => + async ({ provider, daoMultisigVault, payer }) => { + const vaultTokenAccount = getAssociatedTokenAddressSync( + mint, + daoMultisigVault, + true, + ); + const recipientTokenAccount = getAssociatedTokenAddressSync( + mint, + recipient, + true, + ); + + try { + const vaultBalance = + await provider.connection.getTokenAccountBalance(vaultTokenAccount); + console.log("Vault token balance:", vaultBalance.value.uiAmountString); + } catch { + console.warn( + "Vault token account not found - vault holds none of this token yet", + ); + } + + return { + instructions: [ + createTransferInstruction( + vaultTokenAccount, + recipientTokenAccount, + daoMultisigVault, + BigInt(amount.toString()), + ), + ], + setupInstructions: [ + createAssociatedTokenAccountIdempotentInstruction( + payer, + recipientTokenAccount, + recipient, + mint, + ), + ], + }; + }; + +/** + * Runs the action builders and routes their instructions through the admin + * approval system. On top of buildAdminApprovalTransactions' result, returns + * `setupTransaction` - a payer-funded transaction with the actions' setup + * instructions (null if none), to be signed by the payer and sent before the + * others. + */ +export const buildDaoActionTransactions = async ({ + provider, + futarchy, + dao, + payer, + actions, +}: { + provider: AnchorProvider; + futarchy: FutarchyClient; + dao: PublicKey; + payer: PublicKey; + actions: DaoActionBuilder[]; +}) => { + const { multisigPda: daoMultisig, vaultPda: daoMultisigVault } = + await getSquadsPdasFromDao(dao); + + const ctx: DaoActionContext = { + provider, + futarchy, + dao, + daoMultisig, + daoMultisigVault, + payer, + }; + + const built: DaoAction[] = []; + for (const action of actions) { + built.push(await action(ctx)); + } + + const setupInstructions = built.flatMap( + (action) => action.setupInstructions ?? [], + ); + const instructions = built.flatMap((action) => action.instructions); + + if (instructions.length === 0) { + throw new Error("No instructions to enqueue - add at least one action"); + } + + let setupTransaction: Transaction | null = null; + if (setupInstructions.length > 0) { + setupTransaction = new Transaction().add(...setupInstructions); + setupTransaction.recentBlockhash = ( + await provider.connection.getLatestBlockhash() + ).blockhash; + setupTransaction.feePayer = payer; + } + + return { + setupTransaction, + ...(await buildAdminApprovalTransactions({ + provider, + futarchy, + dao, + instructions, + payer, + })), + }; +}; + +/** + * Signs and sends the transactions built by buildDaoActionTransactions in + * order (setup if any, DAO multisig, ops multisig), logging the created + * squads transactions and proposals along the way. + */ +export const signAndSendDaoActionTransactions = async ({ + provider, + payer, + transactions, +}: { + provider: AnchorProvider; + payer: Keypair; + transactions: Awaited>; +}) => { + const { + setupTransaction, + daoTransaction, + daoTransactionIndex, + daoVaultTransactionPda, + daoProposalPda, + metadaoTransaction, + metadaoTransactionIndex, + metadaoVaultTransactionPda, + metadaoProposalPda, + enqueuedApprovalPda, + } = transactions; + + let setupSignature: string | null = null; + if (setupTransaction) { + setupTransaction.sign(payer); + + setupSignature = await provider.connection.sendRawTransaction( + setupTransaction.serialize(), + ); + await provider.connection.confirmTransaction(setupSignature, "confirmed"); + + console.log("Setup transaction sent!"); + console.log("Transaction signature:", setupSignature); + } + + daoTransaction.sign(payer, PERMISSIONLESS_ACCOUNT); + + const daoSignature = await provider.connection.sendRawTransaction( + daoTransaction.serialize(), + ); + await provider.connection.confirmTransaction(daoSignature, "confirmed"); + + console.log("DAO squads transaction created!"); + console.log("Transaction signature:", daoSignature); + console.log("Squads transaction index:", daoTransactionIndex.toString()); + console.log("Squads transaction:", daoVaultTransactionPda.toBase58()); + console.log("Squads proposal:", daoProposalPda.toBase58()); + + metadaoTransaction.sign(payer); + + const metadaoSignature = await provider.connection.sendRawTransaction( + metadaoTransaction.serialize(), + ); + await provider.connection.confirmTransaction(metadaoSignature, "confirmed"); + + console.log("Enqueue approval squads transaction created!"); + console.log("Transaction signature:", metadaoSignature); + console.log("Squads transaction index:", metadaoTransactionIndex.toString()); + console.log("Squads transaction:", metadaoVaultTransactionPda.toBase58()); + console.log("Squads proposal:", metadaoProposalPda.toBase58()); + console.log("Enqueued approval:", enqueuedApprovalPda.toBase58()); + console.log( + "Go ahead and approve + execute the enqueue approval through Squads.", + ); + + return { setupSignature, daoSignature, metadaoSignature }; +}; diff --git a/scripts/utils/squads.ts b/scripts/utils/squads.ts index 3b3cbb7d..0b55a461 100644 --- a/scripts/utils/squads.ts +++ b/scripts/utils/squads.ts @@ -38,11 +38,12 @@ export const createSquadsVaultTxAndProposal = async ( transactionIndex: bigint, transactionMessage: TransactionMessage, payer: PublicKey, + creator: PublicKey = PERMISSIONLESS_ACCOUNT.publicKey, ) => { const vaultTxCreateIx = multisig.instructions.vaultTransactionCreate({ multisigPda: squadsMultisig, transactionIndex: transactionIndex, - creator: PERMISSIONLESS_ACCOUNT.publicKey, + creator, rentPayer: payer, vaultIndex: 0, ephemeralSigners: 0, @@ -52,7 +53,7 @@ export const createSquadsVaultTxAndProposal = async ( const proposalCreateIx = multisig.instructions.proposalCreate({ multisigPda: squadsMultisig, transactionIndex: transactionIndex, - creator: PERMISSIONLESS_ACCOUNT.publicKey, + creator, rentPayer: payer, isDraft: false, }); diff --git a/scripts/v0.6/enqueueTemplate.ts b/scripts/v0.6/enqueueTemplate.ts new file mode 100644 index 00000000..ae3c8e1d --- /dev/null +++ b/scripts/v0.6/enqueueTemplate.ts @@ -0,0 +1,65 @@ +import * as anchor from "@coral-xyz/anchor"; +import BN from "bn.js"; +import { PublicKey } from "@solana/web3.js"; +import { FutarchyClient } from "@metadaoproject/programs/futarchy/v0.6"; +import { MAINNET_USDC } from "@metadaoproject/programs"; +import { + buildDaoActionTransactions, + signAndSendDaoActionTransactions, + transferToken, + updateDao, + withdrawLiquidity, +} from "../utils/daoActions.js"; + +// Template for enqueueing DAO vault actions through the admin approval system. +// Copy it, set the constants, and compose the actions below. Once the ops +// multisig approves + executes the enqueue, approve + execute the DAO proposal +// with executeMultisigProposalApproval.ts. + +/////////////// +// Constants // +/////////////// + +// The DAO whose vault should execute the actions +const DAO = new PublicKey("DAO_PUBKEY"); + +//////////////// +// Operations // +//////////////// + +const provider = anchor.AnchorProvider.env(); + +// Payer MUST be a member of the MetaDAO operational multisig with permission +// to propose transactions +const payer = provider.wallet["payer"]; + +const futarchy = FutarchyClient.createClient({ provider }); + +async function main() { + const transactions = await buildDaoActionTransactions({ + provider, + futarchy, + dao: DAO, + payer: payer.publicKey, + actions: [ + // Compose the actions the DAO's vault should execute, e.g.: + // + // updateDao({ teamAddress: new PublicKey("...") }), + // + // withdrawLiquidity({ fractionBps: 5_000, slippageBps: 2_000 }), + // + // transferToken({ + // mint: MAINNET_USDC, + // recipient: new PublicKey("..."), + // amount: new BN(1_000).mul(new BN(10 ** 6)), + // }), + ], + }); + + await signAndSendDaoActionTransactions({ provider, payer, transactions }); +} + +main().catch((error) => { + console.error("Error enqueueing DAO actions:", error); + process.exit(1); +}); From 3873d82ac40a7f08d0ef90d8670f327423013113 Mon Sep 17 00:00:00 2001 From: Pileks Date: Fri, 31 Jul 2026 17:07:27 +0200 Subject: [PATCH 2/7] address greptile comments --- scripts/utils/adminApproval.ts | 144 ++++++++++++++++++--------------- scripts/utils/daoActions.ts | 54 +++++++++---- 2 files changed, 119 insertions(+), 79 deletions(-) diff --git a/scripts/utils/adminApproval.ts b/scripts/utils/adminApproval.ts index e005b8da..ddc5b59f 100644 --- a/scripts/utils/adminApproval.ts +++ b/scripts/utils/adminApproval.ts @@ -28,11 +28,13 @@ const SEED_ENQUEUED_APPROVAL = Buffer.from("enqueued_approval"); * 1. `daoTransaction` creates the squads vault transaction + proposal holding * `instructions` on the DAO's multisig. Sign with the payer and * PERMISSIONLESS_ACCOUNT (the creator). - * 2. `metadaoTransaction` creates the squads vault transaction + proposal on - * the MetaDAO operational multisig that enqueues the futarchy admin - * approval of the DAO proposal. Sign with the payer, which MUST be a - * member of the operational multisig with permission to propose - * transactions. + * 2. `buildMetadaoTransaction()` builds the transaction that creates the + * squads vault transaction + proposal on the MetaDAO operational multisig + * enqueueing the futarchy admin approval of the DAO proposal. It reads the + * operational multisig's next transaction index at call time, so call it + * right before sending (after `daoTransaction` confirms). Sign with the + * payer, which MUST be a member of the operational multisig with + * permission to propose transactions. * * Once the operational multisig approves + executes its transaction, the DAO * proposal can be approved + executed permissionlessly via @@ -102,15 +104,6 @@ export const buildAdminApprovalTransactions = async ({ // === Ops multisig: vault transaction enqueueing the admin approval === - const metadaoMultisigAccount = - await multisig.accounts.Multisig.fromAccountAddress( - provider.connection, - METADAO_MULTISIG, - ); - - const metadaoTransactionIndex = - BigInt(metadaoMultisigAccount.transactionIndex.toString()) + 1n; - const [enqueuedApprovalPda] = PublicKey.findProgramAddressSync( [ SEED_ENQUEUED_APPROVAL, @@ -120,66 +113,87 @@ export const buildAdminApprovalTransactions = async ({ futarchy.futarchy.programId, ); - // The vault signs as the futarchy admin and pays rent for the enqueued - // approval account, so it needs to hold a small amount of SOL - const enqueueApprovalIx = await futarchy.futarchy.methods - .adminEnqueueMultisigProposalApproval({ - transactionIndex: new BN(daoTransactionIndex.toString()), - }) - .accounts({ - dao, - admin: METADAO_MULTISIG_VAULT, - squadsMultisig: daoMultisig, - squadsMultisigProposal: daoProposalPda, - enqueuedApproval: enqueuedApprovalPda, - }) - .instruction(); - - const enqueueApprovalMessage = new TransactionMessage({ - payerKey: METADAO_MULTISIG_VAULT, - recentBlockhash: (await provider.connection.getLatestBlockhash()).blockhash, - instructions: [enqueueApprovalIx], - }); - - const { - vaultTxCreateIx: enqueueApprovalVaultTxCreateIx, - proposalCreateIx: enqueueApprovalProposalCreateIx, - } = await createSquadsVaultTxAndProposal( - METADAO_MULTISIG, - metadaoTransactionIndex, - enqueueApprovalMessage, - payer, - payer, - ); + // Built lazily so the shared ops multisig's transaction index is read right + // before the transaction is sent - an index read at build time can be + // consumed by another operator's proposal in the meantime, which would make + // vaultTransactionCreate fail and leave the DAO proposal without its + // enqueue proposal + const buildMetadaoTransaction = async () => { + const metadaoMultisigAccount = + await multisig.accounts.Multisig.fromAccountAddress( + provider.connection, + METADAO_MULTISIG, + ); + + const metadaoTransactionIndex = + BigInt(metadaoMultisigAccount.transactionIndex.toString()) + 1n; + + // The vault signs as the futarchy admin and pays rent for the enqueued + // approval account, so it needs to hold a small amount of SOL + const enqueueApprovalIx = await futarchy.futarchy.methods + .adminEnqueueMultisigProposalApproval({ + transactionIndex: new BN(daoTransactionIndex.toString()), + }) + .accounts({ + dao, + admin: METADAO_MULTISIG_VAULT, + squadsMultisig: daoMultisig, + squadsMultisigProposal: daoProposalPda, + enqueuedApproval: enqueuedApprovalPda, + }) + .instruction(); + + const enqueueApprovalMessage = new TransactionMessage({ + payerKey: METADAO_MULTISIG_VAULT, + recentBlockhash: (await provider.connection.getLatestBlockhash()) + .blockhash, + instructions: [enqueueApprovalIx], + }); + + const { + vaultTxCreateIx: enqueueApprovalVaultTxCreateIx, + proposalCreateIx: enqueueApprovalProposalCreateIx, + } = await createSquadsVaultTxAndProposal( + METADAO_MULTISIG, + metadaoTransactionIndex, + enqueueApprovalMessage, + payer, + payer, + ); - const [metadaoVaultTransactionPda] = multisig.getTransactionPda({ - multisigPda: METADAO_MULTISIG, - index: metadaoTransactionIndex, - }); + const [metadaoVaultTransactionPda] = multisig.getTransactionPda({ + multisigPda: METADAO_MULTISIG, + index: metadaoTransactionIndex, + }); - const [metadaoProposalPda] = multisig.getProposalPda({ - multisigPda: METADAO_MULTISIG, - transactionIndex: metadaoTransactionIndex, - }); + const [metadaoProposalPda] = multisig.getProposalPda({ + multisigPda: METADAO_MULTISIG, + transactionIndex: metadaoTransactionIndex, + }); - const metadaoTransaction = new Transaction().add( - enqueueApprovalVaultTxCreateIx, - enqueueApprovalProposalCreateIx, - ); - metadaoTransaction.recentBlockhash = ( - await provider.connection.getLatestBlockhash() - ).blockhash; - metadaoTransaction.feePayer = payer; + const metadaoTransaction = new Transaction().add( + enqueueApprovalVaultTxCreateIx, + enqueueApprovalProposalCreateIx, + ); + metadaoTransaction.recentBlockhash = ( + await provider.connection.getLatestBlockhash() + ).blockhash; + metadaoTransaction.feePayer = payer; + + return { + metadaoTransaction, + metadaoTransactionIndex, + metadaoVaultTransactionPda, + metadaoProposalPda, + }; + }; return { daoTransaction, daoTransactionIndex, daoVaultTransactionPda, daoProposalPda, - metadaoTransaction, - metadaoTransactionIndex, - metadaoVaultTransactionPda, - metadaoProposalPda, enqueuedApprovalPda, + buildMetadaoTransaction, }; }; diff --git a/scripts/utils/daoActions.ts b/scripts/utils/daoActions.ts index 27d4feca..9d083db3 100644 --- a/scripts/utils/daoActions.ts +++ b/scripts/utils/daoActions.ts @@ -72,15 +72,33 @@ export const updateDao = // liquidity is worth right now, so reserve changes between now and execution // beyond that tolerance fail the withdrawal instead of silently accepting a // worse outcome. -export const withdrawLiquidity = - ({ - fractionBps, - slippageBps, - }: { - fractionBps: number; - slippageBps: number; - }): DaoActionBuilder => - async ({ provider, futarchy, dao, daoMultisigVault, payer }) => { +export const withdrawLiquidity = ({ + fractionBps, + slippageBps, +}: { + fractionBps: number; + slippageBps: number; +}): DaoActionBuilder => { + if ( + !Number.isInteger(fractionBps) || + fractionBps <= 0 || + fractionBps > 10_000 + ) { + throw new Error( + `fractionBps must be an integer between 1 and 10000, got ${fractionBps}`, + ); + } + if ( + !Number.isInteger(slippageBps) || + slippageBps < 0 || + slippageBps > 10_000 + ) { + throw new Error( + `slippageBps must be an integer between 0 and 10000, got ${slippageBps}`, + ); + } + + return async ({ provider, futarchy, dao, daoMultisigVault, payer }) => { const daoAccount = await futarchy.getDao(dao); // The DAO's protocol-owned liquidity position is held by its squads vault @@ -179,6 +197,7 @@ export const withdrawLiquidity = ], }; }; +}; // Transfers tokens from the vault's associated token account to the recipient export const transferToken = @@ -303,7 +322,9 @@ export const buildDaoActionTransactions = async ({ /** * Signs and sends the transactions built by buildDaoActionTransactions in * order (setup if any, DAO multisig, ops multisig), logging the created - * squads transactions and proposals along the way. + * squads transactions and proposals along the way. The ops multisig + * transaction is built only after the DAO transaction confirms, so its + * transaction index is read as late as possible. */ export const signAndSendDaoActionTransactions = async ({ provider, @@ -320,11 +341,8 @@ export const signAndSendDaoActionTransactions = async ({ daoTransactionIndex, daoVaultTransactionPda, daoProposalPda, - metadaoTransaction, - metadaoTransactionIndex, - metadaoVaultTransactionPda, - metadaoProposalPda, enqueuedApprovalPda, + buildMetadaoTransaction, } = transactions; let setupSignature: string | null = null; @@ -353,6 +371,14 @@ export const signAndSendDaoActionTransactions = async ({ console.log("Squads transaction:", daoVaultTransactionPda.toBase58()); console.log("Squads proposal:", daoProposalPda.toBase58()); + // Built only now so the ops multisig's transaction index is fresh + const { + metadaoTransaction, + metadaoTransactionIndex, + metadaoVaultTransactionPda, + metadaoProposalPda, + } = await buildMetadaoTransaction(); + metadaoTransaction.sign(payer); const metadaoSignature = await provider.connection.sendRawTransaction( From 765a6f787d6936ec6c7ca8b42c53bba9485b883b Mon Sep 17 00:00:00 2001 From: Pileks Date: Fri, 31 Jul 2026 18:23:27 +0200 Subject: [PATCH 3/7] ensure freshness of DAO's multisig transaction index --- scripts/utils/adminApproval.ts | 251 ++++++++++++++++----------------- scripts/utils/daoActions.ts | 26 ++-- 2 files changed, 139 insertions(+), 138 deletions(-) diff --git a/scripts/utils/adminApproval.ts b/scripts/utils/adminApproval.ts index ddc5b59f..e8e437dc 100644 --- a/scripts/utils/adminApproval.ts +++ b/scripts/utils/adminApproval.ts @@ -23,18 +23,21 @@ const SEED_ENQUEUED_APPROVAL = Buffer.from("enqueued_approval"); /** * Routes a set of instructions the DAO's squads vault should execute through - * the admin approval system, by building two transactions: + * the admin approval system, as two lazily built transactions: * - * 1. `daoTransaction` creates the squads vault transaction + proposal holding - * `instructions` on the DAO's multisig. Sign with the payer and - * PERMISSIONLESS_ACCOUNT (the creator). - * 2. `buildMetadaoTransaction()` builds the transaction that creates the - * squads vault transaction + proposal on the MetaDAO operational multisig - * enqueueing the futarchy admin approval of the DAO proposal. It reads the - * operational multisig's next transaction index at call time, so call it - * right before sending (after `daoTransaction` confirms). Sign with the - * payer, which MUST be a member of the operational multisig with - * permission to propose transactions. + * 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). + * 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 + * of the DAO proposal. Sign with the payer, which MUST be a member of the + * operational multisig with permission to propose transactions. + * + * Each builder reads its multisig's next transaction index at call time, so + * call it right before sending its transaction - an index read earlier can be + * consumed by someone else's proposal while previous transactions in the flow + * confirm, making vaultTransactionCreate fail. * * Once the operational multisig approves + executes its transaction, the DAO * proposal can be approved + executed permissionlessly via @@ -56,144 +59,140 @@ export const buildAdminApprovalTransactions = async ({ const { multisigPda: daoMultisig, vaultPda: daoMultisigVault } = await getSquadsPdasFromDao(dao); - // === DAO multisig: vault transaction with the DAO's instructions === - - const daoMultisigAccount = - await multisig.accounts.Multisig.fromAccountAddress( - provider.connection, - daoMultisig, - ); - - const daoTransactionIndex = - BigInt(daoMultisigAccount.transactionIndex.toString()) + 1n; - - const daoMessage = new TransactionMessage({ - payerKey: daoMultisigVault, - recentBlockhash: (await provider.connection.getLatestBlockhash()).blockhash, - instructions, - }); - - const { - vaultTxCreateIx: daoVaultTxCreateIx, - proposalCreateIx: daoProposalCreateIx, - } = await createSquadsVaultTxAndProposal( - daoMultisig, - daoTransactionIndex, - daoMessage, - payer, - ); - - const [daoVaultTransactionPda] = multisig.getTransactionPda({ - multisigPda: daoMultisig, - index: daoTransactionIndex, - }); - - const [daoProposalPda] = multisig.getProposalPda({ - multisigPda: daoMultisig, - transactionIndex: daoTransactionIndex, - }); - - const daoTransaction = new Transaction().add( - daoVaultTxCreateIx, - daoProposalCreateIx, - ); - daoTransaction.recentBlockhash = ( - await provider.connection.getLatestBlockhash() - ).blockhash; - daoTransaction.feePayer = payer; - - // === Ops multisig: vault transaction enqueueing the admin approval === - - const [enqueuedApprovalPda] = PublicKey.findProgramAddressSync( - [ - SEED_ENQUEUED_APPROVAL, - dao.toBuffer(), - new BN(daoTransactionIndex.toString()).toArrayLike(Buffer, "le", 8), - ], - futarchy.futarchy.programId, - ); - - // Built lazily so the shared ops multisig's transaction index is read right - // before the transaction is sent - an index read at build time can be - // consumed by another operator's proposal in the meantime, which would make - // vaultTransactionCreate fail and leave the DAO proposal without its - // enqueue proposal - const buildMetadaoTransaction = async () => { - const metadaoMultisigAccount = + const buildDaoTransaction = async () => { + const daoMultisigAccount = await multisig.accounts.Multisig.fromAccountAddress( provider.connection, - METADAO_MULTISIG, + daoMultisig, ); - const metadaoTransactionIndex = - BigInt(metadaoMultisigAccount.transactionIndex.toString()) + 1n; - - // The vault signs as the futarchy admin and pays rent for the enqueued - // approval account, so it needs to hold a small amount of SOL - const enqueueApprovalIx = await futarchy.futarchy.methods - .adminEnqueueMultisigProposalApproval({ - transactionIndex: new BN(daoTransactionIndex.toString()), - }) - .accounts({ - dao, - admin: METADAO_MULTISIG_VAULT, - squadsMultisig: daoMultisig, - squadsMultisigProposal: daoProposalPda, - enqueuedApproval: enqueuedApprovalPda, - }) - .instruction(); - - const enqueueApprovalMessage = new TransactionMessage({ - payerKey: METADAO_MULTISIG_VAULT, + const daoTransactionIndex = + BigInt(daoMultisigAccount.transactionIndex.toString()) + 1n; + + const daoMessage = new TransactionMessage({ + payerKey: daoMultisigVault, recentBlockhash: (await provider.connection.getLatestBlockhash()) .blockhash, - instructions: [enqueueApprovalIx], + instructions, }); const { - vaultTxCreateIx: enqueueApprovalVaultTxCreateIx, - proposalCreateIx: enqueueApprovalProposalCreateIx, + vaultTxCreateIx: daoVaultTxCreateIx, + proposalCreateIx: daoProposalCreateIx, } = await createSquadsVaultTxAndProposal( - METADAO_MULTISIG, - metadaoTransactionIndex, - enqueueApprovalMessage, - payer, + daoMultisig, + daoTransactionIndex, + daoMessage, payer, ); - const [metadaoVaultTransactionPda] = multisig.getTransactionPda({ - multisigPda: METADAO_MULTISIG, - index: metadaoTransactionIndex, + const [daoVaultTransactionPda] = multisig.getTransactionPda({ + multisigPda: daoMultisig, + index: daoTransactionIndex, }); - const [metadaoProposalPda] = multisig.getProposalPda({ - multisigPda: METADAO_MULTISIG, - transactionIndex: metadaoTransactionIndex, + const [daoProposalPda] = multisig.getProposalPda({ + multisigPda: daoMultisig, + transactionIndex: daoTransactionIndex, }); - const metadaoTransaction = new Transaction().add( - enqueueApprovalVaultTxCreateIx, - enqueueApprovalProposalCreateIx, + const daoTransaction = new Transaction().add( + daoVaultTxCreateIx, + daoProposalCreateIx, ); - metadaoTransaction.recentBlockhash = ( + daoTransaction.recentBlockhash = ( await provider.connection.getLatestBlockhash() ).blockhash; - metadaoTransaction.feePayer = payer; + daoTransaction.feePayer = payer; + + const [enqueuedApprovalPda] = PublicKey.findProgramAddressSync( + [ + SEED_ENQUEUED_APPROVAL, + dao.toBuffer(), + new BN(daoTransactionIndex.toString()).toArrayLike(Buffer, "le", 8), + ], + futarchy.futarchy.programId, + ); + + const buildMetadaoTransaction = async () => { + const metadaoMultisigAccount = + await multisig.accounts.Multisig.fromAccountAddress( + provider.connection, + METADAO_MULTISIG, + ); + + const metadaoTransactionIndex = + BigInt(metadaoMultisigAccount.transactionIndex.toString()) + 1n; + + // The vault signs as the futarchy admin and pays rent for the enqueued + // approval account, so it needs to hold a small amount of SOL + const enqueueApprovalIx = await futarchy.futarchy.methods + .adminEnqueueMultisigProposalApproval({ + transactionIndex: new BN(daoTransactionIndex.toString()), + }) + .accounts({ + dao, + admin: METADAO_MULTISIG_VAULT, + squadsMultisig: daoMultisig, + squadsMultisigProposal: daoProposalPda, + enqueuedApproval: enqueuedApprovalPda, + }) + .instruction(); + + const enqueueApprovalMessage = new TransactionMessage({ + payerKey: METADAO_MULTISIG_VAULT, + recentBlockhash: (await provider.connection.getLatestBlockhash()) + .blockhash, + instructions: [enqueueApprovalIx], + }); + + const { + vaultTxCreateIx: enqueueApprovalVaultTxCreateIx, + proposalCreateIx: enqueueApprovalProposalCreateIx, + } = await createSquadsVaultTxAndProposal( + METADAO_MULTISIG, + metadaoTransactionIndex, + enqueueApprovalMessage, + payer, + payer, + ); + + const [metadaoVaultTransactionPda] = multisig.getTransactionPda({ + multisigPda: METADAO_MULTISIG, + index: metadaoTransactionIndex, + }); + + const [metadaoProposalPda] = multisig.getProposalPda({ + multisigPda: METADAO_MULTISIG, + transactionIndex: metadaoTransactionIndex, + }); + + const metadaoTransaction = new Transaction().add( + enqueueApprovalVaultTxCreateIx, + enqueueApprovalProposalCreateIx, + ); + metadaoTransaction.recentBlockhash = ( + await provider.connection.getLatestBlockhash() + ).blockhash; + metadaoTransaction.feePayer = payer; + + return { + metadaoTransaction, + metadaoTransactionIndex, + metadaoVaultTransactionPda, + metadaoProposalPda, + }; + }; return { - metadaoTransaction, - metadaoTransactionIndex, - metadaoVaultTransactionPda, - metadaoProposalPda, + daoTransaction, + daoTransactionIndex, + daoVaultTransactionPda, + daoProposalPda, + enqueuedApprovalPda, + buildMetadaoTransaction, }; }; - return { - daoTransaction, - daoTransactionIndex, - daoVaultTransactionPda, - daoProposalPda, - enqueuedApprovalPda, - buildMetadaoTransaction, - }; + return { buildDaoTransaction }; }; diff --git a/scripts/utils/daoActions.ts b/scripts/utils/daoActions.ts index 9d083db3..dcffcd00 100644 --- a/scripts/utils/daoActions.ts +++ b/scripts/utils/daoActions.ts @@ -322,9 +322,9 @@ export const buildDaoActionTransactions = async ({ /** * Signs and sends the transactions built by buildDaoActionTransactions in * order (setup if any, DAO multisig, ops multisig), logging the created - * squads transactions and proposals along the way. The ops multisig - * transaction is built only after the DAO transaction confirms, so its - * transaction index is read as late as possible. + * 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. */ export const signAndSendDaoActionTransactions = async ({ provider, @@ -335,15 +335,7 @@ export const signAndSendDaoActionTransactions = async ({ payer: Keypair; transactions: Awaited>; }) => { - const { - setupTransaction, - daoTransaction, - daoTransactionIndex, - daoVaultTransactionPda, - daoProposalPda, - enqueuedApprovalPda, - buildMetadaoTransaction, - } = transactions; + const { setupTransaction, buildDaoTransaction } = transactions; let setupSignature: string | null = null; if (setupTransaction) { @@ -358,6 +350,16 @@ export const signAndSendDaoActionTransactions = async ({ console.log("Transaction signature:", setupSignature); } + // Built only now so the DAO multisig's transaction index is fresh + const { + daoTransaction, + daoTransactionIndex, + daoVaultTransactionPda, + daoProposalPda, + enqueuedApprovalPda, + buildMetadaoTransaction, + } = await buildDaoTransaction(); + daoTransaction.sign(payer, PERMISSIONLESS_ACCOUNT); const daoSignature = await provider.connection.sendRawTransaction( From 538201a86a574675ecac0626865bf3b23c56bef8 Mon Sep 17 00:00:00 2001 From: Pileks Date: Fri, 31 Jul 2026 18:33:01 +0200 Subject: [PATCH 4/7] prevent liquidity withdraw not working due to small input --- scripts/utils/daoActions.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/utils/daoActions.ts b/scripts/utils/daoActions.ts index dcffcd00..02b9efb5 100644 --- a/scripts/utils/daoActions.ts +++ b/scripts/utils/daoActions.ts @@ -113,6 +113,11 @@ export const withdrawLiquidity = ({ const liquidityToWithdraw = ammPosition.liquidity .muln(fractionBps) .divn(10_000); + if (liquidityToWithdraw.isZero()) { + throw new Error( + `fractionBps ${fractionBps} rounds down to zero liquidity for this position`, + ); + } const spotPool = daoAccount.amm.state.spot; if (!spotPool) { From e5039c40f48ca39ad03175a72dbd189f2176eb4b Mon Sep 17 00:00:00 2001 From: Pileks Date: Fri, 31 Jul 2026 19:00:18 +0200 Subject: [PATCH 5/7] add retries to ops proposal enqueue --- scripts/utils/daoActions.ts | 99 ++++++++++++++++++++++++++++++------- 1 file changed, 80 insertions(+), 19 deletions(-) diff --git a/scripts/utils/daoActions.ts b/scripts/utils/daoActions.ts index 02b9efb5..d576b7fd 100644 --- a/scripts/utils/daoActions.ts +++ b/scripts/utils/daoActions.ts @@ -324,12 +324,36 @@ export const buildDaoActionTransactions = async ({ }; }; +// Sends a signed transaction, throwing if it isn't confirmed or lands with an +// error +const sendAndConfirm = async ( + provider: AnchorProvider, + transaction: Transaction, +) => { + const signature = await provider.connection.sendRawTransaction( + transaction.serialize(), + ); + const status = await provider.connection.confirmTransaction( + signature, + "confirmed", + ); + if (status.value.err) { + throw new Error( + `Transaction ${signature} failed: ${JSON.stringify(status.value.err)}`, + ); + } + return signature; +}; + /** * Signs and sends the transactions built by buildDaoActionTransactions in * order (setup 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. + * read as late as possible, and enqueue proposal creation retries with a + * freshly built transaction when an attempt definitively fails (e.g. an + * index collision with another operator's proposal on the shared ops + * multisig). */ export const signAndSendDaoActionTransactions = async ({ provider, @@ -346,10 +370,7 @@ export const signAndSendDaoActionTransactions = async ({ if (setupTransaction) { setupTransaction.sign(payer); - setupSignature = await provider.connection.sendRawTransaction( - setupTransaction.serialize(), - ); - await provider.connection.confirmTransaction(setupSignature, "confirmed"); + setupSignature = await sendAndConfirm(provider, setupTransaction); console.log("Setup transaction sent!"); console.log("Transaction signature:", setupSignature); @@ -367,10 +388,7 @@ export const signAndSendDaoActionTransactions = async ({ daoTransaction.sign(payer, PERMISSIONLESS_ACCOUNT); - const daoSignature = await provider.connection.sendRawTransaction( - daoTransaction.serialize(), - ); - await provider.connection.confirmTransaction(daoSignature, "confirmed"); + const daoSignature = await sendAndConfirm(provider, daoTransaction); console.log("DAO squads transaction created!"); console.log("Transaction signature:", daoSignature); @@ -378,20 +396,63 @@ export const signAndSendDaoActionTransactions = async ({ console.log("Squads transaction:", daoVaultTransactionPda.toBase58()); console.log("Squads proposal:", daoProposalPda.toBase58()); - // Built only now so the ops multisig's transaction index is fresh + // The ops multisig is shared, so another operator's proposal can consume + // the transaction index between the build's index read and our transaction + // landing. A definitively failed attempt rebuilds with a fresh index and + // retries; an ambiguous confirmation timeout is not retried, since the + // transaction may still land and a second attempt would then create a + // duplicate enqueue proposal. + const sendEnqueueTransactionWithRetries = async (attempts: number) => { + let lastError: unknown; + + for (let attempt = 1; attempt <= attempts; attempt++) { + const enqueue = await buildMetadaoTransaction(); + enqueue.metadaoTransaction.sign(payer); + + let signature: string; + try { + signature = await provider.connection.sendRawTransaction( + enqueue.metadaoTransaction.serialize(), + ); + } catch (error) { + // Rejected at preflight, so nothing was broadcast + console.warn(`Enqueue attempt ${attempt} of ${attempts} rejected`); + lastError = error; + continue; + } + + const status = await provider.connection + .confirmTransaction(signature, "confirmed") + .catch((error) => { + console.error( + `Confirmation of enqueue transaction ${signature} timed out. It may still land - check it before re-running, or a duplicate enqueue proposal could be created.`, + ); + throw error; + }); + + if (status.value.err) { + // Landed on-chain but failed, consuming only the transaction fee + console.warn( + `Enqueue attempt ${attempt} of ${attempts} failed on-chain`, + ); + lastError = new Error( + `Enqueue transaction ${signature} failed: ${JSON.stringify(status.value.err)}`, + ); + continue; + } + + return { ...enqueue, metadaoSignature: signature }; + } + + throw lastError; + }; + const { - metadaoTransaction, metadaoTransactionIndex, metadaoVaultTransactionPda, metadaoProposalPda, - } = await buildMetadaoTransaction(); - - metadaoTransaction.sign(payer); - - const metadaoSignature = await provider.connection.sendRawTransaction( - metadaoTransaction.serialize(), - ); - await provider.connection.confirmTransaction(metadaoSignature, "confirmed"); + metadaoSignature, + } = await sendEnqueueTransactionWithRetries(3); console.log("Enqueue approval squads transaction created!"); console.log("Transaction signature:", metadaoSignature); From 76adcb7d8ff4ac0e9273540abde8de041d6d7bbe Mon Sep 17 00:00:00 2001 From: Pileks Date: Fri, 31 Jul 2026 19:12:55 +0200 Subject: [PATCH 6/7] exit loop on timeout - transaction may have landed --- scripts/utils/daoActions.ts | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/scripts/utils/daoActions.ts b/scripts/utils/daoActions.ts index d576b7fd..4b29feaa 100644 --- a/scripts/utils/daoActions.ts +++ b/scripts/utils/daoActions.ts @@ -3,6 +3,8 @@ import BN from "bn.js"; import { Keypair, PublicKey, + RpcResponseAndContext, + SignatureResult, Transaction, TransactionInstruction, } from "@solana/web3.js"; @@ -421,14 +423,21 @@ export const signAndSendDaoActionTransactions = async ({ continue; } - const status = await provider.connection - .confirmTransaction(signature, "confirmed") - .catch((error) => { - console.error( - `Confirmation of enqueue transaction ${signature} timed out. It may still land - check it before re-running, or a duplicate enqueue proposal could be created.`, - ); - throw error; - }); + let status: RpcResponseAndContext; + try { + status = await provider.connection.confirmTransaction( + signature, + "confirmed", + ); + } catch (error) { + // The timeout is ambiguous - the transaction may still land, so + // retrying could create a duplicate enqueue proposal. Throw out of + // the retry loop instead. + console.error( + `Confirmation of enqueue transaction ${signature} timed out. It may still land - check it before re-running, or a duplicate enqueue proposal could be created.`, + ); + throw error; + } if (status.value.err) { // Landed on-chain but failed, consuming only the transaction fee From c19dda053d3a1db1c188c8858256abe96ef23815 Mon Sep 17 00:00:00 2001 From: Pileks Date: Fri, 31 Jul 2026 19:47:53 +0200 Subject: [PATCH 7/7] include any ambiguous sends to failure route --- scripts/utils/daoActions.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/scripts/utils/daoActions.ts b/scripts/utils/daoActions.ts index 4b29feaa..f0cf958c 100644 --- a/scripts/utils/daoActions.ts +++ b/scripts/utils/daoActions.ts @@ -4,6 +4,7 @@ import { Keypair, PublicKey, RpcResponseAndContext, + SendTransactionError, SignatureResult, Transaction, TransactionInstruction, @@ -100,7 +101,7 @@ export const withdrawLiquidity = ({ ); } - return async ({ provider, futarchy, dao, daoMultisigVault, payer }) => { + 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 @@ -417,7 +418,18 @@ export const signAndSendDaoActionTransactions = async ({ enqueue.metadaoTransaction.serialize(), ); } catch (error) { - // Rejected at preflight, so nothing was broadcast + if (!(error instanceof SendTransactionError)) { + // Anything but the node rejecting the transaction (e.g. a + // transport error) is ambiguous - the transaction may have been + // forwarded and could still land, so retrying could create a + // duplicate enqueue proposal. Throw out of the retry loop instead. + console.error( + `Sending the enqueue transaction failed without a node response. It may still land - check whether proposal ${enqueue.metadaoProposalPda.toBase58()} gets created before re-running.`, + ); + throw error; + } + // The node rejected the transaction at preflight, so nothing was + // broadcast console.warn(`Enqueue attempt ${attempt} of ${attempts} rejected`); lastError = error; continue;