diff --git a/scripts/utils/adminApproval.ts b/scripts/utils/adminApproval.ts new file mode 100644 index 00000000..e8e437dc --- /dev/null +++ b/scripts/utils/adminApproval.ts @@ -0,0 +1,198 @@ +import { AnchorProvider } from "@coral-xyz/anchor"; +import * as multisig from "@sqds/multisig"; +import BN from "bn.js"; +import { + PublicKey, + Transaction, + TransactionInstruction, + TransactionMessage, +} from "@solana/web3.js"; +import { FutarchyClient } from "@metadaoproject/programs/futarchy/v0.6"; +import { METADAO_MULTISIG_VAULT } from "@metadaoproject/programs"; +import { + createSquadsVaultTxAndProposal, + getSquadsPdasFromDao, +} from "./squads.js"; + +// MetaDAO operational multisig - its vault 0 (METADAO_MULTISIG_VAULT) is the futarchy admin +export const METADAO_MULTISIG = new PublicKey( + "8N3Tvc6B1wEVKVC6iD4s6eyaCNqX2ovj2xze2q3Q9DWH", +); + +const SEED_ENQUEUED_APPROVAL = Buffer.from("enqueued_approval"); + +/** + * Routes a set of instructions the DAO's squads vault should execute through + * the admin approval system, as two lazily built transactions: + * + * 1. `buildDaoTransaction()` builds the transaction that creates the squads + * vault transaction + proposal holding `instructions` on the DAO's + * multisig. Sign with the payer and PERMISSIONLESS_ACCOUNT (the creator). + * 2. `buildMetadaoTransaction()` (returned by `buildDaoTransaction`) builds + * the transaction that creates the squads vault transaction + proposal on + * the MetaDAO operational multisig enqueueing the futarchy admin approval + * of the DAO proposal. Sign with the payer, which MUST be a member of the + * operational multisig with permission to propose transactions. + * + * Each builder reads its multisig's next transaction index at call time, so + * call it right before sending its transaction - an index read earlier can be + * consumed by someone else's proposal while previous transactions in the flow + * confirm, making vaultTransactionCreate fail. + * + * Once the operational multisig approves + executes its transaction, the DAO + * proposal can be approved + executed permissionlessly via + * executeMultisigProposalApproval. + */ +export const buildAdminApprovalTransactions = async ({ + provider, + futarchy, + dao, + instructions, + payer, +}: { + provider: AnchorProvider; + futarchy: FutarchyClient; + dao: PublicKey; + instructions: TransactionInstruction[]; + payer: PublicKey; +}) => { + const { multisigPda: daoMultisig, vaultPda: daoMultisigVault } = + await getSquadsPdasFromDao(dao); + + const buildDaoTransaction = async () => { + const daoMultisigAccount = + await multisig.accounts.Multisig.fromAccountAddress( + provider.connection, + daoMultisig, + ); + + const daoTransactionIndex = + BigInt(daoMultisigAccount.transactionIndex.toString()) + 1n; + + const daoMessage = new TransactionMessage({ + payerKey: daoMultisigVault, + recentBlockhash: (await provider.connection.getLatestBlockhash()) + .blockhash, + instructions, + }); + + const { + vaultTxCreateIx: daoVaultTxCreateIx, + proposalCreateIx: daoProposalCreateIx, + } = await createSquadsVaultTxAndProposal( + daoMultisig, + daoTransactionIndex, + daoMessage, + payer, + ); + + const [daoVaultTransactionPda] = multisig.getTransactionPda({ + multisigPda: daoMultisig, + index: daoTransactionIndex, + }); + + const [daoProposalPda] = multisig.getProposalPda({ + multisigPda: daoMultisig, + transactionIndex: daoTransactionIndex, + }); + + const daoTransaction = new Transaction().add( + daoVaultTxCreateIx, + daoProposalCreateIx, + ); + daoTransaction.recentBlockhash = ( + await provider.connection.getLatestBlockhash() + ).blockhash; + daoTransaction.feePayer = payer; + + const [enqueuedApprovalPda] = PublicKey.findProgramAddressSync( + [ + SEED_ENQUEUED_APPROVAL, + dao.toBuffer(), + new BN(daoTransactionIndex.toString()).toArrayLike(Buffer, "le", 8), + ], + futarchy.futarchy.programId, + ); + + const buildMetadaoTransaction = async () => { + const metadaoMultisigAccount = + await multisig.accounts.Multisig.fromAccountAddress( + provider.connection, + METADAO_MULTISIG, + ); + + const metadaoTransactionIndex = + BigInt(metadaoMultisigAccount.transactionIndex.toString()) + 1n; + + // The vault signs as the futarchy admin and pays rent for the enqueued + // approval account, so it needs to hold a small amount of SOL + const enqueueApprovalIx = await futarchy.futarchy.methods + .adminEnqueueMultisigProposalApproval({ + transactionIndex: new BN(daoTransactionIndex.toString()), + }) + .accounts({ + dao, + admin: METADAO_MULTISIG_VAULT, + squadsMultisig: daoMultisig, + squadsMultisigProposal: daoProposalPda, + enqueuedApproval: enqueuedApprovalPda, + }) + .instruction(); + + const enqueueApprovalMessage = new TransactionMessage({ + payerKey: METADAO_MULTISIG_VAULT, + recentBlockhash: (await provider.connection.getLatestBlockhash()) + .blockhash, + instructions: [enqueueApprovalIx], + }); + + const { + vaultTxCreateIx: enqueueApprovalVaultTxCreateIx, + proposalCreateIx: enqueueApprovalProposalCreateIx, + } = await createSquadsVaultTxAndProposal( + METADAO_MULTISIG, + metadaoTransactionIndex, + enqueueApprovalMessage, + payer, + payer, + ); + + const [metadaoVaultTransactionPda] = multisig.getTransactionPda({ + multisigPda: METADAO_MULTISIG, + index: metadaoTransactionIndex, + }); + + const [metadaoProposalPda] = multisig.getProposalPda({ + multisigPda: METADAO_MULTISIG, + transactionIndex: metadaoTransactionIndex, + }); + + const metadaoTransaction = new Transaction().add( + enqueueApprovalVaultTxCreateIx, + enqueueApprovalProposalCreateIx, + ); + metadaoTransaction.recentBlockhash = ( + await provider.connection.getLatestBlockhash() + ).blockhash; + metadaoTransaction.feePayer = payer; + + return { + metadaoTransaction, + metadaoTransactionIndex, + metadaoVaultTransactionPda, + metadaoProposalPda, + }; + }; + + return { + daoTransaction, + daoTransactionIndex, + daoVaultTransactionPda, + daoProposalPda, + enqueuedApprovalPda, + buildMetadaoTransaction, + }; + }; + + return { buildDaoTransaction }; +}; diff --git a/scripts/utils/daoActions.ts b/scripts/utils/daoActions.ts new file mode 100644 index 00000000..f0cf958c --- /dev/null +++ b/scripts/utils/daoActions.ts @@ -0,0 +1,489 @@ +import { AnchorProvider } from "@coral-xyz/anchor"; +import BN from "bn.js"; +import { + Keypair, + PublicKey, + RpcResponseAndContext, + SendTransactionError, + SignatureResult, + Transaction, + TransactionInstruction, +} from "@solana/web3.js"; +import { PERMISSIONLESS_ACCOUNT } from "@metadaoproject/programs"; +import { + createAssociatedTokenAccountIdempotentInstruction, + createTransferInstruction, + getAssociatedTokenAddressSync, + TOKEN_PROGRAM_ID, +} from "@solana/spl-token"; +import { + FutarchyClient, + UpdateDaoParams, +} from "@metadaoproject/programs/futarchy/v0.6"; +import { buildAdminApprovalTransactions } from "./adminApproval.js"; +import { getSquadsPdasFromDao } from "./squads.js"; + +const SEED_AMM_POSITION = Buffer.from("amm_position"); + +export type DaoActionContext = { + provider: AnchorProvider; + futarchy: FutarchyClient; + dao: PublicKey; + daoMultisig: PublicKey; + // The signer of the vault transaction's inner instructions + daoMultisigVault: PublicKey; + payer: PublicKey; +}; + +export type DaoAction = { + // Executed by the DAO's squads vault inside the vault transaction + instructions: TransactionInstruction[]; + // Payer-funded instructions sent up front, so the vault transaction can't + // fail at execution time (e.g. creating token accounts) + setupInstructions?: TransactionInstruction[]; +}; + +export type DaoActionBuilder = (ctx: DaoActionContext) => Promise; + +const EMPTY_UPDATE_DAO_PARAMS: UpdateDaoParams = { + passThresholdBps: null, + secondsPerProposal: null, + twapInitialObservation: null, + twapMaxObservationChangePerUpdate: null, + twapStartDelaySeconds: null, + minQuoteFutarchicLiquidity: null, + minBaseFutarchicLiquidity: null, + baseToStake: null, + teamSponsoredPassThresholdBps: null, + teamAddress: null, + isOptimisticGovernanceEnabled: null, +}; + +// Updates the given DAO config fields, leaving the omitted ones unchanged +export const updateDao = + (params: Partial): DaoActionBuilder => + async ({ futarchy, dao }) => ({ + instructions: [ + await futarchy + .updateDaoIx({ dao, params: { ...EMPTY_UPDATE_DAO_PARAMS, ...params } }) + .instruction(), + ], + }); + +// Withdraws a fraction of the vault's AMM position into the vault's token +// accounts. The min amounts are set `slippageBps` below what the withdrawn +// liquidity is worth right now, so reserve changes between now and execution +// beyond that tolerance fail the withdrawal instead of silently accepting a +// worse outcome. +export const withdrawLiquidity = ({ + fractionBps, + slippageBps, +}: { + fractionBps: number; + slippageBps: number; +}): DaoActionBuilder => { + if ( + !Number.isInteger(fractionBps) || + fractionBps <= 0 || + fractionBps > 10_000 + ) { + throw new Error( + `fractionBps must be an integer between 1 and 10000, got ${fractionBps}`, + ); + } + if ( + !Number.isInteger(slippageBps) || + slippageBps < 0 || + slippageBps > 10_000 + ) { + throw new Error( + `slippageBps must be an integer between 0 and 10000, got ${slippageBps}`, + ); + } + + return async ({ futarchy, dao, daoMultisigVault, payer }) => { + const daoAccount = await futarchy.getDao(dao); + + // The DAO's protocol-owned liquidity position is held by its squads vault + const [ammPositionPda] = PublicKey.findProgramAddressSync( + [SEED_AMM_POSITION, dao.toBuffer(), daoMultisigVault.toBuffer()], + futarchy.futarchy.programId, + ); + + const ammPosition = + await futarchy.futarchy.account.ammPosition.fetch(ammPositionPda); + + const liquidityToWithdraw = ammPosition.liquidity + .muln(fractionBps) + .divn(10_000); + if (liquidityToWithdraw.isZero()) { + throw new Error( + `fractionBps ${fractionBps} rounds down to zero liquidity for this position`, + ); + } + + const spotPool = daoAccount.amm.state.spot; + if (!spotPool) { + throw new Error("DAO AMM is not in spot state"); + } + + // Same math as the program's get_base_and_quote_withdrawable + const baseWithdrawable = liquidityToWithdraw + .mul(spotPool.spot.baseReserves) + .div(daoAccount.amm.totalLiquidity); + const quoteWithdrawable = liquidityToWithdraw + .mul(spotPool.spot.quoteReserves) + .div(daoAccount.amm.totalLiquidity); + + const minBaseAmount = baseWithdrawable + .muln(10_000 - slippageBps) + .divn(10_000); + const minQuoteAmount = quoteWithdrawable + .muln(10_000 - slippageBps) + .divn(10_000); + + console.log("AMM position:", ammPositionPda.toBase58()); + console.log("Position liquidity:", ammPosition.liquidity.toString()); + console.log("Liquidity to withdraw:", liquidityToWithdraw.toString()); + console.log("Expected base out:", baseWithdrawable.toString()); + console.log("Expected quote out:", quoteWithdrawable.toString()); + console.log("Min base amount:", minBaseAmount.toString()); + console.log("Min quote amount:", minQuoteAmount.toString()); + + const vaultBaseTokenAccount = getAssociatedTokenAddressSync( + daoAccount.baseMint, + daoMultisigVault, + true, + ); + const vaultQuoteTokenAccount = getAssociatedTokenAddressSync( + daoAccount.quoteMint, + daoMultisigVault, + true, + ); + + const withdrawLiquidityIx = await futarchy.futarchy.methods + .withdrawLiquidity({ + liquidityToWithdraw, + minBaseAmount, + minQuoteAmount, + }) + .accounts({ + dao, + positionAuthority: daoMultisigVault, + liquidityProviderBaseAccount: vaultBaseTokenAccount, + liquidityProviderQuoteAccount: vaultQuoteTokenAccount, + ammBaseVault: getAssociatedTokenAddressSync( + daoAccount.baseMint, + dao, + true, + ), + ammQuoteVault: getAssociatedTokenAddressSync( + daoAccount.quoteMint, + dao, + true, + ), + ammPosition: ammPositionPda, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .instruction(); + + return { + instructions: [withdrawLiquidityIx], + setupInstructions: [ + createAssociatedTokenAccountIdempotentInstruction( + payer, + vaultBaseTokenAccount, + daoMultisigVault, + daoAccount.baseMint, + ), + createAssociatedTokenAccountIdempotentInstruction( + payer, + vaultQuoteTokenAccount, + daoMultisigVault, + daoAccount.quoteMint, + ), + ], + }; + }; +}; + +// Transfers tokens from the vault's associated token account to the recipient +export const transferToken = + ({ + mint, + recipient, + amount, + }: { + mint: PublicKey; + recipient: PublicKey; + amount: BN; + }): DaoActionBuilder => + async ({ provider, daoMultisigVault, payer }) => { + const vaultTokenAccount = getAssociatedTokenAddressSync( + mint, + daoMultisigVault, + true, + ); + const recipientTokenAccount = getAssociatedTokenAddressSync( + mint, + recipient, + true, + ); + + try { + const vaultBalance = + await provider.connection.getTokenAccountBalance(vaultTokenAccount); + console.log("Vault token balance:", vaultBalance.value.uiAmountString); + } catch { + console.warn( + "Vault token account not found - vault holds none of this token yet", + ); + } + + return { + instructions: [ + createTransferInstruction( + vaultTokenAccount, + recipientTokenAccount, + daoMultisigVault, + BigInt(amount.toString()), + ), + ], + setupInstructions: [ + createAssociatedTokenAccountIdempotentInstruction( + payer, + recipientTokenAccount, + recipient, + mint, + ), + ], + }; + }; + +/** + * Runs the action builders and routes their instructions through the admin + * approval system. On top of buildAdminApprovalTransactions' result, returns + * `setupTransaction` - a payer-funded transaction with the actions' setup + * instructions (null if none), to be signed by the payer and sent before the + * others. + */ +export const buildDaoActionTransactions = async ({ + provider, + futarchy, + dao, + payer, + actions, +}: { + provider: AnchorProvider; + futarchy: FutarchyClient; + dao: PublicKey; + payer: PublicKey; + actions: DaoActionBuilder[]; +}) => { + const { multisigPda: daoMultisig, vaultPda: daoMultisigVault } = + await getSquadsPdasFromDao(dao); + + const ctx: DaoActionContext = { + provider, + futarchy, + dao, + daoMultisig, + daoMultisigVault, + payer, + }; + + const built: DaoAction[] = []; + for (const action of actions) { + built.push(await action(ctx)); + } + + const setupInstructions = built.flatMap( + (action) => action.setupInstructions ?? [], + ); + const instructions = built.flatMap((action) => action.instructions); + + if (instructions.length === 0) { + throw new Error("No instructions to enqueue - add at least one action"); + } + + let setupTransaction: Transaction | null = null; + if (setupInstructions.length > 0) { + setupTransaction = new Transaction().add(...setupInstructions); + setupTransaction.recentBlockhash = ( + await provider.connection.getLatestBlockhash() + ).blockhash; + setupTransaction.feePayer = payer; + } + + return { + setupTransaction, + ...(await buildAdminApprovalTransactions({ + provider, + futarchy, + dao, + instructions, + payer, + })), + }; +}; + +// Sends a signed transaction, throwing if it isn't confirmed or lands with an +// error +const sendAndConfirm = async ( + provider: AnchorProvider, + transaction: Transaction, +) => { + const signature = await provider.connection.sendRawTransaction( + transaction.serialize(), + ); + const status = await provider.connection.confirmTransaction( + signature, + "confirmed", + ); + if (status.value.err) { + throw new Error( + `Transaction ${signature} failed: ${JSON.stringify(status.value.err)}`, + ); + } + return signature; +}; + +/** + * Signs and sends the transactions built by buildDaoActionTransactions in + * order (setup if any, DAO multisig, ops multisig), logging the created + * squads transactions and proposals along the way. Each squads transaction + * is built right before it's sent, so its multisig's transaction index is + * read as late as possible, and enqueue proposal creation retries with a + * freshly built transaction when an attempt definitively fails (e.g. an + * index collision with another operator's proposal on the shared ops + * multisig). + */ +export const signAndSendDaoActionTransactions = async ({ + provider, + payer, + transactions, +}: { + provider: AnchorProvider; + payer: Keypair; + transactions: Awaited>; +}) => { + const { setupTransaction, buildDaoTransaction } = transactions; + + let setupSignature: string | null = null; + if (setupTransaction) { + setupTransaction.sign(payer); + + setupSignature = await sendAndConfirm(provider, setupTransaction); + + console.log("Setup transaction sent!"); + 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); + + 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); + + console.log("Enqueue approval squads transaction created!"); + console.log("Transaction signature:", metadaoSignature); + console.log("Squads transaction index:", metadaoTransactionIndex.toString()); + console.log("Squads transaction:", metadaoVaultTransactionPda.toBase58()); + console.log("Squads proposal:", metadaoProposalPda.toBase58()); + console.log("Enqueued approval:", enqueuedApprovalPda.toBase58()); + console.log( + "Go ahead and approve + execute the enqueue approval through Squads.", + ); + + return { setupSignature, daoSignature, metadaoSignature }; +}; diff --git a/scripts/utils/squads.ts b/scripts/utils/squads.ts index 3b3cbb7d..0b55a461 100644 --- a/scripts/utils/squads.ts +++ b/scripts/utils/squads.ts @@ -38,11 +38,12 @@ export const createSquadsVaultTxAndProposal = async ( transactionIndex: bigint, transactionMessage: TransactionMessage, payer: PublicKey, + creator: PublicKey = PERMISSIONLESS_ACCOUNT.publicKey, ) => { const vaultTxCreateIx = multisig.instructions.vaultTransactionCreate({ multisigPda: squadsMultisig, transactionIndex: transactionIndex, - creator: PERMISSIONLESS_ACCOUNT.publicKey, + creator, rentPayer: payer, vaultIndex: 0, ephemeralSigners: 0, @@ -52,7 +53,7 @@ export const createSquadsVaultTxAndProposal = async ( const proposalCreateIx = multisig.instructions.proposalCreate({ multisigPda: squadsMultisig, transactionIndex: transactionIndex, - creator: PERMISSIONLESS_ACCOUNT.publicKey, + creator, rentPayer: payer, isDraft: false, }); diff --git a/scripts/v0.6/enqueueTemplate.ts b/scripts/v0.6/enqueueTemplate.ts new file mode 100644 index 00000000..ae3c8e1d --- /dev/null +++ b/scripts/v0.6/enqueueTemplate.ts @@ -0,0 +1,65 @@ +import * as anchor from "@coral-xyz/anchor"; +import BN from "bn.js"; +import { PublicKey } from "@solana/web3.js"; +import { FutarchyClient } from "@metadaoproject/programs/futarchy/v0.6"; +import { MAINNET_USDC } from "@metadaoproject/programs"; +import { + buildDaoActionTransactions, + signAndSendDaoActionTransactions, + transferToken, + updateDao, + withdrawLiquidity, +} from "../utils/daoActions.js"; + +// Template for enqueueing DAO vault actions through the admin approval system. +// Copy it, set the constants, and compose the actions below. Once the ops +// multisig approves + executes the enqueue, approve + execute the DAO proposal +// with executeMultisigProposalApproval.ts. + +/////////////// +// Constants // +/////////////// + +// The DAO whose vault should execute the actions +const DAO = new PublicKey("DAO_PUBKEY"); + +//////////////// +// Operations // +//////////////// + +const provider = anchor.AnchorProvider.env(); + +// Payer MUST be a member of the MetaDAO operational multisig with permission +// to propose transactions +const payer = provider.wallet["payer"]; + +const futarchy = FutarchyClient.createClient({ provider }); + +async function main() { + const transactions = await buildDaoActionTransactions({ + provider, + futarchy, + dao: DAO, + payer: payer.publicKey, + actions: [ + // Compose the actions the DAO's vault should execute, e.g.: + // + // updateDao({ teamAddress: new PublicKey("...") }), + // + // withdrawLiquidity({ fractionBps: 5_000, slippageBps: 2_000 }), + // + // transferToken({ + // mint: MAINNET_USDC, + // recipient: new PublicKey("..."), + // amount: new BN(1_000).mul(new BN(10 ** 6)), + // }), + ], + }); + + await signAndSendDaoActionTransactions({ provider, payer, transactions }); +} + +main().catch((error) => { + console.error("Error enqueueing DAO actions:", error); + process.exit(1); +});