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 17216db9..b3ce9b16 100644 --- a/scripts/utils/daoActions.ts +++ b/scripts/utils/daoActions.ts @@ -1,30 +1,53 @@ 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"; -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, } 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"); + +// 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 +237,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 +495,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 +538,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 +554,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 +599,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, ) => { @@ -399,15 +618,149 @@ 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, @@ -431,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 new file mode 100644 index 00000000..13423de8 --- /dev/null +++ b/scripts/utils/futarchyProposal.ts @@ -0,0 +1,442 @@ +import { AnchorProvider } from "@coral-xyz/anchor"; +import * as multisig from "@sqds/multisig"; +import { sha256 } from "@noble/hashes/sha256"; +import { + ComputeBudgetProgram, + Connection, + Keypair, + PublicKey, + Transaction, + TransactionInstruction, + TransactionMessage, +} from "@solana/web3.js"; +import { PERMISSIONLESS_ACCOUNT } from "@metadaoproject/programs"; +import { + FutarchyClient, + getProposalAddr, +} from "@metadaoproject/programs/futarchy/v0.6"; +import { + buildDaoActions, + DaoActionBuilder, + sendAndConfirm, + sendWithRetries, +} from "./daoActions.js"; +import { + compareVaultTransactionInstructions, + createSquadsVaultTxAndProposal, + getSquadsPdasFromDao, + probeSquadsVaultTransaction, +} from "./squads.js"; + +const accountExists = async (connection: Connection, account: PublicKey) => + (await connection.getAccountInfo(account, "confirmed")) !== null; + +/** + * 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 = ({ + provider, + payer, + name, + createdAccounts, + buildTransaction, +}: { + provider: AnchorProvider; + payer: Keypair; + name: string; + createdAccounts: PublicKey[]; + buildTransaction: () => Promise; +}) => + sendWithRetries({ + provider, + payer, + name, + build: async () => ({ transaction: await buildTransaction() }), + probe: async () => { + const existing = await Promise.all( + createdAccounts.map((account) => + accountExists(provider.connection, account), + ), + ); + return existing.every(Boolean) ? "landed" : "absent"; + }, + }); + +/** + * 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; +}; + +/** + * 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, 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, + futarchy, + dao, + payer, + squadsProposal, + instructions, +}: { + provider: AnchorProvider; + futarchy: FutarchyClient; + dao: PublicKey; + payer: Keypair; + squadsProposal: PublicKey; + instructions: TransactionInstruction[]; +}) => { + 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, + }); + + 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()); + + 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 + * 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. + * + * If initialization fails after the squads proposal was created, a + * FutarchyProposalInitializationError carrying the squads proposal is thrown. + * 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. + * + * 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, + resumeSquadsProposal, +}: { + provider: AnchorProvider; + futarchy: FutarchyClient; + dao: PublicKey; + payer: Keypair; + actions: DaoActionBuilder[]; + resumeSquadsProposal?: PublicKey; +}) => { + if (resumeSquadsProposal && actions.length === 0) { + throw new Error( + "Resuming needs the actions the squads proposal was created with, to check it holds them", + ); + } + + 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 (resumeSquadsProposal) { + return resumeFutarchyProposal({ + provider, + futarchy, + dao, + payer, + squadsProposal: resumeSquadsProposal, + instructions, + }); + } + + if (setupTransaction) { + setupTransaction.sign(payer); + + const setupSignature = await sendAndConfirm(provider, setupTransaction); + + console.log("Setup transaction sent!"); + console.log("Transaction signature:", setupSignature); + } + + const { transactionIndex, squadsVaultTransaction, squadsProposal } = + await sendWithRetries({ + provider, + payer, + signers: [PERMISSIONLESS_ACCOUNT], + name: "Squads transaction and proposal", + // 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, + ), + }); + + console.log("Squads transaction index:", transactionIndex.toString()); + console.log("Squads transaction:", squadsVaultTransaction.toBase58()); + console.log("Squads proposal:", squadsProposal.toBase58()); + + 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/utils/squads.ts b/scripts/utils/squads.ts index 0b55a461..ba7d75bf 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,79 @@ export const createSquadsVaultTxAndProposal = async ( proposalCreateIx, }; }; + +/** + * 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[], +): 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], + ); + const sameTarget = + message.accountKeys[compiled.programIdIndex]?.equals( + instruction.programId, + ) && + accounts.length === instruction.keys.length && + accounts.every((account, j) => + account?.equals(instruction.keys[j].pubkey), + ); + 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 + * 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 compareVaultTransactionInstructions( + vaultTransaction.message, + instructions, + ).kind === "exact" + ? "landed" + : "taken"; +}; diff --git a/scripts/v0.6/proposalTemplate.ts b/scripts/v0.6/proposalTemplate.ts new file mode 100644 index 00000000..976cbf40 --- /dev/null +++ b/scripts/v0.6/proposalTemplate.ts @@ -0,0 +1,87 @@ +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, +// 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 // +/////////////// + +// 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); +});