diff --git a/Anchor.toml b/Anchor.toml index 87abaee6e..454055d70 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -14,6 +14,7 @@ launchpad_v7 = "moontUzsdepotRGe5xsfip7vLPTJnVuafqdUWexVnPM" mint_governor = "gvnr27cVeyW3AVf3acL7VCJ5WjGAphytnsgcK1feHyH" performance_package_v2 = "pPV2pfrxnmstSb9j7kEeCLny5BGj6SNwCWGd6xbGGzz" price_based_performance_package = "pbPPQH7jyKoSLu8QYs3rSY3YkDRXEBojKbTgnUg7NDS" +raydium_migration_helper = "migR87BnBEkJbbDECLzRxhmNsQ44WMzhDCpCJhfPvR1" [registry] url = "https://api.apr.dev" @@ -31,6 +32,8 @@ initialize-dao = "yarn run tsx scripts/v0.6/initializeDao.ts" initialize-launch = "yarn run tsx scripts/initializeLaunch.ts" initialize-proposal = "yarn run tsx scripts/v0.5/initializeProposal.ts" migrate-meta = "yarn run tsx scripts/v0.5/migrateMeta.ts" +migrate-to-v6-raydium = "yarn run tsx scripts/v0.5/migrateToV6Raydium.ts" +preview-migration-raydium = "yarn run tsx scripts/v0.5/previewMigrationRaydium.ts" remove-spending-limit = "yarn run tsx scripts/v0.5/squads/removeSpendingLimit.ts" start-launch = "yarn run tsx scripts/v0.5/startLaunch.ts" test = "npx mocha --import=tsx --bail tests/main.test.ts --timeout 5000" @@ -51,7 +54,6 @@ v06-migrate-daos-proposals = "yarn run tsx scripts/v0.6/migrateDaosProposals.ts" v06-create-dao = "yarn run tsx scripts/v0.6/createDao.ts" v06-provide-liquidity = "yarn run tsx scripts/v0.6/provideLiquidity.ts" v06-collect-meteora-damm-fees = "yarn run tsx scripts/v0.6/collectMeteoraDammFees.ts" -v06-return-funds = "yarn run tsx scripts/v0.6/returnFunds.ts" v07-collect-meteora-damm-fees = "yarn run tsx scripts/v0.7/collectMeteoraDammFees.ts" v07-launch-template = "yarn run tsx scripts/v0.7/launchTemplate.ts" v07-start-launch = "yarn run tsx scripts/v0.7/startLaunch.ts" @@ -81,3 +83,27 @@ program = "./tests/fixtures/openbook_twap.so" [[test.genesis]] address = "metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s" program = "./tests/fixtures/mpl_token_metadata.so" + +[[test.genesis]] +address = "CPMMoo8L3F4NbTegBCKVNunggL7H1ZpdTHKxQB5qKP1C" +program = "./tests/fixtures/raydium_cp_swap.so" + +[[test.genesis]] +address = "mooNhciQJi1LqHDmse2JPic2NqG2PXCanbE3ZYzP3qA" +program = "./tests/fixtures/launchpad_v5.so" + +[[test.genesis]] +address = "auToUr3CQza3D4qreT6Std2MTomfzvrEeCC5qh7ivW5" +program = "./tests/fixtures/autocrat_v5.so" + +[[test.genesis]] +address = "AMMJdEiCCa8mdugg6JPF7gFirmmxisTfDJoSNSUi5zDJ" +program = "./tests/fixtures/amm_v5.so" + +[[test.genesis]] +address = "VLTX1ishMBbcX3rdBWGssxawAo1Q2X2qxYFYqiGodVg" +program = "./target/deploy/conditional_vault.so" + +[[test.genesis]] +address = "SQDS4ep65T869zMMBKyuUq6aD6EgTu8psMjkvj52pCf" +program = "./tests/fixtures/squads_multisig.so" diff --git a/Cargo.lock b/Cargo.lock index 8a51feafc..739ca25df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1672,6 +1672,37 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "raydium-cpmm-cpi" +version = "0.1.0" +source = "git+https://github.com/raydium-io/raydium-cpi?branch=anchor-0.29.0#576be7111dd5523fafdb284e46e55003ee0ace3f" +dependencies = [ + "ahash 0.8.6", + "anchor-lang", + "anchor-spl", + "num_enum 0.7.0", + "solana-program", + "spl-memo", + "spl-token", +] + +[[package]] +name = "raydium_migration_helper" +version = "0.1.0" +dependencies = [ + "ahash 0.8.6", + "anchor-lang", + "anchor-spl", + "damm_v2_cpi", + "futarchy", + "raydium-cpmm-cpi", + "solana-program", + "solana-security-txt", + "spl-associated-token-account", + "spl-memo", + "spl-token", +] + [[package]] name = "rayon" version = "1.10.0" diff --git a/programs/raydium_migration_helper/Cargo.toml b/programs/raydium_migration_helper/Cargo.toml new file mode 100644 index 000000000..130302d93 --- /dev/null +++ b/programs/raydium_migration_helper/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "raydium_migration_helper" +version = "0.1.0" +description = "Helper program for migrating autocrat v5 daos to futarchy v6 daos" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "raydium_migration_helper" + +[features] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +cpi = ["no-entrypoint"] +default = [] +production= [] + +[dependencies] +anchor-lang = "0.29.0" +anchor-spl = "0.29.0" +spl-memo = "=4.0.0" +solana-program = "=1.17.14" +spl-token = "=4.0.0" +spl-associated-token-account = "2.2" +ahash = "=0.8.6" +solana-security-txt = "1.1.1" +raydium-cpmm-cpi = { git = "https://github.com/raydium-io/raydium-cpi", package = "raydium-cpmm-cpi", branch = "anchor-0.29.0" } +futarchy = { path = "../futarchy", features = ["cpi"] } +damm_v2_cpi = { path = "../damm_v2_cpi", features = ["cpi"] } + + diff --git a/programs/raydium_migration_helper/Xargo.toml b/programs/raydium_migration_helper/Xargo.toml new file mode 100644 index 000000000..475fb71ed --- /dev/null +++ b/programs/raydium_migration_helper/Xargo.toml @@ -0,0 +1,2 @@ +[target.bpfel-unknown-unknown.dependencies.std] +features = [] diff --git a/programs/raydium_migration_helper/src/error.rs b/programs/raydium_migration_helper/src/error.rs new file mode 100644 index 000000000..bde82004d --- /dev/null +++ b/programs/raydium_migration_helper/src/error.rs @@ -0,0 +1,19 @@ +use anchor_lang::prelude::*; + +#[error_code] +pub enum RaydiumMigrationError { + #[msg("Insufficient LP token balance")] + InsufficientLpBalance, + + #[msg("Token account owner mismatch")] + TokenAccountOwnerMismatch, + + #[msg("Invalid token mint")] + InvalidTokenMint, + + #[msg("Math overflow error")] + MathOverflow, + + #[msg("Base and quote mints must be different")] + DuplicateTokenMints, +} diff --git a/programs/raydium_migration_helper/src/events.rs b/programs/raydium_migration_helper/src/events.rs new file mode 100644 index 000000000..23853ea6d --- /dev/null +++ b/programs/raydium_migration_helper/src/events.rs @@ -0,0 +1,27 @@ +use anchor_lang::prelude::*; + +#[event] +pub struct MigrationExecuted { + /// The vault authority that executed the migration + pub vault_authority: Pubkey, + /// Amount of LP tokens withdrawn from Raydium + pub lp_amount: u64, + /// Amount of base tokens withdrawn from Raydium LP + pub withdrawn_base: u64, + /// Amount of quote tokens withdrawn from Raydium LP + pub withdrawn_quote: u64, + /// Amount of base tokens sent to Meteora DAMM v2 pool (10%) + pub base_to_meteora: u64, + /// Amount of quote tokens sent to Meteora DAMM v2 pool (10%) + pub quote_to_meteora: u64, + /// Amount of base tokens sent to Futarchy AMM (90%) + pub base_to_futarchy: u64, + /// Amount of quote tokens sent to Futarchy AMM (90%) + pub quote_to_futarchy: u64, + /// The Meteora DAMM v2 pool that was created + pub meteora_pool: Pubkey, + /// Amount of base tokens transferred to V6 vault treasury + pub treasury_base_transferred: u64, + /// Amount of quote tokens transferred to V6 vault treasury + pub treasury_quote_transferred: u64, +} diff --git a/programs/raydium_migration_helper/src/instructions/mod.rs b/programs/raydium_migration_helper/src/instructions/mod.rs new file mode 100644 index 000000000..dcba676f3 --- /dev/null +++ b/programs/raydium_migration_helper/src/instructions/mod.rs @@ -0,0 +1,5 @@ +use super::*; + +pub mod withdraw_and_provide_liquidity; + +pub use withdraw_and_provide_liquidity::*; diff --git a/programs/raydium_migration_helper/src/instructions/withdraw_and_provide_liquidity.rs b/programs/raydium_migration_helper/src/instructions/withdraw_and_provide_liquidity.rs new file mode 100644 index 000000000..9db7502a4 --- /dev/null +++ b/programs/raydium_migration_helper/src/instructions/withdraw_and_provide_liquidity.rs @@ -0,0 +1,720 @@ +use super::*; + +#[derive(Accounts)] +pub struct MeteoraAccounts<'info> { + pub damm_v2_program: Program<'info, DammV2Cpi>, + + /// CHECK: checked by damm v2 program - custom Meteora config + pub config: UncheckedAccount<'info>, + + pub token_2022_program: Program<'info, Token2022>, + + /// CHECK: checked by damm v2 program + #[account(mut, seeds = [POSITION_NFT_ACCOUNT_PREFIX.as_ref(), position_nft_mint.key().as_ref()], bump, seeds::program = damm_v2_program)] + pub position_nft_account: UncheckedAccount<'info>, + + /// CHECK: checked by damm v2 program + #[account(mut, seeds = [ + POOL_PREFIX.as_ref(), + config.key().as_ref(), + &max_key(&base_mint.key(), "e_mint.key()), + &min_key(&base_mint.key(), "e_mint.key()), + ], bump, seeds::program = damm_v2_program)] + pub pool: UncheckedAccount<'info>, + + /// CHECK: checked by damm v2 program + #[account(mut, seeds = [POSITION_PREFIX.as_ref(), position_nft_mint.key().as_ref()], bump, seeds::program = damm_v2_program)] + pub position: UncheckedAccount<'info>, + + /// CHECK: checked by damm v2 program - PDA derived from base_mint for uniqueness + #[account(mut, seeds = [b"position_nft_mint", base_mint.key().as_ref()], bump)] + pub position_nft_mint: UncheckedAccount<'info>, + + /// CHECK: references from root struct + pub base_mint: UncheckedAccount<'info>, + + /// CHECK: references from root struct + pub quote_mint: UncheckedAccount<'info>, + + /// CHECK: checked by damm v2 program + #[account(mut, seeds = [ + TOKEN_VAULT_PREFIX.as_ref(), + base_mint.key().as_ref(), + pool.key().as_ref(), + ], bump, seeds::program = damm_v2_program)] + pub token_a_vault: UncheckedAccount<'info>, + + /// CHECK: checked by damm v2 program + #[account(mut, seeds = [ + TOKEN_VAULT_PREFIX.as_ref(), + quote_mint.key().as_ref(), + pool.key().as_ref(), + ], bump, seeds::program = damm_v2_program)] + pub token_b_vault: UncheckedAccount<'info>, + + /// CHECK: checked by damm v2 program - global authority for pool creation + #[account(seeds = [b"damm_pool_creator_authority"], bump)] + pub pool_creator_authority: UncheckedAccount<'info>, + + /// CHECK: checked by damm v2 program + #[account(seeds = [POOL_AUTHORITY_PREFIX.as_ref()], bump, seeds::program = damm_v2_program)] + pub pool_authority: UncheckedAccount<'info>, + + /// CHECK: checked by damm v2 program + pub damm_v2_event_authority: UncheckedAccount<'info>, +} + +#[derive(Accounts)] +pub struct WithdrawAndProvideLiquidity<'info> { + /// The vault/DAO that owns the LP tokens (must sign) + /// This will be the V5 vault PDA signing via Squads + #[account(mut)] + pub vault_authority: Signer<'info>, + + /// Migration signer PDA - used to sign for Meteora CPI token transfers + /// Seeds: ["migration_signer", base_mint] + /// CHECK: PDA owned by this program, validated by seeds + #[account( + mut, + seeds = [b"migration_signer", base_mint.key().as_ref()], + bump + )] + pub migration_signer: UncheckedAccount<'info>, + + /// Migration signer's base token account (receives tokens from vault, transfers to Meteora) + #[account( + mut, + token::mint = base_mint, + token::authority = migration_signer, + )] + pub migration_signer_base_ata: Account<'info, TokenAccount>, + + /// Migration signer's quote token account (receives tokens from vault, transfers to Meteora) + #[account( + mut, + token::mint = quote_mint, + token::authority = migration_signer, + )] + pub migration_signer_quote_ata: Account<'info, TokenAccount>, + + // ===== Raydium Withdrawal Accounts ===== + /// Raydium CPMM pool state + /// CHECK: Validated by Raydium CPMM program via CPI - program verifies pool ownership and structure + #[account(mut)] + pub pool_state: UncheckedAccount<'info>, + + /// LP token mint + #[account(mut)] + pub lp_mint: Account<'info, Mint>, + + /// Vault's LP token account (will be burned from) + #[account( + mut, + constraint = vault_lp_token.owner == vault_authority.key() @ RaydiumMigrationError::TokenAccountOwnerMismatch, + constraint = vault_lp_token.mint == lp_mint.key() @ RaydiumMigrationError::InvalidTokenMint, + )] + pub vault_lp_token: Account<'info, TokenAccount>, + + /// Vault's token0 account (will receive tokens from pool) + /// Note: token0/token1 ordering is derived from base_mint/quote_mint pubkey comparison + #[account( + mut, + constraint = vault_token0.owner == vault_authority.key() @ RaydiumMigrationError::TokenAccountOwnerMismatch, + )] + pub vault_token0: Account<'info, TokenAccount>, + + /// Vault's token1 account (will receive tokens from pool) + /// Note: token0/token1 ordering is derived from base_mint/quote_mint pubkey comparison + #[account( + mut, + constraint = vault_token1.owner == vault_authority.key() @ RaydiumMigrationError::TokenAccountOwnerMismatch, + )] + pub vault_token1: Account<'info, TokenAccount>, + + // ===== V6 Futarchy AMM Accounts ===== + /// V6 DAO account + /// CHECK: Validated by futarchy program via CPI - program verifies DAO ownership and state + #[account(mut)] + pub dao: UncheckedAccount<'info>, + + /// Base token mint (used for determining token0/token1 -> base/quote mapping) + pub base_mint: Account<'info, Mint>, + + /// Quote token mint (used for determining token0/token1 -> base/quote mapping) + pub quote_mint: Account<'info, Mint>, + + /// Raydium authority PDA + /// CHECK: Validated by Raydium program via CPI - derived from pool state seeds + pub raydium_authority: UncheckedAccount<'info>, + + /// Pool's token0 vault + /// CHECK: Validated by Raydium program via CPI - program verifies vault ownership + #[account(mut)] + pub pool_token0_vault: UncheckedAccount<'info>, + + /// Pool's token1 vault + /// CHECK: Validated by Raydium program via CPI - program verifies vault ownership + #[account(mut)] + pub pool_token1_vault: UncheckedAccount<'info>, + + /// AMM position PDA (owned by futarchy program) + /// CHECK: Created/validated by futarchy program via CPI - PDA derived from dao + position_authority + #[account(mut)] + pub amm_position: UncheckedAccount<'info>, + + /// AMM base vault (owned by DAO) + /// CHECK: Validated by futarchy program via CPI - program verifies vault ownership by DAO + #[account(mut)] + pub amm_base_vault: UncheckedAccount<'info>, + + /// AMM quote vault (owned by DAO) + /// CHECK: Validated by futarchy program via CPI - program verifies vault ownership by DAO + #[account(mut)] + pub amm_quote_vault: UncheckedAccount<'info>, + + /// V6 vault base treasury ATA (receives remaining base tokens after liquidity provision) + /// CHECK: Token account validated by SPL token program during transfer - must be valid ATA + #[account(mut)] + pub v6_vault_base_ata: UncheckedAccount<'info>, + + /// V6 vault quote treasury ATA (receives remaining quote tokens after liquidity provision) + /// CHECK: Token account validated by SPL token program during transfer - must be valid ATA + #[account(mut)] + pub v6_vault_quote_ata: UncheckedAccount<'info>, + + /// V6 vault PDA (will be the position authority for the AMM position) + /// This is separate from vault_authority (V5 vault) which signs the transaction + /// CHECK: Used as position_authority in futarchy CPI - futarchy validates during provide_liquidity + pub v6_vault_pda: UncheckedAccount<'info>, + + /// Event authority for futarchy CPI events + /// CHECK: Required by futarchy #[event_cpi] - PDA derived from futarchy program ID + pub event_authority: UncheckedAccount<'info>, + + // ===== Programs ===== + /// Raydium CPMM program + pub raydium_program: Program<'info, RaydiumCpmm>, + + /// Futarchy v0.6 program + pub futarchy_program: Program<'info, Futarchy>, + + /// SPL Token program + pub token_program: Program<'info, Token>, + + /// SPL Token 2022 program (required by Raydium for Token-2022 support) + /// CHECK: Passed to Raydium CPI - Raydium validates program ID internally + pub token_program_2022: UncheckedAccount<'info>, + + /// System program + pub system_program: Program<'info, System>, + + /// Memo program (required by Raydium for withdrawal logs) + /// CHECK: Passed to Raydium CPI - Raydium validates memo program ID + pub memo_program: UncheckedAccount<'info>, + + // ===== Meteora DAMM v2 Accounts ===== + pub meteora_accounts: MeteoraAccounts<'info>, +} + +impl WithdrawAndProvideLiquidity<'_> { + pub fn validate(&self, lp_amount: u64) -> Result<()> { + // Validate base and quote mints are different + require!( + self.base_mint.key() != self.quote_mint.key(), + RaydiumMigrationError::DuplicateTokenMints + ); + + // Derive token0/token1 ordering from base/quote mints + let base_is_token0 = self.base_mint.key() < self.quote_mint.key(); + + // Validate that vault token accounts match the expected base/quote mints + let expected_token0_mint = if base_is_token0 { + self.base_mint.key() + } else { + self.quote_mint.key() + }; + let expected_token1_mint = if base_is_token0 { + self.quote_mint.key() + } else { + self.base_mint.key() + }; + + require!( + self.vault_token0.mint == expected_token0_mint, + RaydiumMigrationError::InvalidTokenMint + ); + require!( + self.vault_token1.mint == expected_token1_mint, + RaydiumMigrationError::InvalidTokenMint + ); + + // Validate LP amount and balance + require!(lp_amount > 0, RaydiumMigrationError::InsufficientLpBalance); + require!( + self.vault_lp_token.amount >= lp_amount, + RaydiumMigrationError::InsufficientLpBalance + ); + + Ok(()) + } + + pub fn handle( + ctx: Context, + lp_amount: u64, + min_raydium_amount_0: u64, + min_raydium_amount_1: u64, + min_futarchy_liquidity: u64, + ) -> Result<()> { + // Derive token0/token1 ordering from base/quote mints + let base_is_token0 = ctx.accounts.base_mint.key() < ctx.accounts.quote_mint.key(); + + // 1. Snapshot current vault token balances BEFORE Raydium withdrawal + let token0_balance_before = ctx.accounts.vault_token0.amount; + let token1_balance_before = ctx.accounts.vault_token1.amount; + + // 2. Execute Raydium withdraw CPI + raydium_cpmm_cpi::cpi::withdraw( + CpiContext::new( + ctx.accounts.raydium_program.to_account_info(), + raydium_cpmm_cpi::cpi::accounts::Withdraw { + owner: ctx.accounts.vault_authority.to_account_info(), + authority: ctx.accounts.raydium_authority.to_account_info(), + pool_state: ctx.accounts.pool_state.to_account_info(), + owner_lp_token: ctx.accounts.vault_lp_token.to_account_info(), + token_0_account: ctx.accounts.vault_token0.to_account_info(), + token_1_account: ctx.accounts.vault_token1.to_account_info(), + token_0_vault: ctx.accounts.pool_token0_vault.to_account_info(), + token_1_vault: ctx.accounts.pool_token1_vault.to_account_info(), + token_program: ctx.accounts.token_program.to_account_info(), + token_program_2022: ctx.accounts.token_program_2022.to_account_info(), + vault_0_mint: if base_is_token0 { + ctx.accounts.base_mint.to_account_info() + } else { + ctx.accounts.quote_mint.to_account_info() + }, + vault_1_mint: if base_is_token0 { + ctx.accounts.quote_mint.to_account_info() + } else { + ctx.accounts.base_mint.to_account_info() + }, + lp_mint: ctx.accounts.lp_mint.to_account_info(), + memo_program: ctx.accounts.memo_program.to_account_info(), + }, + ), + lp_amount, + min_raydium_amount_0, + min_raydium_amount_1, + )?; + + // 3. Reload token accounts and calculate withdrawn amounts (delta) + ctx.accounts.vault_token0.reload()?; + ctx.accounts.vault_token1.reload()?; + + let withdrawn_token0 = ctx + .accounts + .vault_token0 + .amount + .checked_sub(token0_balance_before) + .ok_or(RaydiumMigrationError::MathOverflow)?; + let withdrawn_token1 = ctx + .accounts + .vault_token1 + .amount + .checked_sub(token1_balance_before) + .ok_or(RaydiumMigrationError::MathOverflow)?; + + // 4. Map token0/token1 to base/quote using the ordering we derived earlier + let (withdrawn_base, withdrawn_quote) = if base_is_token0 { + (withdrawn_token0, withdrawn_token1) + } else { + (withdrawn_token1, withdrawn_token0) + }; + + // 5. Calculate split: 90% to futarchy, 10% to Meteora (both base AND quote) + let base_to_meteora = withdrawn_base / 10; // 10% + let base_to_futarchy = withdrawn_base - base_to_meteora; // 90% + + let quote_to_meteora = withdrawn_quote / 10; // 10% + let quote_to_futarchy = withdrawn_quote - quote_to_meteora; // 90% + + // 6. Transfer 10% of tokens to migration_signer's accounts for Meteora CPI + let (migration_signer_base_source, migration_signer_quote_source) = if base_is_token0 { + ( + ctx.accounts.vault_token0.to_account_info(), + ctx.accounts.vault_token1.to_account_info(), + ) + } else { + ( + ctx.accounts.vault_token1.to_account_info(), + ctx.accounts.vault_token0.to_account_info(), + ) + }; + + // Transfer base tokens to migration_signer + let transfer_base_to_signer_ix = spl_token::instruction::transfer( + &ctx.accounts.token_program.key(), + &migration_signer_base_source.key(), + &ctx.accounts.migration_signer_base_ata.key(), + &ctx.accounts.vault_authority.key(), + &[], + base_to_meteora, + )?; + + invoke( + &transfer_base_to_signer_ix, + &[ + migration_signer_base_source.clone(), + ctx.accounts.migration_signer_base_ata.to_account_info(), + ctx.accounts.vault_authority.to_account_info(), + ], + )?; + + // Transfer quote tokens to migration_signer + let transfer_quote_to_signer_ix = spl_token::instruction::transfer( + &ctx.accounts.token_program.key(), + &migration_signer_quote_source.key(), + &ctx.accounts.migration_signer_quote_ata.key(), + &ctx.accounts.vault_authority.key(), + &[], + quote_to_meteora, + )?; + + invoke( + &transfer_quote_to_signer_ix, + &[ + migration_signer_quote_source.clone(), + ctx.accounts.migration_signer_quote_ata.to_account_info(), + ctx.accounts.vault_authority.to_account_info(), + ], + )?; + + // Verify transfers succeeded by reloading and checking balances + ctx.accounts.migration_signer_base_ata.reload()?; + ctx.accounts.migration_signer_quote_ata.reload()?; + + require!( + ctx.accounts.migration_signer_base_ata.amount >= base_to_meteora, + RaydiumMigrationError::InsufficientLpBalance + ); + require!( + ctx.accounts.migration_signer_quote_ata.amount >= quote_to_meteora, + RaydiumMigrationError::InsufficientLpBalance + ); + + // 7. Create Meteora DAMM v2 pool with 10% of tokens (two-sided liquidity) + msg!( + "Before Meteora CPI - migration_signer base: {}, quote: {}", + ctx.accounts.migration_signer_base_ata.amount, + ctx.accounts.migration_signer_quote_ata.amount + ); + + ctx.accounts.create_meteora_pool( + base_to_meteora, + quote_to_meteora, + base_is_token0, + ctx.bumps.migration_signer, + ctx.bumps.meteora_accounts.position_nft_mint, + ctx.bumps.meteora_accounts.pool_creator_authority, + )?; + + // Check if tokens were deducted after Meteora CPI + ctx.accounts.migration_signer_base_ata.reload()?; + ctx.accounts.migration_signer_quote_ata.reload()?; + msg!( + "After Meteora CPI - migration_signer base: {}, quote: {}", + ctx.accounts.migration_signer_base_ata.amount, + ctx.accounts.migration_signer_quote_ata.amount + ); + + // 8. CPI to V6 futarchy program to provide liquidity with 90% of tokens + ctx.accounts.vault_token0.reload()?; + ctx.accounts.vault_token1.reload()?; + msg!( + "Before Futarchy CPI - vault_token0: {}, vault_token1: {}", + ctx.accounts.vault_token0.amount, + ctx.accounts.vault_token1.amount + ); + msg!( + "Futarchy params - quote_to_futarchy: {}, base_to_futarchy: {}, min_liquidity: {}", + quote_to_futarchy, + base_to_futarchy, + min_futarchy_liquidity + ); + + let (liquidity_provider_base_info, liquidity_provider_quote_info) = if base_is_token0 { + ( + ctx.accounts.vault_token0.to_account_info(), + ctx.accounts.vault_token1.to_account_info(), + ) + } else { + ( + ctx.accounts.vault_token1.to_account_info(), + ctx.accounts.vault_token0.to_account_info(), + ) + }; + + futarchy::cpi::provide_liquidity( + CpiContext::new( + ctx.accounts.futarchy_program.to_account_info(), + futarchy::cpi::accounts::ProvideLiquidity { + dao: ctx.accounts.dao.to_account_info(), + liquidity_provider: ctx.accounts.vault_authority.to_account_info(), + liquidity_provider_base_account: liquidity_provider_base_info, + liquidity_provider_quote_account: liquidity_provider_quote_info, + payer: ctx.accounts.vault_authority.to_account_info(), + system_program: ctx.accounts.system_program.to_account_info(), + amm_base_vault: ctx.accounts.amm_base_vault.to_account_info(), + amm_quote_vault: ctx.accounts.amm_quote_vault.to_account_info(), + amm_position: ctx.accounts.amm_position.to_account_info(), + token_program: ctx.accounts.token_program.to_account_info(), + event_authority: ctx.accounts.event_authority.to_account_info(), + program: ctx.accounts.futarchy_program.to_account_info(), + }, + ), + ProvideLiquidityParams { + quote_amount: quote_to_futarchy, + max_base_amount: base_to_futarchy, + min_liquidity: min_futarchy_liquidity as u128, + position_authority: ctx.accounts.v6_vault_pda.key(), + }, + )?; + + msg!("Futarchy provide_liquidity CPI completed"); + + // 9. Transfer REMAINING balances to V6 vault treasury + ctx.accounts.vault_token0.reload()?; + ctx.accounts.vault_token1.reload()?; + + let remaining_token0 = ctx.accounts.vault_token0.amount; + let remaining_token1 = ctx.accounts.vault_token1.amount; + + // Transfer remaining token0 to appropriate V6 vault treasury ATA + if remaining_token0 > 0 { + let (destination_key, destination_account_info) = if base_is_token0 { + ( + ctx.accounts.v6_vault_base_ata.key(), + ctx.accounts.v6_vault_base_ata.to_account_info(), + ) + } else { + ( + ctx.accounts.v6_vault_quote_ata.key(), + ctx.accounts.v6_vault_quote_ata.to_account_info(), + ) + }; + + let transfer_ix = spl_token::instruction::transfer( + &ctx.accounts.token_program.key(), + &ctx.accounts.vault_token0.key(), + &destination_key, + &ctx.accounts.vault_authority.key(), + &[], + remaining_token0, + )?; + + invoke( + &transfer_ix, + &[ + ctx.accounts.vault_token0.to_account_info(), + destination_account_info, + ctx.accounts.vault_authority.to_account_info(), + ], + )?; + } + + // Transfer remaining token1 to appropriate V6 vault treasury ATA + if remaining_token1 > 0 { + let (destination_key, destination_account_info) = if base_is_token0 { + ( + ctx.accounts.v6_vault_quote_ata.key(), + ctx.accounts.v6_vault_quote_ata.to_account_info(), + ) + } else { + ( + ctx.accounts.v6_vault_base_ata.key(), + ctx.accounts.v6_vault_base_ata.to_account_info(), + ) + }; + + let transfer_ix = spl_token::instruction::transfer( + &ctx.accounts.token_program.key(), + &ctx.accounts.vault_token1.key(), + &destination_key, + &ctx.accounts.vault_authority.key(), + &[], + remaining_token1, + )?; + + invoke( + &transfer_ix, + &[ + ctx.accounts.vault_token1.to_account_info(), + destination_account_info, + ctx.accounts.vault_authority.to_account_info(), + ], + )?; + } + + // Map remaining token0/token1 to base/quote for event + let (treasury_base_transferred, treasury_quote_transferred) = if base_is_token0 { + (remaining_token0, remaining_token1) + } else { + (remaining_token1, remaining_token0) + }; + + // 10. Emit migration event for audit trail + emit!(MigrationExecuted { + vault_authority: ctx.accounts.vault_authority.key(), + lp_amount, + withdrawn_base, + withdrawn_quote, + base_to_meteora, + quote_to_meteora, + base_to_futarchy, + quote_to_futarchy, + meteora_pool: ctx.accounts.meteora_accounts.pool.key(), + treasury_base_transferred, + treasury_quote_transferred, + }); + + Ok(()) + } + + #[inline(never)] + fn create_meteora_pool( + &self, + base_to_meteora: u64, + quote_to_meteora: u64, + _base_is_token0: bool, + migration_signer_bump: u8, + position_nft_mint_bump: u8, + pool_creator_authority_bump: u8, + ) -> Result<()> { + let base_mint_key = self.base_mint.key(); + + // Migration signer seeds - this PDA will sign for token transfers to Meteora + let migration_signer_seeds = &[ + b"migration_signer".as_ref(), + base_mint_key.as_ref(), + &[migration_signer_bump], + ]; + + let position_nft_mint_signer_seeds = &[ + b"position_nft_mint".as_ref(), + base_mint_key.as_ref(), + &[position_nft_mint_bump], + ]; + + let pool_creator_authority_signer_seeds = &[ + b"damm_pool_creator_authority".as_ref(), + &[pool_creator_authority_bump], + ]; + + // Include migration_signer_seeds so it can sign for token transfers + let pool_init_signer = &[ + &migration_signer_seeds[..], + &position_nft_mint_signer_seeds[..], + &pool_creator_authority_signer_seeds[..], + ]; + + // Calculate price from the token amounts: price = quote / base + let float_price = quote_to_meteora as f64 / base_to_meteora as f64; + let sqrt_price_float = float_price.sqrt(); + // sqrt_price in Q64.64 format (scaled by 2^64) for the CPI + let sqrt_price = (sqrt_price_float * 2_f64.powf(64.0)) as u128; + + // Calculate liquidity for TWO-SIDED full-range position (MIN to MAX) + let liquidity = (base_to_meteora as u128) + .checked_mul(sqrt_price) + .ok_or(RaydiumMigrationError::MathOverflow)?; + + msg!( + "Meteora two-sided liquidity calc: base={}, quote={}, sqrt_price={}, liquidity={}", + base_to_meteora, + quote_to_meteora, + sqrt_price, + liquidity + ); + + msg!("Meteora liquidity: {}", liquidity); + msg!( + "Meteora pool: {}, token_a_vault: {}, token_b_vault: {}", + self.meteora_accounts.pool.key(), + self.meteora_accounts.token_a_vault.key(), + self.meteora_accounts.token_b_vault.key() + ); + + // Meteora requires token_b (quote) to be SOL or USDC + let payer_token_a = self.migration_signer_base_ata.to_account_info(); + let payer_token_b = self.migration_signer_quote_ata.to_account_info(); + + damm_v2_cpi::cpi::initialize_pool_with_dynamic_config( + CpiContext::new_with_signer( + self.meteora_accounts.damm_v2_program.to_account_info(), + damm_v2_cpi::cpi::accounts::InitializePoolWithDynamicConfigCtx { + creator: self.v6_vault_pda.to_account_info(), + position_nft_mint: self.meteora_accounts.position_nft_mint.to_account_info(), + position_nft_account: self + .meteora_accounts + .position_nft_account + .to_account_info(), + payer: self.migration_signer.to_account_info(), + pool_creator_authority: self + .meteora_accounts + .pool_creator_authority + .to_account_info(), + config: self.meteora_accounts.config.to_account_info(), + pool_authority: self.meteora_accounts.pool_authority.to_account_info(), + token_a_vault: self.meteora_accounts.token_a_vault.to_account_info(), + token_b_vault: self.meteora_accounts.token_b_vault.to_account_info(), + payer_token_a, + payer_token_b, + token_a_program: self.token_program.to_account_info(), + token_b_program: self.token_program.to_account_info(), + token_2022_program: self.meteora_accounts.token_2022_program.to_account_info(), + system_program: self.system_program.to_account_info(), + pool: self.meteora_accounts.pool.to_account_info(), + position: self.meteora_accounts.position.to_account_info(), + token_a_mint: self.base_mint.to_account_info(), + token_b_mint: self.quote_mint.to_account_info(), + event_authority: self + .meteora_accounts + .damm_v2_event_authority + .to_account_info(), + program: self.meteora_accounts.damm_v2_program.to_account_info(), + }, + pool_init_signer, + ), + damm_v2_cpi::InitializeCustomizablePoolParameters { + pool_fees: damm_v2_cpi::PoolFeeParameters { + base_fee: BaseFeeParameters { + cliff_fee_numerator: 5000000, // 0.5% + number_of_period: 0, + period_frequency: 0, + reduction_factor: 0, + fee_scheduler_mode: 0, + }, + padding: [0; 3], + dynamic_fee: None, + }, + activation_point: None, + activation_type: 0, + collect_fee_mode: 0, + sqrt_min_price: MIN_SQRT_PRICE, + sqrt_max_price: MAX_SQRT_PRICE, + has_alpha_vault: false, + liquidity, + sqrt_price, + }, + ) + } +} + +// ===== Helper Functions ===== + +pub fn max_key(left: &Pubkey, right: &Pubkey) -> [u8; 32] { + std::cmp::max(left, right).to_bytes() +} + +pub fn min_key(left: &Pubkey, right: &Pubkey) -> [u8; 32] { + std::cmp::min(left, right).to_bytes() +} diff --git a/programs/raydium_migration_helper/src/lib.rs b/programs/raydium_migration_helper/src/lib.rs new file mode 100644 index 000000000..b52bff4a6 --- /dev/null +++ b/programs/raydium_migration_helper/src/lib.rs @@ -0,0 +1,61 @@ +use anchor_lang::prelude::*; +use anchor_lang::solana_program::program::invoke; +use anchor_spl::token::{Mint, Token, TokenAccount}; +use anchor_spl::token_2022::Token2022; +use damm_v2_cpi::constants::seeds::{ + POOL_AUTHORITY_PREFIX, POOL_PREFIX, POSITION_NFT_ACCOUNT_PREFIX, POSITION_PREFIX, + TOKEN_VAULT_PREFIX, +}; +use damm_v2_cpi::constants::{MAX_SQRT_PRICE, MIN_SQRT_PRICE}; +use damm_v2_cpi::program::DammV2Cpi; +use damm_v2_cpi::BaseFeeParameters; +use futarchy::program::Futarchy; +use futarchy::ProvideLiquidityParams; +use raydium_cpmm_cpi::program::RaydiumCpmm; +use spl_token; + +pub mod error; +pub mod events; +pub mod instructions; + +pub use error::*; +pub use events::*; +pub use instructions::*; + +#[cfg(not(feature = "no-entrypoint"))] +use solana_security_txt::security_txt; + +#[cfg(not(feature = "no-entrypoint"))] +security_txt! { + name: "raydium_migration_helper", + project_url: "https://metadao.fi", + contacts: "telegram:metaproph3t,telegram:kollan_house", + source_code: "https://github.com/metaDAOproject/programs", + source_release: "v0.1.0", + policy: "The market will decide whether we pay a bug bounty.", + acknowledgements: "Helper program for atomically withdrawing Raydium LP and providing liquidity to futarchy V6 AMM" +} + +declare_id!("migR87BnBEkJbbDECLzRxhmNsQ44WMzhDCpCJhfPvR1"); + +#[program] +pub mod raydium_migration_helper { + use super::*; + + #[access_control(ctx.accounts.validate(lp_amount))] + pub fn withdraw_and_provide_liquidity( + ctx: Context, + lp_amount: u64, + min_raydium_amount_0: u64, + min_raydium_amount_1: u64, + min_futarchy_liquidity: u64, + ) -> Result<()> { + WithdrawAndProvideLiquidity::handle( + ctx, + lp_amount, + min_raydium_amount_0, + min_raydium_amount_1, + min_futarchy_liquidity, + ) + } +} diff --git a/scripts/v0.5/migrateToV6Raydium.ts b/scripts/v0.5/migrateToV6Raydium.ts new file mode 100644 index 000000000..59dc53afe --- /dev/null +++ b/scripts/v0.5/migrateToV6Raydium.ts @@ -0,0 +1,1464 @@ +import "dotenv/config"; +import { + PublicKey, + Transaction, + VersionedTransaction, + LAMPORTS_PER_SOL, + SystemProgram, + TransactionMessage, + ComputeBudgetProgram, + AddressLookupTableProgram, + AddressLookupTableAccount, + Keypair, +} from "@solana/web3.js"; +import * as anchor from "@coral-xyz/anchor"; +import { BN } from "bn.js"; +import * as multisig from "@sqds/multisig"; +import { + createSetAuthorityInstruction, + AuthorityType, + getAssociatedTokenAddressSync, + createAssociatedTokenAccountIdempotentInstruction, + getMint, + TOKEN_PROGRAM_ID, + TOKEN_2022_PROGRAM_ID, +} from "@solana/spl-token"; +import { + getMetadataAccountDataSerializer, + updateMetadataAccountV2, +} from "@metaplex-foundation/mpl-token-metadata"; +import { createUmi } from "@metaplex-foundation/umi-bundle-defaults"; +import { + publicKey as UmiPublicKey, + createNoopSigner, +} from "@metaplex-foundation/umi"; +import { toWeb3JsInstruction } from "@metaplex-foundation/umi-web3js-adapters"; +import { sha256 } from "@noble/hashes/sha256"; + +import { + AutocratClient, + AmmClient, + ConditionalVaultClient, + AUTOCRAT_PROGRAM_ID, + PERMISSIONLESS_ACCOUNT, + getProposalAddr, + getMetadataAddr, + InstructionUtils, + LAUNCHPAD_PROGRAM_ID, + RAYDIUM_CP_SWAP_PROGRAM_ID, + RAYDIUM_AUTHORITY, + getLiquidityPoolAddr, + getRaydiumCpmmLpMintAddr, + getRaydiumCpmmPoolVaultAddr, +} from "@metadaoproject/futarchy/v0.5"; + +import { + FutarchyClient as FutarchyClientV6, + DAMM_V2_PROGRAM_ID, +} from "@metadaoproject/futarchy/v0.6"; + +import { getSquadsPdasFromDao } from "../utils/squads.js"; +import { + sendBundle, + getTipAccounts, + getTipFloor, + getBundleStatuses, +} from "../utils/bundles.js"; +import { IDL as RaydiumMigrationHelperIDL } from "../../target/types/raydium_migration_helper.js"; + +// ===== CONFIGURATION ===== +// V5 DAO to migrate FROM +const V5_DAO_ADDRESS = new PublicKey( + "9NCPLEFgiu4XZdp9wtWMc1mXyY26VGeWsoKHCAPP3bAo", +); + +// V6 DAO to migrate TO (must already be initialized) +const V6_DAO_ADDRESS = new PublicKey( + "Cn2wML7SWX2x5mroSKp5eSd9QEkBRjccAXqQ9YWiwZNx", +); + +// Set to true to create full futarchy proposal with Jito bundle +// Set to false to only create Squads proposal (for testing/simulation) +const FULL_PROPOSAL = false; + +// Program IDs +const MEMO_PROGRAM_ID = new PublicKey( + "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr", +); +const RAYDIUM_MIGRATION_HELPER_PROGRAM_ID = new PublicKey( + "migR87BnBEkJbbDECLzRxhmNsQ44WMzhDCpCJhfPvR1", +); +const MIGRATION_METEORA_CONFIG = new PublicKey( + "5FSCTMuJcrsahe8nB7P3LooAYv5U5GNgBPY8JYjWKfHr", +); +// ========================= + +// ===== UTILS ===== +function maxKey(left: PublicKey, right: PublicKey): Buffer { + const leftBuffer = left.toBuffer(); + const rightBuffer = right.toBuffer(); + for (let i = 0; i < 32; i++) { + if (leftBuffer[i] > rightBuffer[i]) return leftBuffer; + if (leftBuffer[i] < rightBuffer[i]) return rightBuffer; + } + return leftBuffer; +} + +function minKey(left: PublicKey, right: PublicKey): Buffer { + const leftBuffer = left.toBuffer(); + const rightBuffer = right.toBuffer(); + for (let i = 0; i < 32; i++) { + if (leftBuffer[i] < rightBuffer[i]) return leftBuffer; + if (leftBuffer[i] > rightBuffer[i]) return rightBuffer; + } + return leftBuffer; +} + +function getMeteoraPdas(baseMint: PublicKey, quoteMint: PublicKey) { + const [migrationSigner] = PublicKey.findProgramAddressSync( + [Buffer.from("migration_signer"), baseMint.toBuffer()], + RAYDIUM_MIGRATION_HELPER_PROGRAM_ID, + ); + + const [positionNftMint] = PublicKey.findProgramAddressSync( + [Buffer.from("position_nft_mint"), baseMint.toBuffer()], + RAYDIUM_MIGRATION_HELPER_PROGRAM_ID, + ); + + const [poolCreatorAuthority] = PublicKey.findProgramAddressSync( + [Buffer.from("damm_pool_creator_authority")], + RAYDIUM_MIGRATION_HELPER_PROGRAM_ID, + ); + + const [pool] = PublicKey.findProgramAddressSync( + [ + Buffer.from("pool"), + MIGRATION_METEORA_CONFIG.toBuffer(), + maxKey(baseMint, quoteMint), + minKey(baseMint, quoteMint), + ], + DAMM_V2_PROGRAM_ID, + ); + + const [positionNftAccount] = PublicKey.findProgramAddressSync( + [Buffer.from("position_nft_account"), positionNftMint.toBuffer()], + DAMM_V2_PROGRAM_ID, + ); + + const [position] = PublicKey.findProgramAddressSync( + [Buffer.from("position"), positionNftMint.toBuffer()], + DAMM_V2_PROGRAM_ID, + ); + + const [tokenAVault] = PublicKey.findProgramAddressSync( + [Buffer.from("token_vault"), baseMint.toBuffer(), pool.toBuffer()], + DAMM_V2_PROGRAM_ID, + ); + + const [tokenBVault] = PublicKey.findProgramAddressSync( + [Buffer.from("token_vault"), quoteMint.toBuffer(), pool.toBuffer()], + DAMM_V2_PROGRAM_ID, + ); + + const [poolAuthority] = PublicKey.findProgramAddressSync( + [Buffer.from("pool_authority")], + DAMM_V2_PROGRAM_ID, + ); + + const [dammV2EventAuthority] = PublicKey.findProgramAddressSync( + [Buffer.from("__event_authority")], + DAMM_V2_PROGRAM_ID, + ); + + const migrationSignerBaseAta = getAssociatedTokenAddressSync( + baseMint, + migrationSigner, + true, + ); + const migrationSignerQuoteAta = getAssociatedTokenAddressSync( + quoteMint, + migrationSigner, + true, + ); + + return { + migrationSigner, + migrationSignerBaseAta, + migrationSignerQuoteAta, + positionNftMint, + poolCreatorAuthority, + pool, + positionNftAccount, + position, + tokenAVault, + tokenBVault, + poolAuthority, + dammV2EventAuthority, + }; +} +// ================= + +const provider = anchor.AnchorProvider.env(); +const payer = provider.wallet["payer"]; + +// V5 clients +const autocratClient = AutocratClient.createClient({ provider }); +const vaultClient = ConditionalVaultClient.createClient({ provider }); +const ammClient = AmmClient.createClient({ provider }); + +// V6 client +const futarchyV6 = FutarchyClientV6.createClient({ provider }); + +async function main() { + if (!process.env.JITO_AUTH_TOKEN) { + console.log( + "Warning: No Jito auth token provided, results may be unreliable", + ); + } + + console.log("=".repeat(60)); + console.log("MIGRATE V5 DAO TO V6 DAO (Raydium LP)"); + console.log("=".repeat(60)); + console.log( + `Mode: ${FULL_PROPOSAL ? "FULL PROPOSAL (Squads + Futarchy markets)" : "SQUADS ONLY (for simulation)"}`, + ); + console.log("=".repeat(60)); + + // Step 1: Fetch V5 DAO data + console.log("\n[1] Fetching V5 DAO data..."); + const v5Dao = await autocratClient.getDao(V5_DAO_ADDRESS); + const { multisigPda: v5MultisigPda, vaultPda: v5VaultPda } = + await getSquadsPdasFromDao(V5_DAO_ADDRESS); + + console.log(" V5 DAO Address:", V5_DAO_ADDRESS.toBase58()); + console.log(" Base Mint:", v5Dao.baseMint.toBase58()); + console.log(" Quote Mint:", v5Dao.quoteMint.toBase58()); + console.log(" V5 Squads Multisig:", v5MultisigPda.toBase58()); + console.log(" V5 Vault PDA:", v5VaultPda.toBase58()); + + // Step 2: Fetch and verify V6 DAO + console.log("\n[2] Fetching V6 DAO data..."); + const v6Dao = await futarchyV6.fetchDao(V6_DAO_ADDRESS); + if (!v6Dao) { + throw new Error( + `V6 DAO not found at ${V6_DAO_ADDRESS.toBase58()}. Make sure it's already initialized.`, + ); + } + + // Verify mints match + if (!v6Dao.baseMint.equals(v5Dao.baseMint)) { + throw new Error( + `Base mint mismatch! V5: ${v5Dao.baseMint.toBase58()}, V6: ${v6Dao.baseMint.toBase58()}`, + ); + } + if (!v6Dao.quoteMint.equals(v5Dao.quoteMint)) { + throw new Error( + `Quote mint mismatch! V5: ${v5Dao.quoteMint.toBase58()}, V6: ${v6Dao.quoteMint.toBase58()}`, + ); + } + + const v6MultisigPda = multisig.getMultisigPda({ + createKey: V6_DAO_ADDRESS, + })[0]; + const v6VaultPda = multisig.getVaultPda({ + multisigPda: v6MultisigPda, + index: 0, + })[0]; + + console.log(" V6 DAO Address:", V6_DAO_ADDRESS.toBase58()); + console.log(" V6 Squads Multisig:", v6MultisigPda.toBase58()); + console.log(" V6 Vault PDA:", v6VaultPda.toBase58()); + console.log(" ✓ Base mint matches V5 DAO"); + console.log(" ✓ Quote mint matches V5 DAO"); + + // Step 3: Fetch token decimals and liquidity requirements + console.log("\n[3] Fetching token decimals and liquidity requirements..."); + + // Get mint decimals + const baseMintInfo = await getMint(provider.connection, v5Dao.baseMint); + const quoteMintInfo = await getMint(provider.connection, v5Dao.quoteMint); + const baseDecimals = baseMintInfo.decimals; + const quoteDecimals = quoteMintInfo.decimals; + + console.log(" Base token decimals:", baseDecimals); + console.log(" Quote token decimals:", quoteDecimals); + + // Get min liquidity requirements from DAO + const minBaseLiquidity = v5Dao.minBaseFutarchicLiquidity; + const minQuoteLiquidity = v5Dao.minQuoteFutarchicLiquidity; + + const baseMultiplier = Math.pow(10, baseDecimals); + const quoteMultiplier = Math.pow(10, quoteDecimals); + + console.log( + " Min base liquidity required:", + (minBaseLiquidity.toNumber() / baseMultiplier).toLocaleString(), + ); + console.log( + " Min quote liquidity required:", + (minQuoteLiquidity.toNumber() / quoteMultiplier).toLocaleString(), + ); + + // Check payer's wallet balances (only needed for full proposal) + if (FULL_PROPOSAL) { + const payerBaseAta = getAssociatedTokenAddressSync( + v5Dao.baseMint, + payer.publicKey, + true, + ); + const payerQuoteAta = getAssociatedTokenAddressSync( + v5Dao.quoteMint, + payer.publicKey, + true, + ); + + let payerBaseBalance = BigInt(0); + let payerQuoteBalance = BigInt(0); + + try { + const baseInfo = + await provider.connection.getTokenAccountBalance(payerBaseAta); + payerBaseBalance = BigInt(baseInfo.value.amount); + } catch { + console.log(" ⚠ Base token account doesn't exist"); + } + + try { + const quoteInfo = + await provider.connection.getTokenAccountBalance(payerQuoteAta); + payerQuoteBalance = BigInt(quoteInfo.value.amount); + } catch { + console.log(" ⚠ Quote token account doesn't exist"); + } + + console.log( + " Your base token balance:", + (Number(payerBaseBalance) / baseMultiplier).toLocaleString(), + ); + console.log( + " Your quote token balance:", + (Number(payerQuoteBalance) / quoteMultiplier).toLocaleString(), + ); + + // Check if payer has enough tokens + if (payerBaseBalance < BigInt(minBaseLiquidity.toString())) { + const needed = ( + minBaseLiquidity.toNumber() / baseMultiplier + ).toLocaleString(); + const have = (Number(payerBaseBalance) / baseMultiplier).toLocaleString(); + throw new Error(`Insufficient base tokens! Need ${needed}, have ${have}`); + } + if (payerQuoteBalance < BigInt(minQuoteLiquidity.toString())) { + const needed = ( + minQuoteLiquidity.toNumber() / quoteMultiplier + ).toLocaleString(); + const have = ( + Number(payerQuoteBalance) / quoteMultiplier + ).toLocaleString(); + throw new Error( + `Insufficient quote tokens! Need ${needed}, have ${have}`, + ); + } + + console.log(" ✓ Wallet has sufficient tokens for proposal liquidity"); + } else { + console.log(" (Skipping wallet balance check - Squads only mode)"); + } + + // Step 4: Get vault token balances to transfer + console.log("\n[4] Fetching V5 vault token balances..."); + // Create token accounts for vaults and DAO AMM using idempotent instructions + const v5VaultBaseAta = getAssociatedTokenAddressSync( + v5Dao.baseMint, + v5VaultPda, + true, + ); + const v5VaultQuoteAta = getAssociatedTokenAddressSync( + v5Dao.quoteMint, + v5VaultPda, + true, + ); + const v6VaultBaseAta = getAssociatedTokenAddressSync( + v5Dao.baseMint, + v6VaultPda, + true, + ); + const v6VaultQuoteAta = getAssociatedTokenAddressSync( + v5Dao.quoteMint, + v6VaultPda, + true, + ); + const ammBaseVault = getAssociatedTokenAddressSync( + v5Dao.baseMint, + V6_DAO_ADDRESS, + true, + ); + const ammQuoteVault = getAssociatedTokenAddressSync( + v5Dao.quoteMint, + V6_DAO_ADDRESS, + true, + ); + + // Get migration_signer PDA and its ATAs (needed for Meteora CPI) + const meteoraPdasForAtas = getMeteoraPdas(v5Dao.baseMint, v5Dao.quoteMint); + const migrationSignerBaseAta = getAssociatedTokenAddressSync( + v5Dao.baseMint, + meteoraPdasForAtas.migrationSigner, + true, + ); + const migrationSignerQuoteAta = getAssociatedTokenAddressSync( + v5Dao.quoteMint, + meteoraPdasForAtas.migrationSigner, + true, + ); + + const createAtasIx = [ + createAssociatedTokenAccountIdempotentInstruction( + payer.publicKey, + v5VaultBaseAta, + v5VaultPda, + v5Dao.baseMint, + ), + createAssociatedTokenAccountIdempotentInstruction( + payer.publicKey, + v6VaultBaseAta, + v6VaultPda, + v5Dao.baseMint, + ), + createAssociatedTokenAccountIdempotentInstruction( + payer.publicKey, + v5VaultQuoteAta, + v5VaultPda, + v5Dao.quoteMint, + ), + createAssociatedTokenAccountIdempotentInstruction( + payer.publicKey, + v6VaultQuoteAta, + v6VaultPda, + v5Dao.quoteMint, + ), + createAssociatedTokenAccountIdempotentInstruction( + payer.publicKey, + ammBaseVault, + V6_DAO_ADDRESS, + v5Dao.baseMint, + ), + createAssociatedTokenAccountIdempotentInstruction( + payer.publicKey, + ammQuoteVault, + V6_DAO_ADDRESS, + v5Dao.quoteMint, + ), + // Migration signer ATAs (needed for Meteora CPI) + createAssociatedTokenAccountIdempotentInstruction( + payer.publicKey, + migrationSignerBaseAta, + meteoraPdasForAtas.migrationSigner, + v5Dao.baseMint, + ), + createAssociatedTokenAccountIdempotentInstruction( + payer.publicKey, + migrationSignerQuoteAta, + meteoraPdasForAtas.migrationSigner, + v5Dao.quoteMint, + ), + ]; + + const createAtasTx = new Transaction().add(...createAtasIx); + createAtasTx.recentBlockhash = ( + await provider.connection.getLatestBlockhash() + ).blockhash; + createAtasTx.feePayer = payer.publicKey; + const signedCreateAtasTx = + await provider.wallet.signTransaction(createAtasTx); + const createAtasSig = await provider.connection.sendRawTransaction( + signedCreateAtasTx.serialize(), + ); + await provider.connection.confirmTransaction(createAtasSig); + console.log(" ✓ Token accounts created:", createAtasSig); + + // Fund migration_signer PDA with SOL for Meteora pool creation rent + // Meteora creates multiple accounts: pool, vaults, position NFT, etc. (~8.63M lamports needed) + const migrationSignerRent = 0.02 * LAMPORTS_PER_SOL; // 0.02 SOL for rent + const fundMigrationSignerIx = SystemProgram.transfer({ + fromPubkey: payer.publicKey, + toPubkey: meteoraPdasForAtas.migrationSigner, + lamports: migrationSignerRent, + }); + const fundMigrationSignerTx = new Transaction().add(fundMigrationSignerIx); + fundMigrationSignerTx.recentBlockhash = ( + await provider.connection.getLatestBlockhash() + ).blockhash; + fundMigrationSignerTx.feePayer = payer.publicKey; + const signedFundMigrationSignerTx = await provider.wallet.signTransaction( + fundMigrationSignerTx, + ); + const fundMigrationSignerSig = await provider.connection.sendRawTransaction( + signedFundMigrationSignerTx.serialize(), + ); + await provider.connection.confirmTransaction(fundMigrationSignerSig); + console.log( + ` ✓ Migration signer funded with ${migrationSignerRent / LAMPORTS_PER_SOL} SOL:`, + fundMigrationSignerSig, + ); + + let baseBalance = BigInt(0); + let quoteBalance = BigInt(0); + + try { + const baseInfo = + await provider.connection.getTokenAccountBalance(v5VaultBaseAta); + baseBalance = BigInt(baseInfo.value.amount); + console.log( + " Base token balance:", + (Number(baseBalance) / baseMultiplier).toLocaleString(), + ); + } catch { + console.log(" Base token balance: 0 (no account)"); + } + + try { + const quoteInfo = + await provider.connection.getTokenAccountBalance(v5VaultQuoteAta); + quoteBalance = BigInt(quoteInfo.value.amount); + console.log( + " Quote token balance:", + (Number(quoteBalance) / quoteMultiplier).toLocaleString(), + ); + } catch { + console.log(" Quote token balance: 0 (no account)"); + } + + // Step 4.5: Fetch Raydium CPMM pool info and LP balance + console.log("\n[4.5] Fetching Raydium CPMM pool info..."); + + const [raydiumPoolState] = getLiquidityPoolAddr( + LAUNCHPAD_PROGRAM_ID, + V5_DAO_ADDRESS, + ); + const [raydiumLpMint] = getRaydiumCpmmLpMintAddr(raydiumPoolState, false); + const v5VaultLpAta = getAssociatedTokenAddressSync( + raydiumLpMint, + v5VaultPda, + true, + ); + + console.log(" Raydium Pool State:", raydiumPoolState.toBase58()); + console.log(" Raydium LP Mint:", raydiumLpMint.toBase58()); + + let lpBalance = BigInt(0); + try { + const lpInfo = + await provider.connection.getTokenAccountBalance(v5VaultLpAta); + lpBalance = BigInt(lpInfo.value.amount); + console.log(" Vault LP token balance:", lpInfo.value.uiAmountString); + } catch { + console.log( + " ⚠ Vault LP token balance: 0 (no account or pool doesn't exist)", + ); + } + + // Token ordering for Raydium: smaller pubkey is token0 + const isBaseToken0 = + v5Dao.baseMint.toBuffer().compare(v5Dao.quoteMint.toBuffer()) < 0; + const token0Mint = isBaseToken0 ? v5Dao.baseMint : v5Dao.quoteMint; + const token1Mint = isBaseToken0 ? v5Dao.quoteMint : v5Dao.baseMint; + + const [poolToken0Vault] = getRaydiumCpmmPoolVaultAddr( + raydiumPoolState, + token0Mint, + false, + ); + const [poolToken1Vault] = getRaydiumCpmmPoolVaultAddr( + raydiumPoolState, + token1Mint, + false, + ); + + console.log( + " Token ordering:", + isBaseToken0 ? "base=token0, quote=token1" : "quote=token0, base=token1", + ); + + // CRITICAL: Verify all pool addresses exist on mainnet + console.log("\n=== VERIFYING RAYDIUM POOL ON MAINNET ==="); + console.log("Pool Token0 Vault:", poolToken0Vault.toBase58()); + console.log("Pool Token1 Vault:", poolToken1Vault.toBase58()); + console.log("V5 Vault LP ATA:", v5VaultLpAta.toBase58()); + + const [ + poolStateInfo, + lpMintInfo, + v5VaultLpAtaInfo, + token0VaultInfo, + token1VaultInfo, + ] = await Promise.all([ + provider.connection.getAccountInfo(raydiumPoolState), + provider.connection.getAccountInfo(raydiumLpMint), + provider.connection.getAccountInfo(v5VaultLpAta), + provider.connection.getAccountInfo(poolToken0Vault), + provider.connection.getAccountInfo(poolToken1Vault), + ]); + + console.log("\nAccount existence check:"); + console.log(" ✓ Pool State exists:", poolStateInfo !== null); + console.log(" ✓ LP Mint exists:", lpMintInfo !== null); + console.log(" ✓ V5 Vault LP ATA exists:", v5VaultLpAtaInfo !== null); + console.log(" ✓ Pool Token0 Vault exists:", token0VaultInfo !== null); + console.log(" ✓ Pool Token1 Vault exists:", token1VaultInfo !== null); + + if (!poolStateInfo || !lpMintInfo) { + throw new Error( + "❌ CRITICAL: Raydium pool does not exist on mainnet!\n" + + `Pool State (${raydiumPoolState.toBase58()}): ${poolStateInfo ? "EXISTS" : "NOT FOUND"}\n` + + `LP Mint (${raydiumLpMint.toBase58()}): ${lpMintInfo ? "EXISTS" : "NOT FOUND"}\n` + + "The launchpad may not have created a Raydium pool, or this DAO uses a different liquidity mechanism.", + ); + } + + if (!token0VaultInfo || !token1VaultInfo) { + throw new Error( + "❌ CRITICAL: Pool vaults do not exist on mainnet!\n" + + `Token0 Vault (${poolToken0Vault.toBase58()}): ${token0VaultInfo ? "EXISTS" : "NOT FOUND"}\n` + + `Token1 Vault (${poolToken1Vault.toBase58()}): ${token1VaultInfo ? "EXISTS" : "NOT FOUND"}`, + ); + } + + console.log("\n✅ All Raydium pool accounts verified on mainnet!"); + + // Calculate expected withdrawal amounts from LP + let expectedBaseFromLp = BigInt(0); + let expectedQuoteFromLp = BigInt(0); + + if (lpBalance > 0n) { + try { + // Fetch pool vault balances and LP supply to calculate proportional withdrawal + const [token0VaultBalance, token1VaultBalance, lpMintInfo] = + await Promise.all([ + provider.connection.getTokenAccountBalance(poolToken0Vault), + provider.connection.getTokenAccountBalance(poolToken1Vault), + getMint(provider.connection, raydiumLpMint), + ]); + + const poolToken0Amount = BigInt(token0VaultBalance.value.amount); + const poolToken1Amount = BigInt(token1VaultBalance.value.amount); + const lpSupply = lpMintInfo.supply; + + // Calculate proportional share: (lpBalance / lpSupply) * poolAmount + const token0Share = (lpBalance * poolToken0Amount) / lpSupply; + const token1Share = (lpBalance * poolToken1Amount) / lpSupply; + + // Assign based on token ordering + if (isBaseToken0) { + expectedBaseFromLp = token0Share; + expectedQuoteFromLp = token1Share; + } else { + expectedBaseFromLp = token1Share; + expectedQuoteFromLp = token0Share; + } + + console.log( + " Expected base from LP withdrawal:", + (Number(expectedBaseFromLp) / baseMultiplier).toLocaleString(), + ); + console.log( + " Expected quote from LP withdrawal:", + (Number(expectedQuoteFromLp) / quoteMultiplier).toLocaleString(), + ); + } catch (e) { + console.log(" ⚠ Could not calculate LP withdrawal amounts:", e); + } + } + + // Step 5: Check mint authority + console.log("\n[5] Checking base mint authority..."); + const currentMintAuthority = baseMintInfo.mintAuthority; + + if (currentMintAuthority) { + console.log(" Current mint authority:", currentMintAuthority.toBase58()); + if (currentMintAuthority.equals(v5VaultPda)) { + console.log(" ✓ Mint authority is the V5 vault (will be transferred)"); + } else { + console.log( + " ⚠ Mint authority is NOT the V5 vault - cannot transfer via proposal", + ); + } + } else { + console.log(" ⚠ No mint authority (token is immutable)"); + } + + let canTransferMetadataAuthority = false; + + // Step 7: Build vault transaction instructions + console.log("\n[7] Building vault transaction instructions..."); + + const vaultInstructions: anchor.web3.TransactionInstruction[] = []; + + // Add compute budget instructions to vault transaction for Squads Explorer simulation + // These need to be included in the vault instructions for proper simulation + vaultInstructions.push( + ComputeBudgetProgram.setComputeUnitLimit({ units: 1_400_000 }), + ); + vaultInstructions.push( + ComputeBudgetProgram.requestHeapFrame({ bytes: 256 * 1024 }), + ); + + // 7a. Withdraw LP and provide liquidity to V6 AMM atomically (if any LP) + if (lpBalance > 0n) { + const helperProgram = new anchor.Program( + RaydiumMigrationHelperIDL, + RAYDIUM_MIGRATION_HELPER_PROGRAM_ID, + provider, + ); + + // Get V6 futarchy AMM accounts + // Note: position_authority is v6VaultPda - the V6 vault will own the AMM position + const [ammPosition] = PublicKey.findProgramAddressSync( + [ + Buffer.from("amm_position"), + V6_DAO_ADDRESS.toBuffer(), + v6VaultPda.toBuffer(), // position_authority (the V6 vault owns the AMM position) + ], + futarchyV6.getProgramId(), + ); + + const [eventAuthority] = PublicKey.findProgramAddressSync( + [Buffer.from("__event_authority")], + futarchyV6.getProgramId(), + ); + + // Reuse Meteora DAMM v2 PDAs for the new pool creation (derived earlier for ATA creation) + const meteoraPdas = meteoraPdasForAtas; + console.log(" Meteora Pool PDA:", meteoraPdas.pool.toBase58()); + console.log( + " Meteora Position NFT Mint:", + meteoraPdas.positionNftMint.toBase58(), + ); + + const withdrawAndProvideLiquidityIx = await helperProgram.methods + .withdrawAndProvideLiquidity( + new BN(lpBalance.toString()), + new BN(0), // min_raydium_amount_0 - no slippage protection for governance + new BN(0), // min_raydium_amount_1 + new BN(0), // min_futarchy_liquidity + ) + .accounts({ + vaultAuthority: v5VaultPda, + // Raydium withdrawal accounts + poolState: raydiumPoolState, + raydiumAuthority: RAYDIUM_AUTHORITY, + lpMint: raydiumLpMint, + vaultLpToken: v5VaultLpAta, + vaultToken0: isBaseToken0 ? v5VaultBaseAta : v5VaultQuoteAta, + vaultToken1: isBaseToken0 ? v5VaultQuoteAta : v5VaultBaseAta, + poolToken0Vault: poolToken0Vault, + poolToken1Vault: poolToken1Vault, + // V6 Futarchy AMM accounts + dao: V6_DAO_ADDRESS, + baseMint: v5Dao.baseMint, + quoteMint: v5Dao.quoteMint, + ammPosition: ammPosition, + ammBaseVault: ammBaseVault, + ammQuoteVault: ammQuoteVault, + v6VaultBaseAta: v6VaultBaseAta, + v6VaultQuoteAta: v6VaultQuoteAta, + v6VaultPda: v6VaultPda, + eventAuthority: eventAuthority, + // Migration signer accounts (PDA that holds tokens during Meteora CPI) + migrationSigner: meteoraPdas.migrationSigner, + migrationSignerBaseAta: meteoraPdas.migrationSignerBaseAta, + migrationSignerQuoteAta: meteoraPdas.migrationSignerQuoteAta, + // Meteora DAMM v2 accounts + meteoraAccounts: { + dammV2Program: DAMM_V2_PROGRAM_ID, + config: MIGRATION_METEORA_CONFIG, + token2022Program: TOKEN_2022_PROGRAM_ID, + positionNftAccount: meteoraPdas.positionNftAccount, + pool: meteoraPdas.pool, + position: meteoraPdas.position, + positionNftMint: meteoraPdas.positionNftMint, + baseMint: v5Dao.baseMint, + quoteMint: v5Dao.quoteMint, + tokenAVault: meteoraPdas.tokenAVault, + tokenBVault: meteoraPdas.tokenBVault, + poolCreatorAuthority: meteoraPdas.poolCreatorAuthority, + poolAuthority: meteoraPdas.poolAuthority, + dammV2EventAuthority: meteoraPdas.dammV2EventAuthority, + }, + // Programs + raydiumProgram: RAYDIUM_CP_SWAP_PROGRAM_ID, + futarchyProgram: futarchyV6.getProgramId(), + tokenProgram: TOKEN_PROGRAM_ID, + tokenProgram2022: TOKEN_2022_PROGRAM_ID, + systemProgram: SystemProgram.programId, + memoProgram: MEMO_PROGRAM_ID, + }) + .instruction(); + + vaultInstructions.push(withdrawAndProvideLiquidityIx); + console.log( + " Added: Withdraw LP → 90% to Futarchy AMM, 10% to Meteora DAMM v2", + ); + console.log( + " Instruction accounts:", + withdrawAndProvideLiquidityIx.keys.length, + ); + console.log(" LP tokens to withdraw:", lpBalance.toString()); + console.log(" → Expected withdrawal from Raydium:"); + console.log( + " Base:", + (Number(expectedBaseFromLp) / baseMultiplier).toLocaleString(), + ); + console.log( + " Quote:", + (Number(expectedQuoteFromLp) / quoteMultiplier).toLocaleString(), + ); + console.log(" → 90% to Futarchy V6 AMM:"); + console.log( + " Base:", + ((Number(expectedBaseFromLp) * 0.9) / baseMultiplier).toLocaleString(), + ); + console.log( + " Quote:", + ((Number(expectedQuoteFromLp) * 0.9) / quoteMultiplier).toLocaleString(), + ); + console.log(" → 10% to Meteora DAMM v2 pool:"); + console.log( + " Base:", + ((Number(expectedBaseFromLp) * 0.1) / baseMultiplier).toLocaleString(), + ); + console.log( + " Quote:", + ((Number(expectedQuoteFromLp) * 0.1) / quoteMultiplier).toLocaleString(), + ); + console.log(" → Treasury (existing balance, transferred to V6):"); + console.log( + " Base:", + (Number(baseBalance) / baseMultiplier).toLocaleString(), + ); + console.log( + " Quote:", + (Number(quoteBalance) / quoteMultiplier).toLocaleString(), + ); + } + + // Calculate total amounts for logging (existing + LP withdrawal) + const totalBaseToTransfer = baseBalance + expectedBaseFromLp; + const totalQuoteToTransfer = quoteBalance + expectedQuoteFromLp; + + // 7d. Transfer mint authority from V5 vault to V6 vault + if (currentMintAuthority && currentMintAuthority.equals(v5VaultPda)) { + vaultInstructions.push( + createSetAuthorityInstruction( + v5Dao.baseMint, + v5VaultPda, + AuthorityType.MintTokens, + v6VaultPda, + ), + ); + console.log(" Added: Transfer mint authority to V6 vault"); + } + + // 7e. Transfer metadata update authority from V5 vault to V6 vault + const [metadataAddr] = getMetadataAddr(v5Dao.baseMint); + try { + const metadataAccountInfo = + await provider.connection.getAccountInfo(metadataAddr); + if (metadataAccountInfo) { + const metadataSerializer = getMetadataAccountDataSerializer(); + const [metadata] = metadataSerializer.deserialize( + metadataAccountInfo.data, + ); + const updateAuthority = new PublicKey(metadata.updateAuthority); + if (updateAuthority.equals(v5VaultPda)) { + // Use UMI to build the metadata update instruction + const umi = createUmi(provider.connection.rpcEndpoint); + // Create a noop signer for the vault - actual signing happens in Squads execution + const vaultSigner = createNoopSigner( + UmiPublicKey(v5VaultPda.toBase58()), + ); + const umiUpdateIxs = updateMetadataAccountV2(umi, { + metadata: UmiPublicKey(metadataAddr.toBase58()), + updateAuthority: vaultSigner, // Current authority (will be signer in vault tx) + newUpdateAuthority: UmiPublicKey(v6VaultPda.toBase58()), + }).getInstructions(); + + for (const umiIx of umiUpdateIxs) { + vaultInstructions.push(toWeb3JsInstruction(umiIx)); + } + canTransferMetadataAuthority = true; + console.log(" Added: Transfer metadata update authority to V6 vault"); + } + } + } catch (e: any) { + console.log( + " ⚠ Could not add metadata authority transfer:", + e.message || e, + ); + } + + console.log("Total vault instructions:", vaultInstructions.length); + + // Step 7.5: Detailed instruction debugging + console.log("\n[7.5] Detailed instruction analysis..."); + const uniqueProgramIds = new Set(); + for (let i = 0; i < vaultInstructions.length; i++) { + const ix = vaultInstructions[i]; + uniqueProgramIds.add(ix.programId.toBase58()); + console.log(`\n Instruction ${i + 1}/${vaultInstructions.length}:`); + console.log(` Program ID: ${ix.programId.toBase58()}`); + console.log(` Data length: ${ix.data.length} bytes`); + console.log(` Accounts (${ix.keys.length}):`); + ix.keys.forEach((key, idx) => { + console.log( + ` [${idx}] ${key.pubkey.toBase58()} ${key.isSigner ? "(signer)" : ""} ${key.isWritable ? "(writable)" : "(readonly)"}`, + ); + }); + } + + // Verify all program IDs exist on-chain + console.log("\n Verifying program IDs exist on-chain..."); + for (const programId of uniqueProgramIds) { + const programInfo = await provider.connection.getAccountInfo( + new PublicKey(programId), + ); + if (!programInfo) { + throw new Error(`❌ Program ID ${programId} does not exist on-chain!`); + } + if (!programInfo.executable) { + throw new Error(`❌ Program ID ${programId} is not executable!`); + } + console.log(` ✓ ${programId} (executable)`); + } + + // Create lookup table to compress transaction (mirrors test's createLookupTableForTransaction) + const tempTx = new Transaction().add(...vaultInstructions); + + // use a different authority for the lookup table to avoid conflicts + const lookupAuthority = Keypair.generate(); + const slot = await provider.connection.getSlot(); + + const [createTableIx, lookupTableAddress] = + AddressLookupTableProgram.createLookupTable({ + authority: lookupAuthority.publicKey, + payer: payer.publicKey, + recentSlot: slot - 1, + }); + + // Extract all unique accounts from the transaction (mirrors test exactly) + const accountsToAdd = tempTx.instructions.map((instruction) => + instruction.keys.map((key) => key.pubkey), + ); + // Deduplicate by base58 string, not object identity + const accountStrings = new Set(); + const uniqueAccounts: PublicKey[] = []; + for (const account of accountsToAdd.flat()) { + const accountStr = account.toBase58(); + if (!accountStrings.has(accountStr)) { + accountStrings.add(accountStr); + uniqueAccounts.push(account); + } + } + console.log("uniqueAccounts", uniqueAccounts.length); + + // Create the lookup table + const createLutTx = new Transaction().add(createTableIx); + createLutTx.recentBlockhash = ( + await provider.connection.getLatestBlockhash() + ).blockhash; + createLutTx.feePayer = payer.publicKey; + createLutTx.partialSign(lookupAuthority); + const signedCreateLutTx = await provider.wallet.signTransaction(createLutTx); + const createLutSig = await provider.connection.sendRawTransaction( + signedCreateLutTx.serialize(), + ); + await provider.connection.confirmTransaction(createLutSig); + console.log(" ✓ LUT created:", lookupTableAddress.toBase58()); + + // Wait for LUT to be available (increased wait time for proper activation) + await new Promise((resolve) => setTimeout(resolve, 5000)); + + // Extend the lookup table with all unique accounts + const addressesPerExtend = 20; + for (let i = 0; i < uniqueAccounts.length; i += addressesPerExtend) { + const batch = uniqueAccounts.slice(i, i + addressesPerExtend); + + const extendTableIx = AddressLookupTableProgram.extendLookupTable({ + authority: lookupAuthority.publicKey, + payer: payer.publicKey, + lookupTable: lookupTableAddress, + addresses: batch, + }); + + const extendLutTx = new Transaction().add(extendTableIx); + extendLutTx.recentBlockhash = ( + await provider.connection.getLatestBlockhash() + ).blockhash; + extendLutTx.feePayer = payer.publicKey; + extendLutTx.partialSign(lookupAuthority); + const signedExtendLutTx = + await provider.wallet.signTransaction(extendLutTx); + const extendLutSig = await provider.connection.sendRawTransaction( + signedExtendLutTx.serialize(), + ); + await provider.connection.confirmTransaction(extendLutSig); + console.log( + ` ✓ Extended LUT batch ${Math.floor(i / addressesPerExtend) + 1}/${Math.ceil(uniqueAccounts.length / addressesPerExtend)}`, + ); + + // Wait for extension to be available (increased wait time) + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + + // Wait for final extension to be available + await new Promise((resolve) => setTimeout(resolve, 3000)); + + // Fetch the raw account data and deserialize it manually (like the test does) + // This ensures the AddressLookupTableAccount is constructed correctly for the Squads SDK + const rawLutAccount = + await provider.connection.getAccountInfo(lookupTableAddress); + if (!rawLutAccount) { + throw new Error("Failed to fetch lookup table account"); + } + + const migrationLut = new AddressLookupTableAccount({ + key: lookupTableAddress, + state: AddressLookupTableAccount.deserialize(rawLutAccount.data), + }); + + // Verify LUT is activated (not deactivating) + if ( + migrationLut.state.deactivationSlot !== undefined && + migrationLut.state.deactivationSlot < BigInt(2 ** 32 - 1) + ) { + throw new Error( + `LUT is deactivating at slot ${migrationLut.state.deactivationSlot}!`, + ); + } + + console.log( + "Migration LUT created with", + migrationLut.state.addresses.length, + "addresses", + ); + console.log("LUT last extended slot:", migrationLut.state.lastExtendedSlot); + console.log("LUT authority:", migrationLut.state.authority?.toBase58()); + + // Verify LUT contents + console.log("\n=== VERIFYING LUT CONTENTS ==="); + for (let i = 0; i < migrationLut.state.addresses.length; i++) { + console.log(` [${i}]:`, migrationLut.state.addresses[i].toBase58()); + } + + // Create transaction message (don't compile to V0 - pass plain message + LUT separately to Squads) + const transactionMessage = new TransactionMessage({ + payerKey: v5VaultPda, + recentBlockhash: "", + instructions: vaultInstructions, + }); + + // Verify all instruction accounts are in the LUT + console.log("\n=== VERIFYING ALL ACCOUNTS IN LUT ==="); + const lutAddresses = migrationLut.state.addresses.map((a) => a.toBase58()); + let missingAccounts = 0; + for (const ix of vaultInstructions) { + for (const key of ix.keys) { + const keyStr = key.pubkey.toBase58(); + if (!lutAddresses.includes(keyStr)) { + console.warn(` ⚠️ Account ${keyStr} NOT in LUT!`); + missingAccounts++; + } + } + } + if (missingAccounts === 0) { + console.log(" ✓ All instruction accounts are in the LUT"); + } else { + throw new Error(`${missingAccounts} accounts are missing from the LUT!`); + } + + // Get transaction index + const v5MultisigAccount = await multisig.accounts.Multisig.fromAccountAddress( + provider.connection, + v5MultisigPda, + ); + const transactionIndex = BigInt( + Number(v5MultisigAccount.transactionIndex) + 1, + ); + + // Create vault transaction with plain message + LUT accounts + const vaultTxCreateIx = multisig.instructions.vaultTransactionCreate({ + multisigPda: v5MultisigPda, + transactionIndex, + creator: PERMISSIONLESS_ACCOUNT.publicKey, + rentPayer: payer.publicKey, + vaultIndex: 0, + ephemeralSigners: 0, + transactionMessage: transactionMessage, + addressLookupTableAccounts: [migrationLut], + }); + + console.log("\n📊 VaultTransactionCreate instruction:"); + console.log(" Accounts:", vaultTxCreateIx.keys.length); + console.log(" Data size:", vaultTxCreateIx.data.length, "bytes"); + + // Create proposal (no approve yet - that happens through autocrat) + const proposalCreateIx = multisig.instructions.proposalCreate({ + multisigPda: v5MultisigPda, + transactionIndex, + creator: PERMISSIONLESS_ACCOUNT.publicKey, + rentPayer: payer.publicKey, + isDraft: false, + }); + + const [squadsProposalPda] = multisig.getProposalPda({ + multisigPda: v5MultisigPda, + transactionIndex, + }); + + // Create Squads proposal using V0 VersionedTransaction with LUT for compression + const { blockhash } = await provider.connection.getLatestBlockhash(); + + const squadsMessage = new TransactionMessage({ + payerKey: payer.publicKey, + recentBlockhash: blockhash, + instructions: [vaultTxCreateIx, proposalCreateIx], + }).compileToV0Message([migrationLut]); + + const squadsTx = new VersionedTransaction(squadsMessage); + + const squadsTxSize = squadsTx.serialize().length; + console.log("\n📊 Squads proposal transaction (create vault tx + proposal):"); + console.log(" Size:", squadsTxSize, "bytes (limit: 1232)"); + if (squadsTxSize > 1232) { + console.log(" ❌ OVER LIMIT by", squadsTxSize - 1232, "bytes"); + } + + // Sign with PERMISSIONLESS_ACCOUNT first + squadsTx.sign([PERMISSIONLESS_ACCOUNT]); + // Then sign with wallet + const signedSquadsTx = await provider.wallet.signTransaction(squadsTx); + const squadsSig = await provider.connection.sendRawTransaction( + signedSquadsTx.serialize(), + { + skipPreflight: true, + preflightCommitment: "confirmed", + }, + ); + console.log(" Squads tx signature:", squadsSig); + await provider.connection.confirmTransaction(squadsSig); + + console.log("Squads proposal created"); + + // Wait a bit for the transaction to be fully confirmed + await new Promise((resolve) => setTimeout(resolve, 3000)); + + const [metaDaoProposal] = getProposalAddr( + AUTOCRAT_PROGRAM_ID, + squadsProposalPda, + ); + + if (FULL_PROPOSAL) { + // Step 8: Build V5 futarchy proposal market transactions (5 txs max for Jito bundle) + console.log("\n[8] Building V5 futarchy proposal markets..."); + console.log(" MetaDAO Proposal PDA:", metaDaoProposal.toBase58()); + + const { + baseVault, + quoteVault, + passAmm, + failAmm, + passBaseMint, + passQuoteMint, + failBaseMint, + failQuoteMint, + question, + } = autocratClient.getProposalPdas( + metaDaoProposal, + v5Dao.baseMint, + v5Dao.quoteMint, + V5_DAO_ADDRESS, + ); + + // Build 5 transactions for Jito bundle + const txns: Transaction[] = []; + + // Transaction 1: Initialize question + const questionTx = await vaultClient + .initializeQuestionIx( + sha256(`Will ${metaDaoProposal} pass?/FAIL/PASS`), + metaDaoProposal, + 2, + ) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 30_000 }), + ]) + .transaction(); + txns.push(questionTx); + + // Transaction 2: Initialize vaults and AMMs + const vaultsTx = await vaultClient + .initializeVaultIx(question, v5Dao.baseMint, 2) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 420_000 }), + ]) + .postInstructions( + await InstructionUtils.getInstructions( + vaultClient.initializeVaultIx(question, v5Dao.quoteMint, 2), + ammClient.initializeAmmIx( + passBaseMint, + passQuoteMint, + v5Dao.twapStartDelaySlots, + v5Dao.twapInitialObservation, + v5Dao.twapMaxObservationChangePerUpdate, + ), + ammClient.initializeAmmIx( + failBaseMint, + failQuoteMint, + v5Dao.twapStartDelaySlots, + v5Dao.twapInitialObservation, + v5Dao.twapMaxObservationChangePerUpdate, + ), + ), + ) + .transaction(); + txns.push(vaultsTx); + + // Transaction 3: Split tokens + const splitTokensTx = await vaultClient + .splitTokensIx(question, baseVault, v5Dao.baseMint, minBaseLiquidity, 2) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 220_000 }), + ]) + .postInstructions( + await InstructionUtils.getInstructions( + vaultClient.splitTokensIx( + question, + quoteVault, + v5Dao.quoteMint, + minQuoteLiquidity, + 2, + ), + ), + ) + .transaction(); + txns.push(splitTokensTx); + + // Transaction 4: Add liquidity to AMMs + const addLiquidityTx = await ammClient + .addLiquidityIx( + passAmm, + passBaseMint, + passQuoteMint, + minQuoteLiquidity, + minBaseLiquidity, + new BN(0), + ) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 170_000 }), + ]) + .postInstructions( + await InstructionUtils.getInstructions( + ammClient.addLiquidityIx( + failAmm, + failBaseMint, + failQuoteMint, + minQuoteLiquidity, + minBaseLiquidity, + new BN(0), + ), + ), + ) + .transaction(); + txns.push(addLiquidityTx); + + // Transaction 5: Initialize proposal + const lpTokens = minQuoteLiquidity; + const proposalTx = await autocratClient + .initializeProposalIx( + "Migrate DAO from v5 to v6 (Raydium LP)", + squadsProposalPda, + V5_DAO_ADDRESS, + v5Dao.baseMint, + v5Dao.quoteMint, + lpTokens, + lpTokens, + question, + ) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), + ]) + .transaction(); + txns.push(proposalTx); + + console.log(" Total transactions to bundle:", txns.length); + + // Step 9: Bundle and send futarchy txs with Jito + console.log("\n[9] Bundling and sending futarchy txs with Jito..."); + + const signedTxns = await prepareBundle(txns); + const bundle = await sendBundle(signedTxns); + + console.log("\n" + "=".repeat(60)); + console.log("BUNDLE SUBMITTED"); + console.log("=".repeat(60)); + console.log("Bundle ID:", bundle.result); + + // Wait a moment and check status + await new Promise((resolve) => setTimeout(resolve, 2000)); + const bundleStatus = await getBundleStatuses(bundle.result); + console.log("Bundle status:", JSON.stringify(bundleStatus, null, 2)); + } else { + console.log("\n[8] Skipping futarchy markets (Squads only mode)"); + } + + console.log("\n" + "=".repeat(60)); + console.log("COMPLETE"); + console.log("=".repeat(60)); + + console.log("\nSUMMARY:"); + console.log(" V5 DAO:", V5_DAO_ADDRESS.toBase58()); + console.log(" V6 DAO:", V6_DAO_ADDRESS.toBase58()); + console.log( + "\n *** SQUADS PROPOSAL PDA:", + squadsProposalPda.toBase58(), + "***", + ); + console.log(" Squads Multisig:", v5MultisigPda.toBase58()); + console.log(" Transaction Index:", transactionIndex); + console.log(" Address Lookup Table:", lookupTableAddress.toBase58()); + if (FULL_PROPOSAL) { + console.log(" MetaDAO Proposal PDA:", metaDaoProposal.toBase58()); + } + console.log("\nMIGRATION ACTIONS (when proposal passes):"); + if (lpBalance > 0n) { + console.log(" - Withdraw Raydium LP tokens:", lpBalance.toString()); + console.log( + " → Expected base:", + (Number(expectedBaseFromLp) / baseMultiplier).toLocaleString(), + ); + console.log( + " → Expected quote:", + (Number(expectedQuoteFromLp) / quoteMultiplier).toLocaleString(), + ); + console.log(" - Create Meteora DAMM v2 pool with 10% of withdrawn tokens"); + console.log( + " → Base to Meteora:", + ((Number(expectedBaseFromLp) * 0.1) / baseMultiplier).toLocaleString(), + ); + console.log( + " → Quote to Meteora:", + ((Number(expectedQuoteFromLp) * 0.1) / quoteMultiplier).toLocaleString(), + ); + console.log( + " - Provide 90% of withdrawn tokens as liquidity to Futarchy V6 AMM", + ); + console.log( + " → Base to Futarchy:", + ((Number(expectedBaseFromLp) * 0.9) / baseMultiplier).toLocaleString(), + ); + console.log( + " → Quote to Futarchy:", + ((Number(expectedQuoteFromLp) * 0.9) / quoteMultiplier).toLocaleString(), + ); + } + if (totalBaseToTransfer > 0n) { + console.log( + " - Transfer", + (Number(totalBaseToTransfer) / baseMultiplier).toLocaleString(), + "base tokens (treasury balance) to V6 vault", + ); + } + if (totalQuoteToTransfer > 0n) { + console.log( + " - Transfer", + (Number(totalQuoteToTransfer) / quoteMultiplier).toLocaleString(), + "quote tokens (treasury balance) to V6 vault", + ); + } + if (currentMintAuthority && currentMintAuthority.equals(v5VaultPda)) { + console.log(" - Transfer mint authority to V6 vault"); + } + if (canTransferMetadataAuthority) { + console.log(" - Transfer metadata update authority to V6 vault"); + } + console.log( + "\nNOTE: The V6 DAO must already be initialized before running this script.", + ); + console.log( + " The vault transaction only executes if the proposal passes.", + ); + console.log("\nIMPORTANT - EXECUTION:"); + console.log( + " When executing this proposal, you MUST use a V0 transaction with the", + ); + console.log( + " Address Lookup Table above to stay under the 1232 byte transaction limit.", + ); + console.log(" The execute transaction should:"); + console.log( + " 1. Add ComputeBudgetProgram.setComputeUnitLimit({ units: 1_400_000 })", + ); + console.log( + " 2. Add ComputeBudgetProgram.requestHeapFrame({ bytes: 256 * 1024 })", + ); + console.log(" 3. Add vaultTransactionExecute instruction"); + console.log( + " 4. Use TransactionMessage.compileToV0Message([lookupTableAccount])", + ); + console.log( + " 5. Create VersionedTransaction and sign with required signers", + ); + if (!FULL_PROPOSAL) { + console.log( + "\n ⚠ SQUADS ONLY MODE: Go simulate the proposal in Squads before running with FULL_PROPOSAL=true", + ); + } +} + +async function prepareBundle( + transactions: Transaction[], +): Promise { + console.log(" Preparing bundle with", transactions.length, "transactions"); + + const tipAccounts = await getTipAccounts(); + const tipFloor = await getTipFloor(); + const tipAccount = + tipAccounts[Math.floor(Math.random() * tipAccounts.length)]; + + console.log( + " Tip floor:", + Math.round(tipFloor * LAMPORTS_PER_SOL), + "lamports", + ); + console.log(" Tip account:", tipAccount); + + const transferInstruction = SystemProgram.transfer({ + fromPubkey: payer.publicKey, + toPubkey: new PublicKey(tipAccount), + lamports: Math.round(tipFloor * LAMPORTS_PER_SOL), + }); + + const { blockhash } = await provider.connection.getLatestBlockhash(); + + for (const transaction of transactions) { + transaction.recentBlockhash = blockhash; + transaction.feePayer = payer.publicKey; + } + + // Add tip to last transaction + const lastTransaction = transactions[transactions.length - 1]; + lastTransaction.add(transferInstruction); + + // Sign all transactions with payer + const signedTxns = await provider.wallet.signAllTransactions([ + ...transactions, + ]); + + console.log(" Transactions signed"); + return signedTxns; +} + +main().catch((error) => { + console.error("Error:", error); + process.exit(1); +}); diff --git a/scripts/v0.5/previewMigrationRaydium.ts b/scripts/v0.5/previewMigrationRaydium.ts new file mode 100644 index 000000000..45fd447a7 --- /dev/null +++ b/scripts/v0.5/previewMigrationRaydium.ts @@ -0,0 +1,844 @@ +import "dotenv/config"; +import { PublicKey } from "@solana/web3.js"; +import * as anchor from "@coral-xyz/anchor"; +import * as multisig from "@sqds/multisig"; +import { getAssociatedTokenAddressSync, getMint } from "@solana/spl-token"; +import { getMetadataAccountDataSerializer } from "@metaplex-foundation/mpl-token-metadata"; + +import { + AutocratClient, + AUTOCRAT_PROGRAM_ID, + getProposalAddr, + getMetadataAddr, + LAUNCHPAD_PROGRAM_ID, + getLiquidityPoolAddr, + getRaydiumCpmmLpMintAddr, + getRaydiumCpmmPoolVaultAddr, +} from "@metadaoproject/futarchy/v0.5"; + +import { + FutarchyClient as FutarchyClientV6, + DAMM_V2_PROGRAM_ID, + MAINNET_METEORA_CONFIG, +} from "@metadaoproject/futarchy/v0.6"; + +import { getSquadsPdasFromDao } from "../utils/squads.js"; + +// Raydium Migration Helper program ID +const RAYDIUM_MIGRATION_HELPER_PROGRAM_ID = new PublicKey( + "migR87BnBEkJbbDECLzRxhmNsQ44WMzhDCpCJhfPvR1", +); + +// Helper functions for Meteora PDA derivation +function maxKey(left: PublicKey, right: PublicKey): Buffer { + const leftBuffer = left.toBuffer(); + const rightBuffer = right.toBuffer(); + for (let i = 0; i < 32; i++) { + if (leftBuffer[i] > rightBuffer[i]) return leftBuffer; + if (leftBuffer[i] < rightBuffer[i]) return rightBuffer; + } + return leftBuffer; +} + +function minKey(left: PublicKey, right: PublicKey): Buffer { + const leftBuffer = left.toBuffer(); + const rightBuffer = right.toBuffer(); + for (let i = 0; i < 32; i++) { + if (leftBuffer[i] < rightBuffer[i]) return leftBuffer; + if (leftBuffer[i] > rightBuffer[i]) return rightBuffer; + } + return leftBuffer; +} + +function getMeteoraPdas(baseMint: PublicKey, quoteMint: PublicKey) { + // migration_signer PDA - used to sign for token transfers in Meteora CPI + const [migrationSigner] = PublicKey.findProgramAddressSync( + [Buffer.from("migration_signer"), baseMint.toBuffer()], + RAYDIUM_MIGRATION_HELPER_PROGRAM_ID, + ); + + // position_nft_mint is seeded by our migration helper program + const [positionNftMint] = PublicKey.findProgramAddressSync( + [Buffer.from("position_nft_mint"), baseMint.toBuffer()], + RAYDIUM_MIGRATION_HELPER_PROGRAM_ID, + ); + + // pool is seeded by DAMM v2 program + const [pool] = PublicKey.findProgramAddressSync( + [ + Buffer.from("pool"), + MAINNET_METEORA_CONFIG.toBuffer(), + maxKey(baseMint, quoteMint), + minKey(baseMint, quoteMint), + ], + DAMM_V2_PROGRAM_ID, + ); + + return { + migrationSigner, + positionNftMint, + pool, + }; +} + +// ===== CONFIGURATION ===== +// V5 DAO to migrate FROM +// v5 launch addy 7DzBXBYSKhrXHPWT6mAKq394vKupaKaqLn9bK1wscpBz +// test raydium v5 - 5j4BeewbwoepQCXGsvk8nnkbi4DCXaw5XEzT9XUnzQ6 +// new test-dao considering meteora split in lp +// 9NCPLEFgiu4XZdp9wtWMc1mXyY26VGeWsoKHCAPP3bAo +const V5_DAO_ADDRESS = new PublicKey( + "9NCPLEFgiu4XZdp9wtWMc1mXyY26VGeWsoKHCAPP3bAo", +); + +// V6 DAO to migrate TO (must already be initialized) +// test raydium v6 - F3APFzjG4ekCohguP7emX2pEwE3CoVQR9s8zwNYfZq4R +// New test-dao for meteora +// Cn2wML7SWX2x5mroSKp5eSd9QEkBRjccAXqQ9YWiwZNx +const V6_DAO_ADDRESS = new PublicKey( + "Cn2wML7SWX2x5mroSKp5eSd9QEkBRjccAXqQ9YWiwZNx", +); +// ========================= + +const provider = anchor.AnchorProvider.env(); +const payer = provider.wallet["payer"]; + +// V5 clients +const autocratClient = AutocratClient.createClient({ provider }); + +// V6 client +const futarchyV6 = FutarchyClientV6.createClient({ provider }); + +async function checkMigrationStatus( + v5VaultPda: PublicKey, + v6VaultPda: PublicKey, + baseMint: PublicKey, + quoteMint: PublicKey, + baseDecimals: number, + quoteDecimals: number, + raydiumLpMint: PublicKey | null, +): Promise<{ + migrationDetected: boolean; + migrationSuccessful: boolean; + details: string[]; +}> { + const details: string[] = []; + let migrationDetected = false; + let migrationSuccessful = true; // Assume success, set to false if any check fails + + const baseMultiplier = Math.pow(10, baseDecimals); + const quoteMultiplier = Math.pow(10, quoteDecimals); + + // Check 1: Mint authority - is it on V6 vault? + const baseMintInfo = await getMint(provider.connection, baseMint); + const currentMintAuthority = baseMintInfo.mintAuthority; + + if (currentMintAuthority?.equals(v6VaultPda)) { + details.push("✓ Mint authority is on V6 vault"); + migrationDetected = true; + } else if (currentMintAuthority?.equals(v5VaultPda)) { + details.push("• Mint authority is still on V5 vault (not migrated)"); + migrationSuccessful = false; + } else if (currentMintAuthority) { + details.push( + `⚠ Mint authority is on unknown address: ${currentMintAuthority.toBase58()}`, + ); + } else { + details.push("• No mint authority (immutable token)"); + } + + // Check 2: Metadata update authority - is it on V6 vault? + const [metadataAddr] = getMetadataAddr(baseMint); + try { + const metadataAccountInfo = + await provider.connection.getAccountInfo(metadataAddr); + if (metadataAccountInfo) { + const metadataSerializer = getMetadataAccountDataSerializer(); + const [metadata] = metadataSerializer.deserialize( + metadataAccountInfo.data, + ); + const updateAuthority = new PublicKey(metadata.updateAuthority); + + if (updateAuthority.equals(v6VaultPda)) { + details.push("✓ Metadata update authority is on V6 vault"); + migrationDetected = true; + } else if (updateAuthority.equals(v5VaultPda)) { + details.push( + "• Metadata update authority is still on V5 vault (not migrated)", + ); + migrationSuccessful = false; + } else { + details.push( + `⚠ Metadata update authority is on unknown address: ${updateAuthority.toBase58()}`, + ); + } + } + } catch { + details.push("• No metadata found"); + } + + // Check 3: V6 vault token balances (indicates successful transfer) + const v6VaultBaseAta = getAssociatedTokenAddressSync( + baseMint, + v6VaultPda, + true, + ); + const v6VaultQuoteAta = getAssociatedTokenAddressSync( + quoteMint, + v6VaultPda, + true, + ); + + try { + const baseInfo = + await provider.connection.getTokenAccountBalance(v6VaultBaseAta); + const balance = BigInt(baseInfo.value.amount); + if (balance > 0n) { + details.push( + `✓ V6 vault has ${(Number(balance) / baseMultiplier).toLocaleString()} base tokens`, + ); + migrationDetected = true; + } + } catch { + // No account means no tokens transferred yet + } + + try { + const quoteInfo = + await provider.connection.getTokenAccountBalance(v6VaultQuoteAta); + const balance = BigInt(quoteInfo.value.amount); + if (balance > 0n) { + details.push( + `✓ V6 vault has ${(Number(balance) / quoteMultiplier).toLocaleString()} quote tokens`, + ); + migrationDetected = true; + } + } catch { + // No account means no tokens transferred yet + } + + // Check 4: V5 vault is empty (indicates complete migration) + const v5VaultBaseAta = getAssociatedTokenAddressSync( + baseMint, + v5VaultPda, + true, + ); + const v5VaultQuoteAta = getAssociatedTokenAddressSync( + quoteMint, + v5VaultPda, + true, + ); + + let v5BaseBalance = 0n; + let v5QuoteBalance = 0n; + + try { + const baseInfo = + await provider.connection.getTokenAccountBalance(v5VaultBaseAta); + v5BaseBalance = BigInt(baseInfo.value.amount); + } catch { + // No account + } + + try { + const quoteInfo = + await provider.connection.getTokenAccountBalance(v5VaultQuoteAta); + v5QuoteBalance = BigInt(quoteInfo.value.amount); + } catch { + // No account + } + + // Check 5: LP tokens withdrawn (Raydium-specific) + if (raydiumLpMint) { + const v5VaultLpAta = getAssociatedTokenAddressSync( + raydiumLpMint, + v5VaultPda, + true, + ); + try { + const lpInfo = + await provider.connection.getTokenAccountBalance(v5VaultLpAta); + const lpBalance = BigInt(lpInfo.value.amount); + if (lpBalance === 0n) { + details.push("✓ LP tokens have been withdrawn from V5 vault"); + migrationDetected = true; + } else if (migrationDetected) { + details.push( + `⚠ V5 vault still has ${lpInfo.value.uiAmountString} LP tokens`, + ); + } + } catch { + // No LP account - might mean withdrawn or never had any + if (migrationDetected) { + details.push("✓ No LP token account in V5 vault"); + } + } + } + + if (migrationDetected) { + if (v5BaseBalance > 0n) { + details.push( + `⚠ V5 vault still has ${(Number(v5BaseBalance) / baseMultiplier).toLocaleString()} base tokens remaining`, + ); + } + if (v5QuoteBalance > 0n) { + details.push( + `⚠ V5 vault still has ${(Number(v5QuoteBalance) / quoteMultiplier).toLocaleString()} quote tokens remaining`, + ); + } + if (v5BaseBalance === 0n && v5QuoteBalance === 0n) { + details.push("✓ V5 vault is empty"); + } + } + + // If nothing detected as migrated, it hasn't been run + if (!migrationDetected) { + migrationSuccessful = false; + } + + return { migrationDetected, migrationSuccessful, details }; +} + +async function main() { + console.log("=".repeat(60)); + console.log("PREVIEW: MIGRATE V5 DAO TO V6 DAO (Raydium LP)"); + console.log("=".repeat(60)); + console.log("\nThis is a dry run - no transactions will be submitted.\n"); + + // Step 1: Fetch V5 DAO data + console.log("[1] Fetching V5 DAO data..."); + const v5Dao = await autocratClient.getDao(V5_DAO_ADDRESS); + const { multisigPda: v5MultisigPda, vaultPda: v5VaultPda } = + await getSquadsPdasFromDao(V5_DAO_ADDRESS); + + console.log(" V5 DAO Address:", V5_DAO_ADDRESS.toBase58()); + console.log(" Base Mint:", v5Dao.baseMint.toBase58()); + console.log(" Quote Mint:", v5Dao.quoteMint.toBase58()); + console.log(" V5 Squads Multisig:", v5MultisigPda.toBase58()); + console.log(" V5 Vault PDA:", v5VaultPda.toBase58()); + + // Step 2: Fetch and verify V6 DAO + console.log("\n[2] Fetching V6 DAO data..."); + const v6Dao = await futarchyV6.fetchDao(V6_DAO_ADDRESS); + if (!v6Dao) { + console.log(" ✗ V6 DAO not found at", V6_DAO_ADDRESS.toBase58()); + console.log( + " Make sure the V6 DAO is already initialized before running migration.", + ); + return; + } + + // Verify mints match + if (!v6Dao.baseMint.equals(v5Dao.baseMint)) { + console.log(" ✗ Base mint mismatch!"); + console.log(" V5:", v5Dao.baseMint.toBase58()); + console.log(" V6:", v6Dao.baseMint.toBase58()); + return; + } + if (!v6Dao.quoteMint.equals(v5Dao.quoteMint)) { + console.log(" ✗ Quote mint mismatch!"); + console.log(" V5:", v5Dao.quoteMint.toBase58()); + console.log(" V6:", v6Dao.quoteMint.toBase58()); + return; + } + + const v6MultisigPda = multisig.getMultisigPda({ + createKey: V6_DAO_ADDRESS, + })[0]; + const v6VaultPda = multisig.getVaultPda({ + multisigPda: v6MultisigPda, + index: 0, + })[0]; + + console.log(" V6 DAO Address:", V6_DAO_ADDRESS.toBase58()); + console.log(" V6 Squads Multisig:", v6MultisigPda.toBase58()); + console.log(" V6 Vault PDA:", v6VaultPda.toBase58()); + console.log(" ✓ Base mint matches V5 DAO"); + console.log(" ✓ Quote mint matches V5 DAO"); + + // Step 3: Fetch token decimals and liquidity requirements + console.log("\n[3] Fetching token decimals and liquidity requirements..."); + + const baseMintInfo = await getMint(provider.connection, v5Dao.baseMint); + const quoteMintInfo = await getMint(provider.connection, v5Dao.quoteMint); + const baseDecimals = baseMintInfo.decimals; + const quoteDecimals = quoteMintInfo.decimals; + + console.log(" Base token decimals:", baseDecimals); + console.log(" Quote token decimals:", quoteDecimals); + + const minBaseLiquidity = v5Dao.minBaseFutarchicLiquidity; + const minQuoteLiquidity = v5Dao.minQuoteFutarchicLiquidity; + + const baseMultiplier = Math.pow(10, baseDecimals); + const quoteMultiplier = Math.pow(10, quoteDecimals); + + console.log( + " Min base liquidity required:", + (minBaseLiquidity.toNumber() / baseMultiplier).toLocaleString(), + ); + console.log( + " Min quote liquidity required:", + (minQuoteLiquidity.toNumber() / quoteMultiplier).toLocaleString(), + ); + + // Step 4: Fetch Raydium CPMM pool info and LP balance + console.log("\n[4] Fetching Raydium CPMM pool info..."); + + const [raydiumPoolState] = getLiquidityPoolAddr( + LAUNCHPAD_PROGRAM_ID, + V5_DAO_ADDRESS, + ); + const [raydiumLpMint] = getRaydiumCpmmLpMintAddr(raydiumPoolState, false); + const v5VaultLpAta = getAssociatedTokenAddressSync( + raydiumLpMint, + v5VaultPda, + true, + ); + + console.log(" Raydium Pool State:", raydiumPoolState.toBase58()); + console.log(" Raydium LP Mint:", raydiumLpMint.toBase58()); + + let lpBalance = BigInt(0); + let poolExists = false; + try { + const lpInfo = + await provider.connection.getTokenAccountBalance(v5VaultLpAta); + lpBalance = BigInt(lpInfo.value.amount); + poolExists = true; + console.log(" Vault LP token balance:", lpInfo.value.uiAmountString); + } catch { + console.log( + " ⚠ Vault LP token balance: 0 (no account or pool doesn't exist)", + ); + } + + // Token ordering for Raydium: smaller pubkey is token0 + const isBaseToken0 = + v5Dao.baseMint.toBuffer().compare(v5Dao.quoteMint.toBuffer()) < 0; + const token0Mint = isBaseToken0 ? v5Dao.baseMint : v5Dao.quoteMint; + const token1Mint = isBaseToken0 ? v5Dao.quoteMint : v5Dao.baseMint; + + const [poolToken0Vault] = getRaydiumCpmmPoolVaultAddr( + raydiumPoolState, + token0Mint, + false, + ); + const [poolToken1Vault] = getRaydiumCpmmPoolVaultAddr( + raydiumPoolState, + token1Mint, + false, + ); + + console.log( + " Token ordering:", + isBaseToken0 ? "base=token0, quote=token1" : "quote=token0, base=token1", + ); + + // Calculate expected withdrawal amounts from LP + let expectedBaseFromLp = BigInt(0); + let expectedQuoteFromLp = BigInt(0); + + if (lpBalance > 0n) { + try { + // Fetch pool vault balances and LP supply to calculate proportional withdrawal + const [token0VaultBalance, token1VaultBalance, lpMintInfo] = + await Promise.all([ + provider.connection.getTokenAccountBalance(poolToken0Vault), + provider.connection.getTokenAccountBalance(poolToken1Vault), + getMint(provider.connection, raydiumLpMint), + ]); + + const poolToken0Amount = BigInt(token0VaultBalance.value.amount); + const poolToken1Amount = BigInt(token1VaultBalance.value.amount); + const lpSupply = lpMintInfo.supply; + + // Calculate proportional share: (lpBalance / lpSupply) * poolAmount + const token0Share = (lpBalance * poolToken0Amount) / lpSupply; + const token1Share = (lpBalance * poolToken1Amount) / lpSupply; + + // Assign based on token ordering + if (isBaseToken0) { + expectedBaseFromLp = token0Share; + expectedQuoteFromLp = token1Share; + } else { + expectedBaseFromLp = token1Share; + expectedQuoteFromLp = token0Share; + } + + console.log( + " Expected base from LP withdrawal:", + (Number(expectedBaseFromLp) / baseMultiplier).toLocaleString(), + ); + console.log( + " Expected quote from LP withdrawal:", + (Number(expectedQuoteFromLp) / quoteMultiplier).toLocaleString(), + ); + } catch (e) { + console.log(" ⚠ Could not calculate LP withdrawal amounts:", e); + } + } + + // Check if migration has already been run + console.log("\n[4.5] Checking migration status..."); + const migrationStatus = await checkMigrationStatus( + v5VaultPda, + v6VaultPda, + v5Dao.baseMint, + v5Dao.quoteMint, + baseDecimals, + quoteDecimals, + poolExists ? raydiumLpMint : null, + ); + + if (migrationStatus.migrationDetected) { + console.log("\n" + "=".repeat(60)); + if (migrationStatus.migrationSuccessful) { + console.log("✓ MIGRATION ALREADY COMPLETED SUCCESSFULLY"); + } else { + console.log("⚠ MIGRATION PARTIALLY COMPLETED"); + } + console.log("=".repeat(60)); + console.log("\nMigration status details:"); + for (const detail of migrationStatus.details) { + console.log(" " + detail); + } + console.log("\n" + "=".repeat(60)); + + if (migrationStatus.migrationSuccessful) { + console.log("No further action needed - migration is complete."); + return; + } else { + console.log("Migration may need manual intervention."); + console.log("Continuing with preview to show current state...\n"); + } + } else { + console.log(" Migration has not been run yet."); + } + + // Check payer's wallet balances + const payerBaseAta = getAssociatedTokenAddressSync( + v5Dao.baseMint, + payer.publicKey, + true, + ); + const payerQuoteAta = getAssociatedTokenAddressSync( + v5Dao.quoteMint, + payer.publicKey, + true, + ); + + let payerBaseBalance = BigInt(0); + let payerQuoteBalance = BigInt(0); + + try { + const baseInfo = + await provider.connection.getTokenAccountBalance(payerBaseAta); + payerBaseBalance = BigInt(baseInfo.value.amount); + } catch { + console.log(" ⚠ Your base token account doesn't exist"); + } + + try { + const quoteInfo = + await provider.connection.getTokenAccountBalance(payerQuoteAta); + payerQuoteBalance = BigInt(quoteInfo.value.amount); + } catch { + console.log(" ⚠ Your quote token account doesn't exist"); + } + + console.log( + " Your base token balance:", + (Number(payerBaseBalance) / baseMultiplier).toLocaleString(), + ); + console.log( + " Your quote token balance:", + (Number(payerQuoteBalance) / quoteMultiplier).toLocaleString(), + ); + + // Check if payer has enough tokens + const hasEnoughBase = payerBaseBalance >= BigInt(minBaseLiquidity.toString()); + const hasEnoughQuote = + payerQuoteBalance >= BigInt(minQuoteLiquidity.toString()); + + if (!hasEnoughBase) { + const needed = ( + minBaseLiquidity.toNumber() / baseMultiplier + ).toLocaleString(); + const have = (Number(payerBaseBalance) / baseMultiplier).toLocaleString(); + console.log(` ✗ Insufficient base tokens! Need ${needed}, have ${have}`); + } else { + console.log(" ✓ Sufficient base tokens for proposal liquidity"); + } + + if (!hasEnoughQuote) { + const needed = ( + minQuoteLiquidity.toNumber() / quoteMultiplier + ).toLocaleString(); + const have = (Number(payerQuoteBalance) / quoteMultiplier).toLocaleString(); + console.log(` ✗ Insufficient quote tokens! Need ${needed}, have ${have}`); + } else { + console.log(" ✓ Sufficient quote tokens for proposal liquidity"); + } + + // Step 5: Get vault token balances to transfer + console.log("\n[5] Fetching V5 vault token balances..."); + const v5VaultBaseAta = getAssociatedTokenAddressSync( + v5Dao.baseMint, + v5VaultPda, + true, + ); + const v5VaultQuoteAta = getAssociatedTokenAddressSync( + v5Dao.quoteMint, + v5VaultPda, + true, + ); + + let baseBalance = BigInt(0); + let quoteBalance = BigInt(0); + + try { + const baseInfo = + await provider.connection.getTokenAccountBalance(v5VaultBaseAta); + baseBalance = BigInt(baseInfo.value.amount); + console.log( + " Vault base token balance:", + (Number(baseBalance) / baseMultiplier).toLocaleString(), + ); + } catch { + console.log(" Vault base token balance: 0 (no account)"); + } + + try { + const quoteInfo = + await provider.connection.getTokenAccountBalance(v5VaultQuoteAta); + quoteBalance = BigInt(quoteInfo.value.amount); + console.log( + " Vault quote token balance:", + (Number(quoteBalance) / quoteMultiplier).toLocaleString(), + ); + } catch { + console.log(" Vault quote token balance: 0 (no account)"); + } + + // Calculate total amounts to transfer (existing + LP withdrawal) + const totalBaseToTransfer = baseBalance + expectedBaseFromLp; + const totalQuoteToTransfer = quoteBalance + expectedQuoteFromLp; + + if (lpBalance > 0n) { + console.log( + "\n Total base to transfer (vault + LP):", + (Number(totalBaseToTransfer) / baseMultiplier).toLocaleString(), + ); + console.log( + " Total quote to transfer (vault + LP):", + (Number(totalQuoteToTransfer) / quoteMultiplier).toLocaleString(), + ); + } + + // Step 6: Check mint authority + console.log("\n[6] Checking base mint authority..."); + const currentMintAuthority = baseMintInfo.mintAuthority; + let canTransferMintAuthority = false; + + if (currentMintAuthority) { + console.log(" Current mint authority:", currentMintAuthority.toBase58()); + if (currentMintAuthority.equals(v5VaultPda)) { + console.log(" ✓ Mint authority is the V5 vault (will be transferred)"); + canTransferMintAuthority = true; + } else { + console.log( + " ⚠ Mint authority is NOT the V5 vault - cannot transfer via proposal", + ); + } + } else { + console.log(" ⚠ No mint authority (token is immutable)"); + } + + // Step 7: Check metadata update authority + console.log("\n[7] Checking metadata update authority..."); + const [metadataAddr] = getMetadataAddr(v5Dao.baseMint); + let canTransferMetadataAuthority = false; + + try { + const metadataAccountInfo = + await provider.connection.getAccountInfo(metadataAddr); + if (metadataAccountInfo) { + const metadataSerializer = getMetadataAccountDataSerializer(); + const [metadata] = metadataSerializer.deserialize( + metadataAccountInfo.data, + ); + const updateAuthority = new PublicKey(metadata.updateAuthority); + console.log(" Metadata address:", metadataAddr.toBase58()); + console.log(" Current update authority:", updateAuthority.toBase58()); + if (updateAuthority.equals(v5VaultPda)) { + console.log( + " ✓ Metadata update authority is the V5 vault (will be transferred)", + ); + canTransferMetadataAuthority = true; + } else { + console.log( + " ⚠ Metadata update authority is NOT the V5 vault - cannot transfer via proposal", + ); + } + } else { + console.log(" ⚠ No metadata found for base mint"); + } + } catch (e: any) { + console.log(" ⚠ Could not fetch metadata:", e.message || e); + } + + // Preview what will happen + console.log("\n" + "=".repeat(60)); + console.log("MIGRATION PREVIEW"); + console.log("=".repeat(60)); + + // Get next transaction index + const v5MultisigAccountInfo = + await multisig.accounts.Multisig.fromAccountAddress( + provider.connection, + v5MultisigPda, + ); + const currentTransactionIndex = Number( + v5MultisigAccountInfo.transactionIndex, + ); + const transactionIndex = currentTransactionIndex + 1; + + const [squadsProposalPda] = multisig.getProposalPda({ + multisigPda: v5MultisigPda, + transactionIndex: BigInt(transactionIndex), + }); + + const [metaDaoProposal] = getProposalAddr( + AUTOCRAT_PROGRAM_ID, + squadsProposalPda, + ); + + // Get Meteora PDAs + const meteoraPdas = getMeteoraPdas(v5Dao.baseMint, v5Dao.quoteMint); + + console.log("\nAddresses:"); + console.log(" V5 DAO:", V5_DAO_ADDRESS.toBase58()); + console.log(" V6 DAO:", V6_DAO_ADDRESS.toBase58()); + console.log(" Next Squads Proposal PDA:", squadsProposalPda.toBase58()); + console.log(" MetaDAO Proposal PDA:", metaDaoProposal.toBase58()); + console.log(" Meteora DAMM v2 Pool:", meteoraPdas.pool.toBase58()); + console.log( + " Meteora Position NFT Mint:", + meteoraPdas.positionNftMint.toBase58(), + ); + + console.log("\nMigration actions (when proposal passes):"); + if (lpBalance > 0n) { + console.log(" 1. Withdraw Raydium LP tokens:", lpBalance.toString()); + console.log( + " → Expected base:", + (Number(expectedBaseFromLp) / baseMultiplier).toLocaleString(), + ); + console.log( + " → Expected quote:", + (Number(expectedQuoteFromLp) / quoteMultiplier).toLocaleString(), + ); + + // Calculate 90/10 split + const baseToMeteora = Number(expectedBaseFromLp) / 10; + const quoteToMeteora = Number(expectedQuoteFromLp) / 10; + const baseToFutarchy = Number(expectedBaseFromLp) - baseToMeteora; + const quoteToFutarchy = Number(expectedQuoteFromLp) - quoteToMeteora; + + console.log( + "\n 2. Create Meteora DAMM v2 pool (10% of withdrawn tokens):", + ); + console.log( + " → Base to Meteora:", + (baseToMeteora / baseMultiplier).toLocaleString(), + ); + console.log( + " → Quote to Meteora:", + (quoteToMeteora / quoteMultiplier).toLocaleString(), + ); + + console.log( + "\n 3. Provide liquidity to Futarchy V6 AMM (90% of withdrawn tokens):", + ); + console.log( + " → Base to Futarchy:", + (baseToFutarchy / baseMultiplier).toLocaleString(), + ); + console.log( + " → Quote to Futarchy:", + (quoteToFutarchy / quoteMultiplier).toLocaleString(), + ); + + console.log("\n 4. Transfer remaining vault balance to V6 treasury:"); + if (baseBalance > 0n) { + console.log( + " → Base:", + (Number(baseBalance) / baseMultiplier).toLocaleString(), + ); + } + if (quoteBalance > 0n) { + console.log( + " → Quote:", + (Number(quoteBalance) / quoteMultiplier).toLocaleString(), + ); + } + if (baseBalance === 0n && quoteBalance === 0n) { + console.log(" → No additional vault balance to transfer"); + } + } else { + console.log(" • No LP tokens to withdraw"); + if (totalBaseToTransfer > 0n) { + console.log( + " • Transfer", + (Number(totalBaseToTransfer) / baseMultiplier).toLocaleString(), + "base tokens to V6 vault", + ); + } else { + console.log(" • No base tokens to transfer"); + } + if (totalQuoteToTransfer > 0n) { + console.log( + " • Transfer", + (Number(totalQuoteToTransfer) / quoteMultiplier).toLocaleString(), + "quote tokens to V6 vault", + ); + } else { + console.log(" • No quote tokens to transfer"); + } + } + if (canTransferMintAuthority) { + console.log("\n 5. Transfer mint authority to V6 vault"); + } + if (canTransferMetadataAuthority) { + console.log(" 6. Transfer metadata update authority to V6 vault"); + } + + console.log("\nProposal liquidity (from your wallet):"); + console.log( + " • Base tokens:", + (minBaseLiquidity.toNumber() / baseMultiplier).toLocaleString(), + ); + console.log( + " • Quote tokens:", + (minQuoteLiquidity.toNumber() / quoteMultiplier).toLocaleString(), + ); + + // Ready check + console.log("\n" + "=".repeat(60)); + const isReady = hasEnoughBase && hasEnoughQuote; + if (isReady) { + console.log("✓ READY TO MIGRATE"); + console.log( + "Run the full migration script (migrateToV6Raydium.ts) when ready.", + ); + } else { + console.log("✗ NOT READY - Fix issues above before migrating"); + } + console.log("=".repeat(60)); +} + +main().catch((error) => { + console.error("Error:", error); + process.exit(1); +}); diff --git a/sdk/src/v0.7/types/raydium_migration_helper.ts b/sdk/src/v0.7/types/raydium_migration_helper.ts new file mode 100644 index 000000000..23006ce6a --- /dev/null +++ b/sdk/src/v0.7/types/raydium_migration_helper.ts @@ -0,0 +1,781 @@ +export type RaydiumMigrationHelper = { + version: "0.1.0"; + name: "raydium_migration_helper"; + instructions: [ + { + name: "withdrawAndProvideLiquidity"; + accounts: [ + { + name: "vaultAuthority"; + isMut: true; + isSigner: true; + docs: [ + "The vault/DAO that owns the LP tokens (must sign)", + "This will be the V5 vault PDA signing via Squads", + ]; + }, + { + name: "migrationSigner"; + isMut: true; + isSigner: false; + docs: [ + "Migration signer PDA - used to sign for Meteora CPI token transfers", + 'Seeds: ["migration_signer", base_mint]', + ]; + }, + { + name: "migrationSignerBaseAta"; + isMut: true; + isSigner: false; + docs: [ + "Migration signer's base token account (receives tokens from vault, transfers to Meteora)", + ]; + }, + { + name: "migrationSignerQuoteAta"; + isMut: true; + isSigner: false; + docs: [ + "Migration signer's quote token account (receives tokens from vault, transfers to Meteora)", + ]; + }, + { + name: "poolState"; + isMut: true; + isSigner: false; + docs: ["Raydium CPMM pool state"]; + }, + { + name: "lpMint"; + isMut: true; + isSigner: false; + docs: ["LP token mint"]; + }, + { + name: "vaultLpToken"; + isMut: true; + isSigner: false; + docs: ["Vault's LP token account (will be burned from)"]; + }, + { + name: "vaultToken0"; + isMut: true; + isSigner: false; + docs: [ + "Vault's token0 account (will receive tokens from pool)", + "Note: token0/token1 ordering is derived from base_mint/quote_mint pubkey comparison", + ]; + }, + { + name: "vaultToken1"; + isMut: true; + isSigner: false; + docs: [ + "Vault's token1 account (will receive tokens from pool)", + "Note: token0/token1 ordering is derived from base_mint/quote_mint pubkey comparison", + ]; + }, + { + name: "dao"; + isMut: true; + isSigner: false; + docs: ["V6 DAO account"]; + }, + { + name: "baseMint"; + isMut: false; + isSigner: false; + docs: [ + "Base token mint (used for determining token0/token1 -> base/quote mapping)", + ]; + }, + { + name: "quoteMint"; + isMut: false; + isSigner: false; + docs: [ + "Quote token mint (used for determining token0/token1 -> base/quote mapping)", + ]; + }, + { + name: "raydiumAuthority"; + isMut: false; + isSigner: false; + docs: ["Raydium authority PDA"]; + }, + { + name: "poolToken0Vault"; + isMut: true; + isSigner: false; + docs: ["Pool's token0 vault"]; + }, + { + name: "poolToken1Vault"; + isMut: true; + isSigner: false; + docs: ["Pool's token1 vault"]; + }, + { + name: "ammPosition"; + isMut: true; + isSigner: false; + docs: ["AMM position PDA (owned by futarchy program)"]; + }, + { + name: "ammBaseVault"; + isMut: true; + isSigner: false; + docs: ["AMM base vault (owned by DAO)"]; + }, + { + name: "ammQuoteVault"; + isMut: true; + isSigner: false; + docs: ["AMM quote vault (owned by DAO)"]; + }, + { + name: "v6VaultBaseAta"; + isMut: true; + isSigner: false; + docs: [ + "V6 vault base treasury ATA (receives remaining base tokens after liquidity provision)", + ]; + }, + { + name: "v6VaultQuoteAta"; + isMut: true; + isSigner: false; + docs: [ + "V6 vault quote treasury ATA (receives remaining quote tokens after liquidity provision)", + ]; + }, + { + name: "v6VaultPda"; + isMut: false; + isSigner: false; + docs: [ + "V6 vault PDA (will be the position authority for the AMM position)", + "This is separate from vault_authority (V5 vault) which signs the transaction", + ]; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + docs: ["Event authority for futarchy CPI events"]; + }, + { + name: "raydiumProgram"; + isMut: false; + isSigner: false; + docs: ["Raydium CPMM program"]; + }, + { + name: "futarchyProgram"; + isMut: false; + isSigner: false; + docs: ["Futarchy v0.6 program"]; + }, + { + name: "tokenProgram"; + isMut: false; + isSigner: false; + docs: ["SPL Token program"]; + }, + { + name: "tokenProgram2022"; + isMut: false; + isSigner: false; + docs: [ + "SPL Token 2022 program (required by Raydium for Token-2022 support)", + ]; + }, + { + name: "systemProgram"; + isMut: false; + isSigner: false; + docs: ["System program"]; + }, + { + name: "memoProgram"; + isMut: false; + isSigner: false; + docs: ["Memo program (required by Raydium for withdrawal logs)"]; + }, + { + name: "meteoraAccounts"; + accounts: [ + { + name: "dammV2Program"; + isMut: false; + isSigner: false; + }, + { + name: "config"; + isMut: false; + isSigner: false; + }, + { + name: "token2022Program"; + isMut: false; + isSigner: false; + }, + { + name: "positionNftAccount"; + isMut: true; + isSigner: false; + }, + { + name: "pool"; + isMut: true; + isSigner: false; + }, + { + name: "position"; + isMut: true; + isSigner: false; + }, + { + name: "positionNftMint"; + isMut: true; + isSigner: false; + }, + { + name: "baseMint"; + isMut: false; + isSigner: false; + }, + { + name: "quoteMint"; + isMut: false; + isSigner: false; + }, + { + name: "tokenAVault"; + isMut: true; + isSigner: false; + }, + { + name: "tokenBVault"; + isMut: true; + isSigner: false; + }, + { + name: "poolCreatorAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "poolAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "dammV2EventAuthority"; + isMut: false; + isSigner: false; + }, + ]; + }, + ]; + args: [ + { + name: "lpAmount"; + type: "u64"; + }, + { + name: "minRaydiumAmount0"; + type: "u64"; + }, + { + name: "minRaydiumAmount1"; + type: "u64"; + }, + { + name: "minFutarchyLiquidity"; + type: "u64"; + }, + ]; + }, + ]; + events: [ + { + name: "MigrationExecuted"; + fields: [ + { + name: "vaultAuthority"; + type: "publicKey"; + index: false; + }, + { + name: "lpAmount"; + type: "u64"; + index: false; + }, + { + name: "withdrawnBase"; + type: "u64"; + index: false; + }, + { + name: "withdrawnQuote"; + type: "u64"; + index: false; + }, + { + name: "baseToMeteora"; + type: "u64"; + index: false; + }, + { + name: "quoteToMeteora"; + type: "u64"; + index: false; + }, + { + name: "baseToFutarchy"; + type: "u64"; + index: false; + }, + { + name: "quoteToFutarchy"; + type: "u64"; + index: false; + }, + { + name: "meteoraPool"; + type: "publicKey"; + index: false; + }, + { + name: "treasuryBaseTransferred"; + type: "u64"; + index: false; + }, + { + name: "treasuryQuoteTransferred"; + type: "u64"; + index: false; + }, + ]; + }, + ]; + errors: [ + { + code: 6000; + name: "InsufficientLpBalance"; + msg: "Insufficient LP token balance"; + }, + { + code: 6001; + name: "TokenAccountOwnerMismatch"; + msg: "Token account owner mismatch"; + }, + { + code: 6002; + name: "InvalidTokenMint"; + msg: "Invalid token mint"; + }, + { + code: 6003; + name: "MathOverflow"; + msg: "Math overflow error"; + }, + { + code: 6004; + name: "DuplicateTokenMints"; + msg: "Base and quote mints must be different"; + }, + ]; +}; + +export const IDL: RaydiumMigrationHelper = { + version: "0.1.0", + name: "raydium_migration_helper", + instructions: [ + { + name: "withdrawAndProvideLiquidity", + accounts: [ + { + name: "vaultAuthority", + isMut: true, + isSigner: true, + docs: [ + "The vault/DAO that owns the LP tokens (must sign)", + "This will be the V5 vault PDA signing via Squads", + ], + }, + { + name: "migrationSigner", + isMut: true, + isSigner: false, + docs: [ + "Migration signer PDA - used to sign for Meteora CPI token transfers", + 'Seeds: ["migration_signer", base_mint]', + ], + }, + { + name: "migrationSignerBaseAta", + isMut: true, + isSigner: false, + docs: [ + "Migration signer's base token account (receives tokens from vault, transfers to Meteora)", + ], + }, + { + name: "migrationSignerQuoteAta", + isMut: true, + isSigner: false, + docs: [ + "Migration signer's quote token account (receives tokens from vault, transfers to Meteora)", + ], + }, + { + name: "poolState", + isMut: true, + isSigner: false, + docs: ["Raydium CPMM pool state"], + }, + { + name: "lpMint", + isMut: true, + isSigner: false, + docs: ["LP token mint"], + }, + { + name: "vaultLpToken", + isMut: true, + isSigner: false, + docs: ["Vault's LP token account (will be burned from)"], + }, + { + name: "vaultToken0", + isMut: true, + isSigner: false, + docs: [ + "Vault's token0 account (will receive tokens from pool)", + "Note: token0/token1 ordering is derived from base_mint/quote_mint pubkey comparison", + ], + }, + { + name: "vaultToken1", + isMut: true, + isSigner: false, + docs: [ + "Vault's token1 account (will receive tokens from pool)", + "Note: token0/token1 ordering is derived from base_mint/quote_mint pubkey comparison", + ], + }, + { + name: "dao", + isMut: true, + isSigner: false, + docs: ["V6 DAO account"], + }, + { + name: "baseMint", + isMut: false, + isSigner: false, + docs: [ + "Base token mint (used for determining token0/token1 -> base/quote mapping)", + ], + }, + { + name: "quoteMint", + isMut: false, + isSigner: false, + docs: [ + "Quote token mint (used for determining token0/token1 -> base/quote mapping)", + ], + }, + { + name: "raydiumAuthority", + isMut: false, + isSigner: false, + docs: ["Raydium authority PDA"], + }, + { + name: "poolToken0Vault", + isMut: true, + isSigner: false, + docs: ["Pool's token0 vault"], + }, + { + name: "poolToken1Vault", + isMut: true, + isSigner: false, + docs: ["Pool's token1 vault"], + }, + { + name: "ammPosition", + isMut: true, + isSigner: false, + docs: ["AMM position PDA (owned by futarchy program)"], + }, + { + name: "ammBaseVault", + isMut: true, + isSigner: false, + docs: ["AMM base vault (owned by DAO)"], + }, + { + name: "ammQuoteVault", + isMut: true, + isSigner: false, + docs: ["AMM quote vault (owned by DAO)"], + }, + { + name: "v6VaultBaseAta", + isMut: true, + isSigner: false, + docs: [ + "V6 vault base treasury ATA (receives remaining base tokens after liquidity provision)", + ], + }, + { + name: "v6VaultQuoteAta", + isMut: true, + isSigner: false, + docs: [ + "V6 vault quote treasury ATA (receives remaining quote tokens after liquidity provision)", + ], + }, + { + name: "v6VaultPda", + isMut: false, + isSigner: false, + docs: [ + "V6 vault PDA (will be the position authority for the AMM position)", + "This is separate from vault_authority (V5 vault) which signs the transaction", + ], + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + docs: ["Event authority for futarchy CPI events"], + }, + { + name: "raydiumProgram", + isMut: false, + isSigner: false, + docs: ["Raydium CPMM program"], + }, + { + name: "futarchyProgram", + isMut: false, + isSigner: false, + docs: ["Futarchy v0.6 program"], + }, + { + name: "tokenProgram", + isMut: false, + isSigner: false, + docs: ["SPL Token program"], + }, + { + name: "tokenProgram2022", + isMut: false, + isSigner: false, + docs: [ + "SPL Token 2022 program (required by Raydium for Token-2022 support)", + ], + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + docs: ["System program"], + }, + { + name: "memoProgram", + isMut: false, + isSigner: false, + docs: ["Memo program (required by Raydium for withdrawal logs)"], + }, + { + name: "meteoraAccounts", + accounts: [ + { + name: "dammV2Program", + isMut: false, + isSigner: false, + }, + { + name: "config", + isMut: false, + isSigner: false, + }, + { + name: "token2022Program", + isMut: false, + isSigner: false, + }, + { + name: "positionNftAccount", + isMut: true, + isSigner: false, + }, + { + name: "pool", + isMut: true, + isSigner: false, + }, + { + name: "position", + isMut: true, + isSigner: false, + }, + { + name: "positionNftMint", + isMut: true, + isSigner: false, + }, + { + name: "baseMint", + isMut: false, + isSigner: false, + }, + { + name: "quoteMint", + isMut: false, + isSigner: false, + }, + { + name: "tokenAVault", + isMut: true, + isSigner: false, + }, + { + name: "tokenBVault", + isMut: true, + isSigner: false, + }, + { + name: "poolCreatorAuthority", + isMut: false, + isSigner: false, + }, + { + name: "poolAuthority", + isMut: false, + isSigner: false, + }, + { + name: "dammV2EventAuthority", + isMut: false, + isSigner: false, + }, + ], + }, + ], + args: [ + { + name: "lpAmount", + type: "u64", + }, + { + name: "minRaydiumAmount0", + type: "u64", + }, + { + name: "minRaydiumAmount1", + type: "u64", + }, + { + name: "minFutarchyLiquidity", + type: "u64", + }, + ], + }, + ], + events: [ + { + name: "MigrationExecuted", + fields: [ + { + name: "vaultAuthority", + type: "publicKey", + index: false, + }, + { + name: "lpAmount", + type: "u64", + index: false, + }, + { + name: "withdrawnBase", + type: "u64", + index: false, + }, + { + name: "withdrawnQuote", + type: "u64", + index: false, + }, + { + name: "baseToMeteora", + type: "u64", + index: false, + }, + { + name: "quoteToMeteora", + type: "u64", + index: false, + }, + { + name: "baseToFutarchy", + type: "u64", + index: false, + }, + { + name: "quoteToFutarchy", + type: "u64", + index: false, + }, + { + name: "meteoraPool", + type: "publicKey", + index: false, + }, + { + name: "treasuryBaseTransferred", + type: "u64", + index: false, + }, + { + name: "treasuryQuoteTransferred", + type: "u64", + index: false, + }, + ], + }, + ], + errors: [ + { + code: 6000, + name: "InsufficientLpBalance", + msg: "Insufficient LP token balance", + }, + { + code: 6001, + name: "TokenAccountOwnerMismatch", + msg: "Token account owner mismatch", + }, + { + code: 6002, + name: "InvalidTokenMint", + msg: "Invalid token mint", + }, + { + code: 6003, + name: "MathOverflow", + msg: "Math overflow error", + }, + { + code: 6004, + name: "DuplicateTokenMints", + msg: "Base and quote mints must be different", + }, + ], +}; diff --git a/tests/conditionalVault/unit/initializeConditionalVault.test.ts b/tests/conditionalVault/unit/initializeConditionalVault.test.ts index 98cc0c9f8..9053ba0e7 100644 --- a/tests/conditionalVault/unit/initializeConditionalVault.test.ts +++ b/tests/conditionalVault/unit/initializeConditionalVault.test.ts @@ -121,32 +121,4 @@ export default function suite() { }); }); }); - - it("doesn't allow initializing vault for question with less than 2 outcomes", async function () { - const oracle = Keypair.generate(); - const questionId = sha256(new Uint8Array([1, 2, 3])); - const callbacks = expectError( - "InsufficientNumConditions", - "Vault initialized despite question having less than 2 outcomes", - ); - - await vaultClient - .initializeQuestionIx(questionId, oracle.publicKey, 1) - .rpc() - .then(callbacks[0], callbacks[1]); - }); - - it("doesn't allow initializing vault for question with more than 10 outcomes", async function () { - const oracle = Keypair.generate(); - const questionId = sha256(new Uint8Array([1, 2, 3])); - const callbacks = expectError( - "TooManyOutcomes", - "Vault initialized despite question having more than 10 outcomes", - ); - - await vaultClient - .initializeQuestionIx(questionId, oracle.publicKey, 11) - .rpc() - .then(callbacks[0], callbacks[1]); - }); } diff --git a/tests/fixtures/amm_v5.so b/tests/fixtures/amm_v5.so new file mode 100644 index 000000000..b1460d06e Binary files /dev/null and b/tests/fixtures/amm_v5.so differ diff --git a/tests/fixtures/autocrat_v5.so b/tests/fixtures/autocrat_v5.so new file mode 100644 index 000000000..fe6c36bcf Binary files /dev/null and b/tests/fixtures/autocrat_v5.so differ diff --git a/tests/fixtures/launchpad_v5.so b/tests/fixtures/launchpad_v5.so new file mode 100644 index 000000000..48c96d27b Binary files /dev/null and b/tests/fixtures/launchpad_v5.so differ diff --git a/tests/futarchy/integration/futarchyAmm.test.ts b/tests/futarchy/integration/futarchyAmm.test.ts index 2325f8469..4ab0c5777 100644 --- a/tests/futarchy/integration/futarchyAmm.test.ts +++ b/tests/futarchy/integration/futarchyAmm.test.ts @@ -211,16 +211,8 @@ export default function suite() { dao, ); - const proposalAccount = await this.futarchy.getProposal(proposal); - await this.futarchy - .launchProposalIx({ - proposal, - dao, - baseMint: META, - quoteMint: USDC, - squadsProposal: proposalAccount.squadsProposal, - }) + .launchProposalIx({ proposal, dao, baseMint: META, quoteMint: USDC }) .rpc(); await this.futarchy diff --git a/tests/futarchy/unit/finalizeProposal.test.ts b/tests/futarchy/unit/finalizeProposal.test.ts index 56e3c47e9..332d2728e 100644 --- a/tests/futarchy/unit/finalizeProposal.test.ts +++ b/tests/futarchy/unit/finalizeProposal.test.ts @@ -70,9 +70,6 @@ export default function suite() { twapMaxObservationChangePerUpdate: null, minQuoteFutarchicLiquidity: null, minBaseFutarchicLiquidity: null, - twapStartDelaySeconds: null, - teamSponsoredPassThresholdBps: null, - teamAddress: null, }, }) .instruction(); @@ -365,9 +362,6 @@ export default function suite() { twapMaxObservationChangePerUpdate: null, minQuoteFutarchicLiquidity: null, minBaseFutarchicLiquidity: null, - twapStartDelaySeconds: null, - teamSponsoredPassThresholdBps: null, - teamAddress: null, }, }) .instruction(); diff --git a/tests/integration/fullLaunch.test.ts b/tests/integration/fullLaunch.test.ts index 398f923c8..5e44ba320 100644 --- a/tests/integration/fullLaunch.test.ts +++ b/tests/integration/fullLaunch.test.ts @@ -462,7 +462,6 @@ export default async function suite() { dao, baseMint: META, quoteMint: MAINNET_USDC, - squadsProposal: squadsProposalPda, }) .rpc(); diff --git a/tests/integration/fullLaunch_v7.test.ts b/tests/integration/fullLaunch_v7.test.ts index d917fd652..25632fff7 100644 --- a/tests/integration/fullLaunch_v7.test.ts +++ b/tests/integration/fullLaunch_v7.test.ts @@ -506,7 +506,6 @@ export default async function suite() { dao, baseMint: META, quoteMint: MAINNET_USDC, - squadsProposal: squadsProposalPda, }) .rpc(); diff --git a/tests/integration/migrateToV6Raydium.test.ts b/tests/integration/migrateToV6Raydium.test.ts new file mode 100644 index 000000000..3d7238ab9 --- /dev/null +++ b/tests/integration/migrateToV6Raydium.test.ts @@ -0,0 +1,1391 @@ +import "dotenv/config"; +import { + Keypair, + PublicKey, + Transaction, + TransactionMessage, + TransactionInstruction, + VersionedTransaction, + SystemProgram, + ComputeBudgetProgram, +} from "@solana/web3.js"; +import { assert } from "chai"; +import { getAccount } from "spl-token-bankrun"; +import { + PERMISSIONLESS_ACCOUNT as PERMISSIONLESS_ACCOUNT_V6, + MAINNET_USDC, + getDaoAddr, + DAMM_V2_PROGRAM_ID, +} from "@metadaoproject/futarchy/v0.6"; +import { + AutocratClient, + AmmClient, + getMetadataAddr, + getLiquidityPoolAddr, + RAYDIUM_CP_SWAP_PROGRAM_ID, + RAYDIUM_AUTHORITY, + PERMISSIONLESS_ACCOUNT, + LaunchpadClient, + getLaunchAddr, + getLaunchSignerAddr, +} from "@metadaoproject/futarchy/v0.5"; +import { FutarchyClient as FutarchyClientV6 } from "@metadaoproject/futarchy/v0.6"; +import * as token from "@solana/spl-token"; +import * as multisig from "@sqds/multisig"; +import * as anchor from "@coral-xyz/anchor"; +import { BN } from "bn.js"; +import { IDL as RaydiumMigrationHelperIDL } from "../../target/types/raydium_migration_helper.js"; +import { getMetadataAccountDataSerializer } from "@metaplex-foundation/mpl-token-metadata"; +import { createLookupTableForTransaction } from "../utils.js"; +import { getSquadsPdasFromDao } from "../../scripts/utils/squads.js"; + +// Memo program ID for Raydium withdraw +const MEMO_PROGRAM_ID = new PublicKey( + "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr", +); + +// Raydium Migration Helper program ID +const RAYDIUM_MIGRATION_HELPER_PROGRAM_ID = new PublicKey( + "migR87BnBEkJbbDECLzRxhmNsQ44WMzhDCpCJhfPvR1", +); + +const MIGRATION_METEORIA_CONFIG = new PublicKey( + "5FSCTMuJcrsahe8nB7P3LooAYv5U5GNgBPY8JYjWKfHr", +); + +// Helper functions for Meteora PDA derivation +function maxKey(left: PublicKey, right: PublicKey): Buffer { + const leftBuffer = left.toBuffer(); + const rightBuffer = right.toBuffer(); + for (let i = 0; i < 32; i++) { + if (leftBuffer[i] > rightBuffer[i]) return leftBuffer; + if (leftBuffer[i] < rightBuffer[i]) return rightBuffer; + } + return leftBuffer; +} + +function minKey(left: PublicKey, right: PublicKey): Buffer { + const leftBuffer = left.toBuffer(); + const rightBuffer = right.toBuffer(); + for (let i = 0; i < 32; i++) { + if (leftBuffer[i] < rightBuffer[i]) return leftBuffer; + if (leftBuffer[i] > rightBuffer[i]) return rightBuffer; + } + return leftBuffer; +} + +function getMeteoraPdas(baseMint: PublicKey, quoteMint: PublicKey) { + // migration_signer PDA - used to sign for token transfers in Meteora CPI + const [migrationSigner] = PublicKey.findProgramAddressSync( + [Buffer.from("migration_signer"), baseMint.toBuffer()], + RAYDIUM_MIGRATION_HELPER_PROGRAM_ID, + ); + + // position_nft_mint is seeded by our migration helper program + const [positionNftMint] = PublicKey.findProgramAddressSync( + [Buffer.from("position_nft_mint"), baseMint.toBuffer()], + RAYDIUM_MIGRATION_HELPER_PROGRAM_ID, + ); + + // pool_creator_authority is seeded by our migration helper program + const [poolCreatorAuthority] = PublicKey.findProgramAddressSync( + [Buffer.from("damm_pool_creator_authority")], + RAYDIUM_MIGRATION_HELPER_PROGRAM_ID, + ); + + // pool is seeded by DAMM v2 program + const [pool] = PublicKey.findProgramAddressSync( + [ + Buffer.from("pool"), + MIGRATION_METEORIA_CONFIG.toBuffer(), + maxKey(baseMint, quoteMint), + minKey(baseMint, quoteMint), + ], + DAMM_V2_PROGRAM_ID, + ); + + // position_nft_account is seeded by DAMM v2 program + const [positionNftAccount] = PublicKey.findProgramAddressSync( + [Buffer.from("position_nft_account"), positionNftMint.toBuffer()], + DAMM_V2_PROGRAM_ID, + ); + + // position is seeded by DAMM v2 program + const [position] = PublicKey.findProgramAddressSync( + [Buffer.from("position"), positionNftMint.toBuffer()], + DAMM_V2_PROGRAM_ID, + ); + + // token_a_vault (base) is seeded by DAMM v2 program + const [tokenAVault] = PublicKey.findProgramAddressSync( + [Buffer.from("token_vault"), baseMint.toBuffer(), pool.toBuffer()], + DAMM_V2_PROGRAM_ID, + ); + + // token_b_vault (quote) is seeded by DAMM v2 program + const [tokenBVault] = PublicKey.findProgramAddressSync( + [Buffer.from("token_vault"), quoteMint.toBuffer(), pool.toBuffer()], + DAMM_V2_PROGRAM_ID, + ); + + // pool_authority is seeded by DAMM v2 program + const [poolAuthority] = PublicKey.findProgramAddressSync( + [Buffer.from("pool_authority")], + DAMM_V2_PROGRAM_ID, + ); + + // damm_v2_event_authority is seeded by DAMM v2 program + const [dammV2EventAuthority] = PublicKey.findProgramAddressSync( + [Buffer.from("__event_authority")], + DAMM_V2_PROGRAM_ID, + ); + + return { + migrationSigner, + positionNftMint, + poolCreatorAuthority, + pool, + positionNftAccount, + position, + tokenAVault, + tokenBVault, + poolAuthority, + dammV2EventAuthority, + }; +} + +/** + * Test suite for V5 to V6 DAO migration with Raydium LP + * + * This tests the complete migration flow: + * 1. Initialize V5 DAO and V6 DAO + * 2. Create Raydium pool with DAO tokens + * 3. Execute migration via Squads vault transaction including: + * - Withdraw Raydium LP tokens + * - Transfer all tokens from V5 vault to V6 vault + * - Transfer mint authority + * - Transfer metadata update authority + */ +export default async function suite() { + let baseMint: PublicKey; + let v5DaoAddress: PublicKey; + let v6DaoAddress: PublicKey; + let v5MultisigPda: PublicKey; + let v5VaultPda: PublicKey; + let v6MultisigPda: PublicKey; + let v6VaultPda: PublicKey; + let ammBaseVault: PublicKey; + let ammQuoteVault: PublicKey; + let futarchyV6: FutarchyClientV6; + + it("should perform complete V5 to V6 migration including Raydium LP withdrawal", async function () { + // Initialize V6 futarchy client + futarchyV6 = FutarchyClientV6.createClient({ provider: this.provider }); + + // Setup Meteora config to accept our migration helper's pool_creator_authority + const dynamicConfig = await this.banksClient.getAccount( + new PublicKey("4mPQ4VuvvtYL3CeMPt14Uj1CLpBWcVdJoLoTH9ea4Kod"), + ); + + // discriminator + vault config authority + const poolCreatorAuthorityOffset = 8 + 32; + // discriminator + vault config authority + pool creator authority + pool fees config + activation type + collect fee mode + const configTypeOffset = 8 + 32 + 32 + 128 + 1 + 1; + + const [migrationPoolCreatorAuthority] = PublicKey.findProgramAddressSync( + [Buffer.from("damm_pool_creator_authority")], + RAYDIUM_MIGRATION_HELPER_PROGRAM_ID, + ); + + dynamicConfig.data.set( + migrationPoolCreatorAuthority.toBuffer(), + poolCreatorAuthorityOffset, + ); + dynamicConfig.data.set([1], configTypeOffset); + + this.context.setAccount(MIGRATION_METEORIA_CONFIG, dynamicConfig); + console.log( + "✓ Set up Meteora config with migration helper's pool_creator_authority:", + migrationPoolCreatorAuthority.toBase58(), + ); + + console.log("\n=== SETUP: Creating funders ==="); + // Create multiple funders + const funder1 = Keypair.generate(); + const funder2 = Keypair.generate(); + const funder3 = Keypair.generate(); + + let META: PublicKey; + let launch: PublicKey; + let dao: PublicKey; + + const minRaise = new BN(600_000_000); // 600 USDC + const launchPeriod = 60 * 60 * 24 * 2; // 2 days + + console.log("=== STEP 1: Initializing launch mint ==="); + + // Create v0.5 launchpad client + const launchpadClient = LaunchpadClient.createClient({ + provider: this.provider, + }); + + // Load the pre-ground keypair that ends in "meta" from environment variable + if (!process.env.META_KEYPAIR) { + throw new Error("META_KEYPAIR environment variable is required"); + } + const metaMintKeypair = Keypair.fromSecretKey( + new Uint8Array(JSON.parse(process.env.META_KEYPAIR)), + ); + META = metaMintKeypair.publicKey; + + [launch] = getLaunchAddr(launchpadClient.getProgramId(), META); + const [launchSigner] = getLaunchSignerAddr( + launchpadClient.getProgramId(), + launch, + ); + + console.log("✓ Using META mint:", META.toBase58()); + console.log("✓ Launch:", launch.toBase58()); + + // Create and initialize the mint + const rent = await this.banksClient.getRent(); + const lamports = Number(rent.minimumBalance(BigInt(token.MINT_SIZE))); + + const tx = new Transaction().add( + SystemProgram.createAccount({ + fromPubkey: this.payer.publicKey, + newAccountPubkey: META, + lamports, + space: token.MINT_SIZE, + programId: token.TOKEN_PROGRAM_ID, + }), + token.createInitializeMint2Instruction(META, 6, launchSigner, null), + ); + tx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + tx.feePayer = this.payer.publicKey; + tx.sign(this.payer, metaMintKeypair); + + await this.banksClient.processTransaction(tx); + console.log("✓ Mint initialized"); + + console.log("\n=== STEP 2: Setting up funder token accounts ==="); + // Setup token accounts for funders + await this.createTokenAccount(MAINNET_USDC, funder1.publicKey); + await this.createTokenAccount(MAINNET_USDC, funder2.publicKey); + await this.createTokenAccount(MAINNET_USDC, funder3.publicKey); + console.log("✓ Funder token accounts created"); + + console.log("\n=== STEP 3: Minting USDC to funders ==="); + // Mint USDC to funders + await this.transfer( + MAINNET_USDC, + this.payer, + funder1.publicKey, + 5000_000_000, + ); + await this.transfer( + MAINNET_USDC, + this.payer, + funder2.publicKey, + 3000_000_000, + ); + await this.transfer( + MAINNET_USDC, + this.payer, + funder3.publicKey, + 4000_000_000, + ); + console.log("✓ USDC transferred to funders"); + + console.log("\n=== STEP 4: Initializing launch ==="); + // Initialize launch + try { + await launchpadClient + .initializeLaunchIx( + "META", + "META", + "https://example.com", + minRaise, + launchPeriod, + META, + MAINNET_USDC, + new BN(100_000_000), // monthlySpendingLimitAmount - 100 USDC + [this.payer.publicKey], // monthlySpendingLimitMembers + ) + .rpc(); + console.log("✓ Launch initialized"); + } catch (e: any) { + console.error("Failed to initialize launch:", e.message); + console.error("Full error:", e); + throw e; + } + + console.log("\n=== STEP 5: Starting launch ==="); + // Start launch + await launchpadClient.startLaunchIx(launch).rpc(); + console.log("✓ Launch started"); + + console.log("\n=== STEP 6: Funding launch ==="); + // Fund from multiple sources + await launchpadClient + .fundIx(launch, new BN(5000_000000), funder1.publicKey, MAINNET_USDC) + .signers([funder1]) + .rpc(); + console.log("✓ Funder1 contributed 5000 USDC"); + + await launchpadClient + .fundIx(launch, new BN(1500_000000), this.payer.publicKey, MAINNET_USDC) + .rpc(); + console.log("✓ Payer contributed 1500 USDC"); + + await launchpadClient + .fundIx(launch, new BN(3500_000000), funder3.publicKey, MAINNET_USDC) + .signers([funder3]) + .rpc(); + console.log("✓ Funder3 contributed 3500 USDC"); + + console.log("\n=== STEP 7: Advancing time and completing launch ==="); + // Advance time and complete launch + await this.advanceBySeconds(launchPeriod + 3600); + console.log("✓ Time advanced"); + + const completeLaunchTx = await launchpadClient + .completeLaunchIx(launch, MAINNET_USDC, META) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 800_000 }), + ]) + .transaction(); + + const completeLaunchLut = await createLookupTableForTransaction( + completeLaunchTx, + this, + ); + + const completeLaunchMessage = new TransactionMessage({ + payerKey: this.payer.publicKey, + recentBlockhash: (await this.banksClient.getLatestBlockhash())[0], + instructions: completeLaunchTx.instructions, + }).compileToV0Message([completeLaunchLut]); + + const completeLaunchVersionedTx = new VersionedTransaction( + completeLaunchMessage, + ); + completeLaunchVersionedTx.sign([this.payer]); + + await this.banksClient.processTransaction(completeLaunchVersionedTx); + console.log("✓ Launch completed"); + + // Verify launch completion and DAO creation + const launchAccount = await launchpadClient.fetchLaunch(launch); + assert.exists(launchAccount.state.complete); + assert.exists(launchAccount.dao); + dao = launchAccount.dao; + + // this is where our script begins + + // Set V5 DAO addresses using same utility function as the script + v5DaoAddress = dao; + baseMint = META; + const v5SquadsPdas = await getSquadsPdasFromDao(v5DaoAddress); + v5MultisigPda = v5SquadsPdas.multisigPda; + v5VaultPda = v5SquadsPdas.vaultPda; + + console.log("V5 DAO launched:", v5DaoAddress.toBase58()); + console.log("V5 Multisig:", v5MultisigPda.toBase58()); + console.log("V5 Vault:", v5VaultPda.toBase58()); + + // Derive the Raydium pool created by completeLaunch + const [raydiumPoolState] = getLiquidityPoolAddr( + launchpadClient.getProgramId(), + v5DaoAddress, + ); + + // Token ordering: smaller pubkey is token0 + const isBaseToken0 = + baseMint.toBuffer().compare(MAINNET_USDC.toBuffer()) < 0; + const token0Mint = isBaseToken0 ? baseMint : MAINNET_USDC; + const token1Mint = isBaseToken0 ? MAINNET_USDC : baseMint; + + // Derive Raydium CPMM PDAs from the launchpad-created pool + const [lpMint] = PublicKey.findProgramAddressSync( + [Buffer.from("pool_lp_mint"), raydiumPoolState.toBuffer()], + RAYDIUM_CP_SWAP_PROGRAM_ID, + ); + + const [token0Vault] = PublicKey.findProgramAddressSync( + [ + Buffer.from("pool_vault"), + raydiumPoolState.toBuffer(), + token0Mint.toBuffer(), + ], + RAYDIUM_CP_SWAP_PROGRAM_ID, + ); + + const [token1Vault] = PublicKey.findProgramAddressSync( + [ + Buffer.from("pool_vault"), + raydiumPoolState.toBuffer(), + token1Mint.toBuffer(), + ], + RAYDIUM_CP_SWAP_PROGRAM_ID, + ); + + const vaultLpAta = token.getAssociatedTokenAddressSync( + lpMint, + v5VaultPda, + true, + ); + + console.log( + "Raydium Pool State (from launchpad):", + raydiumPoolState.toBase58(), + ); + console.log("Raydium LP Mint:", lpMint.toBase58()); + + // Get LP balance from the vault (created by completeLaunch) + const vaultLpBalance = await this.getTokenBalance(lpMint, v5VaultPda); + console.log( + "V5 Vault LP balance from launchpad:", + vaultLpBalance.toString(), + ); + + // Claim tokens for all funders + await launchpadClient.claimIx(launch, META, funder1.publicKey).rpc(); + + await launchpadClient.claimIx(launch, META).rpc(); + + await launchpadClient.claimIx(launch, META, funder3.publicKey).rpc(); + + // Verify token distributions + const funder1Balance = await this.getTokenBalance(META, funder1.publicKey); + const payerBalance = await this.getTokenBalance(META, this.payer.publicKey); + const funder3Balance = await this.getTokenBalance(META, funder3.publicKey); + + assert.equal(funder1Balance.toString(), "5000000000000"); // 5M tokens + assert.equal(payerBalance.toString(), "1500000000000"); // 1.5M tokens + assert.equal(funder3Balance.toString(), "3500000000000"); // 3.5M tokens + + console.log("\n=== INITIALIZING V6 DAO ==="); + + const v6Nonce = new BN(Math.floor(Math.random() * 1000000)); + + try { + const txSig = await futarchyV6 + .initializeDaoIx({ + baseMint, // Shared mint between V5 launch and V6 DAO + quoteMint: MAINNET_USDC, + params: { + nonce: v6Nonce, + twapInitialObservation: new BN(1_000_000), + twapMaxObservationChangePerUpdate: new BN(100_000), + minBaseFutarchicLiquidity: new BN(10_000_000000), + minQuoteFutarchicLiquidity: new BN(10_000_000000), + twapStartDelaySeconds: 100, + passThresholdBps: 300, + secondsPerProposal: 60 * 60 * 24 * 3, // 3 days + initialSpendingLimit: null, + baseToStake: new BN(0), + teamSponsoredPassThresholdBps: 300, + teamAddress: this.payer.publicKey, + }, + provideLiquidity: false, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + console.log("DAO initialization tx:", txSig); + } catch (e: any) { + console.error("DAO initialization failed:", e); + throw e; + } + + // from here is where the script starts + + // Derive V6 addresses + [v6DaoAddress] = getDaoAddr({ + nonce: v6Nonce, + daoCreator: this.payer.publicKey, + }); + const v6MultisigPda = multisig.getMultisigPda({ + createKey: v6DaoAddress, + })[0]; + const v6VaultPda = multisig.getVaultPda({ + multisigPda: v6MultisigPda, + index: 0, + })[0]; + + console.log("V6 DAO initialized:", v6DaoAddress.toBase58()); + console.log("V6 Multisig:", v6MultisigPda.toBase58()); + console.log("V6 Vault:", v6VaultPda.toBase58()); + + // Create token accounts for vaults and DAO AMM using idempotent instructions + const v5VaultBaseAta = token.getAssociatedTokenAddressSync( + baseMint, + v5VaultPda, + true, + ); + const v5VaultQuoteAta = token.getAssociatedTokenAddressSync( + MAINNET_USDC, + v5VaultPda, + true, + ); + const v6VaultBaseAta = token.getAssociatedTokenAddressSync( + baseMint, + v6VaultPda, + true, + ); + const v6VaultQuoteAta = token.getAssociatedTokenAddressSync( + MAINNET_USDC, + v6VaultPda, + true, + ); + const ammBaseVault = token.getAssociatedTokenAddressSync( + baseMint, + v6DaoAddress, + true, + ); + const ammQuoteVault = token.getAssociatedTokenAddressSync( + MAINNET_USDC, + v6DaoAddress, + true, + ); + + // Get migration_signer PDA and its ATAs (needed for Meteora CPI) + const meteoraPdasForAtas = getMeteoraPdas(baseMint, MAINNET_USDC); + const migrationSignerBaseAta = token.getAssociatedTokenAddressSync( + baseMint, + meteoraPdasForAtas.migrationSigner, + true, + ); + const migrationSignerQuoteAta = token.getAssociatedTokenAddressSync( + MAINNET_USDC, + meteoraPdasForAtas.migrationSigner, + true, + ); + + const createAtasIx = [ + token.createAssociatedTokenAccountIdempotentInstruction( + this.payer.publicKey, + v5VaultBaseAta, + v5VaultPda, + baseMint, + ), + token.createAssociatedTokenAccountIdempotentInstruction( + this.payer.publicKey, + v6VaultBaseAta, + v6VaultPda, + baseMint, + ), + token.createAssociatedTokenAccountIdempotentInstruction( + this.payer.publicKey, + v5VaultQuoteAta, + v5VaultPda, + MAINNET_USDC, + ), + token.createAssociatedTokenAccountIdempotentInstruction( + this.payer.publicKey, + v6VaultQuoteAta, + v6VaultPda, + MAINNET_USDC, + ), + token.createAssociatedTokenAccountIdempotentInstruction( + this.payer.publicKey, + ammBaseVault, + v6DaoAddress, + baseMint, + ), + token.createAssociatedTokenAccountIdempotentInstruction( + this.payer.publicKey, + ammQuoteVault, + v6DaoAddress, + MAINNET_USDC, + ), + // Migration signer ATAs (needed for Meteora CPI) + token.createAssociatedTokenAccountIdempotentInstruction( + this.payer.publicKey, + migrationSignerBaseAta, + meteoraPdasForAtas.migrationSigner, + baseMint, + ), + token.createAssociatedTokenAccountIdempotentInstruction( + this.payer.publicKey, + migrationSignerQuoteAta, + meteoraPdasForAtas.migrationSigner, + MAINNET_USDC, + ), + ]; + + const createAtasTx = new Transaction().add(...createAtasIx); + createAtasTx.recentBlockhash = ( + await this.banksClient.getLatestBlockhash() + )[0]; + createAtasTx.feePayer = this.payer.publicKey; + createAtasTx.sign(this.payer); + await this.banksClient.processTransaction(createAtasTx); + + // Transfer tokens to V5 vault (simulating existing treasury) + const transferToV5Ix = token.createTransferInstruction( + token.getAssociatedTokenAddressSync(baseMint, this.payer.publicKey, true), + v5VaultBaseAta, + this.payer.publicKey, + 100_000_000000, // 100k tokens + ); + + const transferToV5Tx = new Transaction().add(transferToV5Ix); + transferToV5Tx.recentBlockhash = ( + await this.banksClient.getLatestBlockhash() + )[0]; + transferToV5Tx.feePayer = this.payer.publicKey; + transferToV5Tx.sign(this.payer); + await this.banksClient.processTransaction(transferToV5Tx); + + console.log("V5 vault funded with 100k base tokens"); + console.log("=== SETUP COMPLETE ===\n"); + + console.log("\n=== PHASE 1: Using Launchpad-Created Raydium Pool ==="); + console.log( + "Pool already created by completeLaunch - using launchpad pool addresses", + ); + + // Add initial treasury funds to V5 vault (distinct from LP tokens) + // These should be transferred to V6 vault ATAs after migration + const initialTreasuryBase = 5_000_000_000; // 5k base tokens + const initialTreasuryQuote = 10_000_000_000; // 10k USDC + + console.log("\n=== Adding initial treasury funds to V5 vault ==="); + + // Create V5 vault ATAs if needed and transfer initial treasury + // (v5VaultBaseAta and v5VaultQuoteAta already declared earlier) + const payerBaseAta = token.getAssociatedTokenAddressSync( + baseMint, + this.payer.publicKey, + true, + ); + const payerQuoteAta = token.getAssociatedTokenAddressSync( + MAINNET_USDC, + this.payer.publicKey, + true, + ); + + const createV5BaseAtaIx = + token.createAssociatedTokenAccountIdempotentInstruction( + this.payer.publicKey, + v5VaultBaseAta, + v5VaultPda, + baseMint, + ); + const createV5QuoteAtaIx = + token.createAssociatedTokenAccountIdempotentInstruction( + this.payer.publicKey, + v5VaultQuoteAta, + v5VaultPda, + MAINNET_USDC, + ); + + const transferBaseToVaultIx = token.createTransferInstruction( + payerBaseAta, + v5VaultBaseAta, + this.payer.publicKey, + initialTreasuryBase, + ); + + const transferQuoteToVaultIx = token.createTransferInstruction( + payerQuoteAta, + v5VaultQuoteAta, + this.payer.publicKey, + initialTreasuryQuote, + ); + + const seedTreasuryTx = new Transaction().add( + createV5BaseAtaIx, + createV5QuoteAtaIx, + transferBaseToVaultIx, + transferQuoteToVaultIx, + ); + seedTreasuryTx.recentBlockhash = ( + await this.banksClient.getLatestBlockhash() + )[0]; + seedTreasuryTx.feePayer = this.payer.publicKey; + seedTreasuryTx.sign(this.payer); + await this.banksClient.processTransaction(seedTreasuryTx); + + const v5BaseAfterSeed = await this.getTokenBalance(baseMint, v5VaultPda); + const v5QuoteAfterSeed = await this.getTokenBalance( + MAINNET_USDC, + v5VaultPda, + ); + console.log( + "V5 vault treasury seeded - Base:", + v5BaseAfterSeed.toString(), + "Quote:", + v5QuoteAfterSeed.toString(), + ); + + console.log("\n=== PHASE 2: Building Migration Vault Transaction ==="); + + // Fund vault PDA with SOL for rent (futarchy provideLiquidity needs to create AMM position) + const ammPositionRent = 1 * anchor.web3.LAMPORTS_PER_SOL; // 1 SOL should be enough for rent + const fundVaultIx = SystemProgram.transfer({ + fromPubkey: this.payer.publicKey, + toPubkey: v5VaultPda, + lamports: ammPositionRent, + }); + const fundVaultTx = new Transaction().add(fundVaultIx); + fundVaultTx.recentBlockhash = ( + await this.banksClient.getLatestBlockhash() + )[0]; + fundVaultTx.feePayer = this.payer.publicKey; + fundVaultTx.sign(this.payer); + await this.banksClient.processTransaction(fundVaultTx); + console.log( + `Funded vault with ${ammPositionRent / anchor.web3.LAMPORTS_PER_SOL} SOL for rent`, + ); + + // Fund migration_signer PDA with SOL for Meteora pool creation rent + const migrationSignerRent = 0.1 * anchor.web3.LAMPORTS_PER_SOL; // 0.1 SOL for rent + const fundMigrationSignerIx = SystemProgram.transfer({ + fromPubkey: this.payer.publicKey, + toPubkey: meteoraPdasForAtas.migrationSigner, + lamports: migrationSignerRent, + }); + const fundMigrationSignerTx = new Transaction().add(fundMigrationSignerIx); + fundMigrationSignerTx.recentBlockhash = ( + await this.banksClient.getLatestBlockhash() + )[0]; + fundMigrationSignerTx.feePayer = this.payer.publicKey; + fundMigrationSignerTx.sign(this.payer); + await this.banksClient.processTransaction(fundMigrationSignerTx); + console.log( + `Funded migration_signer with ${migrationSignerRent / anchor.web3.LAMPORTS_PER_SOL} SOL for Meteora rent`, + ); + + // Get current balances + const v5BaseBalanceBefore = await this.getTokenBalance( + baseMint, + v5VaultPda, + ); + const v5QuoteBalanceBefore = await this.getTokenBalance( + MAINNET_USDC, + v5VaultPda, + ); + + console.log("V5 base balance before:", v5BaseBalanceBefore.toString()); + console.log("V5 quote balance before:", v5QuoteBalanceBefore.toString()); + console.log("V5 LP balance:", vaultLpBalance.toString()); + + // Build vault transaction instructions + const vaultInstructions: anchor.web3.TransactionInstruction[] = []; + + // Add compute budget instructions to vault transaction for Squads simulation + // Need high compute: Raydium withdraw + futarchy provideLiquidity (creates AMM position) + Meteora pool creation + vaultInstructions.push( + ComputeBudgetProgram.setComputeUnitLimit({ units: 1_400_000 }), + ); + vaultInstructions.push( + ComputeBudgetProgram.requestHeapFrame({ bytes: 256 * 1024 }), + ); + + // 1. Withdraw LP and transfer all tokens using helper program + const helperProgram = new anchor.Program( + RaydiumMigrationHelperIDL, + RAYDIUM_MIGRATION_HELPER_PROGRAM_ID, + this.provider, + ); + + // Get V6 futarchy AMM accounts + // Note: position_authority is v6VaultPda - the V6 vault will own the AMM position + const [ammPosition] = PublicKey.findProgramAddressSync( + [ + Buffer.from("amm_position"), + v6DaoAddress.toBuffer(), + v6VaultPda.toBuffer(), // position_authority (the V6 vault owns the AMM position) + ], + futarchyV6.getProgramId(), + ); + + const [eventAuthority] = PublicKey.findProgramAddressSync( + [Buffer.from("__event_authority")], + futarchyV6.getProgramId(), + ); + + // Get Meteora DAMM v2 PDAs for the new pool creation + const meteoraPdas = getMeteoraPdas(baseMint, MAINNET_USDC); + console.log(" Meteora Pool PDA:", meteoraPdas.pool.toBase58()); + console.log( + " Meteora Position NFT Mint:", + meteoraPdas.positionNftMint.toBase58(), + ); + + const withdrawAndProvideLiquidityIx = await helperProgram.methods + .withdrawAndProvideLiquidity( + new BN(vaultLpBalance.toString()), + new BN(0), // min_raydium_amount_0 - no slippage protection for governance + new BN(0), // min_raydium_amount_1 + new BN(0), // min_futarchy_liquidity + ) + .accounts({ + vaultAuthority: v5VaultPda, + // Migration signer accounts (needed for Meteora CPI) + migrationSigner: meteoraPdas.migrationSigner, + migrationSignerBaseAta: migrationSignerBaseAta, + migrationSignerQuoteAta: migrationSignerQuoteAta, + // Raydium accounts - from launchpad pool + poolState: raydiumPoolState, + raydiumAuthority: RAYDIUM_AUTHORITY, + lpMint: lpMint, + vaultLpToken: vaultLpAta, + vaultToken0: isBaseToken0 ? v5VaultBaseAta : v5VaultQuoteAta, + vaultToken1: isBaseToken0 ? v5VaultQuoteAta : v5VaultBaseAta, + poolToken0Vault: token0Vault, + poolToken1Vault: token1Vault, + // V6 Futarchy accounts + dao: v6DaoAddress, + baseMint: baseMint, + quoteMint: MAINNET_USDC, + ammPosition: ammPosition, + ammBaseVault: ammBaseVault, + ammQuoteVault: ammQuoteVault, + v6VaultBaseAta: v6VaultBaseAta, + v6VaultQuoteAta: v6VaultQuoteAta, + v6VaultPda: v6VaultPda, + eventAuthority: eventAuthority, + // Meteora DAMM v2 accounts + meteoraAccounts: { + dammV2Program: DAMM_V2_PROGRAM_ID, + config: MIGRATION_METEORIA_CONFIG, + token2022Program: token.TOKEN_2022_PROGRAM_ID, + positionNftAccount: meteoraPdas.positionNftAccount, + pool: meteoraPdas.pool, + position: meteoraPdas.position, + positionNftMint: meteoraPdas.positionNftMint, + baseMint: baseMint, + quoteMint: MAINNET_USDC, + tokenAVault: meteoraPdas.tokenAVault, + tokenBVault: meteoraPdas.tokenBVault, + poolCreatorAuthority: meteoraPdas.poolCreatorAuthority, + poolAuthority: meteoraPdas.poolAuthority, + dammV2EventAuthority: meteoraPdas.dammV2EventAuthority, + }, + // Programs + raydiumProgram: RAYDIUM_CP_SWAP_PROGRAM_ID, + futarchyProgram: futarchyV6.getProgramId(), + tokenProgram: token.TOKEN_PROGRAM_ID, + tokenProgram2022: token.TOKEN_2022_PROGRAM_ID, + systemProgram: SystemProgram.programId, + memoProgram: MEMO_PROGRAM_ID, + }) + .instruction(); + + console.log( + "Instruction accounts:", + withdrawAndProvideLiquidityIx.keys.map( + (k) => + `${k.pubkey.toBase58()}: ${k.isSigner ? "signer" : ""} ${k.isWritable ? "writable" : "readonly"}`, + ), + ); + + vaultInstructions.push(withdrawAndProvideLiquidityIx); + console.log( + "Added: Withdraw LP, provide liquidity to V6 AMM, transfer treasury", + ); + + // 4. Transfer mint authority + vaultInstructions.push( + token.createSetAuthorityInstruction( + baseMint, + v5VaultPda, + token.AuthorityType.MintTokens, + v6VaultPda, + ), + ); + console.log("Added: Transfer mint authority"); + + // 5. Transfer metadata authority + const [metadataAddr] = getMetadataAddr(baseMint); + try { + const metadataAccountInfo = + await this.provider.connection.getAccountInfo(metadataAddr); + if (metadataAccountInfo) { + const metadataSerializer = getMetadataAccountDataSerializer(); + const [metadata] = metadataSerializer.deserialize( + metadataAccountInfo.data, + ); + const updateAuthority = new PublicKey(metadata.updateAuthority); + + if (updateAuthority.equals(v5VaultPda)) { + // Manually construct metadata update instruction for bankrun + // This is equivalent to updateMetadataAccountV2 but without UMI + const MPL_TOKEN_METADATA_PROGRAM_ID = new PublicKey( + "metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s", + ); + + const updateMetadataIx = new TransactionInstruction({ + programId: MPL_TOKEN_METADATA_PROGRAM_ID, + keys: [ + { pubkey: metadataAddr, isSigner: false, isWritable: true }, + { pubkey: v5VaultPda, isSigner: true, isWritable: false }, + ], + data: Buffer.from([ + // Discriminator for UpdateMetadataAccountV2 instruction + 15, + // Data update (null = no change) + 0, + // Update authority (Some(new_authority)) + 1, + ...v6VaultPda.toBytes(), + // Primary sale happened (null = no change) + 0, + // Is mutable (null = no change) + 0, + ]), + }); + + vaultInstructions.push(updateMetadataIx); + console.log("Added: Transfer metadata authority"); + } + } + } catch (e: any) { + console.log(" ⚠ Could not check metadata authority:", e.message || e); + } + + console.log("Total vault instructions:", vaultInstructions.length); + + // Step: Detailed instruction debugging + console.log("\n=== DETAILED INSTRUCTION ANALYSIS ==="); + const uniqueProgramIds = new Set(); + for (let i = 0; i < vaultInstructions.length; i++) { + const ix = vaultInstructions[i]; + uniqueProgramIds.add(ix.programId.toBase58()); + console.log(`\n Instruction ${i + 1}/${vaultInstructions.length}:`); + console.log(` Program ID: ${ix.programId.toBase58()}`); + console.log(` Data length: ${ix.data.length} bytes`); + console.log(` Accounts (${ix.keys.length}):`); + ix.keys.forEach((key, idx) => { + console.log( + ` [${idx}] ${key.pubkey.toBase58()} ${key.isSigner ? "(signer)" : ""} ${key.isWritable ? "(writable)" : "(readonly)"}`, + ); + }); + } + + // Verify all program IDs exist on-chain + console.log("\n Verifying program IDs exist on-chain..."); + for (const programId of uniqueProgramIds) { + const programInfo = await this.provider.connection.getAccountInfo( + new PublicKey(programId), + ); + if (!programInfo) { + throw new Error(`❌ Program ID ${programId} does not exist on-chain!`); + } + if (!programInfo.executable) { + throw new Error(`❌ Program ID ${programId} is not executable!`); + } + console.log(` ✓ ${programId} (executable)`); + } + + // ==== TRANSACTION SIZE CHECK ==== + const tempTxForSize = new Transaction().add(...vaultInstructions); + tempTxForSize.recentBlockhash = "11111111111111111111111111111111"; + tempTxForSize.feePayer = v5VaultPda; + const uncompressedSize = tempTxForSize.serializeMessage().length; + + console.log("\n" + "=".repeat(70)); + console.log("📊 TRANSACTION SIZE ANALYSIS (Vault Transaction Message)"); + console.log("=".repeat(70)); + console.log(`Uncompressed size: ${uncompressedSize} bytes`); + console.log(`Size limit: 1232 bytes`); + if (uncompressedSize > 1232) { + console.log(`❌ OVER LIMIT by ${uncompressedSize - 1232} bytes`); + } else { + console.log(`✅ UNDER LIMIT by ${1232 - uncompressedSize} bytes`); + } + + // Break down by instruction + console.log("\n📋 Instruction breakdown:"); + vaultInstructions.forEach((ix, idx) => { + const singleIxTx = new Transaction().add(ix); + singleIxTx.recentBlockhash = "11111111111111111111111111111111"; + singleIxTx.feePayer = v5VaultPda; + const ixSize = singleIxTx.serializeMessage().length; + console.log(` [${idx}] ${ixSize} bytes - ${ix.keys.length} accounts`); + }); + console.log("=".repeat(70)); + + console.log("\n=== PHASE 3: Executing Migration via Squads ==="); + + // Create lookup table to compress transaction + const tempTx = new Transaction().add(...vaultInstructions); + const migrationLut = await createLookupTableForTransaction(tempTx, this); + console.log( + "Migration LUT created with", + migrationLut.state.addresses.length, + "addresses", + ); + + // Debug: Check which accounts are in the LUT vs instruction + const lutAddressSet = new Set( + migrationLut.state.addresses.map((a) => a.toBase58()), + ); + let missingFromLut = 0; + for (const ix of vaultInstructions) { + for (const key of ix.keys) { + if (!lutAddressSet.has(key.pubkey.toBase58())) { + console.log(" ⚠️ Account NOT in LUT:", key.pubkey.toBase58()); + missingFromLut++; + } + } + } + console.log( + ` Total accounts in instructions: ${vaultInstructions.reduce((sum, ix) => sum + ix.keys.length, 0)}`, + ); + console.log(` Accounts missing from LUT: ${missingFromLut}`); + + // Create transaction message (don't compile to V0 - pass plain message + LUT separately to Squads) + const transactionMessage = new TransactionMessage({ + payerKey: v5VaultPda, + recentBlockhash: "", + instructions: vaultInstructions, + }); + + // Get transaction index + const v5MultisigAccount = + await multisig.accounts.Multisig.fromAccountAddress( + this.squadsConnection, + v5MultisigPda, + ); + const transactionIndex = BigInt( + Number(v5MultisigAccount.transactionIndex) + 1, + ); + + // Create vault transaction with plain message + LUT accounts + const vaultTxCreateIx = multisig.instructions.vaultTransactionCreate({ + multisigPda: v5MultisigPda, + transactionIndex, + creator: PERMISSIONLESS_ACCOUNT.publicKey, + rentPayer: this.payer.publicKey, + vaultIndex: 0, + ephemeralSigners: 0, + transactionMessage: transactionMessage, + addressLookupTableAccounts: [migrationLut], + }); + + console.log("\n📊 VaultTransactionCreate instruction:"); + console.log(" Accounts:", vaultTxCreateIx.keys.length); + console.log(" Data size:", vaultTxCreateIx.data.length, "bytes"); + + const vaultCreateTx = new Transaction().add(vaultTxCreateIx); + vaultCreateTx.recentBlockhash = "11111111111111111111111111111111"; + vaultCreateTx.feePayer = this.payer.publicKey; + const vaultCreateSize = vaultCreateTx.serializeMessage().length; + console.log(" Total transaction size:", vaultCreateSize, "bytes"); + + // Create proposal (no approve yet - that happens through autocrat) + const proposalCreateIx = multisig.instructions.proposalCreate({ + multisigPda: v5MultisigPda, + transactionIndex, + creator: PERMISSIONLESS_ACCOUNT.publicKey, + rentPayer: this.payer.publicKey, + isDraft: false, + }); + + const [squadsProposalPda] = multisig.getProposalPda({ + multisigPda: v5MultisigPda, + transactionIndex, + }); + + // Create Squads proposal + const squadsTx = new Transaction().add(vaultTxCreateIx, proposalCreateIx); + squadsTx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + squadsTx.feePayer = this.payer.publicKey; + + const squadsTxSize = squadsTx.serializeMessage().length; + console.log( + "\n📊 Squads proposal transaction (create vault tx + proposal):", + ); + console.log(" Size:", squadsTxSize, "bytes (limit: 1232)"); + if (squadsTxSize > 1232) { + console.log(" ❌ OVER LIMIT by", squadsTxSize - 1232, "bytes"); + } + + squadsTx.sign(this.payer, PERMISSIONLESS_ACCOUNT); + await this.banksClient.processTransaction(squadsTx); + + console.log("Squads proposal created"); + + // Create V5 autocrat and AMM clients + const autocratV5Client = AutocratClient.createClient({ + provider: this.provider, + }); + const ammV5Client = AmmClient.createClient({ + provider: this.provider, + }); + + // Initialize autocrat proposal + // DAO requires min 100 tokens liquidity (100_000_000000 for base with 9 decimals, 100_000000 for quote with 6 decimals) + console.log("\n📊 Creating autocrat proposal..."); + const proposal = await autocratV5Client.initializeProposal( + v5DaoAddress, + "Migration to V6", + squadsProposalPda, + new BN(100_000_000000), // baseAmount - 100 tokens + new BN(100_000000), // quoteAmount - 100 USDC + ); + console.log("Autocrat proposal created:", proposal.toBase58()); + + // Get proposal PDAs + const { + passAmm, + failAmm, + passBaseMint, + passQuoteMint, + question, + baseVault, + quoteVault, + } = autocratV5Client.getProposalPdas( + proposal, + baseMint, + MAINNET_USDC, + v5DaoAddress, + ); + + // Split tokens for the proposal markets (matching the liquidity requirements) + await this.conditionalVault + .splitTokensIx(question, baseVault, baseMint, new BN(100_000_000000), 2) + .rpc(); + await this.conditionalVault + .splitTokensIx(question, quoteVault, MAINNET_USDC, new BN(100_000000), 2) + .rpc(); + + console.log("Tokens split for conditional markets"); + + // Trade in pass market to make proposal pass + await ammV5Client + .swapIx( + passAmm, + passBaseMint, + passQuoteMint, + { buy: {} }, + new BN(100_000000), // Buy 100 USDC worth + new BN(0), + ) + .rpc(); + + console.log("Voted in favor of proposal"); + + // Crank TWAP updates until proposal passes + for (let i = 0; i < 100; i++) { + await this.advanceBySlots(20_000n); + + await ammV5Client + .crankThatTwapIx(passAmm) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: i }), + await ammV5Client.crankThatTwapIx(failAmm).instruction(), + ]) + .rpc(); + } + + console.log("Market resolved"); + + // Finalize proposal (this approves the Squads proposal) + await autocratV5Client.finalizeProposal(proposal); + console.log("Autocrat proposal finalized - Squads proposal approved"); + + // Execute vault transaction with high compute budget + const executeIx = await multisig.instructions.vaultTransactionExecute({ + connection: this.squadsConnection, + multisigPda: v5MultisigPda, + transactionIndex, + member: PERMISSIONLESS_ACCOUNT.publicKey, + }); + + console.log( + "\n📊 Execute instruction has", + executeIx.instruction.keys.length, + "accounts", + ); + + // Build as V0 transaction with LUT to compress accounts + const executeMessage = new TransactionMessage({ + payerKey: this.payer.publicKey, + recentBlockhash: (await this.banksClient.getLatestBlockhash())[0], + instructions: [ + ComputeBudgetProgram.setComputeUnitLimit({ units: 1_400_000 }), + ComputeBudgetProgram.requestHeapFrame({ bytes: 256 * 1024 }), + executeIx.instruction, + ], + }).compileToV0Message([migrationLut]); + + const executeV0Tx = new VersionedTransaction(executeMessage); + const executeTxSize = executeV0Tx.message.serialize().length; + console.log( + "📊 Execute V0 transaction size (with LUT, before signing):", + executeTxSize, + "bytes", + ); + console.log( + "📊 After signing (~+128 bytes):", + executeTxSize + 128, + "bytes (limit: 1232)", + ); + if (executeTxSize + 128 > 1232) { + console.log( + "❌ Execute transaction will be", + executeTxSize + 128 - 1232, + "bytes over limit after signing", + ); + } else { + console.log( + "✅ Execute transaction under limit by", + 1232 - (executeTxSize + 128), + "bytes after signing", + ); + } + + executeV0Tx.sign([this.payer, PERMISSIONLESS_ACCOUNT]); + await this.banksClient.processTransaction(executeV0Tx); + + console.log("Migration executed via Squads"); + + console.log("\n=== PHASE 4: Verifying Migration Success ==="); + + // Verify LP tokens burned + const vaultLpBalanceAfter = await this.getTokenBalance(lpMint, v5VaultPda); + assert.equal(vaultLpBalanceAfter, 0n, "LP tokens should be burned"); + console.log("✓ LP tokens burned"); + + // Verify V5 vault empty + const v5BaseBalanceAfter = await this.getTokenBalance(baseMint, v5VaultPda); + const v5QuoteBalanceAfter = await this.getTokenBalance( + MAINNET_USDC, + v5VaultPda, + ); + assert.equal(v5BaseBalanceAfter, 0n, "V5 vault should have no base tokens"); + assert.equal( + v5QuoteBalanceAfter, + 0n, + "V5 vault should have no quote tokens", + ); + console.log("✓ V5 vault emptied"); + + // Verify V6 vault has tokens + const v6BaseBalanceAfter = await this.getTokenBalance(baseMint, v6VaultPda); + const v6QuoteBalanceAfter = await this.getTokenBalance( + MAINNET_USDC, + v6VaultPda, + ); + assert.ok(v6BaseBalanceAfter > 0n, "V6 vault should have base tokens"); + assert.ok(v6QuoteBalanceAfter > 0n, "V6 vault should have quote tokens"); + console.log("✓ V6 vault received tokens"); + console.log(" Base:", v6BaseBalanceAfter.toString()); + console.log(" Quote:", v6QuoteBalanceAfter.toString()); + + // Verify mint authority transferred + const mintInfo = await this.getMint(baseMint); + assert.ok( + mintInfo.mintAuthority && mintInfo.mintAuthority.equals(v6VaultPda), + "Mint authority should be V6 vault", + ); + console.log("✓ Mint authority transferred"); + + // Verify Meteora pool was created with 10% of tokens + const meteoraPdasVerify = getMeteoraPdas(baseMint, MAINNET_USDC); + console.log(" Test derived pool:", meteoraPdasVerify.pool.toBase58()); + console.log( + " Test derived tokenAVault:", + meteoraPdasVerify.tokenAVault.toBase58(), + ); + console.log( + " Test derived tokenBVault:", + meteoraPdasVerify.tokenBVault.toBase58(), + ); + console.log(" baseMint:", baseMint.toBase58()); + console.log(" quoteMint (MAINNET_USDC):", MAINNET_USDC.toBase58()); + + const meteoraPoolAccount = await this.banksClient.getAccount( + meteoraPdasVerify.pool, + ); + assert.ok(meteoraPoolAccount, "Meteora pool should exist"); + console.log( + "✓ Meteora DAMM v2 pool created:", + meteoraPdasVerify.pool.toBase58(), + ); + + // Check Meteora pool vaults have tokens (10% of withdrawn) + // Note: Meteora vaults are PDAs (not ATAs), so we read them directly + const meteoraBaseVaultAccount = await getAccount( + this.banksClient, + meteoraPdasVerify.tokenAVault, + ); + const meteoraQuoteVaultAccount = await getAccount( + this.banksClient, + meteoraPdasVerify.tokenBVault, + ); + const meteoraBaseVaultBalance = meteoraBaseVaultAccount.amount; + const meteoraQuoteVaultBalance = meteoraQuoteVaultAccount.amount; + console.log(" Meteora base vault:", meteoraBaseVaultBalance.toString()); + console.log(" Meteora quote vault:", meteoraQuoteVaultBalance.toString()); + + // Calculate expected amounts (10% of what was withdrawn from Raydium) + // Two-sided liquidity: both base and quote tokens should be in the pool + assert.ok( + meteoraBaseVaultBalance > 0n, + "Meteora pool should have base tokens", + ); + assert.ok( + meteoraQuoteVaultBalance > 0n, + "Meteora pool should have quote tokens", + ); + console.log("✓ Meteora pool received 10% of both base and quote tokens"); + + // Verify Futarchy AMM position was created with 90% of tokens + const ammPositionAccount = await this.banksClient.getAccount(ammPosition); + assert.ok(ammPositionAccount, "Futarchy AMM position should exist"); + console.log("✓ Futarchy AMM position created"); + + // Check Futarchy AMM vaults have tokens (90% of withdrawn) + // Note: AMM vaults are token accounts (not ATAs), so we read them directly + const futarchyBaseVaultAccount = await getAccount( + this.banksClient, + ammBaseVault, + ); + const futarchyQuoteVaultAccount = await getAccount( + this.banksClient, + ammQuoteVault, + ); + const futarchyBaseVaultBalance = futarchyBaseVaultAccount.amount; + const futarchyQuoteVaultBalance = futarchyQuoteVaultAccount.amount; + console.log(" Futarchy base vault:", futarchyBaseVaultBalance.toString()); + console.log( + " Futarchy quote vault:", + futarchyQuoteVaultBalance.toString(), + ); + assert.ok( + futarchyBaseVaultBalance > 0n, + "Futarchy AMM should have base tokens", + ); + assert.ok( + futarchyQuoteVaultBalance > 0n, + "Futarchy AMM should have quote tokens", + ); + console.log("✓ Futarchy AMM received 90% of tokens"); + + // Verify approximate 90/10 split (Meteora should have ~1/9th of Futarchy) + // Allow some tolerance for rounding + const meteoraTotal = meteoraBaseVaultBalance + meteoraQuoteVaultBalance; + const futarchyTotal = futarchyBaseVaultBalance + futarchyQuoteVaultBalance; + const ratio = Number(futarchyTotal) / Number(meteoraTotal); + console.log( + ` Split ratio (Futarchy/Meteora): ${ratio.toFixed(2)}x (expected ~9x)`, + ); + assert.ok( + ratio > 7 && ratio < 11, + `Split ratio should be approximately 9:1, got ${ratio.toFixed(2)}`, + ); + console.log("✓ 90/10 split verified"); + + console.log( + "\n✓✓✓ COMPLETE V5 TO V6 MIGRATION WITH RAYDIUM LP WITHDRAWAL + METEORA POOL CREATION SUCCESSFUL ✓✓✓", + ); + }); +} diff --git a/tests/main.test.ts b/tests/main.test.ts index 35875f199..b23a34a6a 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -39,6 +39,11 @@ import { MintGovernorClient, } from "@metadaoproject/futarchy/v0.7"; import { LaunchpadClient as LaunchpadClientV6 } from "@metadaoproject/futarchy/v0.6"; +import { + LAUNCHPAD_PROGRAM_ID as LAUNCHPAD_V5_PROGRAM_ID, + AUTOCRAT_PROGRAM_ID as AUTOCRAT_V5_PROGRAM_ID, + AMM_PROGRAM_ID as AMM_V5_PROGRAM_ID, +} from "@metadaoproject/futarchy/v0.5"; import { PublicKey, @@ -48,6 +53,7 @@ import { Transaction, ComputeBudgetProgram, TransactionInstruction, + AddressLookupTableAccount, } from "@solana/web3.js"; import { @@ -77,6 +83,7 @@ const RAYDIUM_CP_SWAP_PROGRAM_ID = new PublicKey( import mintAndSwap from "./integration/mintAndSwap.test.js"; import fullLaunch from "./integration/fullLaunch.test.js"; import fullLaunch_v7 from "./integration/fullLaunch_v7.test.js"; +import migrateToV6Raydium from "./integration/migrateToV6Raydium.test.js"; import { BN } from "bn.js"; import { sha256 } from "@metadaoproject/futarchy"; @@ -190,6 +197,26 @@ before(async function () { name: "cp_amm", programId: DAMM_V2_PROGRAM_ID, }, + { + name: "raydium_cp_swap", + programId: RAYDIUM_CP_SWAP_PROGRAM_ID, + }, + { + name: "launchpad_v5", + programId: LAUNCHPAD_V5_PROGRAM_ID, + }, + { + name: "autocrat_v5", + programId: AUTOCRAT_V5_PROGRAM_ID, + }, + { + name: "amm_v5", + programId: AMM_V5_PROGRAM_ID, + }, + { + name: "raydium_migration_helper", + programId: new PublicKey("migR87BnBEkJbbDECLzRxhmNsQ44WMzhDCpCJhfPvR1"), + }, ], [ { @@ -316,6 +343,19 @@ before(async function () { }; return accountInfo; }, + getAddressLookupTable: async (address: PublicKey) => { + try { + const rawAccount = await this.banksClient.getAccount(address); + return { + value: { + key: address, + state: AddressLookupTableAccount.deserialize(rawAccount.data), + }, + }; + } catch (e) { + return { value: null }; + } + }, } as Connection; const assignIx = SystemProgram.assign({ @@ -643,7 +683,6 @@ before(async function () { dao, baseMint: storedDao.baseMint, quoteMint: storedDao.quoteMint, - squadsProposal, }) .rpc(); @@ -747,4 +786,5 @@ describe("project-wide integration tests", function () { it.skip("mint and swap in a single transaction", mintAndSwap); describe("full launch v6", fullLaunch); describe("full launch v7", fullLaunch_v7); + describe("migrate to v6 raydium", migrateToV6Raydium); }); diff --git a/tests/priceBasedPerformancePackage/main.test.ts b/tests/priceBasedPerformancePackage/main.test.ts index a590ae64d..890a38939 100644 --- a/tests/priceBasedPerformancePackage/main.test.ts +++ b/tests/priceBasedPerformancePackage/main.test.ts @@ -4,7 +4,6 @@ import completeUnlock from "./unit/completeUnlock.test.js"; import proposeChange from "./unit/proposeChange.test.js"; import changePerformancePackageAuthority from "./unit/changePerformancePackageAuthority.test.js"; import executeChange from "./unit/executeChange.test.js"; -import burnPerformancePackage from "./unit/burnPerformancePackage.test.js"; export default function suite() { describe("#initialize_performance_package", initializePerformancePackage); @@ -16,5 +15,4 @@ export default function suite() { changePerformancePackageAuthority, ); describe("#execute_change", executeChange); - describe("#burn_performance_package", burnPerformancePackage); }