From 30834402dafb3809a5394d2ea154ec74e5fca9fe Mon Sep 17 00:00:00 2001 From: Pileks Date: Sat, 5 Sep 2026 00:59:49 +0200 Subject: [PATCH 1/4] feat(scripts): add ability to create futarchy transaction from dao action scripts --- scripts/utils/daoActions.ts | 240 +++++++++++++++++++++- scripts/utils/futarchyProposal.ts | 321 ++++++++++++++++++++++++++++++ 2 files changed, 551 insertions(+), 10 deletions(-) create mode 100644 scripts/utils/futarchyProposal.ts diff --git a/scripts/utils/daoActions.ts b/scripts/utils/daoActions.ts index 17216db9..2e5b6771 100644 --- a/scripts/utils/daoActions.ts +++ b/scripts/utils/daoActions.ts @@ -10,13 +10,28 @@ import { Transaction, TransactionInstruction, } from "@solana/web3.js"; -import { PERMISSIONLESS_ACCOUNT } from "@metadaoproject/programs"; +import { + DAMM_V2_POOL_AUTHORITY, + LAUNCHPAD_V0_6_PROGRAM_ID, + LAUNCHPAD_V0_7_PROGRAM_ID, + LAUNCHPAD_V0_8_PROGRAM_ID, + PERMISSIONLESS_ACCOUNT, +} from "@metadaoproject/programs"; import { createAssociatedTokenAccountIdempotentInstruction, createTransferInstruction, + getAccount, getAssociatedTokenAddressSync, + TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID, } from "@solana/spl-token"; +import { createMemoInstruction } from "@solana/spl-memo"; +import { + CpAmm, + derivePositionAddress, + derivePositionNftAccount, + getTokenProgram, +} from "@meteora-ag/cp-amm-sdk"; import { FutarchyClient, UpdateDaoParams, @@ -25,6 +40,15 @@ import { buildAdminApprovalTransactions } from "./adminApproval.js"; import { getSquadsPdasFromDao } from "./squads.js"; const SEED_AMM_POSITION = Buffer.from("amm_position"); +const SEED_POSITION_NFT_MINT = Buffer.from("position_nft_mint"); + +// Launchpad versions that create a DAO's Meteora position at launch, all +// seeding its NFT mint from the base mint +const LAUNCHPAD_PROGRAM_IDS = [ + LAUNCHPAD_V0_6_PROGRAM_ID, + LAUNCHPAD_V0_7_PROGRAM_ID, + LAUNCHPAD_V0_8_PROGRAM_ID, +]; export type DaoActionContext = { provider: AnchorProvider; @@ -214,6 +238,172 @@ export const withdrawLiquidity = ({ }; }; +// Withdraws all unlocked liquidity from the Meteora DAMM v2 position the +// launchpad created for the DAO into the vault's token accounts. The min +// amounts are set `slippageBps` below what the position is worth right now, +// so pool changes between now and execution beyond that tolerance fail the +// withdrawal instead of silently accepting a worse outcome. +export const withdrawMeteoraLiquidity = ({ + slippageBps, +}: { + slippageBps: number; +}): DaoActionBuilder => { + 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); + 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, + 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 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, + sqrtPrice: poolState.sqrtPrice, + minSqrtPrice: poolState.sqrtMinPrice, + maxSqrtPrice: poolState.sqrtMaxPrice, + }); + const tokenAAmountThreshold = outAmountA + .muln(10_000 - slippageBps) + .divn(10_000); + const tokenBAmountThreshold = outAmountB + .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):", + positionState.vestedLiquidity.toString(), + ); + console.log( + "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()); + console.log("Min token B amount:", tokenBAmountThreshold.toString()); + + const removeAllLiquidityIx = await cpAmm._program.methods + .removeAllLiquidity(tokenAAmountThreshold, tokenBAmountThreshold) + .accountsPartial({ + poolAuthority: DAMM_V2_POOL_AUTHORITY, + pool: positionState.pool, + position, + positionNftAccount, + owner: daoMultisigVault, + tokenAAccount: vaultTokenAAccount, + tokenBAccount: vaultTokenBAccount, + tokenAMint: poolState.tokenAMint, + tokenBMint: poolState.tokenBMint, + tokenAVault: poolState.tokenAVault, + tokenBVault: poolState.tokenBVault, + tokenAProgram, + tokenBProgram, + }) + .instruction(); + + return { + instructions: [removeAllLiquidityIx], + setupInstructions: [ + createAssociatedTokenAccountIdempotentInstruction( + payer, + vaultTokenAAccount, + daoMultisigVault, + poolState.tokenAMint, + tokenAProgram, + ), + createAssociatedTokenAccountIdempotentInstruction( + payer, + vaultTokenBAccount, + daoMultisigVault, + poolState.tokenBMint, + tokenBProgram, + ), + ], + }; + }; +}; + +// Logs the text on-chain when the vault transaction executes, with the vault +// as a verified signer +export const memo = + (text: string): DaoActionBuilder => + async ({ daoMultisigVault }) => ({ + instructions: [createMemoInstruction(text, [daoMultisigVault])], + }); + // Transfers tokens from the vault's associated token account to the recipient export const transferToken = ({ @@ -306,14 +496,14 @@ export const removeSpendingLimit = }; /** - * 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 - and `requiresAdminExecution`, set when any action needs the DAO - * proposal executed through admin_execute_multisig_proposal. + * 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. */ -export const buildDaoActionTransactions = async ({ +export const buildDaoActions = async ({ provider, futarchy, dao, @@ -349,7 +539,7 @@ export const buildDaoActionTransactions = async ({ const instructions = built.flatMap((action) => action.instructions); if (instructions.length === 0) { - throw new Error("No instructions to enqueue - add at least one action"); + throw new Error("No instructions - add at least one action"); } const requiresAdminExecution = built.some( @@ -365,6 +555,36 @@ export const buildDaoActionTransactions = async ({ setupTransaction.feePayer = payer; } + return { + daoMultisig, + daoMultisigVault, + instructions, + setupTransaction, + requiresAdminExecution, + }; +}; + +/** + * Runs the action builders and routes their instructions through the admin + * approval system. Returns buildDaoActions' `setupTransaction` and + * `requiresAdminExecution` on top of buildAdminApprovalTransactions' result. + */ +export const buildDaoActionTransactions = async ({ + provider, + futarchy, + dao, + payer, + actions, +}: { + provider: AnchorProvider; + futarchy: FutarchyClient; + dao: PublicKey; + payer: PublicKey; + actions: DaoActionBuilder[]; +}) => { + const { instructions, setupTransaction, requiresAdminExecution } = + await buildDaoActions({ provider, futarchy, dao, payer, actions }); + return { setupTransaction, requiresAdminExecution, @@ -380,7 +600,7 @@ export const buildDaoActionTransactions = async ({ // Sends a signed transaction, throwing if it isn't confirmed or lands with an // error -const sendAndConfirm = async ( +export const sendAndConfirm = async ( provider: AnchorProvider, transaction: Transaction, ) => { diff --git a/scripts/utils/futarchyProposal.ts b/scripts/utils/futarchyProposal.ts new file mode 100644 index 00000000..7d244425 --- /dev/null +++ b/scripts/utils/futarchyProposal.ts @@ -0,0 +1,321 @@ +import { AnchorProvider } from "@coral-xyz/anchor"; +import * as multisig from "@sqds/multisig"; +import { sha256 } from "@noble/hashes/sha256"; +import { + ComputeBudgetProgram, + Connection, + Keypair, + PublicKey, + Transaction, + TransactionMessage, +} from "@solana/web3.js"; +import { PERMISSIONLESS_ACCOUNT } from "@metadaoproject/programs"; +import { + FutarchyClient, + getProposalAddr, +} from "@metadaoproject/programs/futarchy/v0.6"; +import { + buildDaoActions, + DaoActionBuilder, + sendAndConfirm, +} from "./daoActions.js"; +import { createSquadsVaultTxAndProposal } from "./squads.js"; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const accountExists = async (connection: Connection, account: PublicKey) => + (await connection.getAccountInfo(account, "confirmed")) !== null; + +/** + * Signs and sends a transaction that creates `createdAccounts`, confirming it + * at the confirmed commitment, and skips it when they all exist already. A + * failed attempt is retried with a freshly built transaction: a load balanced + * RPC can run preflight on a node that hasn't yet seen the transaction that + * created an account this one reads, and a confirmation timeout doesn't rule + * out the transaction landing - the existence check at the start of the next + * attempt catches that. + */ +const sendCreateTransaction = async ({ + provider, + payer, + name, + createdAccounts, + buildTransaction, + attempts = 5, +}: { + provider: AnchorProvider; + payer: Keypair; + name: string; + createdAccounts: PublicKey[]; + buildTransaction: () => Promise; + attempts?: number; +}) => { + for (let attempt = 1; attempt <= attempts; attempt++) { + const existing = await Promise.all( + createdAccounts.map((account) => + accountExists(provider.connection, account), + ), + ); + if (existing.every(Boolean)) { + console.log(`${name} already exists - skipping`); + return null; + } + + try { + const transaction = await buildTransaction(); + const { blockhash, lastValidBlockHeight } = + await provider.connection.getLatestBlockhash("confirmed"); + transaction.recentBlockhash = blockhash; + transaction.feePayer = payer.publicKey; + transaction.sign(payer); + + const signature = await provider.connection.sendRawTransaction( + transaction.serialize(), + { preflightCommitment: "confirmed" }, + ); + const status = await provider.connection.confirmTransaction( + { signature, blockhash, lastValidBlockHeight }, + "confirmed", + ); + if (status.value.err) { + throw new Error( + `Transaction ${signature} failed: ${JSON.stringify(status.value.err)}`, + ); + } + + console.log(`${name} created!`); + console.log("Transaction signature:", signature); + return signature; + } catch (error) { + if (attempt === attempts) { + throw error; + } + console.warn( + `${name}: attempt ${attempt} of ${attempts} failed, retrying -`, + error instanceof Error ? error.message : error, + ); + await sleep(2_000); + } + } + + throw new Error(`${name}: out of attempts`); +}; + +/** + * Initializes the futarchy proposal for an existing squads proposal on the + * DAO's multisig: the question, both conditional vaults and the proposal + * account, each in its own transaction. Steps whose accounts already exist + * are skipped, so a run that failed partway through can be re-run. + */ +export const initializeFutarchyProposal = async ({ + provider, + futarchy, + dao, + squadsProposal, + payer, +}: { + provider: AnchorProvider; + futarchy: FutarchyClient; + dao: PublicKey; + squadsProposal: PublicKey; + payer: Keypair; +}) => { + const daoAccount = await futarchy.getDao(dao); + const [proposal] = getProposalAddr( + futarchy.futarchy.programId, + squadsProposal, + ); + const { question, baseVault, quoteVault } = futarchy.getProposalPdas( + proposal, + daoAccount.baseMint, + daoAccount.quoteMint, + dao, + ); + const vaultClient = futarchy.vaultClient; + + console.log("Squads proposal:", squadsProposal.toBase58()); + console.log("Proposal:", proposal.toBase58()); + console.log("Question:", question.toBase58()); + console.log("Base vault:", baseVault.toBase58()); + console.log("Quote vault:", quoteVault.toBase58()); + + await sendCreateTransaction({ + provider, + payer, + name: "Question", + createdAccounts: [question], + buildTransaction: () => + vaultClient + .initializeQuestionIx( + sha256(`Will ${proposal} pass?/FAIL/PASS`), + proposal, + 2, + ) + .transaction(), + }); + + await sendCreateTransaction({ + provider, + payer, + name: "Conditional vaults", + createdAccounts: [baseVault, quoteVault], + buildTransaction: async () => { + const transaction = new Transaction(); + for (const [vault, mint] of [ + [baseVault, daoAccount.baseMint], + [quoteVault, daoAccount.quoteMint], + ]) { + if (!(await accountExists(provider.connection, vault))) { + const vaultTransaction = await vaultClient + .initializeVaultIx(question, mint, 2, payer.publicKey) + .transaction(); + transaction.add(...vaultTransaction.instructions); + } + } + return transaction; + }, + }); + + await sendCreateTransaction({ + provider, + payer, + name: "Futarchy proposal", + createdAccounts: [proposal], + buildTransaction: () => + futarchy + .initializeProposalIx( + squadsProposal, + dao, + daoAccount.baseMint, + daoAccount.quoteMint, + question, + payer.publicKey, + ) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .transaction(), + }); + + console.log( + "The proposal is in draft state. Stake base tokens to it, or have the team sponsor it with sponsorProposal.ts, then launch it.", + ); + + return proposal; +}; + +/** + * Runs the action builders and puts their instructions up for a futarchy + * vote: sends the payer-funded setup transaction (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 + * start the vote. + * + * A passed proposal is executed permissionlessly, so actions the DAO itself + * signs (requiresAdminExecution) can't go through here - route those through + * the admin approval flow instead. + */ +export const createFutarchyProposal = async ({ + provider, + futarchy, + dao, + payer, + actions, +}: { + provider: AnchorProvider; + futarchy: FutarchyClient; + dao: PublicKey; + payer: Keypair; + actions: DaoActionBuilder[]; +}) => { + const { + daoMultisig, + daoMultisigVault, + instructions, + setupTransaction, + requiresAdminExecution, + } = await buildDaoActions({ + provider, + futarchy, + dao, + payer: payer.publicKey, + actions, + }); + + if (requiresAdminExecution) { + throw new Error( + "An action is signed by the DAO itself, which a futarchy proposal's permissionless execution can't provide - enqueue it through the admin approval flow instead", + ); + } + + if (setupTransaction) { + setupTransaction.sign(payer); + + const setupSignature = await sendAndConfirm(provider, setupTransaction); + + console.log("Setup transaction sent!"); + console.log("Transaction signature:", setupSignature); + } + + // Read only now so the DAO multisig's transaction index is fresh + const daoMultisigAccount = + await multisig.accounts.Multisig.fromAccountAddress( + provider.connection, + daoMultisig, + ); + const transactionIndex = + BigInt(daoMultisigAccount.transactionIndex.toString()) + 1n; + + const transactionMessage = new TransactionMessage({ + payerKey: daoMultisigVault, + recentBlockhash: (await provider.connection.getLatestBlockhash()).blockhash, + instructions, + }); + + const { vaultTxCreateIx, proposalCreateIx } = + await createSquadsVaultTxAndProposal( + daoMultisig, + transactionIndex, + transactionMessage, + payer.publicKey, + ); + + const [squadsVaultTransaction] = multisig.getTransactionPda({ + multisigPda: daoMultisig, + index: transactionIndex, + }); + const [squadsProposal] = multisig.getProposalPda({ + multisigPda: daoMultisig, + transactionIndex, + }); + + const squadsTransaction = new Transaction().add( + vaultTxCreateIx, + proposalCreateIx, + ); + squadsTransaction.recentBlockhash = ( + await provider.connection.getLatestBlockhash() + ).blockhash; + squadsTransaction.feePayer = payer.publicKey; + squadsTransaction.sign(payer, PERMISSIONLESS_ACCOUNT); + + const squadsSignature = await sendAndConfirm(provider, squadsTransaction); + + console.log("Squads transaction created!"); + console.log("Transaction signature:", squadsSignature); + console.log("Squads transaction index:", transactionIndex.toString()); + console.log("Squads transaction:", squadsVaultTransaction.toBase58()); + console.log("Squads proposal:", squadsProposal.toBase58()); + + // Resumable - if this fails partway through, re-run initializeFutarchyProposal + const proposal = await initializeFutarchyProposal({ + provider, + futarchy, + dao, + squadsProposal, + payer, + }); + + return { proposal, squadsProposal, squadsVaultTransaction, transactionIndex }; +}; From dea3ae4875252615e3f7f7d0e9defd0b4570ea43 Mon Sep 17 00:00:00 2001 From: Pileks Date: Sat, 5 Sep 2026 14:50:24 +0200 Subject: [PATCH 2/4] fixes + add proposalTemplate script --- scripts/utils/futarchyProposal.ts | 234 ++++++++++++++++++++++++------ scripts/v0.6/proposalTemplate.ts | 86 +++++++++++ 2 files changed, 275 insertions(+), 45 deletions(-) create mode 100644 scripts/v0.6/proposalTemplate.ts diff --git a/scripts/utils/futarchyProposal.ts b/scripts/utils/futarchyProposal.ts index 7d244425..fd26fee2 100644 --- a/scripts/utils/futarchyProposal.ts +++ b/scripts/utils/futarchyProposal.ts @@ -1,11 +1,13 @@ import { AnchorProvider } from "@coral-xyz/anchor"; import * as multisig from "@sqds/multisig"; import { sha256 } from "@noble/hashes/sha256"; +import bs58 from "bs58"; import { ComputeBudgetProgram, Connection, Keypair, PublicKey, + SendTransactionError, Transaction, TransactionMessage, } from "@solana/web3.js"; @@ -19,7 +21,10 @@ import { DaoActionBuilder, sendAndConfirm, } from "./daoActions.js"; -import { createSquadsVaultTxAndProposal } from "./squads.js"; +import { + createSquadsVaultTxAndProposal, + getSquadsPdasFromDao, +} from "./squads.js"; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -27,38 +32,47 @@ const accountExists = async (connection: Connection, account: PublicKey) => (await connection.getAccountInfo(account, "confirmed")) !== null; /** - * Signs and sends a transaction that creates `createdAccounts`, confirming it - * at the confirmed commitment, and skips it when they all exist already. A - * failed attempt is retried with a freshly built transaction: a load balanced - * RPC can run preflight on a node that hasn't yet seen the transaction that - * created an account this one reads, and a confirmation timeout doesn't rule - * out the transaction landing - the existence check at the start of the next - * attempt catches that. + * Signs and sends a transaction, confirming it at the confirmed commitment. + * A failed attempt is retried with a freshly built transaction: a load + * balanced RPC can run preflight on a node that hasn't yet seen the + * transaction that created an account this one reads, and a transaction that + * expires unconfirmed can never land, so rebuilding it is safe. + * + * `createdAccounts` are accounts only this flow creates (PDAs of its own + * proposal); when they all exist the transaction is skipped, so an attempt + * that landed without being confirmed isn't repeated. Leave it out for + * accounts anyone could create at the same address, like squads transactions + * at a transaction index - there, only a confirmed signature counts as + * success. */ const sendCreateTransaction = async ({ provider, payer, + signers = [], name, - createdAccounts, + createdAccounts = [], buildTransaction, attempts = 5, }: { provider: AnchorProvider; payer: Keypair; + signers?: Keypair[]; name: string; - createdAccounts: PublicKey[]; + createdAccounts?: PublicKey[]; buildTransaction: () => Promise; attempts?: number; }) => { for (let attempt = 1; attempt <= attempts; attempt++) { - const existing = await Promise.all( - createdAccounts.map((account) => - accountExists(provider.connection, account), - ), - ); - if (existing.every(Boolean)) { - console.log(`${name} already exists - skipping`); - return null; + if (createdAccounts.length > 0) { + const existing = await Promise.all( + createdAccounts.map((account) => + accountExists(provider.connection, account), + ), + ); + if (existing.every(Boolean)) { + console.log(`${name} already exists - skipping`); + return null; + } } try { @@ -67,12 +81,25 @@ const sendCreateTransaction = async ({ await provider.connection.getLatestBlockhash("confirmed"); transaction.recentBlockhash = blockhash; transaction.feePayer = payer.publicKey; - transaction.sign(payer); + transaction.sign(payer, ...signers); + + let signature: string; + try { + signature = await provider.connection.sendRawTransaction( + transaction.serialize(), + { preflightCommitment: "confirmed" }, + ); + } catch (error) { + // The node rejected the transaction, so nothing was broadcast + if (error instanceof SendTransactionError) { + throw error; + } + // Anything else (e.g. a transport error) may have happened after the + // transaction was forwarded, so confirm it by signature: it either + // lands or expires, and only then is rebuilding it safe + signature = bs58.encode(transaction.signature!); + } - const signature = await provider.connection.sendRawTransaction( - transaction.serialize(), - { preflightCommitment: "confirmed" }, - ); const status = await provider.connection.confirmTransaction( { signature, blockhash, lastValidBlockHeight }, "confirmed", @@ -204,6 +231,90 @@ export const initializeFutarchyProposal = async ({ return proposal; }; +/** + * Thrown by createFutarchyProposal when its squads proposal was created but + * initializing the futarchy proposal for it failed. Carries the squads + * proposal to resume from - re-running with `resumeSquadsProposal` set to it + * finishes the initialization instead of creating a second squads proposal. + */ +export class FutarchyProposalInitializationError extends Error { + constructor( + readonly squadsProposal: PublicKey, + readonly squadsVaultTransaction: PublicKey, + readonly transactionIndex: bigint, + readonly cause: unknown, + ) { + super( + `Squads proposal ${squadsProposal.toBase58()} (transaction index ${transactionIndex}) was created, but initializing its futarchy proposal failed: ${ + cause instanceof Error ? cause.message : cause + }. Don't re-run as is - that creates a second squads proposal with the same instructions. Re-run with resumeSquadsProposal set to ${squadsProposal.toBase58()} to finish initializing this one.`, + ); + this.name = "FutarchyProposalInitializationError"; + } +} + +/** + * Finishes a createFutarchyProposal run that failed after its squads proposal + * was created: checks the squads proposal is on the DAO's multisig and still + * active, then initializes the futarchy proposal for it, skipping the + * accounts that already exist. The actions aren't rebuilt - the instructions + * put up for vote are the ones the squads transaction already holds. + */ +const resumeFutarchyProposal = async ({ + provider, + futarchy, + dao, + payer, + squadsProposal, +}: { + provider: AnchorProvider; + futarchy: FutarchyClient; + dao: PublicKey; + payer: Keypair; + squadsProposal: PublicKey; +}) => { + const { multisigPda: daoMultisig } = await getSquadsPdasFromDao(dao); + + const squadsProposalAccount = + await multisig.accounts.Proposal.fromAccountAddress( + provider.connection, + squadsProposal, + ); + + if (!squadsProposalAccount.multisig.equals(daoMultisig)) { + throw new Error( + `Squads proposal ${squadsProposal.toBase58()} belongs to multisig ${squadsProposalAccount.multisig.toBase58()}, not the DAO's (${daoMultisig.toBase58()})`, + ); + } + if (squadsProposalAccount.status.__kind !== "Active") { + throw new Error( + `Squads proposal ${squadsProposal.toBase58()} is ${squadsProposalAccount.status.__kind}, not Active - there's nothing to resume`, + ); + } + + const transactionIndex = BigInt( + squadsProposalAccount.transactionIndex.toString(), + ); + const [squadsVaultTransaction] = multisig.getTransactionPda({ + multisigPda: daoMultisig, + index: transactionIndex, + }); + + console.log("Resuming squads proposal:", squadsProposal.toBase58()); + console.log("Squads transaction index:", transactionIndex.toString()); + console.log("Squads transaction:", squadsVaultTransaction.toBase58()); + + const proposal = await initializeFutarchyProposal({ + provider, + futarchy, + dao, + squadsProposal, + payer, + }); + + return { proposal, squadsProposal, squadsVaultTransaction, transactionIndex }; +}; + /** * Runs the action builders and puts their instructions up for a futarchy * vote: sends the payer-funded setup transaction (if any), creates the squads @@ -212,6 +323,13 @@ export const initializeFutarchyProposal = async ({ * tokens to the proposal - or have the team sponsor it - and launch it to * start the vote. * + * If initialization fails after the squads proposal was created, a + * FutarchyProposalInitializationError carrying the squads proposal is thrown. + * Re-run with `resumeSquadsProposal` set to it to finish the initialization; + * the actions are ignored then, since the instructions are already on-chain. + * Any other error means no squads proposal was confirmed, so the run can be + * repeated as is. + * * A passed proposal is executed permissionlessly, so actions the DAO itself * signs (requiresAdminExecution) can't go through here - route those through * the admin approval flow instead. @@ -222,13 +340,25 @@ export const createFutarchyProposal = async ({ dao, payer, actions, + resumeSquadsProposal, }: { provider: AnchorProvider; futarchy: FutarchyClient; dao: PublicKey; payer: Keypair; actions: DaoActionBuilder[]; + resumeSquadsProposal?: PublicKey; }) => { + if (resumeSquadsProposal) { + return resumeFutarchyProposal({ + provider, + futarchy, + dao, + payer, + squadsProposal: resumeSquadsProposal, + }); + } + const { daoMultisig, daoMultisigVault, @@ -258,7 +388,10 @@ export const createFutarchyProposal = async ({ console.log("Transaction signature:", setupSignature); } - // Read only now so the DAO multisig's transaction index is fresh + // Read only now so the DAO multisig's transaction index is fresh. It stays + // pinned across retries: an attempt that expired can't land anymore, and if + // another proposal took the index meanwhile the retries fail on it instead + // of adopting it. const daoMultisigAccount = await multisig.accounts.Multisig.fromAccountAddress( provider.connection, @@ -290,32 +423,43 @@ export const createFutarchyProposal = async ({ transactionIndex, }); - const squadsTransaction = new Transaction().add( - vaultTxCreateIx, - proposalCreateIx, - ); - squadsTransaction.recentBlockhash = ( - await provider.connection.getLatestBlockhash() - ).blockhash; - squadsTransaction.feePayer = payer.publicKey; - squadsTransaction.sign(payer, PERMISSIONLESS_ACCOUNT); - - const squadsSignature = await sendAndConfirm(provider, squadsTransaction); + try { + await sendCreateTransaction({ + provider, + payer, + signers: [PERMISSIONLESS_ACCOUNT], + name: "Squads transaction and proposal", + buildTransaction: async () => + new Transaction().add(vaultTxCreateIx, proposalCreateIx), + }); + } catch (error) { + console.error( + "Creating the squads transaction and proposal failed. No squads proposal was confirmed, so the run can be repeated as is.", + ); + throw error; + } - console.log("Squads transaction created!"); - console.log("Transaction signature:", squadsSignature); console.log("Squads transaction index:", transactionIndex.toString()); console.log("Squads transaction:", squadsVaultTransaction.toBase58()); console.log("Squads proposal:", squadsProposal.toBase58()); - // Resumable - if this fails partway through, re-run initializeFutarchyProposal - const proposal = await initializeFutarchyProposal({ - provider, - futarchy, - dao, - squadsProposal, - payer, - }); + let proposal: PublicKey; + try { + proposal = await initializeFutarchyProposal({ + provider, + futarchy, + dao, + squadsProposal, + payer, + }); + } catch (error) { + throw new FutarchyProposalInitializationError( + squadsProposal, + squadsVaultTransaction, + transactionIndex, + error, + ); + } return { proposal, squadsProposal, squadsVaultTransaction, transactionIndex }; }; diff --git a/scripts/v0.6/proposalTemplate.ts b/scripts/v0.6/proposalTemplate.ts new file mode 100644 index 00000000..8f65216c --- /dev/null +++ b/scripts/v0.6/proposalTemplate.ts @@ -0,0 +1,86 @@ +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 { + memo, + transferToken, + updateDao, + withdrawLiquidity, + withdrawMeteoraLiquidity, +} from "../utils/daoActions.js"; +import { createFutarchyProposal } from "../utils/futarchyProposal.js"; + +// Template for putting DAO vault actions up for a futarchy vote. Copy it, set +// the constants, and compose the actions below. The proposal is created in +// draft state - stake base tokens to it, or have the team sponsor it with +// sponsorProposal.ts, then launch it. Actions the DAO itself signs (like +// removeSpendingLimit) can't go through here, since a passed proposal is +// executed permissionlessly - enqueue those with enqueueTemplate.ts instead. +// +// 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 and +// re-run to finish initializing the futarchy proposal for it. + +/////////////// +// Constants // +/////////////// + +// The DAO whose vault should execute the actions +const DAO = new PublicKey("DAO_ADDRESS"); + +// 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() { + await createFutarchyProposal({ + provider, + futarchy, + dao: DAO, + payer, + resumeSquadsProposal: RESUME_SQUADS_PROPOSAL ?? undefined, + actions: [ + // Compose the actions the DAO's vault should execute, e.g.: + // + updateDao({ + baseToStake: new BN(1_500_000).mul(new BN(10 ** 6)), + passThresholdBps: 300, + teamSponsoredPassThresholdBps: -300, + minBaseFutarchicLiquidity: new BN(1), + minQuoteFutarchicLiquidity: new BN(1), + }), + // + // withdrawLiquidity({ fractionBps: 5_000, slippageBps: 2_000 }), + // + // withdrawMeteoraLiquidity({ slippageBps: 500 }), + // + // transferToken({ + // mint: MAINNET_USDC, + // recipient: new PublicKey("..."), + // amount: new BN(1_000).mul(new BN(10 ** 6)), + // }), + // + // memo("..."), + ], + }); +} + +main().catch((error) => { + console.error("Error creating futarchy proposal:", error); + process.exit(1); +}); From 9d416d051b0f4344af8e847d4d6dc3704f997138 Mon Sep 17 00:00:00 2001 From: Pileks Date: Mon, 7 Sep 2026 21:42:24 +0200 Subject: [PATCH 3/4] fix(scripts): resolve every squads send before retrying --- scripts/utils/adminApproval.ts | 6 + scripts/utils/daoActions.ts | 262 ++++++++++++++++++++---------- scripts/utils/futarchyProposal.ts | 202 ++++++++--------------- scripts/utils/squads.ts | 58 ++++++- 4 files changed, 307 insertions(+), 221 deletions(-) diff --git a/scripts/utils/adminApproval.ts b/scripts/utils/adminApproval.ts index e8e437dc..0323e287 100644 --- a/scripts/utils/adminApproval.ts +++ b/scripts/utils/adminApproval.ts @@ -39,6 +39,10 @@ const SEED_ENQUEUED_APPROVAL = Buffer.from("enqueued_approval"); * consumed by someone else's proposal while previous transactions in the flow * confirm, making vaultTransactionCreate fail. * + * Each result also carries the instructions its squads transaction holds + * (`daoInstructions`, `metadaoInstructions`), for probing whether it landed + * with probeSquadsVaultTransaction. + * * Once the operational multisig approves + executes its transaction, the DAO * proposal can be approved + executed permissionlessly via * executeMultisigProposalApproval. @@ -181,6 +185,7 @@ export const buildAdminApprovalTransactions = async ({ metadaoTransactionIndex, metadaoVaultTransactionPda, metadaoProposalPda, + metadaoInstructions: [enqueueApprovalIx], }; }; @@ -190,6 +195,7 @@ export const buildAdminApprovalTransactions = async ({ daoVaultTransactionPda, daoProposalPda, enqueuedApprovalPda, + daoInstructions: instructions, buildMetadaoTransaction, }; }; diff --git a/scripts/utils/daoActions.ts b/scripts/utils/daoActions.ts index 2e5b6771..b3ce9b16 100644 --- a/scripts/utils/daoActions.ts +++ b/scripts/utils/daoActions.ts @@ -1,12 +1,11 @@ import { AnchorProvider } from "@coral-xyz/anchor"; import * as multisig from "@sqds/multisig"; import BN from "bn.js"; +import bs58 from "bs58"; import { Keypair, PublicKey, - RpcResponseAndContext, SendTransactionError, - SignatureResult, Transaction, TransactionInstruction, } from "@solana/web3.js"; @@ -37,7 +36,7 @@ import { UpdateDaoParams, } from "@metadaoproject/programs/futarchy/v0.6"; import { buildAdminApprovalTransactions } from "./adminApproval.js"; -import { getSquadsPdasFromDao } from "./squads.js"; +import { getSquadsPdasFromDao, probeSquadsVaultTransaction } from "./squads.js"; const SEED_AMM_POSITION = Buffer.from("amm_position"); const SEED_POSITION_NFT_MINT = Buffer.from("position_nft_mint"); @@ -619,15 +618,149 @@ export const sendAndConfirm = async ( return signature; }; +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +// A preflight rejection: the node simulated the transaction and refused to +// forward it. Any other send error may have come back after the transaction +// was forwarded. +const isPreflightRejection = (error: unknown) => + error instanceof SendTransactionError && + error.message.includes("Transaction simulation failed"); + +/** + * What a probe found at the accounts a transaction creates: they exist + * holding what the transaction puts there (`landed`), don't exist (`absent`), + * or exist holding something else, like another proposal at the same squads + * transaction index (`taken`). + */ +export type ProbeResult = "landed" | "absent" | "taken"; + +/** + * Sends a transaction, retrying until it's confirmed at the confirmed + * commitment or `attempts` run out, without a retry ever duplicating what an + * earlier attempt created: + * + * - A preflight rejection was never broadcast. Any other send error is + * followed by confirming the signature until it lands or its blockhash + * expires, after which it can't land anymore. + * - Before each attempt, `probe` looks at the created accounts: an attempt + * that landed without being confirmed is adopted, and when another + * transaction took the address the transaction is rebuilt via `build`. + * Otherwise `build` is called once, so an address it derives from mutable + * state (a squads transaction index) stays pinned across attempts. + * + * Returns what `build` returned plus the confirmed signature - null when the + * probe found the accounts before anything was sent. + */ +export const sendWithRetries = async ({ + provider, + payer, + signers = [], + name, + build, + probe, + attempts = 5, +}: { + provider: AnchorProvider; + payer: Keypair; + signers?: Keypair[]; + name: string; + build: () => Promise; + probe: (built: T) => Promise; + attempts?: number; +}): Promise => { + let built = await build(); + let lastSignature: string | null = null; + let lastError: unknown; + + const landed = () => { + if (lastSignature) { + console.log(`${name} landed!`); + console.log("Transaction signature:", lastSignature); + } else { + console.log(`${name} already exists - skipping`); + } + return { ...built, signature: lastSignature }; + }; + + for (let attempt = 1; attempt <= attempts; attempt++) { + const state = await probe(built); + if (state === "landed") { + return landed(); + } + if (state === "taken") { + console.warn(`${name}: address taken by another transaction, rebuilding`); + built = await build(); + } + + try { + const { transaction } = built; + const { blockhash, lastValidBlockHeight } = + await provider.connection.getLatestBlockhash("confirmed"); + transaction.recentBlockhash = blockhash; + transaction.feePayer = payer.publicKey; + transaction.sign(payer, ...signers); + const signature = bs58.encode(transaction.signature!); + + try { + await provider.connection.sendRawTransaction(transaction.serialize(), { + preflightCommitment: "confirmed", + }); + } catch (error) { + if (isPreflightRejection(error)) { + throw error; + } + // May have been forwarded before the error came back - confirm it + // like a sent one + } + lastSignature = signature; + + const status = await provider.connection.confirmTransaction( + { signature, blockhash, lastValidBlockHeight }, + "confirmed", + ); + if (status.value.err) { + throw new Error( + `Transaction ${signature} failed: ${JSON.stringify(status.value.err)}`, + ); + } + + console.log(`${name} created!`); + console.log("Transaction signature:", signature); + return { ...built, signature }; + } catch (error) { + lastError = error; + console.warn( + `${name}: attempt ${attempt} of ${attempts} failed -`, + error instanceof Error ? error.message : error, + ); + if (attempt < attempts) { + await sleep(2_000); + } + } + } + + // Out of attempts - a last look so the failure is reported truthfully + const state = await probe(built); + if (state === "landed") { + return landed(); + } + console.error( + state === "taken" + ? `${name}: giving up - another transaction took the address, nothing of this run's landed` + : `${name}: giving up - nothing landed`, + ); + throw lastError; +}; + /** * 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, 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). + * read as late as possible, and goes through sendWithRetries, which keeps + * that index pinned across retries and only moves to a fresh one when another + * proposal took it - which happens on the shared ops multisig. */ export const signAndSendDaoActionTransactions = async ({ provider, @@ -651,104 +784,55 @@ 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 sendAndConfirm(provider, daoTransaction); + signature: daoSignature, + } = await sendWithRetries({ + provider, + payer, + signers: [PERMISSIONLESS_ACCOUNT], + name: "DAO squads transaction", + build: async () => { + const built = await buildDaoTransaction(); + return { ...built, transaction: built.daoTransaction }; + }, + probe: ({ daoVaultTransactionPda, daoInstructions }) => + probeSquadsVaultTransaction( + provider.connection, + daoVaultTransactionPda, + daoInstructions, + ), + }); - 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()); - // 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) { - 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; - } - - 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 - 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 { metadaoTransactionIndex, metadaoVaultTransactionPda, metadaoProposalPda, - metadaoSignature, - } = await sendEnqueueTransactionWithRetries(3); + signature: metadaoSignature, + } = await sendWithRetries({ + provider, + payer, + name: "Enqueue approval squads transaction", + build: async () => { + const built = await buildMetadaoTransaction(); + return { ...built, transaction: built.metadaoTransaction }; + }, + probe: ({ metadaoVaultTransactionPda, metadaoInstructions }) => + probeSquadsVaultTransaction( + provider.connection, + metadaoVaultTransactionPda, + metadaoInstructions, + ), + }); - 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()); diff --git a/scripts/utils/futarchyProposal.ts b/scripts/utils/futarchyProposal.ts index fd26fee2..615cf099 100644 --- a/scripts/utils/futarchyProposal.ts +++ b/scripts/utils/futarchyProposal.ts @@ -1,13 +1,11 @@ import { AnchorProvider } from "@coral-xyz/anchor"; import * as multisig from "@sqds/multisig"; import { sha256 } from "@noble/hashes/sha256"; -import bs58 from "bs58"; import { ComputeBudgetProgram, Connection, Keypair, PublicKey, - SendTransactionError, Transaction, TransactionMessage, } from "@solana/web3.js"; @@ -20,113 +18,50 @@ import { buildDaoActions, DaoActionBuilder, sendAndConfirm, + sendWithRetries, } from "./daoActions.js"; import { createSquadsVaultTxAndProposal, getSquadsPdasFromDao, + probeSquadsVaultTransaction, } from "./squads.js"; -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - const accountExists = async (connection: Connection, account: PublicKey) => (await connection.getAccountInfo(account, "confirmed")) !== null; /** - * Signs and sends a transaction, confirming it at the confirmed commitment. - * A failed attempt is retried with a freshly built transaction: a load - * balanced RPC can run preflight on a node that hasn't yet seen the - * transaction that created an account this one reads, and a transaction that - * expires unconfirmed can never land, so rebuilding it is safe. - * - * `createdAccounts` are accounts only this flow creates (PDAs of its own - * proposal); when they all exist the transaction is skipped, so an attempt - * that landed without being confirmed isn't repeated. Leave it out for - * accounts anyone could create at the same address, like squads transactions - * at a transaction index - there, only a confirmed signature counts as - * success. + * Sends a transaction creating `createdAccounts` - PDAs only this proposal's + * flow creates - through sendWithRetries, treating the step as done once they + * all exist. That's what makes a failed run re-runnable: steps whose accounts + * already exist are skipped. */ -const sendCreateTransaction = async ({ +const sendCreateTransaction = ({ provider, payer, - signers = [], name, - createdAccounts = [], + createdAccounts, buildTransaction, - attempts = 5, }: { provider: AnchorProvider; payer: Keypair; - signers?: Keypair[]; name: string; - createdAccounts?: PublicKey[]; + createdAccounts: PublicKey[]; buildTransaction: () => Promise; - attempts?: number; -}) => { - for (let attempt = 1; attempt <= attempts; attempt++) { - if (createdAccounts.length > 0) { +}) => + sendWithRetries({ + provider, + payer, + name, + build: async () => ({ transaction: await buildTransaction() }), + probe: async () => { const existing = await Promise.all( createdAccounts.map((account) => accountExists(provider.connection, account), ), ); - if (existing.every(Boolean)) { - console.log(`${name} already exists - skipping`); - return null; - } - } - - try { - const transaction = await buildTransaction(); - const { blockhash, lastValidBlockHeight } = - await provider.connection.getLatestBlockhash("confirmed"); - transaction.recentBlockhash = blockhash; - transaction.feePayer = payer.publicKey; - transaction.sign(payer, ...signers); - - let signature: string; - try { - signature = await provider.connection.sendRawTransaction( - transaction.serialize(), - { preflightCommitment: "confirmed" }, - ); - } catch (error) { - // The node rejected the transaction, so nothing was broadcast - if (error instanceof SendTransactionError) { - throw error; - } - // Anything else (e.g. a transport error) may have happened after the - // transaction was forwarded, so confirm it by signature: it either - // lands or expires, and only then is rebuilding it safe - signature = bs58.encode(transaction.signature!); - } - - const status = await provider.connection.confirmTransaction( - { signature, blockhash, lastValidBlockHeight }, - "confirmed", - ); - if (status.value.err) { - throw new Error( - `Transaction ${signature} failed: ${JSON.stringify(status.value.err)}`, - ); - } - - console.log(`${name} created!`); - console.log("Transaction signature:", signature); - return signature; - } catch (error) { - if (attempt === attempts) { - throw error; - } - console.warn( - `${name}: attempt ${attempt} of ${attempts} failed, retrying -`, - error instanceof Error ? error.message : error, - ); - await sleep(2_000); - } - } - - throw new Error(`${name}: out of attempts`); -}; + return existing.every(Boolean) ? "landed" : "absent"; + }, + }); /** * Initializes the futarchy proposal for an existing squads proposal on the @@ -327,8 +262,8 @@ const resumeFutarchyProposal = async ({ * FutarchyProposalInitializationError carrying the squads proposal is thrown. * Re-run with `resumeSquadsProposal` set to it to finish the initialization; * the actions are ignored then, since the instructions are already on-chain. - * Any other error means no squads proposal was confirmed, so the run can be - * repeated as is. + * Any other error means nothing of this run landed on the DAO's multisig, so + * the run can be repeated as is. * * A passed proposal is executed permissionlessly, so actions the DAO itself * signs (requiresAdminExecution) can't go through here - route those through @@ -388,56 +323,61 @@ export const createFutarchyProposal = async ({ console.log("Transaction signature:", setupSignature); } - // Read only now so the DAO multisig's transaction index is fresh. It stays - // pinned across retries: an attempt that expired can't land anymore, and if - // another proposal took the index meanwhile the retries fail on it instead - // of adopting it. - const daoMultisigAccount = - await multisig.accounts.Multisig.fromAccountAddress( - provider.connection, - daoMultisig, - ); - const transactionIndex = - BigInt(daoMultisigAccount.transactionIndex.toString()) + 1n; - - const transactionMessage = new TransactionMessage({ - payerKey: daoMultisigVault, - recentBlockhash: (await provider.connection.getLatestBlockhash()).blockhash, - instructions, - }); - - const { vaultTxCreateIx, proposalCreateIx } = - await createSquadsVaultTxAndProposal( - daoMultisig, - transactionIndex, - transactionMessage, - payer.publicKey, - ); - - const [squadsVaultTransaction] = multisig.getTransactionPda({ - multisigPda: daoMultisig, - index: transactionIndex, - }); - const [squadsProposal] = multisig.getProposalPda({ - multisigPda: daoMultisig, - transactionIndex, - }); - - try { - await sendCreateTransaction({ + const { transactionIndex, squadsVaultTransaction, squadsProposal } = + await sendWithRetries({ provider, payer, signers: [PERMISSIONLESS_ACCOUNT], name: "Squads transaction and proposal", - buildTransaction: async () => - new Transaction().add(vaultTxCreateIx, proposalCreateIx), + // Built only now so the DAO multisig's transaction index is fresh, and + // rebuilt only if another proposal takes that index + build: async () => { + const daoMultisigAccount = + await multisig.accounts.Multisig.fromAccountAddress( + provider.connection, + daoMultisig, + ); + const transactionIndex = + BigInt(daoMultisigAccount.transactionIndex.toString()) + 1n; + + const transactionMessage = new TransactionMessage({ + payerKey: daoMultisigVault, + recentBlockhash: (await provider.connection.getLatestBlockhash()) + .blockhash, + instructions, + }); + + const { vaultTxCreateIx, proposalCreateIx } = + await createSquadsVaultTxAndProposal( + daoMultisig, + transactionIndex, + transactionMessage, + payer.publicKey, + ); + + const [squadsVaultTransaction] = multisig.getTransactionPda({ + multisigPda: daoMultisig, + index: transactionIndex, + }); + const [squadsProposal] = multisig.getProposalPda({ + multisigPda: daoMultisig, + transactionIndex, + }); + + return { + transaction: new Transaction().add(vaultTxCreateIx, proposalCreateIx), + transactionIndex, + squadsVaultTransaction, + squadsProposal, + }; + }, + probe: ({ squadsVaultTransaction }) => + probeSquadsVaultTransaction( + provider.connection, + squadsVaultTransaction, + instructions, + ), }); - } catch (error) { - console.error( - "Creating the squads transaction and proposal failed. No squads proposal was confirmed, so the run can be repeated as is.", - ); - throw error; - } console.log("Squads transaction index:", transactionIndex.toString()); console.log("Squads transaction:", squadsVaultTransaction.toBase58()); diff --git a/scripts/utils/squads.ts b/scripts/utils/squads.ts index 0b55a461..5c287c23 100644 --- a/scripts/utils/squads.ts +++ b/scripts/utils/squads.ts @@ -1,6 +1,12 @@ import { PERMISSIONLESS_ACCOUNT } from "@metadaoproject/programs"; -import { PublicKey, TransactionMessage } from "@solana/web3.js"; +import { + Connection, + PublicKey, + TransactionInstruction, + TransactionMessage, +} from "@solana/web3.js"; import * as multisig from "@sqds/multisig"; +import type { ProbeResult } from "./daoActions.js"; // Returns the multisig, spending limit and 0th vault pda for a given dao address export const getSquadsPdasFromDao = async ( @@ -63,3 +69,53 @@ export const createSquadsVaultTxAndProposal = async ( proposalCreateIx, }; }; + +// Whether a vault transaction's message holds exactly `instructions`: the +// same programs, accounts (in order) and data, with no lookup tables +const holdsInstructions = ( + message: multisig.generated.VaultTransactionMessage, + instructions: TransactionInstruction[], +) => + message.addressTableLookups.length === 0 && + message.instructions.length === instructions.length && + message.instructions.every((compiled, i) => { + const instruction = instructions[i]; + const accounts = Array.from(compiled.accountIndexes).map( + (index) => message.accountKeys[index], + ); + return ( + message.accountKeys[compiled.programIdIndex]?.equals( + instruction.programId, + ) && + accounts.length === instruction.keys.length && + accounts.every((account, j) => + account?.equals(instruction.keys[j].pubkey), + ) && + Buffer.from(compiled.data).equals(instruction.data) + ); + }); + +/** + * Probes the vault transaction at `vaultTransactionPda`: absent, holding + * exactly `instructions` (landed - either ours or an identical one, which + * amounts to the same), or holding something else (taken - another proposal + * got the transaction index). + */ +export const probeSquadsVaultTransaction = async ( + connection: Connection, + vaultTransactionPda: PublicKey, + instructions: TransactionInstruction[], +): Promise => { + const accountInfo = await connection.getAccountInfo( + vaultTransactionPda, + "confirmed", + ); + if (!accountInfo) { + return "absent"; + } + const [vaultTransaction] = + multisig.accounts.VaultTransaction.fromAccountInfo(accountInfo); + return holdsInstructions(vaultTransaction.message, instructions) + ? "landed" + : "taken"; +}; From ac355d782007eb6c801aabd324b05d76acdd0b49 Mon Sep 17 00:00:00 2001 From: Pileks Date: Mon, 7 Sep 2026 22:17:34 +0200 Subject: [PATCH 4/4] fix(scripts): verify a resumed squads proposal holds the actions --- scripts/utils/futarchyProposal.ts | 65 ++++++++++++++++++++++++------- scripts/utils/squads.ts | 52 ++++++++++++++++++------- scripts/v0.6/proposalTemplate.ts | 5 ++- 3 files changed, 93 insertions(+), 29 deletions(-) diff --git a/scripts/utils/futarchyProposal.ts b/scripts/utils/futarchyProposal.ts index 615cf099..13423de8 100644 --- a/scripts/utils/futarchyProposal.ts +++ b/scripts/utils/futarchyProposal.ts @@ -7,6 +7,7 @@ import { Keypair, PublicKey, Transaction, + TransactionInstruction, TransactionMessage, } from "@solana/web3.js"; import { PERMISSIONLESS_ACCOUNT } from "@metadaoproject/programs"; @@ -21,6 +22,7 @@ import { sendWithRetries, } from "./daoActions.js"; import { + compareVaultTransactionInstructions, createSquadsVaultTxAndProposal, getSquadsPdasFromDao, probeSquadsVaultTransaction, @@ -190,10 +192,13 @@ export class FutarchyProposalInitializationError extends Error { /** * Finishes a createFutarchyProposal run that failed after its squads proposal - * was created: checks the squads proposal is on the DAO's multisig and still - * active, then initializes the futarchy proposal for it, skipping the - * accounts that already exist. The actions aren't rebuilt - the instructions - * put up for vote are the ones the squads transaction already holds. + * was created: checks the squads proposal is on the DAO's multisig, still + * active and holds `instructions` - what the actions produce now - then + * initializes the futarchy proposal for it, skipping the accounts that + * already exist. What's put up for vote is what the squads transaction + * already holds: instructions whose data differs (amounts recomputed from + * live state, like withdrawal minimums) are reported, anything else differing + * means it isn't the proposal these actions created. */ const resumeFutarchyProposal = async ({ provider, @@ -201,12 +206,14 @@ const resumeFutarchyProposal = async ({ dao, payer, squadsProposal, + instructions, }: { provider: AnchorProvider; futarchy: FutarchyClient; dao: PublicKey; payer: Keypair; squadsProposal: PublicKey; + instructions: TransactionInstruction[]; }) => { const { multisigPda: daoMultisig } = await getSquadsPdasFromDao(dao); @@ -235,6 +242,28 @@ const resumeFutarchyProposal = async ({ index: transactionIndex, }); + const vaultTransaction = + await multisig.accounts.VaultTransaction.fromAccountAddress( + provider.connection, + squadsVaultTransaction, + ); + const comparison = compareVaultTransactionInstructions( + vaultTransaction.message, + instructions, + ); + if (comparison.kind === "different") { + throw new Error( + `Squads proposal ${squadsProposal.toBase58()} holds other instructions than the actions produce - it isn't the proposal these actions created. Check resumeSquadsProposal against the failed run's log.`, + ); + } + if (comparison.kind === "data") { + for (const i of comparison.differing) { + console.warn( + `Instruction ${i} (${instructions[i].programId.toBase58()}) holds different data on-chain than the actions produce now. The on-chain data is what a passed proposal executes - expected for amounts derived from live state, like withdrawal minimums.`, + ); + } + } + console.log("Resuming squads proposal:", squadsProposal.toBase58()); console.log("Squads transaction index:", transactionIndex.toString()); console.log("Squads transaction:", squadsVaultTransaction.toBase58()); @@ -260,8 +289,9 @@ const resumeFutarchyProposal = async ({ * * If initialization fails after the squads proposal was created, a * FutarchyProposalInitializationError carrying the squads proposal is thrown. - * Re-run with `resumeSquadsProposal` set to it to finish the initialization; - * the actions are ignored then, since the instructions are already on-chain. + * Re-run with `resumeSquadsProposal` set to it and the same actions to finish + * the initialization; the actions are only rebuilt then to check the squads + * proposal holds them, since what's voted on is already on-chain. * Any other error means nothing of this run landed on the DAO's multisig, so * the run can be repeated as is. * @@ -284,14 +314,10 @@ export const createFutarchyProposal = async ({ actions: DaoActionBuilder[]; resumeSquadsProposal?: PublicKey; }) => { - if (resumeSquadsProposal) { - return resumeFutarchyProposal({ - provider, - futarchy, - dao, - payer, - squadsProposal: resumeSquadsProposal, - }); + if (resumeSquadsProposal && actions.length === 0) { + throw new Error( + "Resuming needs the actions the squads proposal was created with, to check it holds them", + ); } const { @@ -314,6 +340,17 @@ export const createFutarchyProposal = async ({ ); } + if (resumeSquadsProposal) { + return resumeFutarchyProposal({ + provider, + futarchy, + dao, + payer, + squadsProposal: resumeSquadsProposal, + instructions, + }); + } + if (setupTransaction) { setupTransaction.sign(payer); diff --git a/scripts/utils/squads.ts b/scripts/utils/squads.ts index 5c287c23..ba7d75bf 100644 --- a/scripts/utils/squads.ts +++ b/scripts/utils/squads.ts @@ -70,30 +70,53 @@ export const createSquadsVaultTxAndProposal = async ( }; }; -// Whether a vault transaction's message holds exactly `instructions`: the -// same programs, accounts (in order) and data, with no lookup tables -const holdsInstructions = ( +/** + * 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. + */ +export type InstructionsComparison = + | { kind: "exact" } + | { kind: "data"; differing: number[] } + | { kind: "different" }; + +export const compareVaultTransactionInstructions = ( message: multisig.generated.VaultTransactionMessage, instructions: TransactionInstruction[], -) => - message.addressTableLookups.length === 0 && - message.instructions.length === instructions.length && - message.instructions.every((compiled, i) => { +): InstructionsComparison => { + if ( + message.addressTableLookups.length > 0 || + message.instructions.length !== instructions.length + ) { + return { kind: "different" }; + } + + 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], ); - return ( + const sameTarget = message.accountKeys[compiled.programIdIndex]?.equals( instruction.programId, ) && accounts.length === instruction.keys.length && accounts.every((account, j) => account?.equals(instruction.keys[j].pubkey), - ) && - Buffer.from(compiled.data).equals(instruction.data) - ); - }); + ); + if (!sameTarget) { + return { kind: "different" }; + } + if (!Buffer.from(compiled.data).equals(instruction.data)) { + differing.push(i); + } + } + + return differing.length > 0 ? { kind: "data", differing } : { kind: "exact" }; +}; /** * Probes the vault transaction at `vaultTransactionPda`: absent, holding @@ -115,7 +138,10 @@ export const probeSquadsVaultTransaction = async ( } const [vaultTransaction] = multisig.accounts.VaultTransaction.fromAccountInfo(accountInfo); - return holdsInstructions(vaultTransaction.message, instructions) + return compareVaultTransactionInstructions( + vaultTransaction.message, + instructions, + ).kind === "exact" ? "landed" : "taken"; }; diff --git a/scripts/v0.6/proposalTemplate.ts b/scripts/v0.6/proposalTemplate.ts index 8f65216c..976cbf40 100644 --- a/scripts/v0.6/proposalTemplate.ts +++ b/scripts/v0.6/proposalTemplate.ts @@ -21,8 +21,9 @@ import { createFutarchyProposal } from "../utils/futarchyProposal.js"; // // 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 and -// re-run to finish initializing the futarchy proposal for it. +// instructions. Set RESUME_SQUADS_PROPOSAL to the logged squads proposal, +// keep the actions as they were (the script checks the proposal holds them), +// and re-run to finish initializing the futarchy proposal for it. /////////////// // Constants //