diff --git a/Anchor.toml b/Anchor.toml index 545001795..87abaee6e 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -62,6 +62,8 @@ v07-close-launch = "yarn run tsx scripts/v0.7/closeLaunch.ts" v07-initialize-performance-package = "yarn run tsx scripts/v0.7/initializePerformancePackage.ts" v07-claim-launch-additional-tokens = "yarn run tsx scripts/v0.7/claimLaunchAdditionalTokens.ts" v07-remove-proposal = "yarn run tsx scripts/v0.7/removeProposal.ts" +v07-audit-liquidity-position-authorities = "yarn run tsx scripts/v0.7/auditLiquidityPositionAuthorities.ts" +v07-fix-position-authorities = "yarn run tsx scripts/v0.7/fixPositionAuthorities.ts" [test] startup_wait = 5000 diff --git a/programs/futarchy/src/events.rs b/programs/futarchy/src/events.rs index fa4b302c3..d2d73cd96 100644 --- a/programs/futarchy/src/events.rs +++ b/programs/futarchy/src/events.rs @@ -220,3 +220,13 @@ pub struct CollectMeteoraDammFeesEvent { pub quote_fees_collected: u64, pub base_fees_collected: u64, } + +#[event] +pub struct AdminFixPositionAuthorityEvent { + pub common: CommonFields, + pub dao: Pubkey, + pub admin: Pubkey, + pub amm_position: Pubkey, + pub old_authority: Pubkey, + pub new_authority: Pubkey, +} diff --git a/programs/futarchy/src/instructions/admin_fix_position_authority.rs b/programs/futarchy/src/instructions/admin_fix_position_authority.rs new file mode 100644 index 000000000..d0bcab21b --- /dev/null +++ b/programs/futarchy/src/instructions/admin_fix_position_authority.rs @@ -0,0 +1,91 @@ +use super::*; + +pub mod admin { + use anchor_lang::prelude::declare_id; + // MetaDAO multisig vault + declare_id!("6awyHMshBGVjJ3ozdSJdyyDE1CTAXUwrpNMaRGMsb4sf"); +} + +pub mod v07_launchpad { + use anchor_lang::prelude::declare_id; + declare_id!("moontUzsdepotRGe5xsfip7vLPTJnVuafqdUWexVnPM"); +} + +pub mod v06_launchpad { + use anchor_lang::prelude::declare_id; + declare_id!("MooNyh4CBUYEKyXVnjGYQ8mEiJDpGvJMdvrZx1iGeHV"); +} + +#[derive(Accounts)] +#[event_cpi] +pub struct AdminFixPositionAuthority<'info> { + #[account(mut)] + pub dao: Box>, + #[account( + mut, + seeds = [b"amm_position", dao.key().as_ref(), dao.squads_multisig_vault.as_ref()], + bump, + has_one = dao, + )] + pub amm_position: Box>, + #[account(mut)] + pub admin: Signer<'info>, +} + +impl AdminFixPositionAuthority<'_> { + pub fn validate(&self) -> Result<()> { + #[cfg(feature = "production")] + require_keys_eq!(self.admin.key(), admin::ID, FutarchyError::InvalidAdmin); + + // Derive v0.7 launch signer + let (v07_launch, _) = Pubkey::find_program_address( + &[b"launch", self.dao.base_mint.as_ref()], + &v07_launchpad::ID, + ); + let (v07_launch_signer, _) = Pubkey::find_program_address( + &[b"launch_signer", v07_launch.as_ref()], + &v07_launchpad::ID, + ); + + // Derive v0.6 launch signer + let (v06_launch, _) = Pubkey::find_program_address( + &[b"launch", self.dao.base_mint.as_ref()], + &v06_launchpad::ID, + ); + let (v06_launch_signer, _) = Pubkey::find_program_address( + &[b"launch_signer", v06_launch.as_ref()], + &v06_launchpad::ID, + ); + + // Verify current authority is a known launch signer (confirms bug-affected position) + require!( + self.amm_position.position_authority == v07_launch_signer + || self.amm_position.position_authority == v06_launch_signer, + FutarchyError::AssertFailed + ); + + Ok(()) + } + + pub fn handle(ctx: Context) -> Result<()> { + let dao = &mut ctx.accounts.dao; + let amm_position = &mut ctx.accounts.amm_position; + + let old_authority = amm_position.position_authority; + amm_position.position_authority = dao.squads_multisig_vault; + + dao.seq_num += 1; + let clock = Clock::get()?; + + emit_cpi!(AdminFixPositionAuthorityEvent { + common: CommonFields::new(&clock, dao.seq_num), + dao: dao.key(), + admin: ctx.accounts.admin.key(), + amm_position: ctx.accounts.amm_position.key(), + old_authority, + new_authority: dao.squads_multisig_vault, + }); + + Ok(()) + } +} diff --git a/programs/futarchy/src/instructions/mod.rs b/programs/futarchy/src/instructions/mod.rs index 3c32078ba..1be7e9e16 100644 --- a/programs/futarchy/src/instructions/mod.rs +++ b/programs/futarchy/src/instructions/mod.rs @@ -2,6 +2,7 @@ use super::*; pub mod admin_approve_execute_multisig_proposal; pub mod admin_cancel_proposal; +pub mod admin_fix_position_authority; pub mod admin_remove_proposal; pub mod collect_fees; pub mod collect_meteora_damm_fees; @@ -21,6 +22,7 @@ pub mod withdraw_liquidity; pub use admin_approve_execute_multisig_proposal::*; pub use admin_cancel_proposal::*; +pub use admin_fix_position_authority::*; pub use admin_remove_proposal::*; pub use collect_fees::*; pub use collect_meteora_damm_fees::*; diff --git a/programs/futarchy/src/lib.rs b/programs/futarchy/src/lib.rs index b060cb11e..b647f40a1 100644 --- a/programs/futarchy/src/lib.rs +++ b/programs/futarchy/src/lib.rs @@ -161,6 +161,11 @@ pub mod futarchy { AdminCancelProposal::handle(ctx) } + #[access_control(ctx.accounts.validate())] + pub fn admin_fix_position_authority(ctx: Context) -> Result<()> { + AdminFixPositionAuthority::handle(ctx) + } + #[access_control(ctx.accounts.validate())] pub fn admin_remove_proposal(ctx: Context) -> Result<()> { AdminRemoveProposal::handle(ctx) diff --git a/scripts/v0.7/auditLiquidityPositionAuthorities.ts b/scripts/v0.7/auditLiquidityPositionAuthorities.ts new file mode 100644 index 000000000..2f3af7de4 --- /dev/null +++ b/scripts/v0.7/auditLiquidityPositionAuthorities.ts @@ -0,0 +1,193 @@ +import * as anchor from "@coral-xyz/anchor"; +import { + FUTARCHY_PROGRAM_ID, + CONDITIONAL_VAULT_PROGRAM_ID, + LAUNCHPAD_PROGRAM_ID, + FutarchyClient, + getLaunchAddr, + getLaunchSignerAddr, +} from "@metadaoproject/futarchy/v0.7"; +import { LAUNCHPAD_PROGRAM_ID as V06_LAUNCHPAD_PROGRAM_ID } from "@metadaoproject/futarchy/v0.6"; +import { PublicKey } from "@solana/web3.js"; +import bs58 from "bs58"; + +const provider = anchor.AnchorProvider.env(); + +const futarchy: FutarchyClient = new FutarchyClient( + provider, + FUTARCHY_PROGRAM_ID, + CONDITIONAL_VAULT_PROGRAM_ID, + [], +); + +function getDiscriminator(accountName: string): Buffer { + return Buffer.from( + anchor.BorshAccountsCoder.accountDiscriminator(accountName), + ); +} + +async function main() { + // 1. Fetch all DAO accounts + console.log("Fetching all DAO accounts..."); + const daoDiscriminator = getDiscriminator("Dao"); + const daoAccounts = await provider.connection.getProgramAccounts( + futarchy.autocrat.programId, + { + filters: [ + { + memcmp: { + offset: 0, + bytes: bs58.encode(daoDiscriminator), + }, + }, + ], + }, + ); + console.log(`Found ${daoAccounts.length} DAOs`); + + // Build map: DAO pubkey -> { squadsMultisigVault, v07LaunchSigner, v06LaunchSigner } + const daoMap = new Map< + string, + { + squadsMultisigVault: PublicKey; + v07LaunchSigner: PublicKey; + v06LaunchSigner: PublicKey; + } + >(); + + for (const { pubkey, account } of daoAccounts) { + const dao = futarchy.autocrat.coder.accounts.decode("dao", account.data); + const [v07Launch] = getLaunchAddr(LAUNCHPAD_PROGRAM_ID, dao.baseMint); + const [v07LaunchSigner] = getLaunchSignerAddr( + LAUNCHPAD_PROGRAM_ID, + v07Launch, + ); + const [v06Launch] = getLaunchAddr(V06_LAUNCHPAD_PROGRAM_ID, dao.baseMint); + const [v06LaunchSigner] = getLaunchSignerAddr( + V06_LAUNCHPAD_PROGRAM_ID, + v06Launch, + ); + daoMap.set(pubkey.toBase58(), { + squadsMultisigVault: dao.squadsMultisigVault, + v07LaunchSigner, + v06LaunchSigner, + }); + } + + // 2. Fetch all AmmPosition accounts + console.log("Fetching all AmmPosition accounts..."); + const positionDiscriminator = getDiscriminator("AmmPosition"); + const positionAccounts = await provider.connection.getProgramAccounts( + futarchy.autocrat.programId, + { + filters: [ + { + memcmp: { + offset: 0, + bytes: bs58.encode(positionDiscriminator), + }, + }, + ], + }, + ); + console.log(`Found ${positionAccounts.length} AmmPositions\n`); + + // 3. Compare each position's authority against its DAO's squadsMultisigVault + // AND verify whether the position's PDA was derived from squadsMultisigVault + let matchCount = 0; + let v07LaunchSignerCount = 0; + let v06LaunchSignerCount = 0; + let unknownCount = 0; + let derivedFromVaultCount = 0; + let notDerivedFromVaultCount = 0; + + for (const { pubkey, account } of positionAccounts) { + const position = futarchy.autocrat.coder.accounts.decode( + "ammPosition", + account.data, + ); + + const daoPubkey = (position.dao as PublicKey).toBase58(); + const daoInfo = daoMap.get(daoPubkey); + + const positionAuthority = ( + position.positionAuthority as PublicKey + ).toBase58(); + const expectedAuthority = daoInfo + ? daoInfo.squadsMultisigVault.toBase58() + : "DAO NOT FOUND"; + const v07LaunchSigner = daoInfo + ? daoInfo.v07LaunchSigner.toBase58() + : "DAO NOT FOUND"; + const v06LaunchSigner = daoInfo + ? daoInfo.v06LaunchSigner.toBase58() + : "DAO NOT FOUND"; + + // Derive the expected PDA using dao.squadsMultisigVault as position authority + let derivedFromVault = false; + let expectedPda = "DAO NOT FOUND"; + if (daoInfo) { + const [pda] = PublicKey.findProgramAddressSync( + [ + Buffer.from("amm_position"), + new PublicKey(daoPubkey).toBuffer(), + daoInfo.squadsMultisigVault.toBuffer(), + ], + FUTARCHY_PROGRAM_ID, + ); + expectedPda = pda.toBase58(); + derivedFromVault = pubkey.toBase58() === expectedPda; + if (derivedFromVault) { + derivedFromVaultCount++; + } else { + notDerivedFromVaultCount++; + } + } + + let status: string; + if (positionAuthority === expectedAuthority) { + status = "OK (squads multisig vault)"; + matchCount++; + } else if (positionAuthority === v07LaunchSigner) { + status = + "V0.7 LAUNCH SIGNER (current authority is the v0.7 launch signer)"; + v07LaunchSignerCount++; + } else if (positionAuthority === v06LaunchSigner) { + status = + "V0.6 LAUNCH SIGNER (current authority is the v0.6 launch signer)"; + v06LaunchSignerCount++; + } else { + status = "UNKNOWN *** MISMATCH ***"; + unknownCount++; + } + + console.log(`Position: ${pubkey.toBase58()}`); + console.log(` DAO: ${daoPubkey}`); + console.log(` Position Authority: ${positionAuthority}`); + console.log(` Expected Authority: ${expectedAuthority}`); + console.log(` v0.7 Launch Signer: ${v07LaunchSigner}`); + console.log(` v0.6 Launch Signer: ${v06LaunchSigner}`); + console.log( + ` Derived from vault: ${derivedFromVault ? "YES" : "NO"} (expected PDA: ${expectedPda})`, + ); + console.log(` Belongs to: ${status}`); + console.log(); + } + + // 4. Summary + console.log("=== Summary ==="); + console.log(`Total positions: ${positionAccounts.length}`); + console.log(`Squads vault (OK): ${matchCount}`); + console.log(`v0.7 launch signer: ${v07LaunchSignerCount}`); + console.log(`v0.6 launch signer: ${v06LaunchSignerCount}`); + console.log(`Unknown mismatch: ${unknownCount}`); + console.log(); + console.log("=== PDA Derivation Check ==="); + console.log(`Derived from vault: ${derivedFromVaultCount}`); + console.log(`NOT derived from vault: ${notDerivedFromVaultCount}`); +} + +main().catch((error) => { + console.error("Fatal error:", error); + process.exit(1); +}); diff --git a/scripts/v0.7/fixPositionAuthorities.ts b/scripts/v0.7/fixPositionAuthorities.ts new file mode 100644 index 000000000..13cf16640 --- /dev/null +++ b/scripts/v0.7/fixPositionAuthorities.ts @@ -0,0 +1,269 @@ +import * as anchor from "@coral-xyz/anchor"; +import * as multisig from "@sqds/multisig"; +import { + FUTARCHY_PROGRAM_ID, + CONDITIONAL_VAULT_PROGRAM_ID, + LAUNCHPAD_PROGRAM_ID, + FutarchyClient, + getLaunchAddr, + getLaunchSignerAddr, + METADAO_MULTISIG_VAULT, +} from "@metadaoproject/futarchy/v0.7"; +import { LAUNCHPAD_PROGRAM_ID as V06_LAUNCHPAD_PROGRAM_ID } from "@metadaoproject/futarchy/v0.6"; +import { PublicKey, TransactionMessage } from "@solana/web3.js"; +import bs58 from "bs58"; + +const provider = anchor.AnchorProvider.env(); + +const payer = provider.wallet["payer"]; + +const futarchy: FutarchyClient = new FutarchyClient( + provider, + FUTARCHY_PROGRAM_ID, + CONDITIONAL_VAULT_PROGRAM_ID, + [], +); + +const metadaoSquadsMultisig = new PublicKey( + "8N3Tvc6B1wEVKVC6iD4s6eyaCNqX2ovj2xze2q3Q9DWH", +); +const metadaoSquadsMultisigVault = METADAO_MULTISIG_VAULT; + +const BATCH_SIZE = 10; + +function getDiscriminator(accountName: string): Buffer { + return Buffer.from( + anchor.BorshAccountsCoder.accountDiscriminator(accountName), + ); +} + +async function main() { + // 1. Fetch all DAO accounts + console.log("Fetching all DAO accounts..."); + const daoDiscriminator = getDiscriminator("Dao"); + const daoAccounts = await provider.connection.getProgramAccounts( + futarchy.autocrat.programId, + { + filters: [ + { + memcmp: { + offset: 0, + bytes: bs58.encode(daoDiscriminator), + }, + }, + ], + }, + ); + console.log(`Found ${daoAccounts.length} DAOs`); + + // Build map: DAO pubkey -> { squadsMultisigVault, v07LaunchSigner, v06LaunchSigner } + const daoMap = new Map< + string, + { + squadsMultisigVault: PublicKey; + v07LaunchSigner: PublicKey; + v06LaunchSigner: PublicKey; + } + >(); + + for (const { pubkey, account } of daoAccounts) { + const dao = futarchy.autocrat.coder.accounts.decode("dao", account.data); + const [v07Launch] = getLaunchAddr(LAUNCHPAD_PROGRAM_ID, dao.baseMint); + const [v07LaunchSigner] = getLaunchSignerAddr( + LAUNCHPAD_PROGRAM_ID, + v07Launch, + ); + const [v06Launch] = getLaunchAddr(V06_LAUNCHPAD_PROGRAM_ID, dao.baseMint); + const [v06LaunchSigner] = getLaunchSignerAddr( + V06_LAUNCHPAD_PROGRAM_ID, + v06Launch, + ); + daoMap.set(pubkey.toBase58(), { + squadsMultisigVault: dao.squadsMultisigVault, + v07LaunchSigner, + v06LaunchSigner, + }); + } + + // 2. Fetch all AmmPosition accounts + console.log("Fetching all AmmPosition accounts..."); + const positionDiscriminator = getDiscriminator("AmmPosition"); + const positionAccounts = await provider.connection.getProgramAccounts( + futarchy.autocrat.programId, + { + filters: [ + { + memcmp: { + offset: 0, + bytes: bs58.encode(positionDiscriminator), + }, + }, + ], + }, + ); + console.log(`Found ${positionAccounts.length} AmmPositions\n`); + + // 3. Filter to affected positions + const affectedDaos: { daoPubkey: PublicKey; positionPubkey: PublicKey }[] = + []; + + for (const { pubkey, account } of positionAccounts) { + const position = futarchy.autocrat.coder.accounts.decode( + "ammPosition", + account.data, + ); + + const daoPubkey = (position.dao as PublicKey).toBase58(); + const daoInfo = daoMap.get(daoPubkey); + if (!daoInfo) continue; + + // Check if this position's address was derived from dao + squadsMultisigVault + const [expectedPda] = PublicKey.findProgramAddressSync( + [ + Buffer.from("amm_position"), + new PublicKey(daoPubkey).toBuffer(), + daoInfo.squadsMultisigVault.toBuffer(), + ], + FUTARCHY_PROGRAM_ID, + ); + const derivedFromVault = pubkey.toBase58() === expectedPda.toBase58(); + if (!derivedFromVault) continue; + + // Check if current authority is a launch signer (i.e. bug-affected) + const positionAuthority = ( + position.positionAuthority as PublicKey + ).toBase58(); + const isV07LaunchSigner = + positionAuthority === daoInfo.v07LaunchSigner.toBase58(); + const isV06LaunchSigner = + positionAuthority === daoInfo.v06LaunchSigner.toBase58(); + + if (!isV07LaunchSigner && !isV06LaunchSigner) continue; + + const version = isV07LaunchSigner ? "v0.7" : "v0.6"; + console.log(`Affected position: ${pubkey.toBase58()}`); + console.log(` DAO: ${daoPubkey}`); + console.log( + ` Current authority: ${positionAuthority} (${version} launch signer)`, + ); + console.log( + ` Expected authority: ${daoInfo.squadsMultisigVault.toBase58()}`, + ); + console.log(); + + affectedDaos.push({ + daoPubkey: new PublicKey(daoPubkey), + positionPubkey: pubkey, + }); + } + + if (affectedDaos.length === 0) { + console.log("No affected positions found. Nothing to fix."); + return; + } + + // 4. Build fix instructions + console.log( + `Building fix instructions for ${affectedDaos.length} affected positions...`, + ); + const instructions = []; + for (const { daoPubkey } of affectedDaos) { + const ix = await futarchy + .adminFixPositionAuthorityIx({ + dao: daoPubkey, + admin: metadaoSquadsMultisigVault, + }) + .instruction(); + instructions.push(ix); + } + + // 5. Batch into groups + const batches = []; + for (let i = 0; i < instructions.length; i += BATCH_SIZE) { + batches.push(instructions.slice(i, i + BATCH_SIZE)); + } + + // 6. Output base64 messages for inspection + console.log( + `\n=== Base64 Transaction Messages (${batches.length} batches) ===\n`, + ); + for (let i = 0; i < batches.length; i++) { + const message = new TransactionMessage({ + payerKey: metadaoSquadsMultisigVault, + recentBlockhash: (await provider.connection.getLatestBlockhash()) + .blockhash, + instructions: batches[i], + }); + const compiled = message.compileToLegacyMessage(); + const base64 = Buffer.from(compiled.serialize()).toString("base64"); + console.log(`Batch ${i + 1} (${batches[i].length} instructions):`); + console.log(base64); + console.log(); + } + + // 7. Summary + console.log("=== Summary ==="); + console.log(`Total affected positions: ${affectedDaos.length}`); + console.log(`Number of batches: ${batches.length}`); + console.log(`DAOs involved:`); + const uniqueDaos = new Set(affectedDaos.map((a) => a.daoPubkey.toBase58())); + for (const dao of uniqueDaos) { + console.log(` - ${dao}`); + } + + // 8. Safety gate + console.log( + "\nReturning early. Uncomment code and remove the return below to create Squads proposals.", + ); + return; + + // // 9. Create Squads vault transactions + proposals + // const squadsMultisigAccount = + // await multisig.accounts.Multisig.fromAccountAddress( + // provider.connection, + // metadaoSquadsMultisig, + // ); + // let txIndex = + // BigInt(squadsMultisigAccount.transactionIndex.toString()) + 1n; + + // for (let i = 0; i < batches.length; i++) { + // const transactionMessage = new TransactionMessage({ + // payerKey: metadaoSquadsMultisigVault, + // recentBlockhash: (await provider.connection.getLatestBlockhash()) + // .blockhash, + // instructions: batches[i], + // }); + + // // Create vault transaction + // const vaultTxSig = await multisig.rpc.vaultTransactionCreate({ + // connection: provider.connection, + // creator: payer.publicKey, + // feePayer: payer.publicKey, + // ephemeralSigners: 0, + // multisigPda: metadaoSquadsMultisig, + // transactionIndex: txIndex, + // vaultIndex: 0, + // transactionMessage, + // }); + // console.log(`Vault tx ${txIndex} (batch ${i + 1}): ${vaultTxSig}`); + + // // Create proposal + // const proposalSig = await multisig.rpc.proposalCreate({ + // connection: provider.connection, + // creator: payer.publicKey, + // feePayer: payer.publicKey, + // multisigPda: metadaoSquadsMultisig, + // transactionIndex: txIndex, + // }); + // console.log(`Proposal ${txIndex} (batch ${i + 1}): ${proposalSig}`); + + // txIndex++; + // } + + // console.log("\nAll Squads proposals created."); +} + +main().catch((error) => { + console.error("Fatal error:", error); + process.exit(1); +}); diff --git a/sdk/src/v0.7/FutarchyClient.ts b/sdk/src/v0.7/FutarchyClient.ts index 0f22606f6..aa38bcc29 100644 --- a/sdk/src/v0.7/FutarchyClient.ts +++ b/sdk/src/v0.7/FutarchyClient.ts @@ -1004,6 +1004,35 @@ export class FutarchyClient { }); } + adminFixPositionAuthorityIx({ + dao, + admin = this.provider.publicKey, + }: { + dao: PublicKey; + admin?: PublicKey; + }) { + const multisigPda = multisig.getMultisigPda({ createKey: dao })[0]; + const squadsMultisigVault = multisig.getVaultPda({ + multisigPda, + index: 0, + })[0]; + + const ammPosition = PublicKey.findProgramAddressSync( + [ + Buffer.from("amm_position"), + dao.toBuffer(), + squadsMultisigVault.toBuffer(), + ], + this.getProgramId(), + )[0]; + + return this.autocrat.methods.adminFixPositionAuthority().accounts({ + dao, + ammPosition, + admin, + }); + } + collectMeteoraDammFeesIx({ dao, baseMint, diff --git a/sdk/src/v0.7/types/futarchy.ts b/sdk/src/v0.7/types/futarchy.ts index 7a75ea0d7..659948bd5 100644 --- a/sdk/src/v0.7/types/futarchy.ts +++ b/sdk/src/v0.7/types/futarchy.ts @@ -1367,6 +1367,37 @@ export type Futarchy = { ]; args: []; }, + { + name: "adminFixPositionAuthority"; + accounts: [ + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "ammPosition"; + isMut: true; + isSigner: false; + }, + { + name: "admin"; + isMut: true; + isSigner: true; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, { name: "adminRemoveProposal"; accounts: [ @@ -3034,6 +3065,43 @@ export type Futarchy = { }, ]; }, + { + name: "AdminFixPositionAuthorityEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "dao"; + type: "publicKey"; + index: false; + }, + { + name: "admin"; + type: "publicKey"; + index: false; + }, + { + name: "ammPosition"; + type: "publicKey"; + index: false; + }, + { + name: "oldAuthority"; + type: "publicKey"; + index: false; + }, + { + name: "newAuthority"; + type: "publicKey"; + index: false; + }, + ]; + }, ]; errors: [ { @@ -4588,6 +4656,37 @@ export const IDL: Futarchy = { ], args: [], }, + { + name: "adminFixPositionAuthority", + accounts: [ + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "ammPosition", + isMut: true, + isSigner: false, + }, + { + name: "admin", + isMut: true, + isSigner: true, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, { name: "adminRemoveProposal", accounts: [ @@ -6255,6 +6354,43 @@ export const IDL: Futarchy = { }, ], }, + { + name: "AdminFixPositionAuthorityEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "dao", + type: "publicKey", + index: false, + }, + { + name: "admin", + type: "publicKey", + index: false, + }, + { + name: "ammPosition", + type: "publicKey", + index: false, + }, + { + name: "oldAuthority", + type: "publicKey", + index: false, + }, + { + name: "newAuthority", + type: "publicKey", + index: false, + }, + ], + }, ], errors: [ { diff --git a/tests/futarchy/main.test.ts b/tests/futarchy/main.test.ts index 1cbf4671c..b64add9fc 100644 --- a/tests/futarchy/main.test.ts +++ b/tests/futarchy/main.test.ts @@ -15,6 +15,7 @@ import executeSpendingLimitChange from "./unit/executeSpendingLimitChange.test.j import collectMeteoraDammFees from "./unit/collectMeteoraDammFees.test.js"; import adminApproveProposal from "./unit/adminApproveExecuteMultisigProposal.test.js"; import adminCancelProposal from "./unit/adminCancelProposal.test.js"; +import adminFixPositionAuthority from "./unit/adminFixPositionAuthority.test.js"; import adminRemoveProposal from "./unit/adminRemoveProposal.test.js"; import { PublicKey } from "@solana/web3.js"; @@ -62,6 +63,7 @@ export default function suite() { describe("#admin_approve_proposal", adminApproveProposal); describe("#admin_cancel_proposal", adminCancelProposal); + describe("#admin_fix_position_authority", adminFixPositionAuthority); describe("#admin_remove_proposal", adminRemoveProposal); // describe("full proposal", fullProposal); // describe("proposal with a squads batch tx", proposalBatchTx); diff --git a/tests/futarchy/unit/adminFixPositionAuthority.test.ts b/tests/futarchy/unit/adminFixPositionAuthority.test.ts new file mode 100644 index 000000000..1ccaf811e --- /dev/null +++ b/tests/futarchy/unit/adminFixPositionAuthority.test.ts @@ -0,0 +1,156 @@ +import { PublicKey, ComputeBudgetProgram } from "@solana/web3.js"; +import BN from "bn.js"; +import { assert } from "chai"; +import { + FUTARCHY_PROGRAM_ID, + LAUNCHPAD_PROGRAM_ID, +} from "@metadaoproject/futarchy/v0.7"; +import { LAUNCHPAD_PROGRAM_ID as V06_LAUNCHPAD_PROGRAM_ID } from "@metadaoproject/futarchy/v0.6"; +import { + getAssociatedTokenAddressSync, + TOKEN_PROGRAM_ID, +} from "@solana/spl-token"; +import { expectError } from "../../utils.js"; +import * as multisig from "@sqds/multisig"; + +export default function suite() { + let META: PublicKey, USDC: PublicKey, dao: PublicKey; + + beforeEach(async function () { + META = await this.createMint(this.payer.publicKey, 6); + USDC = await this.createMint(this.payer.publicKey, 6); + + await this.mintTo(USDC, this.payer.publicKey, this.payer, 1000 * 10 ** 6); + await this.mintTo(META, this.payer.publicKey, this.payer, 1000 * 10 ** 6); + + dao = await this.setupBasicDaoWithLiquidity({ + baseMint: META, + quoteMint: USDC, + }); + }); + + it("fixes corrupted position_authority to squads vault", async function () { + const storedDao = await this.futarchy.getDao(dao); + const multisigPda = multisig.getMultisigPda({ createKey: dao })[0]; + const squadsMultisigVault = multisig.getVaultPda({ + multisigPda, + index: 0, + })[0]; + + // Provide liquidity with positionAuthority = squadsMultisigVault + // This creates the AmmPosition PDA derived from squadsMultisigVault + await this.mintTo(META, this.payer.publicKey, this.payer, 1000 * 10 ** 6); + await this.mintTo(USDC, this.payer.publicKey, this.payer, 1000 * 10 ** 6); + + await this.futarchy + .provideLiquidityIx({ + dao, + baseMint: META, + quoteMint: USDC, + quoteAmount: new BN(100 * 10 ** 6), + maxBaseAmount: new BN(200 * 10 ** 6), + minLiquidity: new BN(1), + positionAuthority: squadsMultisigVault, + liquidityProvider: this.payer.publicKey, + }) + .rpc(); + + // Derive the amm_position PDA + const [ammPositionPda] = PublicKey.findProgramAddressSync( + [ + Buffer.from("amm_position"), + dao.toBuffer(), + squadsMultisigVault.toBuffer(), + ], + FUTARCHY_PROGRAM_ID, + ); + + // Verify the position was created correctly (current code is fixed) + const positionBefore = + await this.futarchy.autocrat.account.ammPosition.fetch(ammPositionPda); + assert.isTrue(positionBefore.positionAuthority.equals(squadsMultisigVault)); + + // Now simulate the bug: overwrite position_authority with the v0.7 launch signer + const [v07Launch] = PublicKey.findProgramAddressSync( + [Buffer.from("launch"), META.toBuffer()], + LAUNCHPAD_PROGRAM_ID, + ); + const [v07LaunchSigner] = PublicKey.findProgramAddressSync( + [Buffer.from("launch_signer"), v07Launch.toBuffer()], + LAUNCHPAD_PROGRAM_ID, + ); + + // Read raw account data and corrupt the position_authority field + const rawAccount = await this.banksClient.getAccount(ammPositionPda); + const data = Buffer.from(rawAccount.data); + + // AmmPosition layout: 8 (discriminator) + 32 (dao) + 32 (position_authority) + 16 (liquidity) + // position_authority starts at offset 40 + data.set(v07LaunchSigner.toBuffer(), 40); + + this.context.setAccount(ammPositionPda, { + ...rawAccount, + data, + }); + + // Verify the corruption + const positionCorrupted = + await this.futarchy.autocrat.account.ammPosition.fetch(ammPositionPda); + assert.isTrue(positionCorrupted.positionAuthority.equals(v07LaunchSigner)); + + const daoBefore = await this.futarchy.getDao(dao); + const seqNumBefore = daoBefore.seqNum.toNumber(); + + // Call admin_fix_position_authority to fix it + await this.futarchy.adminFixPositionAuthorityIx({ dao }).rpc(); + + // Verify the fix + const positionAfter = + await this.futarchy.autocrat.account.ammPosition.fetch(ammPositionPda); + assert.isTrue(positionAfter.positionAuthority.equals(squadsMultisigVault)); + + // Verify seq_num incremented + const storedDaoAfter = await this.futarchy.getDao(dao); + assert.equal(storedDaoAfter.seqNum.toNumber(), seqNumBefore + 1); + + // Verify liquidity is untouched + assert.isTrue(positionAfter.liquidity.eq(positionBefore.liquidity)); + }); + + it("rejects when position_authority is not a recognized launch signer", async function () { + const multisigPda = multisig.getMultisigPda({ createKey: dao })[0]; + const squadsMultisigVault = multisig.getVaultPda({ + multisigPda, + index: 0, + })[0]; + + // Provide liquidity with positionAuthority = squadsMultisigVault + await this.mintTo(META, this.payer.publicKey, this.payer, 1000 * 10 ** 6); + await this.mintTo(USDC, this.payer.publicKey, this.payer, 1000 * 10 ** 6); + + await this.futarchy + .provideLiquidityIx({ + dao, + baseMint: META, + quoteMint: USDC, + quoteAmount: new BN(100 * 10 ** 6), + maxBaseAmount: new BN(200 * 10 ** 6), + minLiquidity: new BN(1), + positionAuthority: squadsMultisigVault, + liquidityProvider: this.payer.publicKey, + }) + .rpc(); + + // The position_authority is correctly set to squadsMultisigVault (not a launch signer), + // so the fix instruction should reject it + const callbacks = expectError( + "AssertFailed", + "should reject when position_authority is not a launch signer", + ); + + await this.futarchy + .adminFixPositionAuthorityIx({ dao }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); +}