diff --git a/programs/futarchy/src/instructions/admin_enqueue_multisig_proposal_approval.rs b/programs/futarchy/src/instructions/admin_enqueue_multisig_proposal_approval.rs new file mode 100644 index 00000000..4cc60356 --- /dev/null +++ b/programs/futarchy/src/instructions/admin_enqueue_multisig_proposal_approval.rs @@ -0,0 +1,127 @@ +use super::*; + +mod admin { + use anchor_lang::prelude::declare_id; + + declare_id!("6awyHMshBGVjJ3ozdSJdyyDE1CTAXUwrpNMaRGMsb4sf"); +} + +#[derive(Debug, Clone, AnchorSerialize, AnchorDeserialize)] +pub struct AdminEnqueueMultisigProposalApprovalArgs { + pub transaction_index: u64, +} + +#[derive(Accounts)] +#[instruction(args: AdminEnqueueMultisigProposalApprovalArgs)] +pub struct AdminEnqueueMultisigProposalApproval<'info> { + #[account(has_one = squads_multisig)] + pub dao: Account<'info, Dao>, + + #[account(mut)] + pub admin: Signer<'info>, + + #[account( + seeds = [ + squads_multisig_program::SEED_PREFIX, + squads_multisig_program::SEED_MULTISIG, + dao.key().as_ref(), + ], + bump, + seeds::program = squads_multisig_program::ID, + )] + pub squads_multisig: Account<'info, squads_multisig_program::Multisig>, + + #[account( + seeds = [ + squads_multisig_program::SEED_PREFIX, + squads_multisig.key().as_ref(), + squads_multisig_program::SEED_TRANSACTION, + args.transaction_index.to_le_bytes().as_ref(), + squads_multisig_program::SEED_PROPOSAL, + ], + bump, + seeds::program = squads_multisig_program::ID, + )] + pub squads_multisig_proposal: Account<'info, squads_multisig_program::Proposal>, + + #[account( + init, + payer = admin, + space = 8 + EnqueuedMultisigProposalApproval::INIT_SPACE, + seeds = [ + SEED_ENQUEUED_MULTISIG_PROPOSAL_APPROVAL, + dao.key().as_ref(), + args.transaction_index.to_le_bytes().as_ref(), + ], + bump, + )] + pub enqueued_approval: Account<'info, EnqueuedMultisigProposalApproval>, + + pub system_program: Program<'info, System>, +} + +impl AdminEnqueueMultisigProposalApproval<'_> { + pub fn validate(&self, _args: &AdminEnqueueMultisigProposalApprovalArgs) -> Result<()> { + #[cfg(feature = "production")] + require_keys_eq!(self.admin.key(), admin::ID, FutarchyError::InvalidAdmin); + + if !matches!(self.dao.amm.state, PoolState::Spot { .. }) { + return Err(FutarchyError::PoolNotInSpotState.into()); + } + + if self.dao.optimistic_proposal.is_some() { + return Err(FutarchyError::ActiveOptimisticProposalAlreadyEnqueued.into()); + } + + validate_squads_proposal( + &self.squads_multisig_proposal, + &self.squads_multisig, + &self.dao.squads_multisig, + &self.dao.key(), + )?; + + Ok(()) + } + + pub fn handle( + ctx: Context, + args: AdminEnqueueMultisigProposalApprovalArgs, + ) -> Result<()> { + let enqueued = &mut ctx.accounts.enqueued_approval; + + enqueued.dao = ctx.accounts.dao.key(); + enqueued.transaction_index = args.transaction_index; + enqueued.pda_bump = ctx.bumps.enqueued_approval; + + Ok(()) + } +} + +pub fn validate_squads_proposal( + squads_proposal: &squads_multisig_program::Proposal, + squads_multisig: &squads_multisig_program::Multisig, + dao_multisig_key: &Pubkey, + dao_key: &Pubkey, +) -> Result<()> { + require_keys_eq!(squads_proposal.multisig, *dao_multisig_key); + + match squads_proposal.status { + squads_multisig_program::ProposalStatus::Active { timestamp: _ } => {} + _ => { + msg!("squads proposal status: {:?}", squads_proposal.status); + return Err(FutarchyError::InvalidSquadsProposalStatus.into()); + } + } + + require_gt!( + squads_proposal.transaction_index, + squads_multisig.stale_transaction_index + ); + + require!( + !squads_proposal.approved.contains(dao_key), + FutarchyError::InvalidSquadsProposalStatus + ); + + Ok(()) +} diff --git a/programs/futarchy/src/instructions/admin_approve_multisig_proposal.rs b/programs/futarchy/src/instructions/execute_multisig_proposal_approval.rs similarity index 63% rename from programs/futarchy/src/instructions/admin_approve_multisig_proposal.rs rename to programs/futarchy/src/instructions/execute_multisig_proposal_approval.rs index c63c3af8..05f64163 100644 --- a/programs/futarchy/src/instructions/admin_approve_multisig_proposal.rs +++ b/programs/futarchy/src/instructions/execute_multisig_proposal_approval.rs @@ -1,24 +1,12 @@ use super::*; -mod admin { - use anchor_lang::prelude::declare_id; - - // MetaDAO-controlled admin - cannot be a Squads signer because of reentrancy - declare_id!("CWGawadYU8CzRVBecnJymNw97H7E3ndDinV5sMzesgY2"); -} - -#[derive(Debug, Clone, AnchorSerialize, AnchorDeserialize)] -pub struct AdminApproveMultisigProposalArgs { - pub transaction_index: u64, -} - #[derive(Accounts)] -#[instruction(args: AdminApproveMultisigProposalArgs)] -pub struct AdminApproveMultisigProposal<'info> { +pub struct ExecuteMultisigProposalApproval<'info> { #[account(mut, has_one = squads_multisig)] pub dao: Account<'info, Dao>, + #[account(mut)] - pub admin: Signer<'info>, + pub rent_receiver: Signer<'info>, #[account( mut, @@ -28,7 +16,7 @@ pub struct AdminApproveMultisigProposal<'info> { dao.key().as_ref(), ], bump, - seeds::program = squads_multisig_program + seeds::program = squads_multisig_program::ID, )] pub squads_multisig: Account<'info, squads_multisig_program::Multisig>, @@ -38,41 +26,58 @@ pub struct AdminApproveMultisigProposal<'info> { squads_multisig_program::SEED_PREFIX, squads_multisig.key().as_ref(), squads_multisig_program::SEED_TRANSACTION, - args.transaction_index.to_le_bytes().as_ref(), + enqueued_approval.transaction_index.to_le_bytes().as_ref(), squads_multisig_program::SEED_PROPOSAL, ], bump, - seeds::program = squads_multisig_program + seeds::program = squads_multisig_program::ID, )] pub squads_multisig_proposal: Account<'info, squads_multisig_program::Proposal>, + #[account( + mut, + close = rent_receiver, + has_one = dao, + seeds = [ + SEED_ENQUEUED_MULTISIG_PROPOSAL_APPROVAL, + dao.key().as_ref(), + enqueued_approval.transaction_index.to_le_bytes().as_ref(), + ], + bump = enqueued_approval.pda_bump, + )] + pub enqueued_approval: Account<'info, EnqueuedMultisigProposalApproval>, + pub squads_multisig_program: Program<'info, squads_multisig_program::program::SquadsMultisigProgram>, } -impl AdminApproveMultisigProposal<'_> { - pub fn validate(&self, _args: &AdminApproveMultisigProposalArgs) -> Result<()> { - #[cfg(feature = "production")] - require_keys_eq!(self.admin.key(), admin::ID, FutarchyError::InvalidAdmin); - +impl ExecuteMultisigProposalApproval<'_> { + pub fn validate(&self) -> Result<()> { if !matches!(self.dao.amm.state, PoolState::Spot { .. }) { return Err(FutarchyError::PoolNotInSpotState.into()); } - require!( - self.dao.optimistic_proposal.is_none(), - FutarchyError::ActiveOptimisticProposalAlreadyEnqueued - ); + if self.dao.optimistic_proposal.is_some() { + return Err(FutarchyError::ActiveOptimisticProposalAlreadyEnqueued.into()); + } + + validate_squads_proposal( + &self.squads_multisig_proposal, + &self.squads_multisig, + &self.dao.squads_multisig, + &self.dao.key(), + )?; Ok(()) } - pub fn handle(ctx: Context, _args: AdminApproveMultisigProposalArgs) -> Result<()> { + pub fn handle(ctx: Context) -> Result<()> { let Self { dao, - admin: _, + rent_receiver: _, squads_multisig, squads_multisig_proposal, + enqueued_approval: _, squads_multisig_program, } = ctx.accounts; @@ -81,7 +86,6 @@ impl AdminApproveMultisigProposal<'_> { let dao_seeds = &[SEED_DAO, dao_creator_key, dao_nonce, &[dao.pda_bump]]; let dao_signer = &[&dao_seeds[..]]; - // Approve the proposal squads_multisig_program::cpi::proposal_approve( CpiContext::new_with_signer( squads_multisig_program.to_account_info(), diff --git a/programs/futarchy/src/instructions/mod.rs b/programs/futarchy/src/instructions/mod.rs index 9bd09c85..0076cb2a 100644 --- a/programs/futarchy/src/instructions/mod.rs +++ b/programs/futarchy/src/instructions/mod.rs @@ -1,12 +1,13 @@ use super::*; -pub mod admin_approve_multisig_proposal; pub mod admin_cancel_proposal; +pub mod admin_enqueue_multisig_proposal_approval; pub mod admin_execute_multisig_proposal; pub mod admin_remove_proposal; pub mod collect_fees; pub mod collect_meteora_damm_fees; pub mod conditional_swap; +pub mod execute_multisig_proposal_approval; pub mod execute_spending_limit_change; pub mod finalize_optimistic_proposal; pub mod finalize_proposal; @@ -23,13 +24,14 @@ pub mod unstake_from_proposal; pub mod update_dao; pub mod withdraw_liquidity; -pub use admin_approve_multisig_proposal::*; pub use admin_cancel_proposal::*; +pub use admin_enqueue_multisig_proposal_approval::*; pub use admin_execute_multisig_proposal::*; pub use admin_remove_proposal::*; pub use collect_fees::*; pub use collect_meteora_damm_fees::*; pub use conditional_swap::*; +pub use execute_multisig_proposal_approval::*; pub use execute_spending_limit_change::*; pub use finalize_optimistic_proposal::*; pub use finalize_proposal::*; diff --git a/programs/futarchy/src/lib.rs b/programs/futarchy/src/lib.rs index 67dce577..7b5dc5e5 100644 --- a/programs/futarchy/src/lib.rs +++ b/programs/futarchy/src/lib.rs @@ -173,11 +173,18 @@ pub mod futarchy { } #[access_control(ctx.accounts.validate(&args))] - pub fn admin_approve_multisig_proposal( - ctx: Context, - args: AdminApproveMultisigProposalArgs, + pub fn admin_enqueue_multisig_proposal_approval( + ctx: Context, + args: AdminEnqueueMultisigProposalApprovalArgs, ) -> Result<()> { - AdminApproveMultisigProposal::handle(ctx, args) + AdminEnqueueMultisigProposalApproval::handle(ctx, args) + } + + #[access_control(ctx.accounts.validate())] + pub fn execute_multisig_proposal_approval( + ctx: Context, + ) -> Result<()> { + ExecuteMultisigProposalApproval::handle(ctx) } #[access_control(ctx.accounts.validate())] diff --git a/programs/futarchy/src/state/enqueued_multisig_proposal_approval.rs b/programs/futarchy/src/state/enqueued_multisig_proposal_approval.rs new file mode 100644 index 00000000..bc7f2956 --- /dev/null +++ b/programs/futarchy/src/state/enqueued_multisig_proposal_approval.rs @@ -0,0 +1,11 @@ +use super::*; + +pub const SEED_ENQUEUED_MULTISIG_PROPOSAL_APPROVAL: &[u8] = b"enqueued_approval"; + +#[account] +#[derive(InitSpace)] +pub struct EnqueuedMultisigProposalApproval { + pub dao: Pubkey, + pub transaction_index: u64, + pub pda_bump: u8, +} diff --git a/programs/futarchy/src/state/mod.rs b/programs/futarchy/src/state/mod.rs index bfd9c177..b261e139 100644 --- a/programs/futarchy/src/state/mod.rs +++ b/programs/futarchy/src/state/mod.rs @@ -1,11 +1,13 @@ pub mod amm_position; pub mod dao; +pub mod enqueued_multisig_proposal_approval; pub mod futarchy_amm; pub mod proposal; pub mod stake_account; pub use amm_position::*; pub use dao::*; +pub use enqueued_multisig_proposal_approval::*; pub use futarchy_amm::*; pub use proposal::*; pub use stake_account::*; diff --git a/scripts/repo-guard.ts b/scripts/repo-guard.ts index d82a1126..b237fb96 100644 --- a/scripts/repo-guard.ts +++ b/scripts/repo-guard.ts @@ -118,8 +118,10 @@ const BASE_REF = process.env.GITHUB_BASE_REF ?? ""; const IS_CI = process.env.CI === "true"; const CONFIG_PATH = join(ROOT, ".github", "repo-guard.toml"); -const cargoExactPattern = /^=\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; -const npmExactPattern = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; +const cargoExactPattern = + /^=\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; +const npmExactPattern = + /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; const sha40Pattern = /^[0-9a-f]{40}$/; const cargoIgnoredDirectories = new Set([".git", "target", "node_modules"]); @@ -192,7 +194,9 @@ function parseToml(text: string): Record> { if (!p) continue; const sm = p.match(/^"([^"]*)"$/); if (!sm) { - throw new Error(`repo-guard.toml line ${i + 1}: array item not a string: ${p}`); + throw new Error( + `repo-guard.toml line ${i + 1}: array item not a string: ${p}`, + ); } items.push(sm[1]!); } @@ -211,7 +215,9 @@ function parseToml(text: string): Record> { continue; } - throw new Error(`repo-guard.toml line ${i + 1}: unsupported value: ${valuePart}`); + throw new Error( + `repo-guard.toml line ${i + 1}: unsupported value: ${valuePart}`, + ); } return result; @@ -239,7 +245,9 @@ function loadConfig(): GuardConfig { const allowlist = new Map>(); for (const [k, v] of Object.entries(toml["actions.sha_allowlist"] ?? {})) { if (!Array.isArray(v)) { - throw new Error(`repo-guard.toml: actions.sha_allowlist.${k} must be array of strings`); + throw new Error( + `repo-guard.toml: actions.sha_allowlist.${k} must be array of strings`, + ); } allowlist.set(k, new Set(v)); } @@ -251,16 +259,23 @@ function loadConfig(): GuardConfig { } const workflowSolanaCli = new Map(); - for (const [k, v] of Object.entries(toml["toolchain.workflow_solana_cli"] ?? {})) { + for (const [k, v] of Object.entries( + toml["toolchain.workflow_solana_cli"] ?? {}, + )) { if (typeof v !== "string") { - throw new Error(`repo-guard.toml: toolchain.workflow_solana_cli.${k} must be a string`); + throw new Error( + `repo-guard.toml: toolchain.workflow_solana_cli.${k} must be a string`, + ); } workflowSolanaCli.set(k, v); } return { anchorVersion: requireString("toolchain", "anchor_version"), - localDevSolanaVersion: requireString("toolchain", "local_dev_solana_version"), + localDevSolanaVersion: requireString( + "toolchain", + "local_dev_solana_version", + ), workflowSolanaCli, anchorLangVersion: requireString("cargo", "anchor_lang_version"), anchorSplVersion: requireString("cargo", "anchor_spl_version"), @@ -362,7 +377,9 @@ function parseCargoToml(file: string): CargoDep[] { const section = stripped.match(/^\[([^\]]+)\]$/); if (section) { - inDeps = /^(dependencies|dev-dependencies|build-dependencies)$/.test(section[1]!); + inDeps = /^(dependencies|dev-dependencies|build-dependencies)$/.test( + section[1]!, + ); continue; } @@ -473,7 +490,10 @@ function checkCrossProgramConsistency(config: GuardConfig): { violations: CrossProgramViolation[]; } { const files = walkCargoToml(join(ROOT, "programs"), []); - const seen = new Map>>(); + const seen = new Map< + string, + Map> + >(); const watched = new Map([ ["anchor-lang", config.anchorLangVersion], @@ -517,18 +537,24 @@ function checkCrossProgramConsistency(config: GuardConfig): { // --- Crates.io age check --- -async function fetchCratePublishTime(crate: string, version: string): Promise { +async function fetchCratePublishTime( + crate: string, + version: string, +): Promise { const url = `https://crates.io/api/v1/crates/${encodeURIComponent(crate)}/${encodeURIComponent(version)}`; // crates.io requires a descriptive User-Agent. Without it the API returns // 403. See https://crates.io/policies#crawlers const response = await fetch(url, { headers: { Accept: "application/json", - "User-Agent": "metadao-repo-guard (https://github.com/metaDAOproject/programs)", + "User-Agent": + "metadao-repo-guard (https://github.com/metaDAOproject/programs)", }, }); if (!response.ok) { - throw new Error(`crates.io returned ${response.status} for ${crate}@${version}`); + throw new Error( + `crates.io returned ${response.status} for ${crate}@${version}`, + ); } const meta = (await response.json()) as { version?: { created_at?: string } }; const at = meta.version?.created_at; @@ -540,7 +566,7 @@ async function fetchCratePublishTime(crate: string, version: string): Promise>, - diffBase: string + diffBase: string, ): Map> { const changed = new Map>(); for (const [key, locations] of all.entries()) { @@ -568,7 +594,10 @@ function filterCratesToPRChanges( const stripped = raw.replace(/\s*#.*$/, "").trimEnd(); const section = stripped.match(/^\[([^\]]+)\]$/); if (section) { - inDeps = /^(dependencies|dev-dependencies|build-dependencies)$/.test(section[1]!); + inDeps = + /^(dependencies|dev-dependencies|build-dependencies)$/.test( + section[1]!, + ); continue; } if (!inDeps) continue; @@ -597,8 +626,12 @@ function filterCratesToPRChanges( async function checkCrateAge( config: GuardConfig, - exactDeps: Map> -): Promise<{ status: CheckStatus; violations: CrateAgeViolation[]; reason?: string }> { + exactDeps: Map>, +): Promise<{ + status: CheckStatus; + violations: CrateAgeViolation[]; + reason?: string; +}> { const diffBase = getDiffBase(); if (!diffBase) { return { @@ -619,7 +652,7 @@ async function checkCrateAge( const version = key.slice(atIdx + 1); const publishedAt = await fetchCratePublishTime(name, version); const ageDays = Math.floor( - (now - new Date(publishedAt).getTime()) / (1000 * 60 * 60 * 24) + (now - new Date(publishedAt).getTime()) / (1000 * 60 * 60 * 24), ); if (ageDays >= config.packageMinAgeDays) continue; violations.push({ @@ -690,7 +723,9 @@ function checkPackageJsonPinning(): { for (const section of npmDepSections) { const deps = pkg[section]; if (!deps || typeof deps !== "object") continue; - for (const [name, spec] of Object.entries(deps as Record)) { + for (const [name, spec] of Object.entries( + deps as Record, + )) { if (typeof spec !== "string") continue; if (!shouldPinNpmSpec(spec)) continue; if (!npmExactPattern.test(spec)) { @@ -712,10 +747,13 @@ function checkPackageJsonPinning(): { }; } -async function fetchNpmPublishTime(name: string, version: string): Promise { +async function fetchNpmPublishTime( + name: string, + version: string, +): Promise { const response = await fetch( `https://registry.npmjs.org/${encodeURIComponent(name)}`, - { headers: { Accept: "application/json" } } + { headers: { Accept: "application/json" } }, ); if (!response.ok) { throw new Error(`npm registry returned ${response.status} for ${name}`); @@ -730,7 +768,7 @@ async function fetchNpmPublishTime(name: string, version: string): Promise>, - diffBase: string + diffBase: string, ): Map> { const locationPattern = /^(.+) \((\w+)\)$/; const changed = new Map>(); @@ -774,11 +812,19 @@ function filterNpmDepsToChanges( async function checkNpmAge( config: GuardConfig, - exactDeps: Map> -): Promise<{ status: CheckStatus; violations: NpmAgeViolation[]; reason?: string }> { + exactDeps: Map>, +): Promise<{ + status: CheckStatus; + violations: NpmAgeViolation[]; + reason?: string; +}> { const diffBase = getDiffBase(); if (!diffBase) { - return { status: "skip", violations: [], reason: "no PR base ref available" }; + return { + status: "skip", + violations: [], + reason: "no PR base ref available", + }; } const scoped = filterNpmDepsToChanges(exactDeps, diffBase); @@ -792,7 +838,7 @@ async function checkNpmAge( const version = key.slice(atIdx + 1); const publishedAt = await fetchNpmPublishTime(name, version); const ageDays = Math.floor( - (now - new Date(publishedAt).getTime()) / (1000 * 60 * 60 * 24) + (now - new Date(publishedAt).getTime()) / (1000 * 60 * 60 * 24), ); if (ageDays >= config.packageMinAgeDays) continue; violations.push({ @@ -822,7 +868,10 @@ async function checkNpmAge( function listWorkflowFiles(): string[] { const acc: string[] = []; - for (const dir of [join(ROOT, ".github", "workflows"), join(ROOT, ".github", "actions")]) { + for (const dir of [ + join(ROOT, ".github", "workflows"), + join(ROOT, ".github", "actions"), + ]) { if (!existsSync(dir)) continue; walkYaml(dir, acc); } @@ -903,10 +952,20 @@ function checkWorkflowToolchain(config: GuardConfig): { // anchor-lang's transitive dep - not our concern here. function checkSolanaProgramCrate(config: GuardConfig): { status: CheckStatus; - violations: Array<{ file: string; line: number; spec: string; expected: string }>; + violations: Array<{ + file: string; + line: number; + spec: string; + expected: string; + }>; } { const files = walkCargoToml(join(ROOT, "programs"), []); - const violations: Array<{ file: string; line: number; spec: string; expected: string }> = []; + const violations: Array<{ + file: string; + line: number; + spec: string; + expected: string; + }> = []; for (const file of files) { const deps = parseCargoToml(file); @@ -942,12 +1001,20 @@ function checkAnchorTomlSolanaVersion(config: GuardConfig): { } { const anchorTomlPath = join(ROOT, "Anchor.toml"); if (!existsSync(anchorTomlPath)) { - return { status: "skip", actual: null, expected: config.localDevSolanaVersion }; + return { + status: "skip", + actual: null, + expected: config.localDevSolanaVersion, + }; } const text = readFileSync(anchorTomlPath, "utf8"); const match = text.match(/^\s*solana_version\s*=\s*"([^"]+)"/m); if (!match) { - return { status: "skip", actual: null, expected: config.localDevSolanaVersion }; + return { + status: "skip", + actual: null, + expected: config.localDevSolanaVersion, + }; } const actual = match[1]!; return { @@ -1057,11 +1124,20 @@ function checkSensitiveDiff(config: GuardConfig): { } const changedFiles = new Set( - run("git", ["diff", "--name-only", `${diffBase}...HEAD`]).split("\n").filter(Boolean) + run("git", ["diff", "--name-only", `${diffBase}...HEAD`]) + .split("\n") + .filter(Boolean), + ); + const touchedSensitiveFiles = [...config.sensitiveFiles].filter((f) => + changedFiles.has(f), ); - const touchedSensitiveFiles = [...config.sensitiveFiles].filter((f) => changedFiles.has(f)); - const diff = run("git", ["diff", "--unified=0", "--no-color", `${diffBase}...HEAD`]); + const diff = run("git", [ + "diff", + "--unified=0", + "--no-color", + `${diffBase}...HEAD`, + ]); const findings: SensitiveFinding[] = []; let currentFile = ""; @@ -1092,7 +1168,9 @@ function checkSensitiveDiff(config: GuardConfig): { continue; } - const kinds = sensitiveRules.filter((r) => r.pattern.test(text)).map((r) => r.kind); + const kinds = sensitiveRules + .filter((r) => r.pattern.test(text)) + .map((r) => r.kind); if (kinds.length > 0) { findings.push({ file: currentFile, @@ -1120,23 +1198,36 @@ function renderHeader(title: string, status: CheckStatus): string[] { return [`### ${title}`, "", `- Status: ${status}`]; } -function renderCargoPinning(r: ReturnType): string[] { +function renderCargoPinning( + r: ReturnType, +): string[] { const lines = renderHeader("Cargo dependency pinning", r.status); if (r.status === "pass") { - lines.push("- Every `programs/*/Cargo.toml` dep uses `=x.y.z`, a `path = ..` workspace ref, or a git dep with a 40-char `rev`."); + lines.push( + "- Every `programs/*/Cargo.toml` dep uses `=x.y.z`, a `path = ..` workspace ref, or a git dep with a 40-char `rev`.", + ); return lines; } lines.push("- The following Cargo entries are not exact:"); for (const v of r.violations) { - lines.push(`- ${fmtPath(v.file)} -> \`${v.dependency}\`: ${v.reason}; spec: \`${v.spec}\``); + lines.push( + `- ${fmtPath(v.file)} -> \`${v.dependency}\`: ${v.reason}; spec: \`${v.spec}\``, + ); } return lines; } -function renderCrossProgram(r: ReturnType): string[] { - const lines = renderHeader("Cross-program Anchor/Solana version consistency", r.status); +function renderCrossProgram( + r: ReturnType, +): string[] { + const lines = renderHeader( + "Cross-program Anchor/Solana version consistency", + r.status, + ); if (r.status === "pass") { - lines.push("- `anchor-lang` and `anchor-spl` are pinned to the version declared in `repo-guard.toml` across every program."); + lines.push( + "- `anchor-lang` and `anchor-spl` are pinned to the version declared in `repo-guard.toml` across every program.", + ); return lines; } for (const v of r.violations) { @@ -1148,41 +1239,59 @@ function renderCrossProgram(r: ReturnType): return lines; } -function renderCrateAge(r: Awaited>, config: GuardConfig): string[] { +function renderCrateAge( + r: Awaited>, + config: GuardConfig, +): string[] { const lines = renderHeader("Crate minimum age", r.status); if (r.status === "pass") { - lines.push(`- All Cargo deps changed by this PR are at least ${config.packageMinAgeDays} days old on crates.io.`); + lines.push( + `- All Cargo deps changed by this PR are at least ${config.packageMinAgeDays} days old on crates.io.`, + ); return lines; } if (r.status === "skip") { lines.push(`- Skipped: ${r.reason}`); return lines; } - lines.push(`- The following crates are newer than ${config.packageMinAgeDays} days:`); + lines.push( + `- The following crates are newer than ${config.packageMinAgeDays} days:`, + ); for (const v of r.violations) { lines.push( - `- \`${v.crate}@${v.version}\` is ${v.ageDays} days old (published ${v.publishedAt}); used in ${v.usedIn.map(fmtPath).join(", ")}` + `- \`${v.crate}@${v.version}\` is ${v.ageDays} days old (published ${v.publishedAt}); used in ${v.usedIn.map(fmtPath).join(", ")}`, ); } return lines; } -function renderPackageJson(r: ReturnType): string[] { +function renderPackageJson( + r: ReturnType, +): string[] { const lines = renderHeader("Yarn package.json pinning", r.status); if (r.status === "pass") { - lines.push("- All `package.json` deps use exact versions (no `^`, `~`, ranges)."); + lines.push( + "- All `package.json` deps use exact versions (no `^`, `~`, ranges).", + ); return lines; } for (const v of r.violations) { - lines.push(`- ${fmtPath(v.file)} -> \`${v.section}.${v.dependency}\` uses \`${v.spec}\``); + lines.push( + `- ${fmtPath(v.file)} -> \`${v.section}.${v.dependency}\` uses \`${v.spec}\``, + ); } return lines; } -function renderNpmAge(r: Awaited>, config: GuardConfig): string[] { +function renderNpmAge( + r: Awaited>, + config: GuardConfig, +): string[] { const lines = renderHeader("npm minimum age", r.status); if (r.status === "pass") { - lines.push(`- All npm deps changed by this PR are at least ${config.packageMinAgeDays} days old.`); + lines.push( + `- All npm deps changed by this PR are at least ${config.packageMinAgeDays} days old.`, + ); return lines; } if (r.status === "skip") { @@ -1191,55 +1300,81 @@ function renderNpmAge(r: Awaited>, config: GuardC } for (const v of r.violations) { lines.push( - `- \`${v.dependency}@${v.version}\` is ${v.ageDays} days old (published ${v.publishedAt}); used in ${v.usedIn.map(fmtPath).join(", ")}` + `- \`${v.dependency}@${v.version}\` is ${v.ageDays} days old (published ${v.publishedAt}); used in ${v.usedIn.map(fmtPath).join(", ")}`, ); } return lines; } -function renderWorkflowToolchain(r: ReturnType, config: GuardConfig): string[] { +function renderWorkflowToolchain( + r: ReturnType, + config: GuardConfig, +): string[] { const lines = renderHeader("Workflow toolchain consistency", r.status); if (r.status === "pass") { - lines.push(`- Every workflow declares \`anchor-version: ${config.anchorVersion}\`.`); - lines.push("- Per-file \`solana-cli-version\` values match \`[toolchain.workflow_solana_cli]\` in \`repo-guard.toml\`."); + lines.push( + `- Every workflow declares \`anchor-version: ${config.anchorVersion}\`.`, + ); + lines.push( + "- Per-file \`solana-cli-version\` values match \`[toolchain.workflow_solana_cli]\` in \`repo-guard.toml\`.", + ); return lines; } for (const v of r.violations) { - lines.push(`- ${fmtPath(v.file)}:${v.line} has \`${v.key}: ${v.actual}\`, expected \`${v.expected}\``); + lines.push( + `- ${fmtPath(v.file)}:${v.line} has \`${v.key}: ${v.actual}\`, expected \`${v.expected}\``, + ); } return lines; } -function renderSolanaProgramCrate(r: ReturnType, config: GuardConfig): string[] { +function renderSolanaProgramCrate( + r: ReturnType, + config: GuardConfig, +): string[] { const lines = renderHeader("solana-program crate pin", r.status); if (r.status === "pass") { - lines.push(`- Every \`solana-program = "=X"\` declaration is \`=${config.solanaProgramVersion}\` (locked to match \`Cargo.lock\`).`); + lines.push( + `- Every \`solana-program = "=X"\` declaration is \`=${config.solanaProgramVersion}\` (locked to match \`Cargo.lock\`).`, + ); return lines; } for (const v of r.violations) { - lines.push(`- ${fmtPath(v.file)}:${v.line} has \`solana-program = "${v.spec}"\`, expected \`${v.expected}\``); + lines.push( + `- ${fmtPath(v.file)}:${v.line} has \`solana-program = "${v.spec}"\`, expected \`${v.expected}\``, + ); } return lines; } -function renderAnchorTomlSolanaVersion(r: ReturnType): string[] { +function renderAnchorTomlSolanaVersion( + r: ReturnType, +): string[] { const lines = renderHeader("Anchor.toml solana_version", r.status); if (r.status === "pass") { - lines.push(`- \`Anchor.toml\` declares \`solana_version = "${r.expected}"\` (local-dev install for \`anchor test\`).`); + lines.push( + `- \`Anchor.toml\` declares \`solana_version = "${r.expected}"\` (local-dev install for \`anchor test\`).`, + ); return lines; } if (r.status === "skip") { lines.push("- Skipped: `Anchor.toml` has no `solana_version` field."); return lines; } - lines.push(`- \`Anchor.toml\` declares \`solana_version = "${r.actual ?? "(missing)"}"\`, expected \`"${r.expected}"\``); + lines.push( + `- \`Anchor.toml\` declares \`solana_version = "${r.actual ?? "(missing)"}"\`, expected \`"${r.expected}"\``, + ); return lines; } -function renderActionPinning(r: ReturnType): string[] { +function renderActionPinning( + r: ReturnType, +): string[] { const lines = renderHeader("GitHub Action SHA pinning", r.status); if (r.status === "pass") { - lines.push("- Every third-party action is pinned to a SHA in `[actions.sha_allowlist]`."); + lines.push( + "- Every third-party action is pinned to a SHA in `[actions.sha_allowlist]`.", + ); return lines; } for (const v of r.violations) { @@ -1255,15 +1390,23 @@ function renderSensitive(r: ReturnType): string[] { return lines; } if (r.status === "pass") { - lines.push("- No suspicious changes to program IDs, error enums, or sensitive files detected."); + lines.push( + "- No suspicious changes to program IDs, error enums, or sensitive files detected.", + ); return lines; } - lines.push("- Review hint only (CODEOWNERS is the merge gate). Lines below match heuristics for security-sensitive changes:"); + lines.push( + "- Review hint only (CODEOWNERS is the merge gate). Lines below match heuristics for security-sensitive changes:", + ); if (r.touchedSensitiveFiles.length > 0) { - lines.push(`- High-sensitivity files touched: ${r.touchedSensitiveFiles.map(fmtPath).join(", ")}`); + lines.push( + `- High-sensitivity files touched: ${r.touchedSensitiveFiles.map(fmtPath).join(", ")}`, + ); } for (const f of r.findings.slice(0, 30)) { - lines.push(`- ${fmtPath(`${f.file}:${f.line}`)} ${f.kind} -> \`${f.text}\``); + lines.push( + `- ${fmtPath(`${f.file}:${f.line}`)} ${f.kind} -> \`${f.text}\``, + ); } return lines; } @@ -1328,10 +1471,14 @@ async function main() { // Sensitive findings as ::warning:: annotations if (sensitive.status === "warn") { for (const f of sensitive.findings.slice(0, 30)) { - console.log(`::warning file=${f.file},line=${f.line}::${f.kind}: ${f.text}`); + console.log( + `::warning file=${f.file},line=${f.line}::${f.kind}: ${f.text}`, + ); } for (const f of sensitive.touchedSensitiveFiles) { - console.log(`::warning file=${f}::High-sensitivity file modified - please review carefully.`); + console.log( + `::warning file=${f}::High-sensitivity file modified - please review carefully.`, + ); } } @@ -1344,49 +1491,57 @@ async function main() { console.error(summary); for (const v of cargoPinning.violations) { - console.error(`::error file=${v.file}::${v.dependency} ${v.reason} (\`${v.spec}\`)`); + console.error( + `::error file=${v.file}::${v.dependency} ${v.reason} (\`${v.spec}\`)`, + ); } for (const v of crossProgram.violations) { for (const inst of v.variants) { console.error( - `::error file=${inst.file}::${v.dependency} = ${inst.spec}, expected =${v.expected}` + `::error file=${inst.file}::${v.dependency} = ${inst.spec}, expected =${v.expected}`, ); } } for (const v of solanaProgramCrate.violations) { console.error( - `::error file=${v.file},line=${v.line}::solana-program = "${v.spec}", expected "${v.expected}"` + `::error file=${v.file},line=${v.line}::solana-program = "${v.spec}", expected "${v.expected}"`, ); } if (anchorTomlSolana.status === "fail") { console.error( - `::error file=Anchor.toml::solana_version = "${anchorTomlSolana.actual ?? "(missing)"}", expected "${anchorTomlSolana.expected}"` + `::error file=Anchor.toml::solana_version = "${anchorTomlSolana.actual ?? "(missing)"}", expected "${anchorTomlSolana.expected}"`, ); } if (crateAge.status === "fail") { for (const v of crateAge.violations) { const where = v.usedIn[0]?.split(":")[0] ?? "Cargo.toml"; console.error( - `::error file=${where}::${v.crate}@${v.version} is ${v.ageDays} days old (min ${config.packageMinAgeDays}d)` + `::error file=${where}::${v.crate}@${v.version} is ${v.ageDays} days old (min ${config.packageMinAgeDays}d)`, ); } } for (const v of npmPinning.violations) { - console.error(`::error file=${v.file}::${v.section}.${v.dependency} uses \`${v.spec}\` - pin to exact`); + console.error( + `::error file=${v.file}::${v.section}.${v.dependency} uses \`${v.spec}\` - pin to exact`, + ); } if (npmAge.status === "fail") { for (const v of npmAge.violations) { const where = v.usedIn[0]?.split(" ")[0] ?? "package.json"; console.error( - `::error file=${where}::${v.dependency}@${v.version} is ${v.ageDays} days old (min ${config.packageMinAgeDays}d)` + `::error file=${where}::${v.dependency}@${v.version} is ${v.ageDays} days old (min ${config.packageMinAgeDays}d)`, ); } } for (const v of workflowToolchain.violations) { - console.error(`::error file=${v.file},line=${v.line}::${v.key}: ${v.actual} (expected ${v.expected})`); + console.error( + `::error file=${v.file},line=${v.line}::${v.key}: ${v.actual} (expected ${v.expected})`, + ); } for (const v of actionPinning.violations) { - console.error(`::error file=${v.file},line=${v.line}::${v.action}: ${v.reason}`); + console.error( + `::error file=${v.file},line=${v.line}::${v.action}: ${v.reason}`, + ); } process.exit(1); diff --git a/sdk/src/futarchy/v0.6/types/futarchy.ts b/sdk/src/futarchy/v0.6/types/futarchy.ts index a54c805a..9af98c32 100644 --- a/sdk/src/futarchy/v0.6/types/futarchy.ts +++ b/sdk/src/futarchy/v0.6/types/futarchy.ts @@ -1321,11 +1321,11 @@ export type Futarchy = { args: []; }, { - name: "adminApproveMultisigProposal"; + name: "adminEnqueueMultisigProposalApproval"; accounts: [ { name: "dao"; - isMut: true; + isMut: false; isSigner: false; }, { @@ -1335,16 +1335,21 @@ export type Futarchy = { }, { name: "squadsMultisig"; - isMut: true; + isMut: false; isSigner: false; }, { name: "squadsMultisigProposal"; + isMut: false; + isSigner: false; + }, + { + name: "enqueuedApproval"; isMut: true; isSigner: false; }, { - name: "squadsMultisigProgram"; + name: "systemProgram"; isMut: false; isSigner: false; }, @@ -1353,11 +1358,47 @@ export type Futarchy = { { name: "args"; type: { - defined: "AdminApproveMultisigProposalArgs"; + defined: "AdminEnqueueMultisigProposalApprovalArgs"; }; }, ]; }, + { + name: "executeMultisigProposalApproval"; + accounts: [ + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "rentReceiver"; + isMut: true; + isSigner: true; + }, + { + name: "squadsMultisig"; + isMut: true; + isSigner: false; + }, + { + name: "squadsMultisigProposal"; + isMut: true; + isSigner: false; + }, + { + name: "enqueuedApproval"; + isMut: true; + isSigner: false; + }, + { + name: "squadsMultisigProgram"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, { name: "adminExecuteMultisigProposal"; accounts: [ @@ -1867,6 +1908,26 @@ export type Futarchy = { ]; }; }, + { + name: "enqueuedMultisigProposalApproval"; + type: { + kind: "struct"; + fields: [ + { + name: "dao"; + type: "publicKey"; + }, + { + name: "transactionIndex"; + type: "u64"; + }, + { + name: "pdaBump"; + type: "u8"; + }, + ]; + }; + }, { name: "proposal"; type: { @@ -1988,7 +2049,7 @@ export type Futarchy = { }; }, { - name: "AdminApproveMultisigProposalArgs"; + name: "AdminEnqueueMultisigProposalApprovalArgs"; type: { kind: "struct"; fields: [ @@ -5066,11 +5127,11 @@ export const IDL: Futarchy = { args: [], }, { - name: "adminApproveMultisigProposal", + name: "adminEnqueueMultisigProposalApproval", accounts: [ { name: "dao", - isMut: true, + isMut: false, isSigner: false, }, { @@ -5080,16 +5141,21 @@ export const IDL: Futarchy = { }, { name: "squadsMultisig", - isMut: true, + isMut: false, isSigner: false, }, { name: "squadsMultisigProposal", + isMut: false, + isSigner: false, + }, + { + name: "enqueuedApproval", isMut: true, isSigner: false, }, { - name: "squadsMultisigProgram", + name: "systemProgram", isMut: false, isSigner: false, }, @@ -5098,11 +5164,47 @@ export const IDL: Futarchy = { { name: "args", type: { - defined: "AdminApproveMultisigProposalArgs", + defined: "AdminEnqueueMultisigProposalApprovalArgs", }, }, ], }, + { + name: "executeMultisigProposalApproval", + accounts: [ + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "rentReceiver", + isMut: true, + isSigner: true, + }, + { + name: "squadsMultisig", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisigProposal", + isMut: true, + isSigner: false, + }, + { + name: "enqueuedApproval", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisigProgram", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, { name: "adminExecuteMultisigProposal", accounts: [ @@ -5612,6 +5714,26 @@ export const IDL: Futarchy = { ], }, }, + { + name: "enqueuedMultisigProposalApproval", + type: { + kind: "struct", + fields: [ + { + name: "dao", + type: "publicKey", + }, + { + name: "transactionIndex", + type: "u64", + }, + { + name: "pdaBump", + type: "u8", + }, + ], + }, + }, { name: "proposal", type: { @@ -5733,7 +5855,7 @@ export const IDL: Futarchy = { }, }, { - name: "AdminApproveMultisigProposalArgs", + name: "AdminEnqueueMultisigProposalApprovalArgs", type: { kind: "struct", fields: [ diff --git a/tests/futarchy/main.test.ts b/tests/futarchy/main.test.ts index ee37f0ec..e800e372 100644 --- a/tests/futarchy/main.test.ts +++ b/tests/futarchy/main.test.ts @@ -16,7 +16,8 @@ import collectMeteoraDammFees from "./unit/collectMeteoraDammFees.test.js"; import initiateVaultSpendOptimisticProposal from "./unit/initiateVaultSpendOptimisticProposal.test.js"; import finalizeOptimisticProposal from "./unit/finalizeOptimisticProposal.test.js"; -import adminApproveMultisigProposal from "./unit/adminApproveMultisigProposal.test.js"; +import adminEnqueueMultisigProposalApproval from "./unit/adminEnqueueMultisigProposalApproval.test.js"; +import executeMultisigProposalApproval from "./unit/executeMultisigProposalApproval.test.js"; import adminExecuteMultisigProposal from "./unit/adminExecuteMultisigProposal.test.js"; import adminCancelProposal from "./unit/adminCancelProposal.test.js"; import adminRemoveProposal from "./unit/adminRemoveProposal.test.js"; @@ -73,7 +74,14 @@ export default function suite() { initiateVaultSpendOptimisticProposal, ); describe("#finalize_optimistic_proposal", finalizeOptimisticProposal); - describe("#admin_approve_multisig_proposal", adminApproveMultisigProposal); + describe( + "#admin_enqueue_multisig_proposal_approval", + adminEnqueueMultisigProposalApproval, + ); + describe( + "#execute_multisig_proposal_approval", + executeMultisigProposalApproval, + ); describe("#admin_execute_multisig_proposal", adminExecuteMultisigProposal); describe("#admin_cancel_proposal", adminCancelProposal); describe("#admin_remove_proposal", adminRemoveProposal); diff --git a/tests/futarchy/unit/adminApproveMultisigProposal.test.ts b/tests/futarchy/unit/adminApproveMultisigProposal.test.ts deleted file mode 100644 index 6c413855..00000000 --- a/tests/futarchy/unit/adminApproveMultisigProposal.test.ts +++ /dev/null @@ -1,400 +0,0 @@ -import { PERMISSIONLESS_ACCOUNT } from "@metadaoproject/programs"; -import { - ComputeBudgetProgram, - Keypair, - PublicKey, - Transaction, - TransactionMessage, -} from "@solana/web3.js"; -import { expectError, setupBasicDao } from "../../utils.js"; -import { assert } from "chai"; -import * as multisig from "@sqds/multisig"; -import { createMemoInstruction } from "@solana/spl-memo"; -import BN from "bn.js"; - -export default function suite() { - let META: PublicKey, USDC: PublicKey, dao: PublicKey; - - beforeEach(async function () { - META = await this.createMint(this.payer.publicKey, 9); - USDC = await this.createMint(this.payer.publicKey, 6); - - // Create payer's token accounts for both mints - await this.createTokenAccount(META, this.payer.publicKey); - await this.createTokenAccount(USDC, this.payer.publicKey); - - // Mint tokens to payer's accounts - await this.mintTo(META, this.payer.publicKey, this.payer, 100 * 10 ** 9); - await this.mintTo( - USDC, - this.payer.publicKey, - this.payer, - 100_000 * 1_000_000, - ); - - dao = await setupBasicDao({ - context: this, - baseMint: META, - quoteMint: USDC, - }); - }); - - it("should approve a squads proposal", async function () { - const daoAccount = await this.futarchy.getDao(dao); - - const vaultTransactionCreateIx = - multisig.instructions.vaultTransactionCreate({ - multisigPda: daoAccount.squadsMultisig, - transactionIndex: 1n, - creator: PERMISSIONLESS_ACCOUNT.publicKey, - rentPayer: this.payer.publicKey, - vaultIndex: 0, - transactionMessage: new TransactionMessage({ - payerKey: this.payer.publicKey, - recentBlockhash: (await this.banksClient.getLatestBlockhash())[0], - instructions: [createMemoInstruction("hello world")], - }), - ephemeralSigners: 0, - }); - - const proposalCreateIx = multisig.instructions.proposalCreate({ - multisigPda: daoAccount.squadsMultisig, - transactionIndex: 1n, - creator: PERMISSIONLESS_ACCOUNT.publicKey, - rentPayer: this.payer.publicKey, - }); - - const squadsCreateTx = new Transaction().add( - vaultTransactionCreateIx, - proposalCreateIx, - ); - squadsCreateTx.recentBlockhash = ( - await this.banksClient.getLatestBlockhash() - )[0]; - squadsCreateTx.feePayer = this.payer.publicKey; - squadsCreateTx.sign(this.payer, PERMISSIONLESS_ACCOUNT); - - await this.banksClient.processTransaction(squadsCreateTx); - - const [vaultTransactionPda] = multisig.getTransactionPda({ - multisigPda: daoAccount.squadsMultisig, - index: 1n, - }); - - const [squadsProposalPda] = multisig.getProposalPda({ - multisigPda: daoAccount.squadsMultisig, - transactionIndex: 1n, - }); - - let squadsProposal = await multisig.accounts.Proposal.fromAccountAddress( - this.squadsConnection, - squadsProposalPda, - ); - - assert.equal(squadsProposal.transactionIndex, 1); - assert.equal(squadsProposal.approved.length, 0); - assert.isTrue( - multisig.generated.isProposalStatusActive(squadsProposal.status), - ); - - await this.futarchy.futarchy.methods - .adminApproveMultisigProposal({ transactionIndex: new BN(1) }) - .accounts({ - dao: dao, - squadsMultisig: daoAccount.squadsMultisig, - squadsMultisigProposal: squadsProposalPda, - admin: this.payer.publicKey, - squadsMultisigProgram: multisig.PROGRAM_ID, - }) - .signers([this.payer]) - .rpc(); - - squadsProposal = await multisig.accounts.Proposal.fromAccountAddress( - this.squadsConnection, - squadsProposalPda, - ); - - assert.equal(squadsProposal.transactionIndex, 1); - assert.equal(squadsProposal.approved[0].toBase58(), dao.toBase58()); - assert.isTrue( - multisig.generated.isProposalStatusApproved(squadsProposal.status), - ); - }); - - it("should fail to approve an invalidated proposal", async function () { - const daoAccount = await this.futarchy.getDao(dao); - - // Create a vault transaction that will be invalidated by the config transaction - const vaultTransactionToInvalidateCreateIx = - multisig.instructions.vaultTransactionCreate({ - multisigPda: daoAccount.squadsMultisig, - transactionIndex: 1n, - creator: PERMISSIONLESS_ACCOUNT.publicKey, - rentPayer: this.payer.publicKey, - vaultIndex: 0, - transactionMessage: new TransactionMessage({ - payerKey: this.payer.publicKey, - recentBlockhash: (await this.banksClient.getLatestBlockhash())[0], - instructions: [ - createMemoInstruction("I will never see the light of day"), - ], - }), - ephemeralSigners: 0, - }); - - const vaultProposalToInvalidateCreateIx = - multisig.instructions.proposalCreate({ - multisigPda: daoAccount.squadsMultisig, - transactionIndex: 1n, - creator: PERMISSIONLESS_ACCOUNT.publicKey, - rentPayer: this.payer.publicKey, - }); - - const configTransactionIndex = 2n; - - const multisigSetTimeLockIx = multisig.instructions.multisigSetTimeLock({ - multisigPda: daoAccount.squadsMultisig, - timeLock: 100, - configAuthority: dao, - }); - - const setTimeLockMessage = new TransactionMessage({ - payerKey: this.payer.publicKey, - recentBlockhash: (await this.banksClient.getLatestBlockhash())[0], - instructions: [multisigSetTimeLockIx], - }); - - const vaultConfigTransactionCreateIx = - multisig.instructions.vaultTransactionCreate({ - multisigPda: daoAccount.squadsMultisig, - transactionIndex: configTransactionIndex, - creator: PERMISSIONLESS_ACCOUNT.publicKey, - rentPayer: this.payer.publicKey, - vaultIndex: 0, - ephemeralSigners: 0, - transactionMessage: setTimeLockMessage, - }); - - const multisigConfigProposalCreateIx = multisig.instructions.proposalCreate( - { - multisigPda: daoAccount.squadsMultisig, - transactionIndex: configTransactionIndex, - creator: PERMISSIONLESS_ACCOUNT.publicKey, - rentPayer: this.payer.publicKey, - }, - ); - - // Create the squads proposals - const squadsTransactionsCreateTx = new Transaction().add( - vaultTransactionToInvalidateCreateIx, - vaultProposalToInvalidateCreateIx, - vaultConfigTransactionCreateIx, - multisigConfigProposalCreateIx, - ); - squadsTransactionsCreateTx.recentBlockhash = ( - await this.banksClient.getLatestBlockhash() - )[0]; - squadsTransactionsCreateTx.feePayer = this.payer.publicKey; - squadsTransactionsCreateTx.sign(this.payer, PERMISSIONLESS_ACCOUNT); - - await this.banksClient.processTransaction(squadsTransactionsCreateTx); - - const [vaultConfigTransactionPda] = multisig.getTransactionPda({ - multisigPda: daoAccount.squadsMultisig, - index: configTransactionIndex, - }); - - const [squadsConfigProposalPda] = multisig.getProposalPda({ - multisigPda: daoAccount.squadsMultisig, - transactionIndex: configTransactionIndex, - }); - - const configTransactionAccount = - await multisig.accounts.VaultTransaction.fromAccountAddress( - this.squadsConnection, - vaultConfigTransactionPda, - ); - - const { accountMetas: configTransactionAccountMetas } = - await multisig.utils.accountsForTransactionExecute({ - connection: this.squadsConnection, - message: configTransactionAccount.message, - ephemeralSignerBumps: [ - ...configTransactionAccount.ephemeralSignerBumps, - ], - vaultPda: daoAccount.squadsMultisigVault, - transactionPda: vaultConfigTransactionPda, - programId: multisig.PROGRAM_ID, - }); - - // Approve and execute the config transaction using the new split instructions - await this.futarchy.futarchy.methods - .adminApproveMultisigProposal({ - transactionIndex: new BN(configTransactionIndex.toString()), - }) - .accounts({ - dao: dao, - squadsMultisig: daoAccount.squadsMultisig, - squadsMultisigProposal: squadsConfigProposalPda, - admin: this.payer.publicKey, - squadsMultisigProgram: multisig.PROGRAM_ID, - }) - .signers([this.payer]) - .rpc(); - - await this.futarchy.futarchy.methods - .adminExecuteMultisigProposal() - .accounts({ - dao: dao, - squadsMultisig: daoAccount.squadsMultisig, - squadsMultisigProposal: squadsConfigProposalPda, - squadsMultisigVaultTransaction: vaultConfigTransactionPda, - admin: this.payer.publicKey, - squadsMultisigProgram: multisig.PROGRAM_ID, - }) - .remainingAccounts( - configTransactionAccountMetas.map((meta) => - meta.pubkey.equals(dao) ? { ...meta, isSigner: false } : meta, - ), - ) - .preInstructions([ - ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), - ]) - .signers([this.payer]) - .rpc(); - - // Now try to approve the invalidated proposal (index 1) - const [squadsInvalidatedProposalPda] = multisig.getProposalPda({ - multisigPda: daoAccount.squadsMultisig, - transactionIndex: 1n, - }); - - const [vaultInvalidatedTransactionPda] = multisig.getTransactionPda({ - multisigPda: daoAccount.squadsMultisig, - index: 1n, - }); - - const callbacks = expectError( - "StaleProposal", - "The proposal should not be approved because it should have been invalidated", - ); - - await this.futarchy.futarchy.methods - .adminApproveMultisigProposal({ transactionIndex: new BN(1) }) - .accounts({ - dao: dao, - squadsMultisig: daoAccount.squadsMultisig, - squadsMultisigProposal: squadsInvalidatedProposalPda, - admin: this.payer.publicKey, - squadsMultisigProgram: multisig.PROGRAM_ID, - }) - .preInstructions([ - ComputeBudgetProgram.setComputeUnitLimit({ units: 200_001 }), - ]) - .signers([this.payer]) - .rpc() - .then(callbacks[0], callbacks[1]); - }); - - it("should fail when DAO has an active optimistic proposal", async function () { - const daoAccount = await this.futarchy.getDao(dao); - - // Create a simple vault transaction + proposal for the admin to approve - const transactionIndex = 1n; - - const memoMessage = new TransactionMessage({ - payerKey: this.payer.publicKey, - recentBlockhash: (await this.banksClient.getLatestBlockhash())[0], - instructions: [createMemoInstruction("test memo")], - }); - - const createVtAndProposalTx = new Transaction().add( - multisig.instructions.vaultTransactionCreate({ - multisigPda: daoAccount.squadsMultisig, - transactionIndex, - creator: PERMISSIONLESS_ACCOUNT.publicKey, - rentPayer: this.payer.publicKey, - vaultIndex: 0, - ephemeralSigners: 0, - transactionMessage: memoMessage, - }), - multisig.instructions.proposalCreate({ - multisigPda: daoAccount.squadsMultisig, - transactionIndex, - creator: PERMISSIONLESS_ACCOUNT.publicKey, - rentPayer: this.payer.publicKey, - }), - ); - createVtAndProposalTx.recentBlockhash = ( - await this.banksClient.getLatestBlockhash() - )[0]; - createVtAndProposalTx.feePayer = this.payer.publicKey; - createVtAndProposalTx.sign(this.payer, PERMISSIONLESS_ACCOUNT); - - await this.banksClient.processTransaction(createVtAndProposalTx); - - const [vaultTransactionPda] = multisig.getTransactionPda({ - multisigPda: daoAccount.squadsMultisig, - index: transactionIndex, - }); - - const [squadsProposalPda] = multisig.getProposalPda({ - multisigPda: daoAccount.squadsMultisig, - transactionIndex, - }); - - const transactionAccount = - await multisig.accounts.VaultTransaction.fromAccountAddress( - this.squadsConnection, - vaultTransactionPda, - ); - - const { accountMetas } = await multisig.utils.accountsForTransactionExecute( - { - connection: this.squadsConnection, - message: transactionAccount.message, - ephemeralSignerBumps: [...transactionAccount.ephemeralSignerBumps], - vaultPda: daoAccount.squadsMultisigVault, - transactionPda: vaultTransactionPda, - programId: multisig.PROGRAM_ID, - }, - ); - - // Set optimisticProposal on the DAO via direct state manipulation - daoAccount.optimisticProposal = { - squadsProposal: Keypair.generate().publicKey, - enqueuedTimestamp: new BN(1000), - }; - const daoAccountBuffer = - await this.futarchy.futarchy.account.dao.coder.accounts.encode( - "dao", - daoAccount, - ); - const daoBanksAccount = await this.banksClient.getAccount(dao); - daoBanksAccount.data.set(daoAccountBuffer, 0); - this.context.setAccount(dao, daoBanksAccount); - - const callbacks = expectError( - "ActiveOptimisticProposalAlreadyEnqueued", - "Should fail because DAO has an active optimistic proposal", - ); - - await this.futarchy.futarchy.methods - .adminApproveMultisigProposal({ transactionIndex: new BN(1) }) - .accounts({ - dao: dao, - squadsMultisig: daoAccount.squadsMultisig, - squadsMultisigProposal: squadsProposalPda, - admin: this.payer.publicKey, - squadsMultisigProgram: multisig.PROGRAM_ID, - }) - .remainingAccounts( - accountMetas.map((meta) => - meta.pubkey.equals(dao) ? { ...meta, isSigner: false } : meta, - ), - ) - .signers([this.payer]) - .rpc() - .then(callbacks[0], callbacks[1]); - }); -} diff --git a/tests/futarchy/unit/adminEnqueueMultisigProposalApproval.test.ts b/tests/futarchy/unit/adminEnqueueMultisigProposalApproval.test.ts new file mode 100644 index 00000000..2bcdb77a --- /dev/null +++ b/tests/futarchy/unit/adminEnqueueMultisigProposalApproval.test.ts @@ -0,0 +1,422 @@ +import { PERMISSIONLESS_ACCOUNT } from "@metadaoproject/programs"; +import { + ComputeBudgetProgram, + PublicKey, + Transaction, + TransactionMessage, +} from "@solana/web3.js"; +import { expectError } from "../../utils.js"; +import { assert } from "chai"; +import * as multisig from "@sqds/multisig"; +import { createMemoInstruction } from "@solana/spl-memo"; +import BN from "bn.js"; + +const SEED_ENQUEUED_APPROVAL = Buffer.from("enqueued_approval"); + +export default function suite() { + let META: PublicKey, USDC: PublicKey, dao: PublicKey; + + beforeEach(async function () { + META = await this.createMint(this.payer.publicKey, 9); + USDC = await this.createMint(this.payer.publicKey, 6); + + await this.createTokenAccount(META, this.payer.publicKey); + await this.createTokenAccount(USDC, this.payer.publicKey); + + await this.mintTo(META, this.payer.publicKey, this.payer, 100 * 10 ** 9); + await this.mintTo( + USDC, + this.payer.publicKey, + this.payer, + 100_000 * 1_000_000, + ); + + dao = await this.setupBasicDaoWithLiquidity({ + baseMint: META, + quoteMint: USDC, + }); + }); + + const deriveEnqueuedApprovalPda = ( + context: any, + daoKey: PublicKey, + transactionIndex: bigint, + ): PublicKey => { + const [pda] = PublicKey.findProgramAddressSync( + [ + SEED_ENQUEUED_APPROVAL, + daoKey.toBuffer(), + new BN(transactionIndex.toString()).toArrayLike(Buffer, "le", 8), + ], + context.futarchy.futarchy.programId, + ); + return pda; + }; + + const createSquadsVaultTxAndProposal = async function ( + context: any, + squadsMultisig: PublicKey, + transactionIndex: bigint, + memo = "hello world", + ) { + const vaultTransactionCreateIx = + multisig.instructions.vaultTransactionCreate({ + multisigPda: squadsMultisig, + transactionIndex, + creator: PERMISSIONLESS_ACCOUNT.publicKey, + rentPayer: context.payer.publicKey, + vaultIndex: 0, + transactionMessage: new TransactionMessage({ + payerKey: context.payer.publicKey, + recentBlockhash: (await context.banksClient.getLatestBlockhash())[0], + instructions: [createMemoInstruction(memo)], + }), + ephemeralSigners: 0, + }); + + const proposalCreateIx = multisig.instructions.proposalCreate({ + multisigPda: squadsMultisig, + transactionIndex, + creator: PERMISSIONLESS_ACCOUNT.publicKey, + rentPayer: context.payer.publicKey, + }); + + const tx = new Transaction().add( + vaultTransactionCreateIx, + proposalCreateIx, + ); + tx.recentBlockhash = (await context.banksClient.getLatestBlockhash())[0]; + tx.feePayer = context.payer.publicKey; + tx.sign(context.payer, PERMISSIONLESS_ACCOUNT); + + await context.banksClient.processTransaction(tx); + + const [proposalPda] = multisig.getProposalPda({ + multisigPda: squadsMultisig, + transactionIndex, + }); + + return { proposalPda }; + }; + + it("should enqueue a proposal approval", async function () { + const daoAccount = await this.futarchy.getDao(dao); + const { proposalPda } = await createSquadsVaultTxAndProposal( + this, + daoAccount.squadsMultisig, + 1n, + ); + + const enqueuedApprovalPda = deriveEnqueuedApprovalPda(this, dao, 1n); + + await this.futarchy.futarchy.methods + .adminEnqueueMultisigProposalApproval({ transactionIndex: new BN(1) }) + .accounts({ + dao, + admin: this.payer.publicKey, + squadsMultisig: daoAccount.squadsMultisig, + squadsMultisigProposal: proposalPda, + enqueuedApproval: enqueuedApprovalPda, + }) + .signers([this.payer]) + .rpc(); + + const enqueued = + await this.futarchy.futarchy.account.enqueuedMultisigProposalApproval.fetch( + enqueuedApprovalPda, + ); + assert.equal(enqueued.dao.toBase58(), dao.toBase58()); + assert.equal(enqueued.transactionIndex.toString(), "1"); + }); + + it("should fail with PoolNotInSpotState when a futarchy proposal is active", async function () { + const daoAccount = await this.futarchy.getDao(dao); + + // Launching a futarchy proposal creates a Squads proposal at index 1 and + // moves the AMM out of Spot. Use that Squads proposal as our approval + // target — any Active Squads proposal would do here; we just need one + // that exists when the AMM is non-Spot. + const { squadsProposal } = await this.initializeAndLaunchProposal({ + dao, + instructions: [], + }); + + const enqueuedApprovalPda = deriveEnqueuedApprovalPda(this, dao, 1n); + + const callbacks = expectError( + "PoolNotInSpotState", + "enqueue should fail when the AMM is not in Spot state", + ); + + await this.futarchy.futarchy.methods + .adminEnqueueMultisigProposalApproval({ transactionIndex: new BN(1) }) + .accounts({ + dao, + admin: this.payer.publicKey, + squadsMultisig: daoAccount.squadsMultisig, + squadsMultisigProposal: squadsProposal, + enqueuedApproval: enqueuedApprovalPda, + }) + .signers([this.payer]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("should fail when enqueuing twice for the same transaction_index", async function () { + const daoAccount = await this.futarchy.getDao(dao); + const { proposalPda } = await createSquadsVaultTxAndProposal( + this, + daoAccount.squadsMultisig, + 1n, + ); + + const enqueuedApprovalPda = deriveEnqueuedApprovalPda(this, dao, 1n); + + await this.futarchy.futarchy.methods + .adminEnqueueMultisigProposalApproval({ transactionIndex: new BN(1) }) + .accounts({ + dao, + admin: this.payer.publicKey, + squadsMultisig: daoAccount.squadsMultisig, + squadsMultisigProposal: proposalPda, + enqueuedApproval: enqueuedApprovalPda, + }) + .signers([this.payer]) + .rpc(); + + try { + await this.futarchy.futarchy.methods + .adminEnqueueMultisigProposalApproval({ transactionIndex: new BN(1) }) + .accounts({ + dao, + admin: this.payer.publicKey, + squadsMultisig: daoAccount.squadsMultisig, + squadsMultisigProposal: proposalPda, + enqueuedApproval: enqueuedApprovalPda, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_001 }), + ]) + .signers([this.payer]) + .rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + // The init constraint fails because the account already exists + // (system program error 0x0). + assert.include(e.message, "custom program error: 0x0"); + } + }); + + it("should fail with InvalidSquadsProposalStatus when the Squads proposal is no longer Active", async function () { + const daoAccount = await this.futarchy.getDao(dao); + const { proposalPda } = await createSquadsVaultTxAndProposal( + this, + daoAccount.squadsMultisig, + 1n, + ); + + const enqueuedApprovalPda = deriveEnqueuedApprovalPda(this, dao, 1n); + + await this.futarchy.futarchy.methods + .adminEnqueueMultisigProposalApproval({ transactionIndex: new BN(1) }) + .accounts({ + dao, + admin: this.payer.publicKey, + squadsMultisig: daoAccount.squadsMultisig, + squadsMultisigProposal: proposalPda, + enqueuedApproval: enqueuedApprovalPda, + }) + .signers([this.payer]) + .rpc(); + + await this.futarchy.futarchy.methods + .executeMultisigProposalApproval() + .accounts({ + dao, + rentReceiver: this.payer.publicKey, + squadsMultisig: daoAccount.squadsMultisig, + squadsMultisigProposal: proposalPda, + enqueuedApproval: enqueuedApprovalPda, + squadsMultisigProgram: multisig.PROGRAM_ID, + }) + .signers([this.payer]) + .rpc(); + + const callbacks = expectError( + "InvalidSquadsProposalStatus", + "second enqueue should fail because proposal is no longer Active", + ); + + await this.futarchy.futarchy.methods + .adminEnqueueMultisigProposalApproval({ transactionIndex: new BN(1) }) + .accounts({ + dao, + admin: this.payer.publicKey, + squadsMultisig: daoAccount.squadsMultisig, + squadsMultisigProposal: proposalPda, + enqueuedApproval: enqueuedApprovalPda, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_001 }), + ]) + .signers([this.payer]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("should fail with RequireGtViolated when the Squads proposal is stale", async function () { + const daoAccount = await this.futarchy.getDao(dao); + + const { proposalPda: victimProposalPda } = + await createSquadsVaultTxAndProposal( + this, + daoAccount.squadsMultisig, + 1n, + "will be invalidated", + ); + + const configTransactionIndex = 2n; + const multisigSetTimeLockIx = multisig.instructions.multisigSetTimeLock({ + multisigPda: daoAccount.squadsMultisig, + timeLock: 100, + configAuthority: dao, + }); + + const setTimeLockMessage = new TransactionMessage({ + payerKey: this.payer.publicKey, + recentBlockhash: (await this.banksClient.getLatestBlockhash())[0], + instructions: [multisigSetTimeLockIx], + }); + + const vaultConfigTransactionCreateIx = + multisig.instructions.vaultTransactionCreate({ + multisigPda: daoAccount.squadsMultisig, + transactionIndex: configTransactionIndex, + creator: PERMISSIONLESS_ACCOUNT.publicKey, + rentPayer: this.payer.publicKey, + vaultIndex: 0, + ephemeralSigners: 0, + transactionMessage: setTimeLockMessage, + }); + + const configProposalCreateIx = multisig.instructions.proposalCreate({ + multisigPda: daoAccount.squadsMultisig, + transactionIndex: configTransactionIndex, + creator: PERMISSIONLESS_ACCOUNT.publicKey, + rentPayer: this.payer.publicKey, + }); + + const squadsCreateConfigTx = new Transaction().add( + vaultConfigTransactionCreateIx, + configProposalCreateIx, + ); + squadsCreateConfigTx.recentBlockhash = ( + await this.banksClient.getLatestBlockhash() + )[0]; + squadsCreateConfigTx.feePayer = this.payer.publicKey; + squadsCreateConfigTx.sign(this.payer, PERMISSIONLESS_ACCOUNT); + await this.banksClient.processTransaction(squadsCreateConfigTx); + + const [vaultConfigTransactionPda] = multisig.getTransactionPda({ + multisigPda: daoAccount.squadsMultisig, + index: configTransactionIndex, + }); + const [configProposalPda] = multisig.getProposalPda({ + multisigPda: daoAccount.squadsMultisig, + transactionIndex: configTransactionIndex, + }); + const configEnqueuedApprovalPda = deriveEnqueuedApprovalPda( + this, + dao, + configTransactionIndex, + ); + + await this.futarchy.futarchy.methods + .adminEnqueueMultisigProposalApproval({ + transactionIndex: new BN(configTransactionIndex.toString()), + }) + .accounts({ + dao, + admin: this.payer.publicKey, + squadsMultisig: daoAccount.squadsMultisig, + squadsMultisigProposal: configProposalPda, + enqueuedApproval: configEnqueuedApprovalPda, + }) + .signers([this.payer]) + .rpc(); + + await this.futarchy.futarchy.methods + .executeMultisigProposalApproval() + .accounts({ + dao, + rentReceiver: this.payer.publicKey, + squadsMultisig: daoAccount.squadsMultisig, + squadsMultisigProposal: configProposalPda, + enqueuedApproval: configEnqueuedApprovalPda, + squadsMultisigProgram: multisig.PROGRAM_ID, + }) + .signers([this.payer]) + .rpc(); + + const configTransactionAccount = + await multisig.accounts.VaultTransaction.fromAccountAddress( + this.squadsConnection, + vaultConfigTransactionPda, + ); + const { accountMetas: configTransactionAccountMetas } = + await multisig.utils.accountsForTransactionExecute({ + connection: this.squadsConnection, + message: configTransactionAccount.message, + ephemeralSignerBumps: [ + ...configTransactionAccount.ephemeralSignerBumps, + ], + vaultPda: daoAccount.squadsMultisigVault, + transactionPda: vaultConfigTransactionPda, + programId: multisig.PROGRAM_ID, + }); + + await this.futarchy.futarchy.methods + .adminExecuteMultisigProposal() + .accounts({ + dao, + squadsMultisig: daoAccount.squadsMultisig, + squadsMultisigProposal: configProposalPda, + squadsMultisigVaultTransaction: vaultConfigTransactionPda, + admin: this.payer.publicKey, + squadsMultisigProgram: multisig.PROGRAM_ID, + }) + .remainingAccounts( + configTransactionAccountMetas.map((meta) => + meta.pubkey.equals(dao) ? { ...meta, isSigner: false } : meta, + ), + ) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), + ]) + .signers([this.payer]) + .rpc(); + + const victimEnqueuedApprovalPda = deriveEnqueuedApprovalPda(this, dao, 1n); + + const callbacks = expectError( + "RequireGtViolated", + "enqueue should fail because the proposal was invalidated by a later config tx", + ); + + await this.futarchy.futarchy.methods + .adminEnqueueMultisigProposalApproval({ transactionIndex: new BN(1) }) + .accounts({ + dao, + admin: this.payer.publicKey, + squadsMultisig: daoAccount.squadsMultisig, + squadsMultisigProposal: victimProposalPda, + enqueuedApproval: victimEnqueuedApprovalPda, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_001 }), + ]) + .signers([this.payer]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); +} diff --git a/tests/futarchy/unit/adminExecuteMultisigProposal.test.ts b/tests/futarchy/unit/adminExecuteMultisigProposal.test.ts index 55b744f6..5c9d7ef7 100644 --- a/tests/futarchy/unit/adminExecuteMultisigProposal.test.ts +++ b/tests/futarchy/unit/adminExecuteMultisigProposal.test.ts @@ -11,6 +11,8 @@ import * as multisig from "@sqds/multisig"; import { createMemoInstruction } from "@solana/spl-memo"; import BN from "bn.js"; +const SEED_ENQUEUED_APPROVAL = Buffer.from("enqueued_approval"); + export default function suite() { let META: PublicKey, USDC: PublicKey, dao: PublicKey; @@ -109,14 +111,37 @@ export default function suite() { programId: multisig.PROGRAM_ID, }); - // First approve + const [enqueuedApprovalPda] = PublicKey.findProgramAddressSync( + [ + SEED_ENQUEUED_APPROVAL, + dao.toBuffer(), + new BN(1).toArrayLike(Buffer, "le", 8), + ], + this.futarchy.futarchy.programId, + ); + + // First enqueue an approval (admin-gated) await this.futarchy.futarchy.methods - .adminApproveMultisigProposal({ transactionIndex: new BN(1) }) + .adminEnqueueMultisigProposalApproval({ transactionIndex: new BN(1) }) .accounts({ dao: dao, + admin: this.payer.publicKey, squadsMultisig: daoAccount.squadsMultisig, squadsMultisigProposal: squadsProposalPda, - admin: this.payer.publicKey, + enqueuedApproval: enqueuedApprovalPda, + }) + .signers([this.payer]) + .rpc(); + + // Then execute the approval (permissionless) + await this.futarchy.futarchy.methods + .executeMultisigProposalApproval() + .accounts({ + dao: dao, + rentReceiver: this.payer.publicKey, + squadsMultisig: daoAccount.squadsMultisig, + squadsMultisigProposal: squadsProposalPda, + enqueuedApproval: enqueuedApprovalPda, squadsMultisigProgram: multisig.PROGRAM_ID, }) .signers([this.payer]) diff --git a/tests/futarchy/unit/executeMultisigProposalApproval.test.ts b/tests/futarchy/unit/executeMultisigProposalApproval.test.ts new file mode 100644 index 00000000..233118d1 --- /dev/null +++ b/tests/futarchy/unit/executeMultisigProposalApproval.test.ts @@ -0,0 +1,418 @@ +import { PERMISSIONLESS_ACCOUNT } from "@metadaoproject/programs"; +import { + ComputeBudgetProgram, + PublicKey, + Transaction, + TransactionMessage, +} from "@solana/web3.js"; +import { expectError } from "../../utils.js"; +import { assert } from "chai"; +import * as multisig from "@sqds/multisig"; +import { createMemoInstruction } from "@solana/spl-memo"; +import BN from "bn.js"; + +const SEED_ENQUEUED_APPROVAL = Buffer.from("enqueued_approval"); + +export default function suite() { + let META: PublicKey, USDC: PublicKey, dao: PublicKey; + + beforeEach(async function () { + META = await this.createMint(this.payer.publicKey, 9); + USDC = await this.createMint(this.payer.publicKey, 6); + + await this.createTokenAccount(META, this.payer.publicKey); + await this.createTokenAccount(USDC, this.payer.publicKey); + + await this.mintTo(META, this.payer.publicKey, this.payer, 100 * 10 ** 9); + await this.mintTo( + USDC, + this.payer.publicKey, + this.payer, + 100_000 * 1_000_000, + ); + + dao = await this.setupBasicDaoWithLiquidity({ + baseMint: META, + quoteMint: USDC, + }); + }); + + const deriveEnqueuedApprovalPda = ( + context: any, + daoKey: PublicKey, + transactionIndex: bigint, + ): PublicKey => { + const [pda] = PublicKey.findProgramAddressSync( + [ + SEED_ENQUEUED_APPROVAL, + daoKey.toBuffer(), + new BN(transactionIndex.toString()).toArrayLike(Buffer, "le", 8), + ], + context.futarchy.futarchy.programId, + ); + return pda; + }; + + const createSquadsVaultTxAndProposal = async function ( + context: any, + squadsMultisig: PublicKey, + transactionIndex: bigint, + memo = "hello world", + ) { + const vaultTransactionCreateIx = + multisig.instructions.vaultTransactionCreate({ + multisigPda: squadsMultisig, + transactionIndex, + creator: PERMISSIONLESS_ACCOUNT.publicKey, + rentPayer: context.payer.publicKey, + vaultIndex: 0, + transactionMessage: new TransactionMessage({ + payerKey: context.payer.publicKey, + recentBlockhash: (await context.banksClient.getLatestBlockhash())[0], + instructions: [createMemoInstruction(memo)], + }), + ephemeralSigners: 0, + }); + + const proposalCreateIx = multisig.instructions.proposalCreate({ + multisigPda: squadsMultisig, + transactionIndex, + creator: PERMISSIONLESS_ACCOUNT.publicKey, + rentPayer: context.payer.publicKey, + }); + + const tx = new Transaction().add( + vaultTransactionCreateIx, + proposalCreateIx, + ); + tx.recentBlockhash = (await context.banksClient.getLatestBlockhash())[0]; + tx.feePayer = context.payer.publicKey; + tx.sign(context.payer, PERMISSIONLESS_ACCOUNT); + + await context.banksClient.processTransaction(tx); + + const [proposalPda] = multisig.getProposalPda({ + multisigPda: squadsMultisig, + transactionIndex, + }); + + return { proposalPda }; + }; + + const enqueue = async function ( + context: any, + daoKey: PublicKey, + squadsMultisigKey: PublicKey, + squadsProposalPda: PublicKey, + transactionIndex: bigint, + ) { + const enqueuedApprovalPda = deriveEnqueuedApprovalPda( + context, + daoKey, + transactionIndex, + ); + await context.futarchy.futarchy.methods + .adminEnqueueMultisigProposalApproval({ + transactionIndex: new BN(transactionIndex.toString()), + }) + .accounts({ + dao: daoKey, + admin: context.payer.publicKey, + squadsMultisig: squadsMultisigKey, + squadsMultisigProposal: squadsProposalPda, + enqueuedApproval: enqueuedApprovalPda, + }) + .signers([context.payer]) + .rpc(); + return enqueuedApprovalPda; + }; + + it("should execute an enqueued approval with a permissionless signer", async function () { + const daoAccount = await this.futarchy.getDao(dao); + const { proposalPda } = await createSquadsVaultTxAndProposal( + this, + daoAccount.squadsMultisig, + 1n, + ); + const enqueuedApprovalPda = await enqueue( + this, + dao, + daoAccount.squadsMultisig, + proposalPda, + 1n, + ); + + let squadsProposal = await multisig.accounts.Proposal.fromAccountAddress( + this.squadsConnection, + proposalPda, + ); + assert.isTrue( + multisig.generated.isProposalStatusActive(squadsProposal.status), + ); + + await this.futarchy.futarchy.methods + .executeMultisigProposalApproval() + .accounts({ + dao, + rentReceiver: PERMISSIONLESS_ACCOUNT.publicKey, + squadsMultisig: daoAccount.squadsMultisig, + squadsMultisigProposal: proposalPda, + enqueuedApproval: enqueuedApprovalPda, + squadsMultisigProgram: multisig.PROGRAM_ID, + }) + .signers([PERMISSIONLESS_ACCOUNT]) + .rpc(); + + squadsProposal = await multisig.accounts.Proposal.fromAccountAddress( + this.squadsConnection, + proposalPda, + ); + assert.equal(squadsProposal.approved[0].toBase58(), dao.toBase58()); + assert.isTrue( + multisig.generated.isProposalStatusApproved(squadsProposal.status), + ); + + const enqueuedAccount = + await this.banksClient.getAccount(enqueuedApprovalPda); + assert.isNull(enqueuedAccount); + }); + + it("should fail when no enqueued approval exists", async function () { + const daoAccount = await this.futarchy.getDao(dao); + const { proposalPda } = await createSquadsVaultTxAndProposal( + this, + daoAccount.squadsMultisig, + 1n, + ); + + const enqueuedApprovalPda = deriveEnqueuedApprovalPda(this, dao, 1n); + + const callbacks = expectError( + "AccountNotInitialized", + "execute should fail without an enqueued approval PDA", + ); + + await this.futarchy.futarchy.methods + .executeMultisigProposalApproval() + .accounts({ + dao, + rentReceiver: this.payer.publicKey, + squadsMultisig: daoAccount.squadsMultisig, + squadsMultisigProposal: proposalPda, + enqueuedApproval: enqueuedApprovalPda, + squadsMultisigProgram: multisig.PROGRAM_ID, + }) + .signers([this.payer]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("should fail with PoolNotInSpotState when a futarchy proposal launches between enqueue and execute", async function () { + const daoAccount = await this.futarchy.getDao(dao); + + // Initialize (but don't launch) a futarchy proposal. This creates a + // Squads proposal at index 1 and leaves the AMM in Spot — so we can + // enqueue approval against it. Launching is done separately below. + const { proposal, squadsProposal: proposalPda } = + await this.initializeProposal({ dao, instructions: [] }); + + const enqueuedApprovalPda = await enqueue( + this, + dao, + daoAccount.squadsMultisig, + proposalPda, + 1n, + ); + + // Now launch the futarchy proposal to push the AMM out of Spot. + const storedDao = await this.futarchy.getDao(dao); + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: storedDao.baseMint, + quoteMint: storedDao.quoteMint, + squadsProposal: proposalPda, + }) + .rpc(); + + const callbacks = expectError( + "PoolNotInSpotState", + "execute should fail once the AMM is no longer in Spot state", + ); + + await this.futarchy.futarchy.methods + .executeMultisigProposalApproval() + .accounts({ + dao, + rentReceiver: this.payer.publicKey, + squadsMultisig: daoAccount.squadsMultisig, + squadsMultisigProposal: proposalPda, + enqueuedApproval: enqueuedApprovalPda, + squadsMultisigProgram: multisig.PROGRAM_ID, + }) + .signers([this.payer]) + .rpc() + .then(callbacks[0], callbacks[1]); + + const enqueuedAccount = + await this.banksClient.getAccount(enqueuedApprovalPda); + assert.isNotNull(enqueuedAccount); + }); + + it("should fail with RequireGtViolated when the Squads proposal is invalidated between enqueue and execute", async function () { + const daoAccount = await this.futarchy.getDao(dao); + + const { proposalPda: victimProposalPda } = + await createSquadsVaultTxAndProposal( + this, + daoAccount.squadsMultisig, + 1n, + "will be invalidated", + ); + + const victimEnqueuedApprovalPda = await enqueue( + this, + dao, + daoAccount.squadsMultisig, + victimProposalPda, + 1n, + ); + + const configTransactionIndex = 2n; + const multisigSetTimeLockIx = multisig.instructions.multisigSetTimeLock({ + multisigPda: daoAccount.squadsMultisig, + timeLock: 100, + configAuthority: dao, + }); + + const setTimeLockMessage = new TransactionMessage({ + payerKey: this.payer.publicKey, + recentBlockhash: (await this.banksClient.getLatestBlockhash())[0], + instructions: [multisigSetTimeLockIx], + }); + + const vaultConfigTransactionCreateIx = + multisig.instructions.vaultTransactionCreate({ + multisigPda: daoAccount.squadsMultisig, + transactionIndex: configTransactionIndex, + creator: PERMISSIONLESS_ACCOUNT.publicKey, + rentPayer: this.payer.publicKey, + vaultIndex: 0, + ephemeralSigners: 0, + transactionMessage: setTimeLockMessage, + }); + + const configProposalCreateIx = multisig.instructions.proposalCreate({ + multisigPda: daoAccount.squadsMultisig, + transactionIndex: configTransactionIndex, + creator: PERMISSIONLESS_ACCOUNT.publicKey, + rentPayer: this.payer.publicKey, + }); + + const squadsCreateConfigTx = new Transaction().add( + vaultConfigTransactionCreateIx, + configProposalCreateIx, + ); + squadsCreateConfigTx.recentBlockhash = ( + await this.banksClient.getLatestBlockhash() + )[0]; + squadsCreateConfigTx.feePayer = this.payer.publicKey; + squadsCreateConfigTx.sign(this.payer, PERMISSIONLESS_ACCOUNT); + await this.banksClient.processTransaction(squadsCreateConfigTx); + + const [vaultConfigTransactionPda] = multisig.getTransactionPda({ + multisigPda: daoAccount.squadsMultisig, + index: configTransactionIndex, + }); + const [configProposalPda] = multisig.getProposalPda({ + multisigPda: daoAccount.squadsMultisig, + transactionIndex: configTransactionIndex, + }); + const configEnqueuedApprovalPda = await enqueue( + this, + dao, + daoAccount.squadsMultisig, + configProposalPda, + configTransactionIndex, + ); + + await this.futarchy.futarchy.methods + .executeMultisigProposalApproval() + .accounts({ + dao, + rentReceiver: this.payer.publicKey, + squadsMultisig: daoAccount.squadsMultisig, + squadsMultisigProposal: configProposalPda, + enqueuedApproval: configEnqueuedApprovalPda, + squadsMultisigProgram: multisig.PROGRAM_ID, + }) + .signers([this.payer]) + .rpc(); + + const configTransactionAccount = + await multisig.accounts.VaultTransaction.fromAccountAddress( + this.squadsConnection, + vaultConfigTransactionPda, + ); + const { accountMetas: configTransactionAccountMetas } = + await multisig.utils.accountsForTransactionExecute({ + connection: this.squadsConnection, + message: configTransactionAccount.message, + ephemeralSignerBumps: [ + ...configTransactionAccount.ephemeralSignerBumps, + ], + vaultPda: daoAccount.squadsMultisigVault, + transactionPda: vaultConfigTransactionPda, + programId: multisig.PROGRAM_ID, + }); + + await this.futarchy.futarchy.methods + .adminExecuteMultisigProposal() + .accounts({ + dao, + squadsMultisig: daoAccount.squadsMultisig, + squadsMultisigProposal: configProposalPda, + squadsMultisigVaultTransaction: vaultConfigTransactionPda, + admin: this.payer.publicKey, + squadsMultisigProgram: multisig.PROGRAM_ID, + }) + .remainingAccounts( + configTransactionAccountMetas.map((meta) => + meta.pubkey.equals(dao) ? { ...meta, isSigner: false } : meta, + ), + ) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), + ]) + .signers([this.payer]) + .rpc(); + + const callbacks = expectError( + "RequireGtViolated", + "execute should fail because the proposal was invalidated by the config tx", + ); + + await this.futarchy.futarchy.methods + .executeMultisigProposalApproval() + .accounts({ + dao, + rentReceiver: this.payer.publicKey, + squadsMultisig: daoAccount.squadsMultisig, + squadsMultisigProposal: victimProposalPda, + enqueuedApproval: victimEnqueuedApprovalPda, + squadsMultisigProgram: multisig.PROGRAM_ID, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_001 }), + ]) + .signers([this.payer]) + .rpc() + .then(callbacks[0], callbacks[1]); + + const stillEnqueued = await this.banksClient.getAccount( + victimEnqueuedApprovalPda, + ); + assert.isNotNull(stillEnqueued); + }); +}