diff --git a/.github/workflows/deploy-programs.yaml b/.github/workflows/deploy-programs.yaml index 725df880..4d99d78c 100644 --- a/.github/workflows/deploy-programs.yaml +++ b/.github/workflows/deploy-programs.yaml @@ -13,6 +13,7 @@ on: - launchpad_v6 - price_based_performance_package_v6 - launchpad_v7 + - launchpad_v8 - bid_wall - liquidation - mint_governor @@ -189,6 +190,25 @@ jobs: use-squads: true features: "production" priority-fee: ${{ inputs.priority-fee }} + secrets: + MAINNET_SOLANA_DEPLOY_URL: ${{ secrets.MAINNET_SOLANA_DEPLOY_URL }} + MAINNET_DEPLOYER_KEYPAIR: ${{ secrets.MAINNET_DEPLOYER_KEYPAIR }} + MAINNET_MULTISIG: ${{ secrets.MAINNET_MULTISIG }} + MAINNET_MULTISIG_VAULT: ${{ secrets.MAINNET_MULTISIG_VAULT }} + + launchpad-v8: + if: inputs.program == 'launchpad_v8' + uses: ./.github/workflows/reusable-build.yaml + with: + program: "launchpad_v8" + override-program-id: "moonDJUoHteKkGATejA5bdJVwJ6V6Dg74gyqyJTx73n" + network: "mainnet" + deploy: true + upload_idl: true + verify: true + use-squads: true + features: "production" + priority-fee: ${{ inputs.priority-fee }} secrets: MAINNET_SOLANA_DEPLOY_URL: ${{ secrets.MAINNET_SOLANA_DEPLOY_URL }} MAINNET_DEPLOYER_KEYPAIR: ${{ secrets.MAINNET_DEPLOYER_KEYPAIR }} diff --git a/.github/workflows/generate-verifiable-builds.yaml b/.github/workflows/generate-verifiable-builds.yaml index c4fe27e5..d7bbb717 100644 --- a/.github/workflows/generate-verifiable-builds.yaml +++ b/.github/workflows/generate-verifiable-builds.yaml @@ -162,4 +162,21 @@ jobs: uses: EndBug/add-and-commit@v9.1.4 with: default_author: github_actions - message: 'Update performance_package_v2 verifiable build' \ No newline at end of file + message: 'Update performance_package_v2 verifiable build' + generate-verifiable-launchpad-v8: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - uses: metadaoproject/anchor-verifiable-build@v0.4 + with: + program: launchpad_v8 + anchor-version: '0.29.0' + solana-cli-version: '1.17.31' + features: 'production' + - run: 'git pull --rebase' + - run: cp target/deploy/launchpad_v8.so ./verifiable-builds + - name: Commit verifiable build back to mainline + uses: EndBug/add-and-commit@v9.1.4 + with: + default_author: github_actions + message: 'Update launchpad_v8 verifiable build' \ No newline at end of file diff --git a/Anchor.toml b/Anchor.toml index 5d83318d..3016da94 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -11,6 +11,7 @@ conditional_vault = "VLTX1ishMBbcX3rdBWGssxawAo1Q2X2qxYFYqiGodVg" futarchy = "FUTARELBfJfQ8RDGhg1wdhddq1odMAJUePHFuBYfUxKq" launchpad = "MooNyh4CBUYEKyXVnjGYQ8mEiJDpGvJMdvrZx1iGeHV" launchpad_v7 = "moontUzsdepotRGe5xsfip7vLPTJnVuafqdUWexVnPM" +launchpad_v8 = "moonDJUoHteKkGATejA5bdJVwJ6V6Dg74gyqyJTx73n" liquidation = "LiQnowFbFQdYyZhF4pUbpsrZCjxRTQ1upKJxZ2VXjde" mint_governor = "gvnr27cVeyW3AVf3acL7VCJ5WjGAphytnsgcK1feHyH" performance_package_v2 = "pPV2pfrxnmstSb9j7kEeCLny5BGj6SNwCWGd6xbGGzz" diff --git a/Cargo.lock b/Cargo.lock index 99592050..0bb4c021 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1185,6 +1185,25 @@ dependencies = [ "squads-multisig-program", ] +[[package]] +name = "launchpad_v8" +version = "0.8.0" +dependencies = [ + "ahash 0.8.6", + "anchor-lang", + "anchor-spl", + "bid_wall", + "damm_v2_cpi", + "futarchy", + "mint_governor", + "performance_package_v2", + "solana-program", + "solana-security-txt", + "spl-memo", + "spl-token", + "squads-multisig-program", +] + [[package]] name = "lazy_static" version = "1.5.0" diff --git a/README.md b/README.md index 5b89a6a2..ed07e0b9 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Programs for unruggable capital formation and market-driven governance. | program | tag | program ID | | ----------------- | ---- | -------------------------------------------- | +| launchpad | v0.8.0 | moonDJUoHteKkGATejA5bdJVwJ6V6Dg74gyqyJTx73n | | launchpad | v0.7.0 | moontUzsdepotRGe5xsfip7vLPTJnVuafqdUWexVnPM | | bid_wall | v0.7.0 | WALL8ucBuUyL46QYxwYJjidaFYhdvxUFrgvBxPshERx | | mint_governor | v0.7.0 | gvnr27cVeyW3AVf3acL7VCJ5WjGAphytnsgcK1feHyH | diff --git a/programs/launchpad b/programs/launchpad index 95304772..9cb96919 120000 --- a/programs/launchpad +++ b/programs/launchpad @@ -1 +1 @@ -v07_launchpad \ No newline at end of file +v08_launchpad \ No newline at end of file diff --git a/programs/v08_launchpad/Cargo.toml b/programs/v08_launchpad/Cargo.toml new file mode 100644 index 00000000..d2f0d277 --- /dev/null +++ b/programs/v08_launchpad/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "launchpad_v8" +version = "0.8.0" +description = "Created with Anchor" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "launchpad_v8" + +[features] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +cpi = ["no-entrypoint"] +default = ["custom-heap"] +devnet = [] +production = [] +custom-heap = [] + +[dependencies] +anchor-lang = { version = "0.29.0", features = ["init-if-needed", "event-cpi"] } +anchor-spl = "0.29.0" +futarchy = { path = "../futarchy", features = ["cpi"] } +performance_package_v2 = { path = "../performance_package_v2", features = ["cpi"] } +mint_governor = { path = "../mint_governor", features = ["cpi"] } +spl-memo = "=4.0.0" +solana-program = "=1.17.14" +spl-token = "=4.0.0" +ahash = "=0.8.6" +solana-security-txt = "1.1.1" +squads-multisig-program = { git = "https://github.com/Squads-Protocol/v4", package = "squads-multisig-program", rev = "6d5235da621a2e9b7379ea358e48760e981053be", features = ["cpi"] } +damm_v2_cpi = { path = "../damm_v2_cpi", features = ["cpi"] } +bid_wall = { path = "../bid_wall", features = ["cpi"] } diff --git a/programs/v08_launchpad/Xargo.toml b/programs/v08_launchpad/Xargo.toml new file mode 100644 index 00000000..475fb71e --- /dev/null +++ b/programs/v08_launchpad/Xargo.toml @@ -0,0 +1,2 @@ +[target.bpfel-unknown-unknown.dependencies.std] +features = [] diff --git a/programs/v08_launchpad/src/allocator.rs b/programs/v08_launchpad/src/allocator.rs new file mode 100644 index 00000000..88fdc88f --- /dev/null +++ b/programs/v08_launchpad/src/allocator.rs @@ -0,0 +1,136 @@ +// THIS COMES DIRECTLY FROM SQUADS V4, WHICH HAS BEEN AUDITED 10 TIMES: +// https://github.com/Squads-Protocol/v4/blob/8a5642853b3dda9817477c9b540d0f84d67ede13/programs/squads_multisig_program/src/allocator.rs#L1 + +/* +Optimizing Bump Heap Allocation + +Objective: Increase available heap memory while maintaining flexibility in program invocation. + +1. Initial State: Default 32 KiB Heap + +Memory Layout: +0x300000000 0x300008000 + | | + v v + [--------------------] + ^ ^ + | | + VM Lower VM Upper + Boundary Boundary + +Default Allocator (Allocates Backwards / Top Down) (Default 32 KiB): +0x300000000 0x300008000 + | | + [--------------------] + ^ + | + Allocation starts here (SAFE) + +2. Naive Approach: Increase HEAP_LENGTH to 8 * 32 KiB + Default Allocator + +Memory Layout with Increased HEAP_LENGTH: +0x300000000 0x300008000 0x300040000 + | | | + v v v + [--------------------|------------------------------------|] + ^ ^ ^ + | | | + VM Lower VM Upper Allocation starts here + Boundary Boundary (ACCESS VIOLATION!) + +Issue: Access violation occurs without requestHeapFrame, requiring it for every transaction. + +3. Optimized Solution: Forward Allocation with Flexible Heap Usage + +Memory Layout (Same as Naive Approach): +0x300000000 0x300008000 0x300040000 + | | | + v v v + [--------------------|------------------------------------|] + ^ ^ ^ + | | | + VM Lower VM Upper Allocator & VM + Boundary Boundary Heap Limit + +Forward Allocator Behavior: + +a) Without requestHeapFrame: +0x300000000 0x300008000 + | | + [--------------------] + ^ ^ + | | + VM Lower VM Upper + Boundary Boundary + Allocation + starts here (SAFE) + +b) With requestHeapFrame: +0x300000000 0x300008000 0x300040000 + | | | + [--------------------|------------------------------------|] + ^ ^ ^ + | | | + VM Lower | VM Upper + Boundary Boundary + Allocation Allocation continues Maximum allocation + starts here with requestHeapFrame with requestHeapFrame +(SAFE) + +Key Advantages: +1. Compatibility: Functions without requestHeapFrame for allocations ≤32 KiB. +2. Extensibility: Supports larger allocations when requestHeapFrame is invoked. +3. Efficiency: Eliminates mandatory requestHeapFrame calls for all transactions. + +Conclusion: +The forward allocation strategy offers a robust solution, providing both backward +compatibility for smaller heap requirements and the flexibility to utilize extended +heap space when necessary. + +The following allocator is a copy of the bump allocator found in +solana_program::entrypoint and +https://github.com/solana-labs/solana-program-library/blob/master/examples/rust/custom-heap/src/entrypoint.rs + +but with changes to its HEAP_LENGTH and its +starting allocation address. +*/ + +use solana_program::entrypoint::HEAP_START_ADDRESS; +use std::{alloc::Layout, mem::size_of, ptr::null_mut}; + +/// Length of the memory region used for program heap. +pub const HEAP_LENGTH: usize = 8 * 32 * 1024; + +struct BumpAllocator; + +unsafe impl std::alloc::GlobalAlloc for BumpAllocator { + #[inline] + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + const POS_PTR: *mut usize = HEAP_START_ADDRESS as *mut usize; + const TOP_ADDRESS: usize = HEAP_START_ADDRESS as usize + HEAP_LENGTH; + const BOTTOM_ADDRESS: usize = HEAP_START_ADDRESS as usize + size_of::<*mut u8>(); + let mut pos = *POS_PTR; + if pos == 0 { + // First time, set starting position to bottom address + pos = BOTTOM_ADDRESS; + } + // Align the position upwards + pos = (pos + layout.align() - 1) & !(layout.align() - 1); + let next_pos = pos.saturating_add(layout.size()); + if next_pos > TOP_ADDRESS { + return null_mut(); + } + *POS_PTR = next_pos; + pos as *mut u8 + } + + #[inline] + unsafe fn dealloc(&self, _: *mut u8, _: Layout) { + // I'm a bump allocator, I don't free + } +} + +// Only use the allocator if we're not in a no-entrypoint context +#[cfg(not(feature = "no-entrypoint"))] +#[global_allocator] +static A: BumpAllocator = BumpAllocator; diff --git a/programs/v08_launchpad/src/error.rs b/programs/v08_launchpad/src/error.rs new file mode 100644 index 00000000..43bccd9e --- /dev/null +++ b/programs/v08_launchpad/src/error.rs @@ -0,0 +1,71 @@ +use anchor_lang::prelude::*; + +#[error_code] +pub enum LaunchpadError { + #[msg("Invalid amount")] + InvalidAmount, + #[msg("Supply must be zero")] + SupplyNonZero, + #[msg("Launch period must be between 1 hour and 2 weeks")] + InvalidSecondsForLaunch, + #[msg("Insufficient funds")] + InsufficientFunds, + #[msg("Invalid launch state")] + InvalidLaunchState, + #[msg("Launch period not over")] + LaunchPeriodNotOver, + #[msg("Launch is complete, no more funding allowed")] + LaunchExpired, + #[msg("Refund not available")] + LaunchNotRefunding, + #[msg("Launch must be initialized to be started")] + LaunchNotInitialized, + #[msg("Freeze authority can't be set on launchpad tokens")] + FreezeAuthoritySet, + #[msg("Monthly spending limit must be less than 1/6th of the minimum raise amount and cannot be 0")] + InvalidMonthlySpendingLimit, + #[msg("There can only be at most 10 monthly spending limit members")] + InvalidMonthlySpendingLimitMembers, + #[msg("Invalid performance package token amount")] + InvalidPerformancePackageTokenAmount, + #[msg("Insiders must wait at least 12 months before unlocking")] + InvalidPerformancePackageMinUnlockTime, + #[msg("Launch authority must be set to complete the launch until 2 days after closing")] + LaunchAuthorityNotSet, + #[msg("The final amount raised must be >= the minimum raise amount")] + FinalRaiseAmountTooLow, + #[msg("Tokens already claimed")] + TokensAlreadyClaimed, + #[msg("USDC already refunded")] + MoneyAlreadyRefunded, + #[msg("Invariant violated")] + InvariantViolated, + #[msg("Launch must be live to be closed")] + LaunchNotLive, + #[msg("Minimum raise amount too low for liquidity")] + InvalidMinimumRaiseAmount, + #[msg("Final raise amount already set")] + FinalRaiseAmountAlreadySet, + #[msg("Total approved amount too low")] + TotalApprovedAmountTooLow, + #[msg("Additional tokens recipient must be set when amount > 0")] + InvalidAdditionalTokensRecipient, + #[msg("No additional tokens recipient set")] + NoAdditionalTokensRecipientSet, + #[msg("Additional tokens already claimed")] + AdditionalTokensAlreadyClaimed, + #[msg("Funding record approval period is over")] + FundingRecordApprovalPeriodOver, + #[msg("Performance package already initialized")] + PerformancePackageAlreadyInitialized, + #[msg("Invalid DAO")] + InvalidDao, + #[msg("Accumulator activation delay must be less than the launch duration")] + InvalidAccumulatorActivationDelaySeconds, + #[msg("Extend duration would exceed maximum allowed launch duration")] + ExtendDurationExceedsMax, + #[msg("Mint authority does not match expected")] + InvalidMintAuthority, + #[msg("Invalid Meteora account")] + InvalidMeteoraAccount, +} diff --git a/programs/v08_launchpad/src/events.rs b/programs/v08_launchpad/src/events.rs new file mode 100644 index 00000000..01062aae --- /dev/null +++ b/programs/v08_launchpad/src/events.rs @@ -0,0 +1,141 @@ +use crate::state::LaunchState; +use anchor_lang::prelude::*; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct CommonFields { + pub slot: u64, + pub unix_timestamp: i64, + pub launch_seq_num: u64, +} + +impl CommonFields { + pub fn new(clock: &Clock, launch_seq_num: u64) -> Self { + Self { + slot: clock.slot, + unix_timestamp: clock.unix_timestamp, + launch_seq_num, + } + } +} + +#[event] +pub struct LaunchInitializedEvent { + pub common: CommonFields, + pub launch: Pubkey, + pub minimum_raise_amount: u64, + pub launch_authority: Pubkey, + pub launch_signer: Pubkey, + pub launch_signer_pda_bump: u8, + pub launch_usdc_vault: Pubkey, + pub launch_token_vault: Pubkey, + pub performance_package_grantee: Pubkey, + pub performance_package_token_amount: u64, + pub months_until_insiders_can_unlock: u8, + pub monthly_spending_limit_amount: u64, + pub monthly_spending_limit_members: Vec, + pub base_mint: Pubkey, + pub quote_mint: Pubkey, + pub pda_bump: u8, + pub seconds_for_launch: u32, + pub additional_tokens_amount: u64, + pub additional_tokens_recipient: Option, + pub accumulator_activation_delay_seconds: u32, + pub has_bid_wall: bool, + pub mint_governor: Pubkey, +} + +#[event] +pub struct LaunchStartedEvent { + pub common: CommonFields, + pub launch: Pubkey, + pub launch_authority: Pubkey, + pub slot_started: u64, +} + +#[event] +pub struct LaunchFundedEvent { + pub common: CommonFields, + pub funding_record: Pubkey, + pub launch: Pubkey, + pub funder: Pubkey, + pub amount: u64, + pub total_committed_by_funder: u64, + pub total_committed: u64, + pub committed_amount_accumulator: u128, +} + +#[event] +pub struct FundingRecordApprovalSetEvent { + pub common: CommonFields, + pub launch: Pubkey, + pub funding_record: Pubkey, + pub funder: Pubkey, + pub approved_amount: u64, + pub total_approved: u64, +} + +#[event] +pub struct LaunchSettledEvent { + pub common: CommonFields, + pub launch: Pubkey, + pub final_state: LaunchState, + pub total_committed: u64, + pub dao: Option, + pub dao_treasury: Option, + pub total_approved_amount: u64, + pub bid_wall: Option, + pub bid_wall_amount: u64, + pub tokens_minted: u64, +} + +#[event] +pub struct LaunchFinalizedEvent { + pub common: CommonFields, + pub launch: Pubkey, + pub performance_package: Pubkey, + pub mint_governor: Pubkey, + pub mint_governor_new_admin: Pubkey, + pub pp_mint_authority: Pubkey, + pub dao_mint_authority: Pubkey, +} + +#[event] +pub struct LaunchRefundedEvent { + pub common: CommonFields, + pub launch: Pubkey, + pub funder: Pubkey, + pub usdc_refunded: u64, + pub funding_record: Pubkey, +} + +#[event] +pub struct LaunchClaimEvent { + pub common: CommonFields, + pub launch: Pubkey, + pub funder: Pubkey, + pub tokens_claimed: u64, + pub funding_record: Pubkey, +} + +#[event] +pub struct LaunchCloseEvent { + pub common: CommonFields, + pub launch: Pubkey, + pub new_state: LaunchState, +} + +#[event] +pub struct LaunchClaimAdditionalTokenAllocationEvent { + pub common: CommonFields, + pub launch: Pubkey, + pub additional_tokens_amount: u64, + pub additional_tokens_recipient: Pubkey, +} + +#[event] +pub struct LaunchExtendedEvent { + pub common: CommonFields, + pub launch: Pubkey, + pub old_seconds_for_launch: u32, + pub new_seconds_for_launch: u32, +} diff --git a/programs/v08_launchpad/src/instructions/claim.rs b/programs/v08_launchpad/src/instructions/claim.rs new file mode 100644 index 00000000..ffb571f8 --- /dev/null +++ b/programs/v08_launchpad/src/instructions/claim.rs @@ -0,0 +1,111 @@ +use anchor_lang::prelude::*; +use anchor_spl::token::{self, Mint, Token, TokenAccount, Transfer}; + +use crate::error::LaunchpadError; +use crate::events::{CommonFields, LaunchClaimEvent}; +use crate::state::{FundingRecord, Launch, LaunchState}; +use crate::TOKENS_TO_PARTICIPANTS; + +#[event_cpi] +#[derive(Accounts)] +pub struct Claim<'info> { + #[account( + mut, + has_one = launch_signer, + has_one = base_mint, + has_one = launch_base_vault, + )] + pub launch: Account<'info, Launch>, + + #[account( + mut, + has_one = launch, + has_one = funder, + seeds = [b"funding_record", launch.key().as_ref(), funder.key().as_ref()], + bump = funding_record.pda_bump + )] + pub funding_record: Account<'info, FundingRecord>, + + /// CHECK: just a signer + pub launch_signer: UncheckedAccount<'info>, + + pub base_mint: Account<'info, Mint>, + + #[account(mut)] + pub launch_base_vault: Account<'info, TokenAccount>, + + /// CHECK: not used, just for constraints + pub funder: UncheckedAccount<'info>, + + #[account( + mut, + associated_token::mint = base_mint, + associated_token::authority = funder + )] + pub funder_token_account: Account<'info, TokenAccount>, + + pub token_program: Program<'info, Token>, +} + +impl Claim<'_> { + pub fn validate(&self) -> Result<()> { + require!( + self.launch.state == LaunchState::Complete, + LaunchpadError::InvalidLaunchState + ); + + require!( + !self.funding_record.is_tokens_claimed, + LaunchpadError::TokensAlreadyClaimed + ); + + Ok(()) + } + + pub fn handle(ctx: Context) -> Result<()> { + let launch = &mut ctx.accounts.launch; + let funding_record = &mut ctx.accounts.funding_record; + let launch_key = launch.key(); + + let token_amount = (funding_record.approved_amount as u128) + .checked_mul(TOKENS_TO_PARTICIPANTS as u128) + .unwrap() + .checked_div(launch.total_approved_amount as u128) + .unwrap() as u64; + + let seeds = &[ + b"launch_signer", + launch_key.as_ref(), + &[launch.launch_signer_pda_bump], + ]; + let signer = &[&seeds[..]]; + + funding_record.is_tokens_claimed = true; + + token::transfer( + CpiContext::new_with_signer( + ctx.accounts.token_program.to_account_info(), + Transfer { + from: ctx.accounts.launch_base_vault.to_account_info(), + to: ctx.accounts.funder_token_account.to_account_info(), + authority: ctx.accounts.launch_signer.to_account_info(), + }, + signer, + ), + token_amount, + )?; + + launch.seq_num += 1; + + let clock = Clock::get()?; + emit_cpi!(LaunchClaimEvent { + common: CommonFields::new(&clock, launch.seq_num), + launch: launch.key(), + funder: ctx.accounts.funder.key(), + tokens_claimed: token_amount, + funding_record: funding_record.key(), + }); + + Ok(()) + } +} diff --git a/programs/v08_launchpad/src/instructions/claim_additional_token_allocation.rs b/programs/v08_launchpad/src/instructions/claim_additional_token_allocation.rs new file mode 100644 index 00000000..b6e9ef2c --- /dev/null +++ b/programs/v08_launchpad/src/instructions/claim_additional_token_allocation.rs @@ -0,0 +1,112 @@ +use anchor_lang::prelude::*; +use anchor_spl::associated_token::AssociatedToken; +use anchor_spl::token::{self, Mint, Token, TokenAccount, Transfer}; + +use crate::error::LaunchpadError; +use crate::events::{CommonFields, LaunchClaimAdditionalTokenAllocationEvent}; +use crate::state::{Launch, LaunchState}; + +#[event_cpi] +#[derive(Accounts)] +pub struct ClaimAdditionalTokenAllocation<'info> { + #[account( + mut, + has_one = launch_base_vault, + has_one = launch_signer, + has_one = base_mint, + )] + pub launch: Account<'info, Launch>, + + #[account(mut)] + pub payer: Signer<'info>, + + /// CHECK: just a signer + pub launch_signer: UncheckedAccount<'info>, + + #[account(mut)] + pub launch_base_vault: Account<'info, TokenAccount>, + + pub base_mint: Account<'info, Mint>, + + /// CHECK: explicitly checked in validate + pub additional_tokens_recipient: AccountInfo<'info>, + + #[account( + init_if_needed, + payer = payer, + associated_token::mint = base_mint, + associated_token::authority = additional_tokens_recipient, + )] + pub additional_tokens_recipient_token_account: Account<'info, TokenAccount>, + + pub system_program: Program<'info, System>, + pub token_program: Program<'info, Token>, + pub associated_token_program: Program<'info, AssociatedToken>, +} + +impl ClaimAdditionalTokenAllocation<'_> { + pub fn validate(&self) -> Result<()> { + require!( + self.launch.state == LaunchState::Complete, + LaunchpadError::InvalidLaunchState + ); + + require!( + !self.launch.additional_tokens_claimed, + LaunchpadError::AdditionalTokensAlreadyClaimed + ); + + require!( + self.launch.additional_tokens_recipient.is_some(), + LaunchpadError::NoAdditionalTokensRecipientSet + ); + + require_keys_eq!( + self.additional_tokens_recipient.key(), + self.launch.additional_tokens_recipient.unwrap(), + LaunchpadError::InvalidAdditionalTokensRecipient + ); + + Ok(()) + } + + pub fn handle(ctx: Context) -> Result<()> { + let launch_key = ctx.accounts.launch.key(); + let seeds = &[ + b"launch_signer", + launch_key.as_ref(), + &[ctx.accounts.launch.launch_signer_pda_bump], + ]; + let signer = &[&seeds[..]]; + + token::transfer( + CpiContext::new_with_signer( + ctx.accounts.token_program.to_account_info(), + Transfer { + from: ctx.accounts.launch_base_vault.to_account_info(), + to: ctx + .accounts + .additional_tokens_recipient_token_account + .to_account_info(), + authority: ctx.accounts.launch_signer.to_account_info(), + }, + signer, + ), + ctx.accounts.launch.additional_tokens_amount, + )?; + + ctx.accounts.launch.additional_tokens_claimed = true; + ctx.accounts.launch.seq_num += 1; + + let clock = Clock::get()?; + + emit_cpi!(LaunchClaimAdditionalTokenAllocationEvent { + common: CommonFields::new(&clock, ctx.accounts.launch.seq_num), + launch: ctx.accounts.launch.key(), + additional_tokens_amount: ctx.accounts.launch.additional_tokens_amount, + additional_tokens_recipient: ctx.accounts.additional_tokens_recipient.key(), + }); + + Ok(()) + } +} diff --git a/programs/v08_launchpad/src/instructions/close_launch.rs b/programs/v08_launchpad/src/instructions/close_launch.rs new file mode 100644 index 00000000..1eeb4be7 --- /dev/null +++ b/programs/v08_launchpad/src/instructions/close_launch.rs @@ -0,0 +1,57 @@ +use crate::error::LaunchpadError; +use crate::events::{CommonFields, LaunchCloseEvent}; +use crate::state::{Launch, LaunchState}; +use anchor_lang::prelude::*; + +#[event_cpi] +#[derive(Accounts)] +pub struct CloseLaunch<'info> { + #[account(mut)] + pub launch: Account<'info, Launch>, +} + +impl CloseLaunch<'_> { + pub fn validate(&self) -> Result<()> { + require_eq!( + self.launch.state, + LaunchState::Live, + LaunchpadError::LaunchNotLive + ); + + let clock = Clock::get()?; + + require_gte!( + clock.unix_timestamp, + self.launch + .unix_timestamp_started + .unwrap() + .saturating_add(self.launch.seconds_for_launch.try_into().unwrap()), + LaunchpadError::LaunchPeriodNotOver + ); + + Ok(()) + } + + pub fn handle(ctx: Context) -> Result<()> { + let launch = &mut ctx.accounts.launch; + let clock = Clock::get()?; + + if launch.minimum_raise_amount > launch.total_committed_amount { + launch.state = LaunchState::Refunding; + launch.unix_timestamp_closed = Some(clock.unix_timestamp); + } else { + launch.state = LaunchState::Closed; + launch.unix_timestamp_closed = Some(clock.unix_timestamp); + } + + launch.seq_num += 1; + + emit_cpi!(LaunchCloseEvent { + common: CommonFields::new(&clock, launch.seq_num), + launch: launch.key(), + new_state: launch.state, + }); + + Ok(()) + } +} diff --git a/programs/v08_launchpad/src/instructions/extend_launch.rs b/programs/v08_launchpad/src/instructions/extend_launch.rs new file mode 100644 index 00000000..6e4f891f --- /dev/null +++ b/programs/v08_launchpad/src/instructions/extend_launch.rs @@ -0,0 +1,69 @@ +use anchor_lang::prelude::*; + +use crate::error::LaunchpadError; +use crate::events::{CommonFields, LaunchExtendedEvent}; +use crate::state::{Launch, LaunchState}; + +#[cfg(feature = "production")] +use crate::metadao_multisig_vault; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct ExtendLaunchArgs { + pub duration_seconds: u32, +} + +#[event_cpi] +#[derive(Accounts)] +pub struct ExtendLaunch<'info> { + #[account(mut)] + pub launch: Account<'info, Launch>, + + pub admin: Signer<'info>, +} + +impl ExtendLaunch<'_> { + pub fn validate(&self, args: &ExtendLaunchArgs) -> Result<()> { + #[cfg(feature = "production")] + require_keys_eq!(self.admin.key(), metadao_multisig_vault::ID); + + require!( + self.launch.state == LaunchState::Live, + LaunchpadError::InvalidLaunchState + ); + + require_gt!(args.duration_seconds, 0, LaunchpadError::InvalidAmount); + + require!( + self.launch + .seconds_for_launch + .checked_add(args.duration_seconds) + .is_some(), + LaunchpadError::ExtendDurationExceedsMax + ); + + Ok(()) + } + + pub fn handle(ctx: Context, args: ExtendLaunchArgs) -> Result<()> { + let launch = &mut ctx.accounts.launch; + let clock = Clock::get()?; + + let old_seconds_for_launch = launch.seconds_for_launch; + + launch.seconds_for_launch = launch + .seconds_for_launch + .checked_add(args.duration_seconds) + .unwrap(); + + launch.seq_num += 1; + + emit_cpi!(LaunchExtendedEvent { + common: CommonFields::new(&clock, launch.seq_num), + launch: launch.key(), + old_seconds_for_launch, + new_seconds_for_launch: launch.seconds_for_launch, + }); + + Ok(()) + } +} diff --git a/programs/v08_launchpad/src/instructions/finalize_launch.rs b/programs/v08_launchpad/src/instructions/finalize_launch.rs new file mode 100644 index 00000000..45bf49a2 --- /dev/null +++ b/programs/v08_launchpad/src/instructions/finalize_launch.rs @@ -0,0 +1,277 @@ +use anchor_lang::prelude::*; +use anchor_spl::token::{Mint, Token}; + +use mint_governor::{ + cpi::{ + accounts::{AddMintAuthority, UpdateMintGovernorAdmin}, + add_mint_authority, update_mint_governor_admin, + }, + program::MintGovernor as MintGovernorProgram, + state::MintGovernor, + AddMintAuthorityArgs, +}; + +use performance_package_v2::{ + cpi::{ + accounts::InitializePerformancePackage as InitializePerformancePackageCpi, + initialize_performance_package, + }, + program::PerformancePackageV2, + InitializePerformancePackageArgs, OracleReader, RewardFunction, ThresholdTranche, +}; + +use crate::error::LaunchpadError; +use crate::events::{CommonFields, LaunchFinalizedEvent}; +use crate::state::{Launch, LaunchState}; +use crate::{ + PP_NUM_TRANCHES, PP_PRICE_MULTIPLIERS, PP_TWAP_MIN_DURATION, PRICE_SCALE, + TOKENS_TO_PARTICIPANTS, +}; + +#[event_cpi] +#[derive(Accounts)] +pub struct FinalizeLaunch<'info> { + #[account( + mut, + has_one = launch_signer, + has_one = base_mint, + has_one = mint_governor, + has_one = performance_package_grantee, + constraint = launch.dao == Some(dao.key()) @ LaunchpadError::InvalidDao, + )] + pub launch: Box>, + + #[account(mut)] + pub payer: Signer<'info>, + + /// CHECK: validated via has_one on launch + pub launch_signer: UncheckedAccount<'info>, + + #[account(address = launch.base_mint)] + pub base_mint: Account<'info, Mint>, + + // DAO / Squads + /// CHECK: validated via constraint on launch.dao + pub dao: UncheckedAccount<'info>, + + /// CHECK: PDA derived from squads program + #[account( + seeds = [ + squads_multisig_program::SEED_PREFIX, + squads_multisig_program::SEED_MULTISIG, + dao.key().as_ref(), + ], + seeds::program = squads_program, + bump, + )] + pub squads_multisig: UncheckedAccount<'info>, + + /// CHECK: PDA derived from squads program + #[account( + seeds = [ + squads_multisig_program::SEED_PREFIX, + squads_multisig.key().as_ref(), + squads_multisig_program::SEED_VAULT, + 0_u8.to_le_bytes().as_ref(), + ], + seeds::program = squads_program, + bump, + )] + pub squads_multisig_vault: UncheckedAccount<'info>, + + /// CHECK: validated via has_one on launch + pub performance_package_grantee: UncheckedAccount<'info>, + + // MintGovernor + #[account(mut)] + pub mint_governor: Account<'info, MintGovernor>, + + /// CHECK: initialized via CPI + #[account( + mut, + seeds = [b"mint_authority", mint_governor.key().as_ref(), performance_package.key().as_ref()], + bump, + seeds::program = mint_governor_program.key(), + )] + pub pp_mint_authority: UncheckedAccount<'info>, + + /// CHECK: initialized via CPI + #[account( + mut, + seeds = [b"mint_authority", mint_governor.key().as_ref(), squads_multisig_vault.key().as_ref()], + bump, + seeds::program = mint_governor_program.key(), + )] + pub dao_mint_authority: UncheckedAccount<'info>, + + /// CHECK: initialized via CPI + #[account( + mut, + seeds = [b"performance_package", launch_signer.key().as_ref()], + bump, + seeds::program = performance_package_v2_program.key(), + )] + pub performance_package: UncheckedAccount<'info>, + + // Programs + pub system_program: Program<'info, System>, + pub token_program: Program<'info, Token>, + pub squads_program: Program<'info, squads_multisig_program::program::SquadsMultisigProgram>, + pub mint_governor_program: Program<'info, MintGovernorProgram>, + /// CHECK: checked by mint_governor program + pub mint_governor_event_authority: UncheckedAccount<'info>, + pub performance_package_v2_program: Program<'info, PerformancePackageV2>, + /// CHECK: checked by performance_package_v2 program + pub performance_package_v2_event_authority: UncheckedAccount<'info>, +} + +impl FinalizeLaunch<'_> { + pub fn validate(&self) -> Result<()> { + require!( + self.launch.state == LaunchState::Complete, + LaunchpadError::InvalidLaunchState + ); + require!( + !self.launch.is_finalized, + LaunchpadError::PerformancePackageAlreadyInitialized + ); + Ok(()) + } + + pub fn handle(ctx: Context) -> Result<()> { + let launch_key = ctx.accounts.launch.key(); + let launch_signer_seeds = &[ + b"launch_signer", + launch_key.as_ref(), + &[ctx.accounts.launch.launch_signer_pda_bump], + ]; + let signer = &[&launch_signer_seeds[..]]; + + let launch_price = (ctx.accounts.launch.total_approved_amount as u128 * PRICE_SCALE) + / (TOKENS_TO_PARTICIPANTS as u128); + + // Build threshold tranches for PP v2 + let pp_token_amount = ctx.accounts.launch.performance_package_token_amount; + let mut tranches = Vec::with_capacity(PP_NUM_TRANCHES); + for (i, multiplier) in PP_PRICE_MULTIPLIERS.iter().enumerate() { + tranches.push(ThresholdTranche { + threshold: launch_price * multiplier, + cumulative_amount: pp_token_amount * (i as u64 + 1) / PP_NUM_TRANCHES as u64, + }); + } + + // CPI → mint_governor::add_mint_authority + add_mint_authority( + CpiContext::new_with_signer( + ctx.accounts.mint_governor_program.to_account_info(), + AddMintAuthority { + mint_governor: ctx.accounts.mint_governor.to_account_info(), + mint_authority: ctx.accounts.pp_mint_authority.to_account_info(), + admin: ctx.accounts.launch_signer.to_account_info(), + authorized_minter: ctx.accounts.performance_package.to_account_info(), + payer: ctx.accounts.payer.to_account_info(), + system_program: ctx.accounts.system_program.to_account_info(), + event_authority: ctx.accounts.mint_governor_event_authority.to_account_info(), + program: ctx.accounts.mint_governor_program.to_account_info(), + }, + signer, + ), + AddMintAuthorityArgs { + max_total: Some(pp_token_amount), + }, + )?; + + // CPI → mint_governor::add_mint_authority (DAO) + add_mint_authority( + CpiContext::new_with_signer( + ctx.accounts.mint_governor_program.to_account_info(), + AddMintAuthority { + mint_governor: ctx.accounts.mint_governor.to_account_info(), + mint_authority: ctx.accounts.dao_mint_authority.to_account_info(), + admin: ctx.accounts.launch_signer.to_account_info(), + authorized_minter: ctx.accounts.squads_multisig_vault.to_account_info(), + payer: ctx.accounts.payer.to_account_info(), + system_program: ctx.accounts.system_program.to_account_info(), + event_authority: ctx.accounts.mint_governor_event_authority.to_account_info(), + program: ctx.accounts.mint_governor_program.to_account_info(), + }, + signer, + ), + AddMintAuthorityArgs { max_total: None }, + )?; + + // CPI → performance_package_v2::initialize_performance_package + let min_unlock_timestamp = ctx.accounts.launch.unix_timestamp_completed.unwrap() + + (ctx.accounts.launch.months_until_insiders_can_unlock as i64) * 30 * 24 * 60 * 60; + + initialize_performance_package( + CpiContext::new_with_signer( + ctx.accounts + .performance_package_v2_program + .to_account_info(), + InitializePerformancePackageCpi { + performance_package: ctx.accounts.performance_package.to_account_info(), + mint: ctx.accounts.base_mint.to_account_info(), + mint_governor: ctx.accounts.mint_governor.to_account_info(), + mint_authority: ctx.accounts.pp_mint_authority.to_account_info(), + create_key: ctx.accounts.launch_signer.to_account_info(), + authority: ctx.accounts.squads_multisig_vault.to_account_info(), + recipient: ctx.accounts.performance_package_grantee.to_account_info(), + payer: ctx.accounts.payer.to_account_info(), + system_program: ctx.accounts.system_program.to_account_info(), + event_authority: ctx + .accounts + .performance_package_v2_event_authority + .to_account_info(), + program: ctx + .accounts + .performance_package_v2_program + .to_account_info(), + }, + signer, + ), + InitializePerformancePackageArgs { + oracle_reader: OracleReader::FutarchyTwap { + amm: ctx.accounts.dao.key(), + min_duration: PP_TWAP_MIN_DURATION, + start_value: 0, + start_time: 0, + end_value: 0, + end_time: 0, + }, + reward_function: RewardFunction::Threshold { tranches }, + min_unlock_timestamp, + }, + )?; + + // CPI → mint_governor::update_mint_governor_admin + update_mint_governor_admin(CpiContext::new_with_signer( + ctx.accounts.mint_governor_program.to_account_info(), + UpdateMintGovernorAdmin { + mint_governor: ctx.accounts.mint_governor.to_account_info(), + admin: ctx.accounts.launch_signer.to_account_info(), + new_admin: ctx.accounts.squads_multisig_vault.to_account_info(), + event_authority: ctx.accounts.mint_governor_event_authority.to_account_info(), + program: ctx.accounts.mint_governor_program.to_account_info(), + }, + signer, + ))?; + + let launch = &mut ctx.accounts.launch; + launch.is_finalized = true; + launch.seq_num += 1; + + let clock = Clock::get()?; + emit_cpi!(LaunchFinalizedEvent { + common: CommonFields::new(&clock, launch.seq_num), + launch: launch.key(), + performance_package: ctx.accounts.performance_package.key(), + mint_governor: ctx.accounts.mint_governor.key(), + mint_governor_new_admin: ctx.accounts.squads_multisig_vault.key(), + pp_mint_authority: ctx.accounts.pp_mint_authority.key(), + dao_mint_authority: ctx.accounts.dao_mint_authority.key(), + }); + + Ok(()) + } +} diff --git a/programs/v08_launchpad/src/instructions/fund.rs b/programs/v08_launchpad/src/instructions/fund.rs new file mode 100644 index 00000000..a4f182ce --- /dev/null +++ b/programs/v08_launchpad/src/instructions/fund.rs @@ -0,0 +1,134 @@ +use anchor_lang::prelude::*; +use anchor_spl::token::{self, Token, TokenAccount, Transfer}; + +use crate::error::LaunchpadError; +use crate::events::{CommonFields, LaunchFundedEvent}; +use crate::state::{FundingRecord, Launch, LaunchState}; + +#[event_cpi] +#[derive(Accounts)] +pub struct Fund<'info> { + #[account( + mut, + has_one = launch_quote_vault, + )] + pub launch: Account<'info, Launch>, + + #[account( + init_if_needed, + payer = payer, + space = 8 + FundingRecord::INIT_SPACE, + seeds = [b"funding_record", launch.key().as_ref(), funder.key().as_ref()], + bump + )] + pub funding_record: Account<'info, FundingRecord>, + + #[account(mut)] + pub launch_quote_vault: Account<'info, TokenAccount>, + + pub funder: Signer<'info>, + + #[account(mut)] + pub payer: Signer<'info>, + + #[account( + mut, + associated_token::mint = launch.quote_mint, + associated_token::authority = funder + )] + pub funder_quote_account: Account<'info, TokenAccount>, + + pub token_program: Program<'info, Token>, + pub system_program: Program<'info, System>, +} + +impl Fund<'_> { + pub fn validate(&self, amount: u64) -> Result<()> { + require!(amount > 0, LaunchpadError::InvalidAmount); + + require_gte!( + self.funder_quote_account.amount, + amount, + LaunchpadError::InsufficientFunds + ); + + require!( + self.launch.state == LaunchState::Live, + LaunchpadError::InvalidLaunchState + ); + + let clock = Clock::get()?; + + require_gt!( + self.launch.unix_timestamp_started.unwrap() + self.launch.seconds_for_launch as i64, + clock.unix_timestamp, + LaunchpadError::LaunchExpired + ); + + Ok(()) + } + + pub fn handle(ctx: Context, amount: u64) -> Result<()> { + token::transfer( + CpiContext::new( + ctx.accounts.token_program.to_account_info(), + Transfer { + from: ctx.accounts.funder_quote_account.to_account_info(), + to: ctx.accounts.launch_quote_vault.to_account_info(), + authority: ctx.accounts.funder.to_account_info(), + }, + ), + amount, + )?; + + let funding_record = &mut ctx.accounts.funding_record; + let clock = Clock::get()?; + + if funding_record.funder == ctx.accounts.funder.key() { + // Existing funding record — flush accumulator before changing committed_amount + let activation_timestamp = ctx.accounts.launch.unix_timestamp_started.unwrap() + + ctx.accounts.launch.accumulator_activation_delay_seconds as i64; + let now = clock.unix_timestamp; + + if funding_record.last_accumulator_update > 0 && now > activation_timestamp { + let period_start = + std::cmp::max(funding_record.last_accumulator_update, activation_timestamp); + let elapsed = now - period_start; + funding_record.committed_amount_accumulator += + (funding_record.committed_amount as u128) * (elapsed as u128); + } + + funding_record.last_accumulator_update = now; + funding_record.committed_amount += amount; + } else { + funding_record.set_inner(FundingRecord { + pda_bump: ctx.bumps.funding_record, + funder: ctx.accounts.funder.key(), + launch: ctx.accounts.launch.key(), + committed_amount: amount, + is_tokens_claimed: false, + is_usdc_refunded: false, + approved_amount: 0, + committed_amount_accumulator: 0, + last_accumulator_update: clock.unix_timestamp, + }); + } + + ctx.accounts.launch.total_committed_amount += amount; + + ctx.accounts.launch.seq_num += 1; + + emit_cpi!(LaunchFundedEvent { + common: CommonFields::new(&clock, ctx.accounts.launch.seq_num), + launch: ctx.accounts.launch.key(), + funder: ctx.accounts.funder.key(), + amount, + total_committed: ctx.accounts.launch.total_committed_amount, + funding_record: funding_record.key(), + total_committed_by_funder: funding_record.committed_amount, + committed_amount_accumulator: funding_record.committed_amount_accumulator, + }); + + Ok(()) + } +} diff --git a/programs/v08_launchpad/src/instructions/initialize_launch.rs b/programs/v08_launchpad/src/instructions/initialize_launch.rs new file mode 100644 index 00000000..6ea5f626 --- /dev/null +++ b/programs/v08_launchpad/src/instructions/initialize_launch.rs @@ -0,0 +1,378 @@ +use anchor_lang::prelude::*; +use anchor_spl::associated_token::AssociatedToken; +use anchor_spl::token::{self, Mint, MintTo, Token, TokenAccount}; + +use mint_governor::{ + cpi::{ + accounts::{InitializeMintGovernor, TransferAuthorityToGovernor}, + initialize_mint_governor, transfer_authority_to_governor, + }, + program::MintGovernor as MintGovernorProgram, +}; + +use crate::error::LaunchpadError; +use crate::events::{CommonFields, LaunchInitializedEvent}; +use crate::state::{Launch, LaunchState}; +use crate::{ + usdc_mint, TOKENS_TO_DAMM_V2_LIQUIDITY, TOKENS_TO_FUTARCHY_LIQUIDITY, TOKENS_TO_PARTICIPANTS, +}; +use anchor_spl::metadata::{ + create_metadata_accounts_v3, mpl_token_metadata::types::DataV2, + mpl_token_metadata::ID as MPL_TOKEN_METADATA_PROGRAM_ID, CreateMetadataAccountsV3, Metadata, +}; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct InitializeLaunchArgs { + pub minimum_raise_amount: u64, + pub monthly_spending_limit_amount: u64, + pub monthly_spending_limit_members: Vec, + pub seconds_for_launch: u32, + pub token_name: String, + pub token_symbol: String, + pub token_uri: String, + pub performance_package_grantee: Pubkey, + pub performance_package_token_amount: u64, + pub months_until_insiders_can_unlock: u8, + pub team_address: Pubkey, + pub additional_tokens_amount: u64, + pub accumulator_activation_delay_seconds: u32, + pub has_bid_wall: bool, +} + +#[event_cpi] +#[derive(Accounts)] +pub struct InitializeLaunch<'info> { + #[account( + init, + payer = payer, + space = 8 + Launch::INIT_SPACE, + seeds = [b"launch", base_mint.key().as_ref()], + bump + )] + pub launch: Box>, + + #[account( + mut, + mint::decimals = 6, + mint::authority = launch_signer, + )] + pub base_mint: Box>, + + /// CHECK: This is the token metadata + #[account( + mut, + seeds = [b"metadata", MPL_TOKEN_METADATA_PROGRAM_ID.as_ref(), base_mint.key().as_ref()], + seeds::program = MPL_TOKEN_METADATA_PROGRAM_ID, + bump + )] + pub token_metadata: UncheckedAccount<'info>, + + /// CHECK: This is the launch signer + #[account( + seeds = [b"launch_signer", launch.key().as_ref()], + bump + )] + pub launch_signer: UncheckedAccount<'info>, + + #[account( + init_if_needed, + payer = payer, + associated_token::mint = quote_mint, + associated_token::authority = launch_signer + )] + pub quote_vault: Box>, + + #[account( + init_if_needed, + payer = payer, + associated_token::mint = base_mint, + associated_token::authority = launch_signer + )] + pub base_vault: Box>, + + #[account(mut)] + pub payer: Signer<'info>, + + /// CHECK: account not used, just for constraints + pub launch_authority: UncheckedAccount<'info>, + + #[account(mint::decimals = 6, address = usdc_mint::id())] + pub quote_mint: Box>, + + /// CHECK: Just the recipient of the additional tokens + pub additional_tokens_recipient: Option>, + + /// CHECK: initialized via CPI + #[account( + mut, + seeds = [b"mint_governor", base_mint.key().as_ref(), launch_signer.key().as_ref()], + bump, + seeds::program = mint_governor_program.key(), + )] + pub mint_governor: UncheckedAccount<'info>, + + pub mint_governor_program: Program<'info, MintGovernorProgram>, + + /// CHECK: checked by mint_governor program + pub mint_governor_event_authority: UncheckedAccount<'info>, + + pub rent: Sysvar<'info, Rent>, + pub token_program: Program<'info, Token>, + pub associated_token_program: Program<'info, AssociatedToken>, + pub system_program: Program<'info, System>, + pub token_metadata_program: Program<'info, Metadata>, +} + +impl InitializeLaunch<'_> { + pub fn validate(&self, args: &InitializeLaunchArgs) -> Result<()> { + require_gt!( + args.minimum_raise_amount, + 0, + LaunchpadError::InvalidMinimumRaiseAmount + ); + + require_gte!( + 60 * 60 * 24 * 14, + args.seconds_for_launch, + LaunchpadError::InvalidSecondsForLaunch + ); + + require_gt!( + args.seconds_for_launch, + args.accumulator_activation_delay_seconds, + LaunchpadError::InvalidAccumulatorActivationDelaySeconds + ); + + require!( + self.base_mint.freeze_authority.is_none(), + LaunchpadError::FreezeAuthoritySet + ); + + require_gte!( + args.minimum_raise_amount, + args.monthly_spending_limit_amount * 6, + LaunchpadError::InvalidMonthlySpendingLimit + ); + + require_gte!( + args.minimum_raise_amount, + futarchy::MIN_QUOTE_LIQUIDITY * 5, + LaunchpadError::InvalidMinimumRaiseAmount + ); + + require_neq!( + args.monthly_spending_limit_amount, + 0, + LaunchpadError::InvalidMonthlySpendingLimit + ); + + require_gte!( + futarchy::MAX_SPENDING_LIMIT_MEMBERS, + args.monthly_spending_limit_members.len(), + LaunchpadError::InvalidMonthlySpendingLimitMembers + ); + + require!( + !args.monthly_spending_limit_members.is_empty(), + LaunchpadError::InvalidMonthlySpendingLimitMembers + ); + + let mut sorted_members = args.monthly_spending_limit_members.clone(); + sorted_members.sort(); + let has_duplicates = sorted_members.windows(2).any(|win| win[0] == win[1]); + require!( + !has_duplicates, + LaunchpadError::InvalidMonthlySpendingLimitMembers + ); + + require_gte!( + args.months_until_insiders_can_unlock, + 12, + LaunchpadError::InvalidPerformancePackageMinUnlockTime + ); + + require_gte!( + args.performance_package_token_amount, + 10, + LaunchpadError::InvalidPerformancePackageTokenAmount + ); + + require!(self.base_mint.supply == 0, LaunchpadError::SupplyNonZero); + + if args.additional_tokens_amount > 0 { + require!( + self.additional_tokens_recipient.is_some(), + LaunchpadError::InvalidAdditionalTokensRecipient + ); + } else { + require!( + self.additional_tokens_recipient.is_none(), + LaunchpadError::InvalidAdditionalTokensRecipient + ); + } + + Ok(()) + } + + pub fn handle(ctx: Context, args: InitializeLaunchArgs) -> Result<()> { + // Initialize Launch account + ctx.accounts.launch.set_inner(Launch { + minimum_raise_amount: args.minimum_raise_amount, + monthly_spending_limit_amount: args.monthly_spending_limit_amount, + monthly_spending_limit_members: args.monthly_spending_limit_members.clone(), + launch_authority: ctx.accounts.launch_authority.key(), + launch_signer: ctx.accounts.launch_signer.key(), + launch_signer_pda_bump: ctx.bumps.launch_signer, + launch_quote_vault: ctx.accounts.quote_vault.key(), + launch_base_vault: ctx.accounts.base_vault.key(), + total_committed_amount: 0, + base_mint: ctx.accounts.base_mint.key(), + quote_mint: ctx.accounts.quote_mint.key(), + pda_bump: ctx.bumps.launch, + seq_num: 0, + state: LaunchState::Initialized, + unix_timestamp_started: None, + unix_timestamp_closed: None, + seconds_for_launch: args.seconds_for_launch, + dao: None, + dao_vault: None, + performance_package_grantee: args.performance_package_grantee, + performance_package_token_amount: args.performance_package_token_amount, + months_until_insiders_can_unlock: args.months_until_insiders_can_unlock, + team_address: args.team_address, + total_approved_amount: 0, + additional_tokens_amount: args.additional_tokens_amount, + additional_tokens_recipient: ctx + .accounts + .additional_tokens_recipient + .as_ref() + .map(|a| a.key()), + additional_tokens_claimed: false, + unix_timestamp_completed: None, + is_finalized: false, + accumulator_activation_delay_seconds: args.accumulator_activation_delay_seconds, + has_bid_wall: args.has_bid_wall, + mint_governor: ctx.accounts.mint_governor.key(), + }); + + let launch_key = ctx.accounts.launch.key(); + let seeds = &[ + b"launch_signer", + launch_key.as_ref(), + &[ctx.bumps.launch_signer], + ]; + let signer = &[&seeds[..]]; + + // Create token metadata + create_metadata_accounts_v3( + CpiContext::new_with_signer( + ctx.accounts.token_metadata_program.to_account_info(), + CreateMetadataAccountsV3 { + metadata: ctx.accounts.token_metadata.to_account_info(), + mint: ctx.accounts.base_mint.to_account_info(), + mint_authority: ctx.accounts.launch_signer.to_account_info(), + payer: ctx.accounts.payer.to_account_info(), + update_authority: ctx.accounts.launch_signer.to_account_info(), + system_program: ctx.accounts.system_program.to_account_info(), + rent: ctx.accounts.rent.to_account_info(), + }, + signer, + ), + DataV2 { + name: args.token_name.clone(), + symbol: args.token_symbol.clone(), + uri: args.token_uri.clone(), + seller_fee_basis_points: 0, + creators: None, + collection: None, + uses: None, + }, + true, + true, + None, + )?; + + // Mint fixed token amounts into base vault while launch_signer is still the direct authority. + // These tokens will be distributed during settlement (participants, futarchy AMM, Meteora LP). + // Minting is done here rather than in settle_launch to stay within CPI limits during settlement, + // which already performs multiple nested CPIs (initialize_dao, meteora, bid wall). + let tokens_to_mint = TOKENS_TO_PARTICIPANTS + + TOKENS_TO_FUTARCHY_LIQUIDITY + + TOKENS_TO_DAMM_V2_LIQUIDITY + + args.additional_tokens_amount; + + token::mint_to( + CpiContext::new_with_signer( + ctx.accounts.token_program.to_account_info(), + MintTo { + mint: ctx.accounts.base_mint.to_account_info(), + to: ctx.accounts.base_vault.to_account_info(), + authority: ctx.accounts.launch_signer.to_account_info(), + }, + signer, + ), + tokens_to_mint, + )?; + + // Set up MintGovernor: create governor, add launch_signer as minter, transfer mint authority + initialize_mint_governor(CpiContext::new_with_signer( + ctx.accounts.mint_governor_program.to_account_info(), + InitializeMintGovernor { + mint: ctx.accounts.base_mint.to_account_info(), + mint_governor: ctx.accounts.mint_governor.to_account_info(), + create_key: ctx.accounts.launch_signer.to_account_info(), + admin: ctx.accounts.launch_signer.to_account_info(), + payer: ctx.accounts.payer.to_account_info(), + system_program: ctx.accounts.system_program.to_account_info(), + event_authority: ctx.accounts.mint_governor_event_authority.to_account_info(), + program: ctx.accounts.mint_governor_program.to_account_info(), + }, + signer, + ))?; + + transfer_authority_to_governor(CpiContext::new_with_signer( + ctx.accounts.mint_governor_program.to_account_info(), + TransferAuthorityToGovernor { + mint_governor: ctx.accounts.mint_governor.to_account_info(), + mint: ctx.accounts.base_mint.to_account_info(), + current_authority: ctx.accounts.launch_signer.to_account_info(), + token_program: ctx.accounts.token_program.to_account_info(), + event_authority: ctx.accounts.mint_governor_event_authority.to_account_info(), + program: ctx.accounts.mint_governor_program.to_account_info(), + }, + signer, + ))?; + + let clock = Clock::get()?; + emit_cpi!(LaunchInitializedEvent { + common: CommonFields::new(&clock, 0), + launch: ctx.accounts.launch.key(), + minimum_raise_amount: args.minimum_raise_amount, + performance_package_grantee: args.performance_package_grantee, + performance_package_token_amount: args.performance_package_token_amount, + months_until_insiders_can_unlock: args.months_until_insiders_can_unlock, + monthly_spending_limit_amount: args.monthly_spending_limit_amount, + monthly_spending_limit_members: args.monthly_spending_limit_members, + launch_authority: ctx.accounts.launch_authority.key(), + launch_signer: ctx.accounts.launch_signer.key(), + launch_signer_pda_bump: ctx.bumps.launch_signer, + launch_usdc_vault: ctx.accounts.quote_vault.key(), + launch_token_vault: ctx.accounts.base_vault.key(), + base_mint: ctx.accounts.base_mint.key(), + quote_mint: ctx.accounts.quote_mint.key(), + pda_bump: ctx.bumps.launch, + seconds_for_launch: args.seconds_for_launch, + additional_tokens_amount: args.additional_tokens_amount, + additional_tokens_recipient: ctx + .accounts + .additional_tokens_recipient + .as_ref() + .map(|a| a.key()), + accumulator_activation_delay_seconds: args.accumulator_activation_delay_seconds, + has_bid_wall: args.has_bid_wall, + mint_governor: ctx.accounts.mint_governor.key(), + }); + + Ok(()) + } +} diff --git a/programs/v08_launchpad/src/instructions/mod.rs b/programs/v08_launchpad/src/instructions/mod.rs new file mode 100644 index 00000000..854175d2 --- /dev/null +++ b/programs/v08_launchpad/src/instructions/mod.rs @@ -0,0 +1,23 @@ +pub mod claim; +pub mod claim_additional_token_allocation; +pub mod close_launch; +pub mod extend_launch; +pub mod finalize_launch; +pub mod fund; +pub mod initialize_launch; +pub mod refund; +pub mod set_funding_record_approval; +pub mod settle_launch; +pub mod start_launch; + +pub use claim::*; +pub use claim_additional_token_allocation::*; +pub use close_launch::*; +pub use extend_launch::*; +pub use finalize_launch::*; +pub use fund::*; +pub use initialize_launch::*; +pub use refund::*; +pub use set_funding_record_approval::*; +pub use settle_launch::*; +pub use start_launch::*; diff --git a/programs/v08_launchpad/src/instructions/refund.rs b/programs/v08_launchpad/src/instructions/refund.rs new file mode 100644 index 00000000..6e5fbb40 --- /dev/null +++ b/programs/v08_launchpad/src/instructions/refund.rs @@ -0,0 +1,106 @@ +use anchor_lang::prelude::*; +use anchor_spl::token::{self, Token, TokenAccount, Transfer}; + +use crate::error::LaunchpadError; +use crate::events::{CommonFields, LaunchRefundedEvent}; +use crate::state::{FundingRecord, Launch, LaunchState}; + +#[event_cpi] +#[derive(Accounts)] +pub struct Refund<'info> { + #[account( + mut, + has_one = launch_quote_vault, + has_one = launch_signer, + )] + pub launch: Account<'info, Launch>, + + #[account( + mut, + has_one = launch, + has_one = funder, + seeds = [b"funding_record", launch.key().as_ref(), funder.key().as_ref()], + bump = funding_record.pda_bump + )] + pub funding_record: Account<'info, FundingRecord>, + + #[account(mut)] + pub launch_quote_vault: Account<'info, TokenAccount>, + + /// CHECK: just a signer + pub launch_signer: UncheckedAccount<'info>, + + /// CHECK: not used, just for constraints + pub funder: UncheckedAccount<'info>, + + #[account(mut, associated_token::mint = launch.quote_mint, associated_token::authority = funder)] + pub funder_quote_account: Account<'info, TokenAccount>, + + pub token_program: Program<'info, Token>, +} + +impl Refund<'_> { + pub fn validate(&self) -> Result<()> { + require!( + self.launch.state == LaunchState::Refunding + || self.launch.state == LaunchState::Complete, + LaunchpadError::LaunchNotRefunding + ); + + require!( + !self.funding_record.is_usdc_refunded, + LaunchpadError::MoneyAlreadyRefunded + ); + + Ok(()) + } + + pub fn handle(ctx: Context) -> Result<()> { + let launch = &mut ctx.accounts.launch; + let launch_key = launch.key(); + let funding_record = &mut ctx.accounts.funding_record; + + let amount_to_refund = match launch.state { + LaunchState::Refunding => funding_record.committed_amount, + LaunchState::Complete => { + funding_record.committed_amount - funding_record.approved_amount + } + _ => unreachable!(), + }; + + let seeds = &[ + b"launch_signer", + launch_key.as_ref(), + &[launch.launch_signer_pda_bump], + ]; + let signer = &[&seeds[..]]; + + funding_record.is_usdc_refunded = true; + + token::transfer( + CpiContext::new_with_signer( + ctx.accounts.token_program.to_account_info(), + Transfer { + from: ctx.accounts.launch_quote_vault.to_account_info(), + to: ctx.accounts.funder_quote_account.to_account_info(), + authority: ctx.accounts.launch_signer.to_account_info(), + }, + signer, + ), + amount_to_refund, + )?; + + launch.seq_num += 1; + + let clock = Clock::get()?; + emit_cpi!(LaunchRefundedEvent { + common: CommonFields::new(&clock, launch.seq_num), + launch: ctx.accounts.launch.key(), + funder: ctx.accounts.funder.key(), + usdc_refunded: amount_to_refund, + funding_record: ctx.accounts.funding_record.key(), + }); + + Ok(()) + } +} diff --git a/programs/v08_launchpad/src/instructions/set_funding_record_approval.rs b/programs/v08_launchpad/src/instructions/set_funding_record_approval.rs new file mode 100644 index 00000000..768e027c --- /dev/null +++ b/programs/v08_launchpad/src/instructions/set_funding_record_approval.rs @@ -0,0 +1,81 @@ +use anchor_lang::prelude::*; + +use crate::error::LaunchpadError; +use crate::events::{CommonFields, FundingRecordApprovalSetEvent}; +use crate::state::{FundingRecord, Launch, LaunchState}; + +#[event_cpi] +#[derive(Accounts)] +pub struct SetFundingRecordApproval<'info> { + #[account( + mut, + has_one = launch_authority, + )] + pub launch: Account<'info, Launch>, + + #[account( + mut, + has_one = launch, + )] + pub funding_record: Account<'info, FundingRecord>, + + pub launch_authority: Signer<'info>, +} + +impl SetFundingRecordApproval<'_> { + pub fn validate(&self, approved_amount: u64) -> Result<()> { + let clock = Clock::get()?; + + require!( + self.launch.state == LaunchState::Closed, + LaunchpadError::InvalidLaunchState + ); + + let two_days_after_close = self + .launch + .unix_timestamp_closed + .unwrap() + .saturating_add(60 * 60 * 24 * 2); + + require_gt!( + two_days_after_close, + clock.unix_timestamp, + LaunchpadError::FundingRecordApprovalPeriodOver + ); + + require_gte!( + self.funding_record.committed_amount, + approved_amount, + LaunchpadError::InsufficientFunds + ); + + Ok(()) + } + + pub fn handle(ctx: Context, approved_amount: u64) -> Result<()> { + let funding_record = &mut ctx.accounts.funding_record; + let launch = &mut ctx.accounts.launch; + + if approved_amount >= funding_record.approved_amount { + launch.total_approved_amount += approved_amount - funding_record.approved_amount; + } else { + launch.total_approved_amount -= funding_record.approved_amount - approved_amount; + } + + launch.seq_num += 1; + + funding_record.approved_amount = approved_amount; + + let clock = Clock::get()?; + emit_cpi!(FundingRecordApprovalSetEvent { + common: CommonFields::new(&clock, launch.seq_num), + launch: launch.key(), + funding_record: funding_record.key(), + funder: funding_record.funder, + approved_amount, + total_approved: launch.total_approved_amount, + }); + + Ok(()) + } +} diff --git a/programs/v08_launchpad/src/instructions/settle_launch.rs b/programs/v08_launchpad/src/instructions/settle_launch.rs new file mode 100644 index 00000000..a80b07f9 --- /dev/null +++ b/programs/v08_launchpad/src/instructions/settle_launch.rs @@ -0,0 +1,725 @@ +use anchor_lang::{prelude::*, system_program}; +use anchor_spl::associated_token::AssociatedToken; +use anchor_spl::metadata::UpdateMetadataAccountsV2; +use anchor_spl::token::{self, Mint, Token, TokenAccount, Transfer}; +use anchor_spl::token_2022::Token2022; +use anchor_spl::token_interface; +use bid_wall::program::BidWall; +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; +use damm_v2_cpi::BaseFeeParameters; + +use crate::error::LaunchpadError; +use crate::events::{CommonFields, LaunchCloseEvent, LaunchSettledEvent}; +use crate::state::{Launch, LaunchState}; +use crate::{ + metadao_multisig_vault, PRICE_SCALE, PROPOSAL_MIN_STAKE_TOKENS, TOKENS_TO_DAMM_V2_LIQUIDITY, + TOKENS_TO_DAMM_V2_LIQUIDITY_UNSCALED, TOKENS_TO_FUTARCHY_LIQUIDITY, TOKENS_TO_PARTICIPANTS, + TOKEN_SCALE, +}; +use anchor_spl::metadata::{ + mpl_token_metadata::ID as MPL_TOKEN_METADATA_PROGRAM_ID, update_metadata_accounts_v2, Metadata, +}; + +use futarchy::program::Futarchy; +use futarchy::{InitialSpendingLimit, InitializeDaoParams, ProvideLiquidityParams}; + +use damm_v2_cpi::program::DammV2Cpi; + +/// Static accounts for settling a launch, used to reduce code duplication +/// and conserve stack space. +#[derive(Accounts)] +pub struct StaticCompleteLaunchAccounts<'info> { + pub futarchy_program: Program<'info, Futarchy>, + pub token_metadata_program: Program<'info, Metadata>, + /// CHECK: checked by futarchy program + pub futarchy_event_authority: UncheckedAccount<'info>, + pub squads_program: Program<'info, squads_multisig_program::program::SquadsMultisigProgram>, + /// CHECK: checked by squads multisig program + #[account(seeds = [squads_multisig_program::SEED_PREFIX, squads_multisig_program::SEED_PROGRAM_CONFIG], bump, seeds::program = squads_program)] + pub squads_program_config: UncheckedAccount<'info>, + /// CHECK: checked by squads multisig program + #[account(mut)] + pub squads_program_config_treasury: UncheckedAccount<'info>, + pub bid_wall_program: Program<'info, BidWall>, + /// CHECK: checked by bid wall program + pub bid_wall_event_authority: UncheckedAccount<'info>, +} + +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() +} + +#[derive(Accounts)] +pub struct MeteoraAccounts<'info> { + pub damm_v2_program: Program<'info, DammV2Cpi>, + /// CHECK: checked by damm v2 program, there should only be one config that works for us + 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 + #[account(mut, seeds = [ + b"position_nft_mint", + base_mint.key().as_ref()], bump)] + pub position_nft_mint: UncheckedAccount<'info>, + + /// CHECK: checked by root struct + pub base_mint: UncheckedAccount<'info>, + /// CHECK: checked by 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 + #[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>, +} + +#[event_cpi] +#[derive(Accounts)] +pub struct SettleLaunch<'info> { + #[account( + mut, + has_one = launch_quote_vault, + has_one = launch_base_vault, + has_one = launch_signer, + has_one = base_mint, + has_one = quote_mint, + )] + pub launch: Box>, + + pub launch_authority: Option>, + + /// CHECK: Token metadata + #[account( + mut, + seeds = [b"metadata", MPL_TOKEN_METADATA_PROGRAM_ID.as_ref(), base_mint.key().as_ref()], + seeds::program = MPL_TOKEN_METADATA_PROGRAM_ID, + bump + )] + pub token_metadata: UncheckedAccount<'info>, + + #[account(mut)] + pub payer: Signer<'info>, + + /// CHECK: just a signer + #[account(mut)] + pub launch_signer: UncheckedAccount<'info>, + + #[account( + mut, + associated_token::mint = quote_mint, + associated_token::authority = launch_signer, + )] + pub launch_quote_vault: Box>, + + #[account( + mut, + associated_token::mint = base_mint, + associated_token::authority = launch_signer, + )] + pub launch_base_vault: Box>, + + #[account( + init_if_needed, + payer = payer, + associated_token::mint = quote_mint, + associated_token::authority = squads_multisig_vault, + )] + pub treasury_quote_account: Box>, + + #[account(mut, address = meteora_accounts.base_mint.key())] + pub base_mint: Box>, + + #[account(address = meteora_accounts.quote_mint.key())] + pub quote_mint: Box>, + + /// CHECK: init by futarchy program + #[account( + mut, + seeds = [b"amm_position", dao.key().as_ref(), squads_multisig_vault.key().as_ref()], + bump, + seeds::program = static_accounts.futarchy_program + )] + pub dao_owned_lp_position: UncheckedAccount<'info>, + + /// CHECK: checked by futarchy program + #[account(mut)] + pub futarchy_amm_base_vault: UncheckedAccount<'info>, + + /// CHECK: checked by futarchy program + #[account(mut)] + pub futarchy_amm_quote_vault: UncheckedAccount<'info>, + + /// CHECK: this is the DAO account, init by futarchy program + #[account(mut)] + pub dao: UncheckedAccount<'info>, + + /// CHECK: checked by futarchy program + #[account( + mut, + seeds = [squads_multisig_program::SEED_PREFIX, squads_multisig_program::SEED_MULTISIG, dao.key().as_ref()], + bump, + seeds::program = static_accounts.squads_program + )] + pub squads_multisig: UncheckedAccount<'info>, + /// CHECK: just a signer + #[account( + seeds = [squads_multisig_program::SEED_PREFIX, squads_multisig.key().as_ref(), squads_multisig_program::SEED_VAULT, 0_u8.to_le_bytes().as_ref()], + bump, + seeds::program = static_accounts.squads_program + )] + pub squads_multisig_vault: UncheckedAccount<'info>, + /// CHECK: initialized by squads + #[account( + mut, + seeds = [squads_multisig_program::SEED_PREFIX, squads_multisig.key().as_ref(), squads_multisig_program::SEED_SPENDING_LIMIT, dao.key().as_ref()], + bump, + seeds::program = static_accounts.squads_program + )] + pub spending_limit: UncheckedAccount<'info>, + + /// CHECK: checked by bid wall program + #[account(mut)] + pub bid_wall: UncheckedAccount<'info>, + /// CHECK: checked by bid wall program + #[account(mut)] + pub bid_wall_quote_token_account: UncheckedAccount<'info>, + + /// CHECK: The fee recipient of bid wall fees, a fixed address + #[account(address = metadao_multisig_vault::id())] + pub fee_recipient: AccountInfo<'info>, + + pub system_program: Program<'info, System>, + pub token_program: Program<'info, Token>, + pub associated_token_program: Program<'info, AssociatedToken>, + pub static_accounts: StaticCompleteLaunchAccounts<'info>, + pub meteora_accounts: MeteoraAccounts<'info>, +} + +impl SettleLaunch<'_> { + pub fn validate(&self) -> Result<()> { + let clock = Clock::get()?; + + require!( + self.launch.state == LaunchState::Closed, + LaunchpadError::InvalidLaunchState + ); + + // if the launch was closed within 2 days, the launch authority must be the one + // to settle the launch + let two_days_after_close = self.launch.unix_timestamp_closed.unwrap() + 60 * 60 * 24 * 2; + if two_days_after_close > clock.unix_timestamp { + if self.launch_authority.is_none() { + msg!("Launch authority must settle launch until unix timestamp {}. Current time is {}.", two_days_after_close, clock.unix_timestamp); + return Err(LaunchpadError::LaunchAuthorityNotSet.into()); + } + } + + if self.launch_authority.is_some() { + require!( + self.launch_authority.as_ref().unwrap().is_signer, + LaunchpadError::LaunchAuthorityNotSet + ); + + require_keys_eq!( + self.launch_authority.as_ref().unwrap().key(), + self.launch.launch_authority, + LaunchpadError::LaunchAuthorityNotSet + ); + + // If the launch authority is settling the launch, the total approved amount must be + // greater than or equal to the minimum raise amount + require_gte!( + self.launch.total_approved_amount, + self.launch.minimum_raise_amount, + LaunchpadError::TotalApprovedAmountTooLow + ); + } + + Ok(()) + } + + pub fn handle(ctx: Context) -> Result<()> { + let launch_total_approved_amount = ctx.accounts.launch.total_approved_amount; + + let clock = Clock::get()?; + + // If the launch authority doesn't approve enough funding, the launch will go into refunding state + if launch_total_approved_amount < ctx.accounts.launch.minimum_raise_amount { + let launch = &mut ctx.accounts.launch; + + launch.state = LaunchState::Refunding; + launch.seq_num += 1; + + emit_cpi!(LaunchCloseEvent { + common: CommonFields::new(&clock, launch.seq_num), + launch: launch.key(), + new_state: launch.state, + }); + + return Ok(()); + }; + + let launch_key = ctx.accounts.launch.key(); + let launch_signer_seeds = &[ + b"launch_signer", + launch_key.as_ref(), + &[ctx.accounts.launch.launch_signer_pda_bump], + ]; + let launch_signer = &[&launch_signer_seeds[..]]; + + let price_1e12 = ((launch_total_approved_amount as u128) * PRICE_SCALE) + / (TOKENS_TO_PARTICIPANTS as u128); + + // We first determine how much USDC to allocate to the LP pool, with the rest going to the DAO + let usdc_to_lp = launch_total_approved_amount.saturating_div(5); + let usdc_to_dao = launch_total_approved_amount.saturating_sub(usdc_to_lp); + + // We only activate the bid wall if the launch has one configured. + // Otherwise, we allocate the entire amount after LP allocation to the DAO treasury. + let (usdc_to_dao_treasury, usdc_to_bid_wall) = if ctx.accounts.launch.has_bid_wall { + let usdc_to_dao_treasury = usdc_to_dao.min(ctx.accounts.launch.minimum_raise_amount); + let usdc_to_bid_wall = usdc_to_dao.saturating_sub(usdc_to_dao_treasury); + (usdc_to_dao_treasury, usdc_to_bid_wall) + } else { + (usdc_to_dao, 0) + }; + + ctx.accounts.initialize_dao(launch_signer, price_1e12)?; + + if usdc_to_bid_wall > 0 { + ctx.accounts + .initialize_bid_wall(usdc_to_bid_wall, usdc_to_lp, launch_signer)?; + } + + ctx.accounts.provide_futarchy_amm_liquidity( + usdc_to_lp, + TOKENS_TO_FUTARCHY_LIQUIDITY, + launch_signer, + )?; + + ctx.accounts.provide_single_sided_meteora_liquidity( + launch_total_approved_amount, + ctx.bumps.meteora_accounts.position_nft_mint, + ctx.bumps.meteora_accounts.pool_creator_authority, + launch_signer_seeds, + )?; + + ctx.accounts + .send_usdc_to_dao(usdc_to_dao_treasury, launch_signer)?; + + ctx.accounts + .transfer_metadata_authority_to_dao(launch_signer)?; + + let launch = &mut ctx.accounts.launch; + + launch.dao = Some(ctx.accounts.dao.key()); + launch.dao_vault = Some(ctx.accounts.squads_multisig_vault.key()); + launch.state = LaunchState::Complete; + launch.unix_timestamp_completed = Some(clock.unix_timestamp); + launch.seq_num += 1; + + emit_cpi!(LaunchSettledEvent { + common: CommonFields::new(&clock, launch.seq_num), + launch: launch.key(), + final_state: launch.state, + total_committed: launch.total_committed_amount, + total_approved_amount: launch.total_approved_amount, + dao: launch.dao, + dao_treasury: launch.dao_vault, + bid_wall: if usdc_to_bid_wall > 0 { + Some(ctx.accounts.bid_wall.key()) + } else { + None + }, + bid_wall_amount: usdc_to_bid_wall, + tokens_minted: TOKENS_TO_PARTICIPANTS + + TOKENS_TO_FUTARCHY_LIQUIDITY + + TOKENS_TO_DAMM_V2_LIQUIDITY + + launch.additional_tokens_amount, + }); + + let refundable_usdc = launch.total_committed_amount - launch_total_approved_amount; + + ctx.accounts.verify_position_nft()?; + ctx.accounts.verify_vaults(refundable_usdc)?; + + Ok(()) + } + + #[inline(never)] + fn initialize_dao(&self, launch_signer: &[&[&[u8]]], launch_price_1e12: u128) -> Result<()> { + futarchy::cpi::initialize_dao( + CpiContext::new_with_signer( + self.static_accounts.futarchy_program.to_account_info(), + futarchy::cpi::accounts::InitializeDao { + dao: self.dao.to_account_info(), + dao_creator: self.launch_signer.to_account_info(), + payer: self.payer.to_account_info(), + system_program: self.system_program.to_account_info(), + base_mint: self.base_mint.to_account_info(), + quote_mint: self.quote_mint.to_account_info(), + event_authority: self + .static_accounts + .futarchy_event_authority + .to_account_info(), + program: self.static_accounts.futarchy_program.to_account_info(), + squads_multisig: self.squads_multisig.to_account_info(), + squads_multisig_vault: self.squads_multisig_vault.to_account_info(), + squads_program: self.static_accounts.squads_program.to_account_info(), + squads_program_config: self + .static_accounts + .squads_program_config + .to_account_info(), + squads_program_config_treasury: self + .static_accounts + .squads_program_config_treasury + .to_account_info(), + spending_limit: self.spending_limit.to_account_info(), + futarchy_amm_base_vault: self.futarchy_amm_base_vault.to_account_info(), + futarchy_amm_quote_vault: self.futarchy_amm_quote_vault.to_account_info(), + associated_token_program: self.associated_token_program.to_account_info(), + token_program: self.token_program.to_account_info(), + }, + launch_signer, + ), + InitializeDaoParams { + twap_initial_observation: launch_price_1e12, + twap_max_observation_change_per_update: launch_price_1e12 / 20, + min_quote_futarchic_liquidity: 1, + min_base_futarchic_liquidity: 1, + pass_threshold_bps: 300, + base_to_stake: PROPOSAL_MIN_STAKE_TOKENS, + seconds_per_proposal: 3 * 24 * 60 * 60, + twap_start_delay_seconds: 24 * 60 * 60, + nonce: 0, + initial_spending_limit: Some(InitialSpendingLimit { + amount_per_month: self.launch.monthly_spending_limit_amount, + members: self.launch.monthly_spending_limit_members.clone(), + }), + team_sponsored_pass_threshold_bps: -300, + team_address: self.launch.team_address, + }, + ) + } + + fn initialize_bid_wall( + &self, + usdc_to_bid_wall: u64, + usdc_to_lp: u64, + launch_signer: &[&[&[u8]]], + ) -> Result<()> { + bid_wall::cpi::initialize_bid_wall( + CpiContext::new_with_signer( + self.static_accounts.bid_wall_program.to_account_info(), + bid_wall::cpi::accounts::InitializeBidWall { + bid_wall: self.bid_wall.to_account_info(), + payer: self.payer.to_account_info(), + fee_recipient: self.fee_recipient.to_account_info(), + creator: self.launch_signer.to_account_info(), + authority: self.squads_multisig_vault.to_account_info(), + bid_wall_quote_token_account: self + .bid_wall_quote_token_account + .to_account_info(), + creator_quote_token_account: self.launch_quote_vault.to_account_info(), + dao_treasury: self.squads_multisig_vault.to_account_info(), + base_mint: self.base_mint.to_account_info(), + quote_mint: self.quote_mint.to_account_info(), + token_program: self.token_program.to_account_info(), + associated_token_program: self.associated_token_program.to_account_info(), + system_program: self.system_program.to_account_info(), + event_authority: self + .static_accounts + .bid_wall_event_authority + .to_account_info(), + program: self.static_accounts.bid_wall_program.to_account_info(), + }, + launch_signer, + ), + bid_wall::instructions::InitializeBidWallArgs { + amount: usdc_to_bid_wall, + nonce: 0, + initial_amm_quote_reserves: usdc_to_lp, + duration_seconds: 3 * 30 * 24 * 60 * 60, // 3 months + }, + ) + } + + #[inline(never)] + fn provide_futarchy_amm_liquidity( + &self, + usdc_to_lp: u64, + tokens_to_lp: u64, + launch_signer: &[&[&[u8]]], + ) -> Result<()> { + futarchy::cpi::provide_liquidity( + CpiContext::new_with_signer( + self.static_accounts.futarchy_program.to_account_info(), + futarchy::cpi::accounts::ProvideLiquidity { + dao: self.dao.to_account_info(), + liquidity_provider: self.launch_signer.to_account_info(), + liquidity_provider_base_account: self.launch_base_vault.to_account_info(), + liquidity_provider_quote_account: self.launch_quote_vault.to_account_info(), + payer: self.payer.to_account_info(), + system_program: self.system_program.to_account_info(), + amm_base_vault: self.futarchy_amm_base_vault.to_account_info(), + amm_quote_vault: self.futarchy_amm_quote_vault.to_account_info(), + amm_position: self.dao_owned_lp_position.to_account_info(), + token_program: self.token_program.to_account_info(), + program: self.static_accounts.futarchy_program.to_account_info(), + event_authority: self + .static_accounts + .futarchy_event_authority + .to_account_info(), + }, + launch_signer, + ), + ProvideLiquidityParams { + max_base_amount: tokens_to_lp, + quote_amount: usdc_to_lp, + min_liquidity: 0, + position_authority: self.squads_multisig_vault.key(), + }, + ) + } + + fn provide_single_sided_meteora_liquidity( + &self, + final_raise_amount: u64, + position_nft_mint_bump: u8, + pool_creator_authority_bump: u8, + launch_signer_seeds: &[&[u8]], + ) -> Result<()> { + system_program::transfer( + CpiContext::new( + self.system_program.to_account_info(), + system_program::Transfer { + from: self.payer.to_account_info(), + to: self.launch_signer.to_account_info(), + }, + ), + 5e7 as u64, + )?; + + let base_mint_key = self.base_mint.key(); + 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], + ]; + + let pool_init_signer = &[ + &launch_signer_seeds[..], + &position_nft_mint_signer_seeds[..], + &pool_creator_authority_signer_seeds[..], + ]; + + require_eq!( + self.base_mint.decimals, + 6, + LaunchpadError::InvariantViolated + ); + require_eq!( + self.quote_mint.decimals, + 6, + LaunchpadError::InvariantViolated + ); + + let float_price = final_raise_amount as f64 / TOKENS_TO_PARTICIPANTS as f64; + let sqrt_price = (float_price.sqrt() * 2_f64.powf(64.0)) as u128; + + let liquidity = ((MAX_SQRT_PRICE * TOKENS_TO_DAMM_V2_LIQUIDITY_UNSCALED as u128) + / (MAX_SQRT_PRICE - sqrt_price)) + * TOKEN_SCALE as u128 + * sqrt_price; + + 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.squads_multisig_vault.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.launch_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: self.launch_base_vault.to_account_info(), + payer_token_b: self.launch_quote_vault.to_account_info(), + 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, + 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: sqrt_price, + sqrt_max_price: MAX_SQRT_PRICE, + has_alpha_vault: false, + liquidity, + sqrt_price, + }, + ) + } + + fn transfer_metadata_authority_to_dao(&self, launch_signer: &[&[&[u8]]]) -> Result<()> { + update_metadata_accounts_v2( + CpiContext::new_with_signer( + self.static_accounts + .token_metadata_program + .to_account_info(), + UpdateMetadataAccountsV2 { + metadata: self.token_metadata.to_account_info(), + update_authority: self.launch_signer.to_account_info(), + }, + launch_signer, + ), + Some(self.squads_multisig_vault.key()), + None, + None, + None, + ) + } + + fn send_usdc_to_dao(&self, usdc_to_send: u64, launch_signer: &[&[&[u8]]]) -> Result<()> { + token::transfer( + CpiContext::new_with_signer( + self.token_program.to_account_info(), + Transfer { + from: self.launch_quote_vault.to_account_info(), + to: self.treasury_quote_account.to_account_info(), + authority: self.launch_signer.to_account_info(), + }, + launch_signer, + ), + usdc_to_send, + ) + } + + #[inline(never)] + fn verify_vaults(&mut self, refundable_usdc: u64) -> Result<()> { + self.launch_base_vault.reload()?; + self.launch_quote_vault.reload()?; + + require_gte!( + self.launch_base_vault.amount, + TOKENS_TO_PARTICIPANTS + self.launch.additional_tokens_amount, + LaunchpadError::InvariantViolated + ); + require_gte!( + self.launch_quote_vault.amount, + refundable_usdc, + LaunchpadError::InvariantViolated + ); + + Ok(()) + } + + #[inline(never)] + fn verify_position_nft(&self) -> Result<()> { + let position_nft_account = token_interface::TokenAccount::try_deserialize( + &mut &self.meteora_accounts.position_nft_account.data.borrow()[..], + )?; + require_eq!( + position_nft_account.amount, + 1, + LaunchpadError::InvariantViolated + ); + require_keys_eq!( + position_nft_account.owner, + self.squads_multisig_vault.key(), + LaunchpadError::InvariantViolated + ); + Ok(()) + } +} diff --git a/programs/v08_launchpad/src/instructions/start_launch.rs b/programs/v08_launchpad/src/instructions/start_launch.rs new file mode 100644 index 00000000..54bd9b26 --- /dev/null +++ b/programs/v08_launchpad/src/instructions/start_launch.rs @@ -0,0 +1,46 @@ +use crate::error::LaunchpadError; +use crate::events::{CommonFields, LaunchStartedEvent}; +use crate::state::{Launch, LaunchState}; +use anchor_lang::prelude::*; + +#[event_cpi] +#[derive(Accounts)] +pub struct StartLaunch<'info> { + #[account( + mut, + has_one = launch_authority, + )] + pub launch: Account<'info, Launch>, + + pub launch_authority: Signer<'info>, +} + +impl StartLaunch<'_> { + pub fn validate(&self) -> Result<()> { + require!( + self.launch.state == LaunchState::Initialized, + LaunchpadError::LaunchNotInitialized + ); + + Ok(()) + } + + pub fn handle(ctx: Context) -> Result<()> { + let launch = &mut ctx.accounts.launch; + let clock = Clock::get()?; + + launch.state = LaunchState::Live; + launch.unix_timestamp_started = Some(clock.unix_timestamp); + + launch.seq_num += 1; + + emit_cpi!(LaunchStartedEvent { + common: CommonFields::new(&clock, launch.seq_num), + launch: ctx.accounts.launch.key(), + launch_authority: ctx.accounts.launch_authority.key(), + slot_started: clock.slot, + }); + + Ok(()) + } +} diff --git a/programs/v08_launchpad/src/lib.rs b/programs/v08_launchpad/src/lib.rs new file mode 100644 index 00000000..273f4bca --- /dev/null +++ b/programs/v08_launchpad/src/lib.rs @@ -0,0 +1,133 @@ +//! A smart contract that facilitates the creation of new futarchic DAOs. +use anchor_lang::prelude::*; + +pub mod allocator; +pub mod error; +pub mod events; +pub mod instructions; +pub mod state; + +pub use instructions::*; + +#[cfg(not(feature = "no-entrypoint"))] +use solana_security_txt::security_txt; + +#[cfg(not(feature = "no-entrypoint"))] +security_txt! { + name: "launchpad_v8", + project_url: "https://metadao.fi", + contacts: "telegram:metaproph3t,telegram:kollan_house", + source_code: "https://github.com/metaDAOproject/programs", + source_release: "v0.8.0", + policy: "The market will decide whether we pay a bug bounty.", + acknowledgements: "DCF = (CF1 / (1 + r)^1) + (CF2 / (1 + r)^2) + ... (CFn / (1 + r)^n)" +} + +declare_id!("moonDJUoHteKkGATejA5bdJVwJ6V6Dg74gyqyJTx73n"); + +pub const TOKEN_SCALE: u64 = 1_000_000; + +pub const PRICE_SCALE: u128 = 1_000_000_000_000; + +/// 10M tokens with 6 decimals +pub const TOKENS_TO_PARTICIPANTS: u64 = 10_000_000 * TOKEN_SCALE; +/// 20% to liquidity +pub const TOKENS_TO_FUTARCHY_LIQUIDITY: u64 = 2_000_000 * TOKEN_SCALE; +/// 3M tokens to single-sided DammV2 liquidity +pub const TOKENS_TO_DAMM_V2_LIQUIDITY: u64 = TOKENS_TO_DAMM_V2_LIQUIDITY_UNSCALED * TOKEN_SCALE; +/// we need this to prevent overflow +pub const TOKENS_TO_DAMM_V2_LIQUIDITY_UNSCALED: u64 = 900_000; +/// 15% of the floating supply to stake +pub const PROPOSAL_MIN_STAKE_TOKENS: u64 = 1_500_000 * TOKEN_SCALE; + +// PP v2 tranche config +pub const PP_NUM_TRANCHES: usize = 5; +pub const PP_PRICE_MULTIPLIERS: [u128; 5] = [2, 4, 8, 16, 32]; + +// FutarchyTwap min_duration: 3 months +pub const PP_TWAP_MIN_DURATION: u32 = 3 * 30 * 24 * 60 * 60; // 7_776_000 seconds + +pub mod usdc_mint { + use anchor_lang::prelude::declare_id; + + #[cfg(feature = "devnet")] + declare_id!("4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"); + + #[cfg(not(feature = "devnet"))] + declare_id!("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"); +} + +pub mod metadao_multisig_vault { + use anchor_lang::prelude::declare_id; + + // MetaDAO operations multisig vault + declare_id!("6awyHMshBGVjJ3ozdSJdyyDE1CTAXUwrpNMaRGMsb4sf"); +} + +#[program] +pub mod launchpad_v8 { + use super::*; + + #[access_control(ctx.accounts.validate(&args))] + pub fn initialize_launch( + ctx: Context, + args: InitializeLaunchArgs, + ) -> Result<()> { + InitializeLaunch::handle(ctx, args) + } + + #[access_control(ctx.accounts.validate())] + pub fn start_launch(ctx: Context) -> Result<()> { + StartLaunch::handle(ctx) + } + + #[access_control(ctx.accounts.validate(amount))] + pub fn fund(ctx: Context, amount: u64) -> Result<()> { + Fund::handle(ctx, amount) + } + + #[access_control(ctx.accounts.validate())] + pub fn close_launch(ctx: Context) -> Result<()> { + CloseLaunch::handle(ctx) + } + + #[access_control(ctx.accounts.validate(approved_amount))] + pub fn set_funding_record_approval( + ctx: Context, + approved_amount: u64, + ) -> Result<()> { + SetFundingRecordApproval::handle(ctx, approved_amount) + } + + #[access_control(ctx.accounts.validate())] + pub fn settle_launch(ctx: Context) -> Result<()> { + SettleLaunch::handle(ctx) + } + + #[access_control(ctx.accounts.validate())] + pub fn claim(ctx: Context) -> Result<()> { + Claim::handle(ctx) + } + + #[access_control(ctx.accounts.validate())] + pub fn refund(ctx: Context) -> Result<()> { + Refund::handle(ctx) + } + + #[access_control(ctx.accounts.validate())] + pub fn claim_additional_token_allocation( + ctx: Context, + ) -> Result<()> { + ClaimAdditionalTokenAllocation::handle(ctx) + } + + #[access_control(ctx.accounts.validate())] + pub fn finalize_launch(ctx: Context) -> Result<()> { + FinalizeLaunch::handle(ctx) + } + + #[access_control(ctx.accounts.validate(&args))] + pub fn extend_launch(ctx: Context, args: ExtendLaunchArgs) -> Result<()> { + ExtendLaunch::handle(ctx, args) + } +} diff --git a/programs/v08_launchpad/src/state/funding_record.rs b/programs/v08_launchpad/src/state/funding_record.rs new file mode 100644 index 00000000..a3e03f68 --- /dev/null +++ b/programs/v08_launchpad/src/state/funding_record.rs @@ -0,0 +1,25 @@ +use anchor_lang::prelude::*; + +#[account] +#[derive(InitSpace)] +pub struct FundingRecord { + /// The PDA bump. + pub pda_bump: u8, + /// The funder. + pub funder: Pubkey, + /// The launch. + pub launch: Pubkey, + /// The amount of USDC that has been committed by the funder. + pub committed_amount: u64, + /// Whether the tokens have been claimed. + pub is_tokens_claimed: bool, + /// Whether the USDC has been refunded. + pub is_usdc_refunded: bool, + /// The amount of USDC that the launch authority has approved for the funder. + /// If zero, the funder has not been approved for any amount. + pub approved_amount: u64, + /// Running integral of committed_amount over time (committed_amount * seconds). + pub committed_amount_accumulator: u128, + /// Unix timestamp of the last accumulator update. + pub last_accumulator_update: i64, +} diff --git a/programs/v08_launchpad/src/state/launch.rs b/programs/v08_launchpad/src/state/launch.rs new file mode 100644 index 00000000..4d92dccf --- /dev/null +++ b/programs/v08_launchpad/src/state/launch.rs @@ -0,0 +1,96 @@ +use anchor_lang::prelude::*; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy, PartialEq, Eq, InitSpace)] +pub enum LaunchState { + Initialized, + Live, + Closed, + Complete, + Refunding, +} + +impl ToString for LaunchState { + fn to_string(&self) -> String { + match self { + LaunchState::Initialized => "Initialized", + LaunchState::Live => "Live", + LaunchState::Closed => "Closed", + LaunchState::Complete => "Complete", + LaunchState::Refunding => "Refunding", + } + .to_string() + } +} + +#[account] +#[derive(InitSpace)] +pub struct Launch { + /// The PDA bump. + pub pda_bump: u8, + /// The minimum amount of USDC that must be raised, otherwise + /// everyone can get their USDC back. + pub minimum_raise_amount: u64, + /// The monthly spending limit the DAO allocates to the team. Must be + /// less than 1/6th of the minimum raise amount (so 6 months of burn). + pub monthly_spending_limit_amount: u64, + /// The wallets that have access to the monthly spending limit. + #[max_len(10)] + pub monthly_spending_limit_members: Vec, + /// The account that can start the launch. + pub launch_authority: Pubkey, + /// The launch signer address. + pub launch_signer: Pubkey, + /// The PDA bump for the launch signer. + pub launch_signer_pda_bump: u8, + /// The USDC vault that will hold the USDC raised until the launch is over. + pub launch_quote_vault: Pubkey, + /// The token vault, used to send tokens to the AMM. + pub launch_base_vault: Pubkey, + /// The token that will be minted to funders and that will control the DAO. + pub base_mint: Pubkey, + /// The USDC mint. + pub quote_mint: Pubkey, + /// The unix timestamp when the launch was started. + pub unix_timestamp_started: Option, + /// The unix timestamp when the launch stopped taking new contributions. + pub unix_timestamp_closed: Option, + /// The amount of USDC that has been committed by the users. + pub total_committed_amount: u64, + /// The state of the launch. + pub state: LaunchState, + /// The sequence number of this launch. Useful for sorting events. + pub seq_num: u64, + /// The number of seconds that the launch will be live for. + pub seconds_for_launch: u32, + /// The DAO, if the launch is complete. + pub dao: Option, + /// The DAO treasury that USDC / LP is sent to, if the launch is complete. + pub dao_vault: Option, + /// The address that will receive the performance package tokens. + pub performance_package_grantee: Pubkey, + /// The amount of tokens to be granted to the performance package grantee. + pub performance_package_token_amount: u64, + /// The number of months that insiders must wait before unlocking their tokens. + pub months_until_insiders_can_unlock: u8, + /// The initial address used to sponsor team proposals. + pub team_address: Pubkey, + /// The amount of USDC that the launch authority has approved across all funders. + pub total_approved_amount: u64, + /// The amount of additional tokens to be minted on a successful launch. + pub additional_tokens_amount: u64, + /// The token account that will receive the additional tokens. + pub additional_tokens_recipient: Option, + /// Are the additional tokens claimed. + pub additional_tokens_claimed: bool, + /// The unix timestamp when the launch was completed. + pub unix_timestamp_completed: Option, + /// Whether the launch has been finalized. + pub is_finalized: bool, + /// Number of seconds after launch start before the funding accumulator + /// begins tracking. + pub accumulator_activation_delay_seconds: u32, + /// Whether the launch has a bid wall. + pub has_bid_wall: bool, + /// The MintGovernor PDA that owns the SPL mint authority. + pub mint_governor: Pubkey, +} diff --git a/programs/v08_launchpad/src/state/mod.rs b/programs/v08_launchpad/src/state/mod.rs new file mode 100644 index 00000000..d1d1addd --- /dev/null +++ b/programs/v08_launchpad/src/state/mod.rs @@ -0,0 +1,5 @@ +pub mod funding_record; +pub mod launch; + +pub use funding_record::*; +pub use launch::*; diff --git a/sdk/src/constants.ts b/sdk/src/constants.ts index 8701b71b..de13e9f2 100644 --- a/sdk/src/constants.ts +++ b/sdk/src/constants.ts @@ -44,6 +44,9 @@ export const LAUNCHPAD_V0_6_PROGRAM_ID = new PublicKey( export const LAUNCHPAD_V0_7_PROGRAM_ID = new PublicKey( "moontUzsdepotRGe5xsfip7vLPTJnVuafqdUWexVnPM", ); +export const LAUNCHPAD_V0_8_PROGRAM_ID = new PublicKey( + "moonDJUoHteKkGATejA5bdJVwJ6V6Dg74gyqyJTx73n", +); export const SHARED_LIQUIDITY_MANAGER_PROGRAM_ID = new PublicKey( "EoJc1PYxZbnCjszampLcwJGYcB5Md47jM4oSQacRtD4d", ); @@ -155,6 +158,10 @@ export const LAUNCHPAD_V0_7_MAINNET_METEORA_CONFIG = new PublicKey( "FaA6RM9enPh1tU9Y8LiGCq715JubLc49WGcYTdNvDfsc", ); +export const LAUNCHPAD_V0_8_MAINNET_METEORA_CONFIG = new PublicKey( + "83xS3s3egswDMouWkBwEFNZckAuAYhNrGQxrZKAe8GV", +); + export const METADAO_MULTISIG_VAULT = new PublicKey( "6awyHMshBGVjJ3ozdSJdyyDE1CTAXUwrpNMaRGMsb4sf", ); diff --git a/sdk/src/launchpad/v0.8/LaunchpadClient.ts b/sdk/src/launchpad/v0.8/LaunchpadClient.ts new file mode 100644 index 00000000..c49d8b14 --- /dev/null +++ b/sdk/src/launchpad/v0.8/LaunchpadClient.ts @@ -0,0 +1,807 @@ +import { AnchorProvider, Program } from "@coral-xyz/anchor"; +import { PublicKey, AccountInfo, ComputeBudgetProgram } from "@solana/web3.js"; +import { + createAssociatedTokenAccountIdempotentInstruction, + getAssociatedTokenAddressSync, + TOKEN_2022_PROGRAM_ID, +} from "@solana/spl-token"; +import BN from "bn.js"; +import * as multisig from "@sqds/multisig"; +import { + LAUNCHPAD_V0_8_PROGRAM_ID, + MPL_TOKEN_METADATA_PROGRAM_ID, + MAINNET_USDC, + SQUADS_PROGRAM_ID, + SQUADS_PROGRAM_CONFIG, + SQUADS_PROGRAM_CONFIG_TREASURY, + SQUADS_PROGRAM_CONFIG_TREASURY_DEVNET, + DAMM_V2_PROGRAM_ID, + LAUNCHPAD_V0_8_MAINNET_METEORA_CONFIG, + METADAO_MULTISIG_VAULT, +} from "../../constants.js"; +import { + getFundingRecordAddr, + getLaunchAddr, + getLaunchSignerAddr, +} from "./pda.js"; +import { getEventAuthorityAddr, getMetadataAddr } from "../../pda.js"; +import { + LaunchpadProgram, + LaunchpadIDL, + Launch, + FundingRecord, +} from "./types/index.js"; + +import { FutarchyClient, getDaoAddr } from "../../futarchy/v0.6/index.js"; +import { + MintGovernorClient, + getMintGovernorAddr, + getMintAuthorityAddr, +} from "../../mint_governor/v0.7/index.js"; +import { + PerformancePackageV2Client, + getPerformancePackageV2Addr, +} from "../../performance_package_v2/v0.7/index.js"; +import { BidWallClient } from "../../bid_wall/v0.7/index.js"; + +export type CreateLaunchpadClientParams = { + provider: AnchorProvider; + launchpadProgramId?: PublicKey; + futarchyProgramId?: PublicKey; + conditionalVaultProgramId?: PublicKey; + mintGovernorProgramId?: PublicKey; + performancePackageV2ProgramId?: PublicKey; + bidWallProgramId?: PublicKey; +}; + +export class LaunchpadClient { + public launchpad: Program; + public provider: AnchorProvider; + public futarchyClient: FutarchyClient; + public mintGovernorClient: MintGovernorClient; + public performancePackageV2: PerformancePackageV2Client; + public bidWall: BidWallClient; + + private constructor(params: CreateLaunchpadClientParams) { + this.provider = params.provider; + this.launchpad = new Program( + LaunchpadIDL as any, + params.launchpadProgramId || LAUNCHPAD_V0_8_PROGRAM_ID, + this.provider, + ); + this.futarchyClient = FutarchyClient.createClient({ + provider: this.provider, + futarchyProgramId: params.futarchyProgramId, + conditionalVaultProgramId: params.conditionalVaultProgramId, + }); + this.mintGovernorClient = MintGovernorClient.createClient({ + provider: this.provider, + programId: params.mintGovernorProgramId, + }); + this.performancePackageV2 = PerformancePackageV2Client.createClient({ + provider: this.provider, + programId: params.performancePackageV2ProgramId, + }); + this.bidWall = BidWallClient.createClient({ + provider: this.provider, + bidWallProgramId: params.bidWallProgramId, + }); + } + + static createClient(params: CreateLaunchpadClientParams): LaunchpadClient { + return new LaunchpadClient(params); + } + + getProgramId(): PublicKey { + return this.launchpad.programId; + } + + async getLaunch(launch: PublicKey): Promise { + return await this.launchpad.account.launch.fetch(launch); + } + + async fetchLaunch(launch: PublicKey): Promise { + return await this.launchpad.account.launch.fetchNullable(launch); + } + + async deserializeLaunch(accountInfo: AccountInfo): Promise { + return this.launchpad.coder.accounts.decode("launch", accountInfo.data); + } + + async getFundingRecord(fundingRecord: PublicKey): Promise { + return await this.launchpad.account.fundingRecord.fetch(fundingRecord); + } + + async fetchFundingRecord( + fundingRecord: PublicKey, + ): Promise { + return await this.launchpad.account.fundingRecord.fetchNullable( + fundingRecord, + ); + } + + async deserializeFundingRecord( + accountInfo: AccountInfo, + ): Promise { + return this.launchpad.coder.accounts.decode( + "fundingRecord", + accountInfo.data, + ); + } + + getLaunchAddress({ baseMint }: { baseMint: PublicKey }): PublicKey { + return getLaunchAddr(this.launchpad.programId, baseMint)[0]; + } + + getLaunchSignerAddress({ launch }: { launch: PublicKey }): PublicKey { + return getLaunchSignerAddr(this.launchpad.programId, launch)[0]; + } + + getMintGovernorAddress({ + baseMint, + launchSigner, + }: { + baseMint: PublicKey; + launchSigner: PublicKey; + }): PublicKey { + return getMintGovernorAddr({ + programId: this.mintGovernorClient.programId, + mint: baseMint, + createKey: launchSigner, + })[0]; + } + + getMintAuthorityAddress({ + mintGovernor, + authorizedMinter, + }: { + mintGovernor: PublicKey; + authorizedMinter: PublicKey; + }): PublicKey { + return getMintAuthorityAddr({ + programId: this.mintGovernorClient.programId, + mintGovernor, + authorizedMinter, + })[0]; + } + + getLaunchPerformancePackageAddress({ + launch, + }: { + launch: PublicKey; + }): PublicKey { + const launchSigner = this.getLaunchSignerAddress({ launch }); + + return getPerformancePackageV2Addr({ createKey: launchSigner })[0]; + } + + getLaunchDaoAddress({ launch }: { launch: PublicKey }): PublicKey { + const launchSigner = this.getLaunchSignerAddress({ launch }); + + return getDaoAddr({ nonce: new BN(0), daoCreator: launchSigner })[0]; + } + + getFundingRecordAddress({ + launch, + funder, + }: { + launch: PublicKey; + funder: PublicKey; + }): PublicKey { + return getFundingRecordAddr(this.launchpad.programId, launch, funder)[0]; + } + + initializeLaunchIx({ + tokenName, + tokenSymbol, + tokenUri, + minimumRaiseAmount, + secondsForLaunch = 60 * 60 * 24 * 5, // 5 days + baseMint, + quoteMint = MAINNET_USDC, + monthlySpendingLimitAmount, + monthlySpendingLimitMembers, + performancePackageGrantee, + performancePackageTokenAmount, + monthsUntilInsidersCanUnlock, + teamAddress, + launchAuthority = this.provider.publicKey, + payer = this.provider.publicKey, + additionalTokensRecipient, + additionalTokensAmount, + accumulatorActivationDelaySeconds = 0, + hasBidWall = false, + }: { + tokenName: string; + tokenSymbol: string; + tokenUri: string; + minimumRaiseAmount: BN; + secondsForLaunch?: number; + baseMint: PublicKey; + quoteMint?: PublicKey; + monthlySpendingLimitAmount: BN; + monthlySpendingLimitMembers: PublicKey[]; + performancePackageGrantee: PublicKey; + performancePackageTokenAmount: BN; + monthsUntilInsidersCanUnlock: number; + teamAddress: PublicKey; + launchAuthority?: PublicKey; + payer?: PublicKey; + additionalTokensRecipient?: PublicKey; + additionalTokensAmount?: BN; + accumulatorActivationDelaySeconds?: number; + hasBidWall: boolean; + }) { + const [launch] = getLaunchAddr(this.launchpad.programId, baseMint); + const [launchSigner] = getLaunchSignerAddr( + this.launchpad.programId, + launch, + ); + const quoteVault = getAssociatedTokenAddressSync( + quoteMint, + launchSigner, + true, + ); + const baseVault = getAssociatedTokenAddressSync( + baseMint, + launchSigner, + true, + ); + const [tokenMetadata] = getMetadataAddr(baseMint); + + // MintGovernor PDAs + const [mintGovernor] = getMintGovernorAddr({ + programId: this.mintGovernorClient.programId, + mint: baseMint, + createKey: launchSigner, + }); + const [mintGovernorEventAuthority] = getEventAuthorityAddr( + this.mintGovernorClient.programId, + ); + + return this.launchpad.methods + .initializeLaunch({ + minimumRaiseAmount, + secondsForLaunch, + tokenName, + tokenSymbol, + tokenUri, + monthlySpendingLimitAmount, + monthlySpendingLimitMembers, + performancePackageGrantee, + performancePackageTokenAmount, + monthsUntilInsidersCanUnlock, + teamAddress, + additionalTokensAmount: additionalTokensAmount ?? new BN(0), + accumulatorActivationDelaySeconds, + hasBidWall, + }) + .accounts({ + launch, + launchSigner, + quoteVault, + baseVault, + launchAuthority, + quoteMint, + baseMint, + tokenMetadata, + tokenMetadataProgram: MPL_TOKEN_METADATA_PROGRAM_ID, + payer, + additionalTokensRecipient: additionalTokensRecipient ?? null, + mintGovernor, + mintGovernorProgram: this.mintGovernorClient.programId, + mintGovernorEventAuthority, + }) + .preInstructions([ + createAssociatedTokenAccountIdempotentInstruction( + payer, + getAssociatedTokenAddressSync(quoteMint, launchSigner, true), + launchSigner, + quoteMint, + ), + ]); + } + + startLaunchIx({ + launch, + launchAuthority = this.provider.publicKey, + }: { + launch: PublicKey; + launchAuthority?: PublicKey; + }) { + return this.launchpad.methods.startLaunch().accounts({ + launch, + launchAuthority, + }); + } + + fundIx({ + launch, + amount, + funder = this.provider.publicKey, + payer = this.provider.publicKey, + quoteMint = MAINNET_USDC, + }: { + launch: PublicKey; + amount: BN; + funder?: PublicKey; + payer?: PublicKey; + quoteMint?: PublicKey; + }) { + const launchSigner = this.getLaunchSignerAddress({ launch }); + + const launchQuoteVault = getAssociatedTokenAddressSync( + quoteMint, + launchSigner, + true, + ); + const funderQuoteAccount = getAssociatedTokenAddressSync( + quoteMint, + funder, + true, + ); + const [fundingRecord] = getFundingRecordAddr( + this.launchpad.programId, + launch, + funder, + ); + + return this.launchpad.methods.fund(amount).accounts({ + launch, + launchQuoteVault, + fundingRecord, + funder, + payer, + funderQuoteAccount, + }); + } + + closeLaunchIx({ launch }: { launch: PublicKey }) { + return this.launchpad.methods.closeLaunch().accounts({ + launch, + }); + } + + setFundingRecordApprovalIx({ + launch, + funder, + launchAuthority = this.provider.publicKey, + approvedAmount, + }: { + launch: PublicKey; + funder: PublicKey; + launchAuthority?: PublicKey; + approvedAmount: BN; + }) { + let fundingRecord = getFundingRecordAddr( + this.launchpad.programId, + launch, + funder, + )[0]; + + return this.launchpad.methods + .setFundingRecordApproval(approvedAmount) + .accounts({ + launch, + fundingRecord, + launchAuthority, + }); + } + + settleLaunchIx({ + launch, + baseMint, + quoteMint = MAINNET_USDC, + launchAuthority, + isDevnet = false, + meteoraConfig = LAUNCHPAD_V0_8_MAINNET_METEORA_CONFIG, + feeRecipient = METADAO_MULTISIG_VAULT, + payer = this.provider.publicKey, + }: { + launch: PublicKey; + baseMint: PublicKey; + quoteMint?: PublicKey; + launchAuthority: PublicKey | null; + isDevnet?: boolean; + meteoraConfig?: PublicKey; + feeRecipient?: PublicKey; + payer?: PublicKey; + }) { + const launchSigner = this.getLaunchSignerAddress({ launch }); + + const launchQuoteVault = getAssociatedTokenAddressSync( + quoteMint, + launchSigner, + true, + ); + const launchBaseVault = getAssociatedTokenAddressSync( + baseMint, + launchSigner, + true, + ); + + const [dao] = getDaoAddr({ + nonce: new BN(0), + daoCreator: launchSigner, + }); + + const [futarchyEventAuthority] = getEventAuthorityAddr( + this.futarchyClient.getProgramId(), + ); + + const [tokenMetadata] = getMetadataAddr(baseMint); + + const [multisigPda] = multisig.getMultisigPda({ createKey: dao }); + const [multisigVault] = multisig.getVaultPda({ + multisigPda, + index: 0, + }); + + const [spendingLimit] = multisig.getSpendingLimitPda({ + multisigPda, + createKey: dao, + }); + + const treasuryQuoteAccount = getAssociatedTokenAddressSync( + quoteMint, + multisigVault, + true, + ); + + const [ammPosition] = PublicKey.findProgramAddressSync( + [Buffer.from("amm_position"), dao.toBuffer(), multisigVault.toBuffer()], + this.futarchyClient.getProgramId(), + ); + + // Meteora PDAs + const [positionNftMint] = PublicKey.findProgramAddressSync( + [Buffer.from("position_nft_mint"), baseMint.toBuffer()], + this.launchpad.programId, + ); + + const [positionNftAccount] = PublicKey.findProgramAddressSync( + [Buffer.from("position_nft_account"), positionNftMint.toBuffer()], + DAMM_V2_PROGRAM_ID, + ); + + function getFirstKey(key1: PublicKey, key2: PublicKey) { + const buf1 = key1.toBuffer(); + const buf2 = key2.toBuffer(); + if (Buffer.compare(buf1, buf2) === 1) { + return buf1; + } + return buf2; + } + + function getSecondKey(key1: PublicKey, key2: PublicKey) { + const buf1 = key1.toBuffer(); + const buf2 = key2.toBuffer(); + if (Buffer.compare(buf1, buf2) === 1) { + return buf2; + } + return buf1; + } + + const [pool] = PublicKey.findProgramAddressSync( + [ + Buffer.from("pool"), + meteoraConfig.toBuffer(), + getFirstKey(baseMint, quoteMint), + getSecondKey(baseMint, quoteMint), + ], + 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 [poolCreatorAuthority] = PublicKey.findProgramAddressSync( + [Buffer.from("damm_pool_creator_authority")], + this.launchpad.programId, + ); + + const [poolAuthority] = PublicKey.findProgramAddressSync( + [Buffer.from("pool_authority")], + DAMM_V2_PROGRAM_ID, + ); + + const [dammV2EventAuthority] = getEventAuthorityAddr(DAMM_V2_PROGRAM_ID); + + // Bid wall PDAs + const bidWall = this.bidWall.getBidWallAddress({ + baseMint, + creator: launchSigner, + nonce: new BN(0), + }); + const bidWallQuoteTokenAccount = getAssociatedTokenAddressSync( + quoteMint, + bidWall, + true, + ); + + return this.launchpad.methods + .settleLaunch() + .accounts({ + launch, + launchSigner, + launchQuoteVault, + launchBaseVault, + launchAuthority, + dao, + treasuryQuoteAccount, + quoteMint, + baseMint, + tokenMetadata, + payer, + daoOwnedLpPosition: ammPosition, + futarchyAmmQuoteVault: getAssociatedTokenAddressSync( + quoteMint, + dao, + true, + ), + futarchyAmmBaseVault: getAssociatedTokenAddressSync( + baseMint, + dao, + true, + ), + staticAccounts: { + futarchyProgram: this.futarchyClient.getProgramId(), + tokenMetadataProgram: MPL_TOKEN_METADATA_PROGRAM_ID, + futarchyEventAuthority, + squadsProgram: SQUADS_PROGRAM_ID, + squadsProgramConfig: SQUADS_PROGRAM_CONFIG, + squadsProgramConfigTreasury: isDevnet + ? SQUADS_PROGRAM_CONFIG_TREASURY_DEVNET + : SQUADS_PROGRAM_CONFIG_TREASURY, + bidWallProgram: this.bidWall.programId, + bidWallEventAuthority: this.bidWall.getEventAuthorityAddress(), + }, + squadsMultisig: multisigPda, + squadsMultisigVault: multisigVault, + spendingLimit, + bidWall, + bidWallQuoteTokenAccount, + feeRecipient, + meteoraAccounts: { + dammV2Program: DAMM_V2_PROGRAM_ID, + config: meteoraConfig, + token2022Program: TOKEN_2022_PROGRAM_ID, + positionNftAccount, + pool, + position, + positionNftMint, + baseMint, + quoteMint, + tokenAVault, + tokenBVault, + poolCreatorAuthority, + poolAuthority, + dammV2EventAuthority, + }, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 800_000 }), + ComputeBudgetProgram.requestHeapFrame({ bytes: 255 * 1024 }), + ]); + } + + claimIx({ + launch, + baseMint, + funder = this.provider.publicKey, + }: { + launch: PublicKey; + baseMint: PublicKey; + funder?: PublicKey; + }) { + const launchSigner = this.getLaunchSignerAddress({ launch }); + const [fundingRecord] = getFundingRecordAddr( + this.launchpad.programId, + launch, + funder, + ); + + return this.launchpad.methods + .claim() + .accounts({ + launch, + fundingRecord, + launchSigner, + baseMint, + launchBaseVault: getAssociatedTokenAddressSync( + baseMint, + launchSigner, + true, + ), + funder, + funderTokenAccount: getAssociatedTokenAddressSync( + baseMint, + funder, + true, + ), + }) + .preInstructions([ + createAssociatedTokenAccountIdempotentInstruction( + this.provider.publicKey, + getAssociatedTokenAddressSync(baseMint, funder, true), + funder, + baseMint, + ), + ]); + } + + refundIx({ + launch, + funder = this.provider.publicKey, + quoteMint = MAINNET_USDC, + }: { + launch: PublicKey; + funder?: PublicKey; + quoteMint?: PublicKey; + }) { + const launchSigner = this.getLaunchSignerAddress({ launch }); + const [fundingRecord] = getFundingRecordAddr( + this.launchpad.programId, + launch, + funder, + ); + + const launchQuoteVault = getAssociatedTokenAddressSync( + quoteMint, + launchSigner, + true, + ); + const funderQuoteAccount = getAssociatedTokenAddressSync( + quoteMint, + funder, + true, + ); + + return this.launchpad.methods.refund().accounts({ + launch, + fundingRecord, + launchQuoteVault, + launchSigner, + funder, + funderQuoteAccount, + }); + } + + finalizeLaunchIx({ + launch, + baseMint, + performancePackageGrantee, + payer = this.provider.publicKey, + }: { + launch: PublicKey; + baseMint: PublicKey; + performancePackageGrantee: PublicKey; + payer?: PublicKey; + }) { + const launchSigner = this.getLaunchSignerAddress({ launch }); + + const [dao] = getDaoAddr({ + nonce: new BN(0), + daoCreator: launchSigner, + }); + + const [squadsMultisig] = multisig.getMultisigPda({ createKey: dao }); + const [squadsMultisigVault] = multisig.getVaultPda({ + multisigPda: squadsMultisig, + index: 0, + }); + + const [mintGovernor] = getMintGovernorAddr({ + programId: this.mintGovernorClient.programId, + mint: baseMint, + createKey: launchSigner, + }); + + const performancePackage = getPerformancePackageV2Addr({ + createKey: launchSigner, + })[0]; + + const [ppMintAuthority] = getMintAuthorityAddr({ + programId: this.mintGovernorClient.programId, + mintGovernor, + authorizedMinter: performancePackage, + }); + + const [daoMintAuthority] = getMintAuthorityAddr({ + programId: this.mintGovernorClient.programId, + mintGovernor, + authorizedMinter: squadsMultisigVault, + }); + + const [mintGovernorEventAuthority] = getEventAuthorityAddr( + this.mintGovernorClient.programId, + ); + const [performancePackageV2EventAuthority] = getEventAuthorityAddr( + this.performancePackageV2.programId, + ); + + return this.launchpad.methods + .finalizeLaunch() + .accounts({ + launch, + payer, + launchSigner, + baseMint, + dao, + squadsMultisig, + squadsMultisigVault, + performancePackageGrantee, + mintGovernor, + ppMintAuthority, + daoMintAuthority, + performancePackage, + squadsProgram: SQUADS_PROGRAM_ID, + mintGovernorProgram: this.mintGovernorClient.programId, + mintGovernorEventAuthority, + performancePackageV2Program: this.performancePackageV2.programId, + performancePackageV2EventAuthority, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 400_000 }), + ]); + } + + claimAdditionalTokenAllocationIx({ + launch, + baseMint, + additionalTokensRecipient, + payer = this.provider.publicKey, + }: { + launch: PublicKey; + baseMint: PublicKey; + additionalTokensRecipient: PublicKey; + payer?: PublicKey; + }) { + const launchSigner = this.getLaunchSignerAddress({ launch }); + + return this.launchpad.methods.claimAdditionalTokenAllocation().accounts({ + launch, + payer, + launchSigner, + launchBaseVault: getAssociatedTokenAddressSync( + baseMint, + launchSigner, + true, + ), + baseMint, + additionalTokensRecipient, + additionalTokensRecipientTokenAccount: getAssociatedTokenAddressSync( + baseMint, + additionalTokensRecipient, + true, + ), + }); + } + + extendLaunchIx({ + launch, + durationSeconds, + admin = METADAO_MULTISIG_VAULT, + }: { + launch: PublicKey; + durationSeconds: number; + admin?: PublicKey; + }) { + return this.launchpad.methods.extendLaunch({ durationSeconds }).accounts({ + launch, + admin, + }); + } +} diff --git a/sdk/src/launchpad/v0.8/index.ts b/sdk/src/launchpad/v0.8/index.ts new file mode 100644 index 00000000..b5b9717d --- /dev/null +++ b/sdk/src/launchpad/v0.8/index.ts @@ -0,0 +1,3 @@ +export * from "./types/index.js"; +export * from "./pda.js"; +export * from "./LaunchpadClient.js"; diff --git a/sdk/src/launchpad/v0.8/pda.ts b/sdk/src/launchpad/v0.8/pda.ts new file mode 100644 index 00000000..b643fc01 --- /dev/null +++ b/sdk/src/launchpad/v0.8/pda.ts @@ -0,0 +1,33 @@ +import { PublicKey } from "@solana/web3.js"; +import { LAUNCHPAD_V0_8_PROGRAM_ID } from "../../constants.js"; + +export function getLaunchAddr( + programId: PublicKey = LAUNCHPAD_V0_8_PROGRAM_ID, + tokenMint: PublicKey, +): [PublicKey, number] { + return PublicKey.findProgramAddressSync( + [Buffer.from("launch"), tokenMint.toBuffer()], + programId, + ); +} + +export const getLaunchSignerAddr = ( + programId: PublicKey = LAUNCHPAD_V0_8_PROGRAM_ID, + launch: PublicKey, +): [PublicKey, number] => { + return PublicKey.findProgramAddressSync( + [Buffer.from("launch_signer"), launch.toBuffer()], + programId, + ); +}; + +export const getFundingRecordAddr = ( + programId: PublicKey = LAUNCHPAD_V0_8_PROGRAM_ID, + launch: PublicKey, + funder: PublicKey, +): [PublicKey, number] => { + return PublicKey.findProgramAddressSync( + [Buffer.from("funding_record"), launch.toBuffer(), funder.toBuffer()], + programId, + ); +}; diff --git a/sdk/src/launchpad/v0.8/types/index.ts b/sdk/src/launchpad/v0.8/types/index.ts new file mode 100644 index 00000000..4815b985 --- /dev/null +++ b/sdk/src/launchpad/v0.8/types/index.ts @@ -0,0 +1,44 @@ +import type { IdlAccounts, IdlEvents } from "@coral-xyz/anchor"; + +import { + LaunchpadV8 as LaunchpadProgram, + IDL as LaunchpadIDL, +} from "./launchpad_v8.js"; +export { LaunchpadProgram, LaunchpadIDL }; + +export type Launch = IdlAccounts["launch"]; +export type FundingRecord = IdlAccounts["fundingRecord"]; + +export type LaunchInitializedEvent = + IdlEvents["LaunchInitializedEvent"]; +export type LaunchStartedEvent = + IdlEvents["LaunchStartedEvent"]; +export type LaunchFundedEvent = + IdlEvents["LaunchFundedEvent"]; +export type FundingRecordApprovalSetEvent = + IdlEvents["FundingRecordApprovalSetEvent"]; +export type LaunchSettledEvent = + IdlEvents["LaunchSettledEvent"]; +export type LaunchFinalizedEvent = + IdlEvents["LaunchFinalizedEvent"]; +export type LaunchRefundedEvent = + IdlEvents["LaunchRefundedEvent"]; +export type LaunchClaimEvent = IdlEvents["LaunchClaimEvent"]; +export type LaunchCloseEvent = IdlEvents["LaunchCloseEvent"]; +export type LaunchClaimAdditionalTokenAllocationEvent = + IdlEvents["LaunchClaimAdditionalTokenAllocationEvent"]; +export type LaunchExtendedEvent = + IdlEvents["LaunchExtendedEvent"]; + +export type LaunchpadEvent = + | LaunchInitializedEvent + | LaunchStartedEvent + | LaunchFundedEvent + | FundingRecordApprovalSetEvent + | LaunchSettledEvent + | LaunchFinalizedEvent + | LaunchRefundedEvent + | LaunchClaimEvent + | LaunchCloseEvent + | LaunchClaimAdditionalTokenAllocationEvent + | LaunchExtendedEvent; diff --git a/sdk/src/launchpad/v0.8/types/launchpad_v8.ts b/sdk/src/launchpad/v0.8/types/launchpad_v8.ts new file mode 100644 index 00000000..42cbef70 --- /dev/null +++ b/sdk/src/launchpad/v0.8/types/launchpad_v8.ts @@ -0,0 +1,3767 @@ +export type LaunchpadV8 = { + version: "0.8.0"; + name: "launchpad_v8"; + instructions: [ + { + name: "initializeLaunch"; + accounts: [ + { + name: "launch"; + isMut: true; + isSigner: false; + }, + { + name: "baseMint"; + isMut: true; + isSigner: false; + }, + { + name: "tokenMetadata"; + isMut: true; + isSigner: false; + }, + { + name: "launchSigner"; + isMut: false; + isSigner: false; + }, + { + name: "quoteVault"; + isMut: true; + isSigner: false; + }, + { + name: "baseVault"; + isMut: true; + isSigner: false; + }, + { + name: "payer"; + isMut: true; + isSigner: true; + }, + { + name: "launchAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "quoteMint"; + isMut: false; + isSigner: false; + }, + { + name: "additionalTokensRecipient"; + isMut: false; + isSigner: false; + isOptional: true; + }, + { + name: "mintGovernor"; + isMut: true; + isSigner: false; + }, + { + name: "mintGovernorProgram"; + isMut: false; + isSigner: false; + }, + { + name: "mintGovernorEventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "rent"; + isMut: false; + isSigner: false; + }, + { + name: "tokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "associatedTokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "systemProgram"; + isMut: false; + isSigner: false; + }, + { + name: "tokenMetadataProgram"; + isMut: false; + isSigner: false; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: [ + { + name: "args"; + type: { + defined: "InitializeLaunchArgs"; + }; + }, + ]; + }, + { + name: "startLaunch"; + accounts: [ + { + name: "launch"; + isMut: true; + isSigner: false; + }, + { + name: "launchAuthority"; + isMut: false; + isSigner: true; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, + { + name: "fund"; + accounts: [ + { + name: "launch"; + isMut: true; + isSigner: false; + }, + { + name: "fundingRecord"; + isMut: true; + isSigner: false; + }, + { + name: "launchQuoteVault"; + isMut: true; + isSigner: false; + }, + { + name: "funder"; + isMut: false; + isSigner: true; + }, + { + name: "payer"; + isMut: true; + isSigner: true; + }, + { + name: "funderQuoteAccount"; + isMut: true; + isSigner: false; + }, + { + name: "tokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "systemProgram"; + isMut: false; + isSigner: false; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: [ + { + name: "amount"; + type: "u64"; + }, + ]; + }, + { + name: "closeLaunch"; + accounts: [ + { + name: "launch"; + isMut: true; + isSigner: false; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, + { + name: "setFundingRecordApproval"; + accounts: [ + { + name: "launch"; + isMut: true; + isSigner: false; + }, + { + name: "fundingRecord"; + isMut: true; + isSigner: false; + }, + { + name: "launchAuthority"; + isMut: false; + isSigner: true; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: [ + { + name: "approvedAmount"; + type: "u64"; + }, + ]; + }, + { + name: "settleLaunch"; + accounts: [ + { + name: "launch"; + isMut: true; + isSigner: false; + }, + { + name: "launchAuthority"; + isMut: false; + isSigner: true; + isOptional: true; + }, + { + name: "tokenMetadata"; + isMut: true; + isSigner: false; + }, + { + name: "payer"; + isMut: true; + isSigner: true; + }, + { + name: "launchSigner"; + isMut: true; + isSigner: false; + }, + { + name: "launchQuoteVault"; + isMut: true; + isSigner: false; + }, + { + name: "launchBaseVault"; + isMut: true; + isSigner: false; + }, + { + name: "treasuryQuoteAccount"; + isMut: true; + isSigner: false; + }, + { + name: "baseMint"; + isMut: true; + isSigner: false; + }, + { + name: "quoteMint"; + isMut: false; + isSigner: false; + }, + { + name: "daoOwnedLpPosition"; + isMut: true; + isSigner: false; + }, + { + name: "futarchyAmmBaseVault"; + isMut: true; + isSigner: false; + }, + { + name: "futarchyAmmQuoteVault"; + isMut: true; + isSigner: false; + }, + { + name: "dao"; + isMut: true; + isSigner: false; + }, + { + name: "squadsMultisig"; + isMut: true; + isSigner: false; + }, + { + name: "squadsMultisigVault"; + isMut: false; + isSigner: false; + }, + { + name: "spendingLimit"; + isMut: true; + isSigner: false; + }, + { + name: "bidWall"; + isMut: true; + isSigner: false; + }, + { + name: "bidWallQuoteTokenAccount"; + isMut: true; + isSigner: false; + }, + { + name: "feeRecipient"; + isMut: false; + isSigner: false; + }, + { + name: "systemProgram"; + isMut: false; + isSigner: false; + }, + { + name: "tokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "associatedTokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "staticAccounts"; + accounts: [ + { + name: "futarchyProgram"; + isMut: false; + isSigner: false; + }, + { + name: "tokenMetadataProgram"; + isMut: false; + isSigner: false; + }, + { + name: "futarchyEventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "squadsProgram"; + isMut: false; + isSigner: false; + }, + { + name: "squadsProgramConfig"; + isMut: false; + isSigner: false; + }, + { + name: "squadsProgramConfigTreasury"; + isMut: true; + isSigner: false; + }, + { + name: "bidWallProgram"; + isMut: false; + isSigner: false; + }, + { + name: "bidWallEventAuthority"; + isMut: false; + isSigner: false; + }, + ]; + }, + { + 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; + }, + ]; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, + { + name: "claim"; + accounts: [ + { + name: "launch"; + isMut: true; + isSigner: false; + }, + { + name: "fundingRecord"; + isMut: true; + isSigner: false; + }, + { + name: "launchSigner"; + isMut: false; + isSigner: false; + }, + { + name: "baseMint"; + isMut: false; + isSigner: false; + }, + { + name: "launchBaseVault"; + isMut: true; + isSigner: false; + }, + { + name: "funder"; + isMut: false; + isSigner: false; + }, + { + name: "funderTokenAccount"; + isMut: true; + isSigner: false; + }, + { + name: "tokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, + { + name: "refund"; + accounts: [ + { + name: "launch"; + isMut: true; + isSigner: false; + }, + { + name: "fundingRecord"; + isMut: true; + isSigner: false; + }, + { + name: "launchQuoteVault"; + isMut: true; + isSigner: false; + }, + { + name: "launchSigner"; + isMut: false; + isSigner: false; + }, + { + name: "funder"; + isMut: false; + isSigner: false; + }, + { + name: "funderQuoteAccount"; + isMut: true; + isSigner: false; + }, + { + name: "tokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, + { + name: "claimAdditionalTokenAllocation"; + accounts: [ + { + name: "launch"; + isMut: true; + isSigner: false; + }, + { + name: "payer"; + isMut: true; + isSigner: true; + }, + { + name: "launchSigner"; + isMut: false; + isSigner: false; + }, + { + name: "launchBaseVault"; + isMut: true; + isSigner: false; + }, + { + name: "baseMint"; + isMut: false; + isSigner: false; + }, + { + name: "additionalTokensRecipient"; + isMut: false; + isSigner: false; + }, + { + name: "additionalTokensRecipientTokenAccount"; + isMut: true; + isSigner: false; + }, + { + name: "systemProgram"; + isMut: false; + isSigner: false; + }, + { + name: "tokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "associatedTokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, + { + name: "finalizeLaunch"; + accounts: [ + { + name: "launch"; + isMut: true; + isSigner: false; + }, + { + name: "payer"; + isMut: true; + isSigner: true; + }, + { + name: "launchSigner"; + isMut: false; + isSigner: false; + }, + { + name: "baseMint"; + isMut: false; + isSigner: false; + }, + { + name: "dao"; + isMut: false; + isSigner: false; + }, + { + name: "squadsMultisig"; + isMut: false; + isSigner: false; + }, + { + name: "squadsMultisigVault"; + isMut: false; + isSigner: false; + }, + { + name: "performancePackageGrantee"; + isMut: false; + isSigner: false; + }, + { + name: "mintGovernor"; + isMut: true; + isSigner: false; + }, + { + name: "ppMintAuthority"; + isMut: true; + isSigner: false; + }, + { + name: "daoMintAuthority"; + isMut: true; + isSigner: false; + }, + { + name: "performancePackage"; + isMut: true; + isSigner: false; + }, + { + name: "systemProgram"; + isMut: false; + isSigner: false; + }, + { + name: "tokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "squadsProgram"; + isMut: false; + isSigner: false; + }, + { + name: "mintGovernorProgram"; + isMut: false; + isSigner: false; + }, + { + name: "mintGovernorEventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "performancePackageV2Program"; + isMut: false; + isSigner: false; + }, + { + name: "performancePackageV2EventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, + { + name: "extendLaunch"; + accounts: [ + { + name: "launch"; + isMut: true; + isSigner: false; + }, + { + name: "admin"; + isMut: false; + isSigner: true; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: [ + { + name: "args"; + type: { + defined: "ExtendLaunchArgs"; + }; + }, + ]; + }, + ]; + accounts: [ + { + name: "fundingRecord"; + type: { + kind: "struct"; + fields: [ + { + name: "pdaBump"; + docs: ["The PDA bump."]; + type: "u8"; + }, + { + name: "funder"; + docs: ["The funder."]; + type: "publicKey"; + }, + { + name: "launch"; + docs: ["The launch."]; + type: "publicKey"; + }, + { + name: "committedAmount"; + docs: ["The amount of USDC that has been committed by the funder."]; + type: "u64"; + }, + { + name: "isTokensClaimed"; + docs: ["Whether the tokens have been claimed."]; + type: "bool"; + }, + { + name: "isUsdcRefunded"; + docs: ["Whether the USDC has been refunded."]; + type: "bool"; + }, + { + name: "approvedAmount"; + docs: [ + "The amount of USDC that the launch authority has approved for the funder.", + "If zero, the funder has not been approved for any amount.", + ]; + type: "u64"; + }, + { + name: "committedAmountAccumulator"; + docs: [ + "Running integral of committed_amount over time (committed_amount * seconds).", + ]; + type: "u128"; + }, + { + name: "lastAccumulatorUpdate"; + docs: ["Unix timestamp of the last accumulator update."]; + type: "i64"; + }, + ]; + }; + }, + { + name: "launch"; + type: { + kind: "struct"; + fields: [ + { + name: "pdaBump"; + docs: ["The PDA bump."]; + type: "u8"; + }, + { + name: "minimumRaiseAmount"; + docs: [ + "The minimum amount of USDC that must be raised, otherwise", + "everyone can get their USDC back.", + ]; + type: "u64"; + }, + { + name: "monthlySpendingLimitAmount"; + docs: [ + "The monthly spending limit the DAO allocates to the team. Must be", + "less than 1/6th of the minimum raise amount (so 6 months of burn).", + ]; + type: "u64"; + }, + { + name: "monthlySpendingLimitMembers"; + docs: [ + "The wallets that have access to the monthly spending limit.", + ]; + type: { + vec: "publicKey"; + }; + }, + { + name: "launchAuthority"; + docs: ["The account that can start the launch."]; + type: "publicKey"; + }, + { + name: "launchSigner"; + docs: ["The launch signer address."]; + type: "publicKey"; + }, + { + name: "launchSignerPdaBump"; + docs: ["The PDA bump for the launch signer."]; + type: "u8"; + }, + { + name: "launchQuoteVault"; + docs: [ + "The USDC vault that will hold the USDC raised until the launch is over.", + ]; + type: "publicKey"; + }, + { + name: "launchBaseVault"; + docs: ["The token vault, used to send tokens to the AMM."]; + type: "publicKey"; + }, + { + name: "baseMint"; + docs: [ + "The token that will be minted to funders and that will control the DAO.", + ]; + type: "publicKey"; + }, + { + name: "quoteMint"; + docs: ["The USDC mint."]; + type: "publicKey"; + }, + { + name: "unixTimestampStarted"; + docs: ["The unix timestamp when the launch was started."]; + type: { + option: "i64"; + }; + }, + { + name: "unixTimestampClosed"; + docs: [ + "The unix timestamp when the launch stopped taking new contributions.", + ]; + type: { + option: "i64"; + }; + }, + { + name: "totalCommittedAmount"; + docs: ["The amount of USDC that has been committed by the users."]; + type: "u64"; + }, + { + name: "state"; + docs: ["The state of the launch."]; + type: { + defined: "LaunchState"; + }; + }, + { + name: "seqNum"; + docs: [ + "The sequence number of this launch. Useful for sorting events.", + ]; + type: "u64"; + }, + { + name: "secondsForLaunch"; + docs: ["The number of seconds that the launch will be live for."]; + type: "u32"; + }, + { + name: "dao"; + docs: ["The DAO, if the launch is complete."]; + type: { + option: "publicKey"; + }; + }, + { + name: "daoVault"; + docs: [ + "The DAO treasury that USDC / LP is sent to, if the launch is complete.", + ]; + type: { + option: "publicKey"; + }; + }, + { + name: "performancePackageGrantee"; + docs: [ + "The address that will receive the performance package tokens.", + ]; + type: "publicKey"; + }, + { + name: "performancePackageTokenAmount"; + docs: [ + "The amount of tokens to be granted to the performance package grantee.", + ]; + type: "u64"; + }, + { + name: "monthsUntilInsidersCanUnlock"; + docs: [ + "The number of months that insiders must wait before unlocking their tokens.", + ]; + type: "u8"; + }, + { + name: "teamAddress"; + docs: ["The initial address used to sponsor team proposals."]; + type: "publicKey"; + }, + { + name: "totalApprovedAmount"; + docs: [ + "The amount of USDC that the launch authority has approved across all funders.", + ]; + type: "u64"; + }, + { + name: "additionalTokensAmount"; + docs: [ + "The amount of additional tokens to be minted on a successful launch.", + ]; + type: "u64"; + }, + { + name: "additionalTokensRecipient"; + docs: [ + "The token account that will receive the additional tokens.", + ]; + type: { + option: "publicKey"; + }; + }, + { + name: "additionalTokensClaimed"; + docs: ["Are the additional tokens claimed."]; + type: "bool"; + }, + { + name: "unixTimestampCompleted"; + docs: ["The unix timestamp when the launch was completed."]; + type: { + option: "i64"; + }; + }, + { + name: "isFinalized"; + docs: ["Whether the launch has been finalized."]; + type: "bool"; + }, + { + name: "accumulatorActivationDelaySeconds"; + docs: [ + "Number of seconds after launch start before the funding accumulator", + "begins tracking.", + ]; + type: "u32"; + }, + { + name: "hasBidWall"; + docs: ["Whether the launch has a bid wall."]; + type: "bool"; + }, + { + name: "mintGovernor"; + docs: ["The MintGovernor PDA that owns the SPL mint authority."]; + type: "publicKey"; + }, + ]; + }; + }, + ]; + types: [ + { + name: "CommonFields"; + type: { + kind: "struct"; + fields: [ + { + name: "slot"; + type: "u64"; + }, + { + name: "unixTimestamp"; + type: "i64"; + }, + { + name: "launchSeqNum"; + type: "u64"; + }, + ]; + }; + }, + { + name: "ExtendLaunchArgs"; + type: { + kind: "struct"; + fields: [ + { + name: "durationSeconds"; + type: "u32"; + }, + ]; + }; + }, + { + name: "InitializeLaunchArgs"; + type: { + kind: "struct"; + fields: [ + { + name: "minimumRaiseAmount"; + type: "u64"; + }, + { + name: "monthlySpendingLimitAmount"; + type: "u64"; + }, + { + name: "monthlySpendingLimitMembers"; + type: { + vec: "publicKey"; + }; + }, + { + name: "secondsForLaunch"; + type: "u32"; + }, + { + name: "tokenName"; + type: "string"; + }, + { + name: "tokenSymbol"; + type: "string"; + }, + { + name: "tokenUri"; + type: "string"; + }, + { + name: "performancePackageGrantee"; + type: "publicKey"; + }, + { + name: "performancePackageTokenAmount"; + type: "u64"; + }, + { + name: "monthsUntilInsidersCanUnlock"; + type: "u8"; + }, + { + name: "teamAddress"; + type: "publicKey"; + }, + { + name: "additionalTokensAmount"; + type: "u64"; + }, + { + name: "accumulatorActivationDelaySeconds"; + type: "u32"; + }, + { + name: "hasBidWall"; + type: "bool"; + }, + ]; + }; + }, + { + name: "LaunchState"; + type: { + kind: "enum"; + variants: [ + { + name: "Initialized"; + }, + { + name: "Live"; + }, + { + name: "Closed"; + }, + { + name: "Complete"; + }, + { + name: "Refunding"; + }, + ]; + }; + }, + ]; + events: [ + { + name: "LaunchInitializedEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "launch"; + type: "publicKey"; + index: false; + }, + { + name: "minimumRaiseAmount"; + type: "u64"; + index: false; + }, + { + name: "launchAuthority"; + type: "publicKey"; + index: false; + }, + { + name: "launchSigner"; + type: "publicKey"; + index: false; + }, + { + name: "launchSignerPdaBump"; + type: "u8"; + index: false; + }, + { + name: "launchUsdcVault"; + type: "publicKey"; + index: false; + }, + { + name: "launchTokenVault"; + type: "publicKey"; + index: false; + }, + { + name: "performancePackageGrantee"; + type: "publicKey"; + index: false; + }, + { + name: "performancePackageTokenAmount"; + type: "u64"; + index: false; + }, + { + name: "monthsUntilInsidersCanUnlock"; + type: "u8"; + index: false; + }, + { + name: "monthlySpendingLimitAmount"; + type: "u64"; + index: false; + }, + { + name: "monthlySpendingLimitMembers"; + type: { + vec: "publicKey"; + }; + index: false; + }, + { + name: "baseMint"; + type: "publicKey"; + index: false; + }, + { + name: "quoteMint"; + type: "publicKey"; + index: false; + }, + { + name: "pdaBump"; + type: "u8"; + index: false; + }, + { + name: "secondsForLaunch"; + type: "u32"; + index: false; + }, + { + name: "additionalTokensAmount"; + type: "u64"; + index: false; + }, + { + name: "additionalTokensRecipient"; + type: { + option: "publicKey"; + }; + index: false; + }, + { + name: "accumulatorActivationDelaySeconds"; + type: "u32"; + index: false; + }, + { + name: "hasBidWall"; + type: "bool"; + index: false; + }, + { + name: "mintGovernor"; + type: "publicKey"; + index: false; + }, + ]; + }, + { + name: "LaunchStartedEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "launch"; + type: "publicKey"; + index: false; + }, + { + name: "launchAuthority"; + type: "publicKey"; + index: false; + }, + { + name: "slotStarted"; + type: "u64"; + index: false; + }, + ]; + }, + { + name: "LaunchFundedEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "fundingRecord"; + type: "publicKey"; + index: false; + }, + { + name: "launch"; + type: "publicKey"; + index: false; + }, + { + name: "funder"; + type: "publicKey"; + index: false; + }, + { + name: "amount"; + type: "u64"; + index: false; + }, + { + name: "totalCommittedByFunder"; + type: "u64"; + index: false; + }, + { + name: "totalCommitted"; + type: "u64"; + index: false; + }, + { + name: "committedAmountAccumulator"; + type: "u128"; + index: false; + }, + ]; + }, + { + name: "FundingRecordApprovalSetEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "launch"; + type: "publicKey"; + index: false; + }, + { + name: "fundingRecord"; + type: "publicKey"; + index: false; + }, + { + name: "funder"; + type: "publicKey"; + index: false; + }, + { + name: "approvedAmount"; + type: "u64"; + index: false; + }, + { + name: "totalApproved"; + type: "u64"; + index: false; + }, + ]; + }, + { + name: "LaunchSettledEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "launch"; + type: "publicKey"; + index: false; + }, + { + name: "finalState"; + type: { + defined: "LaunchState"; + }; + index: false; + }, + { + name: "totalCommitted"; + type: "u64"; + index: false; + }, + { + name: "dao"; + type: { + option: "publicKey"; + }; + index: false; + }, + { + name: "daoTreasury"; + type: { + option: "publicKey"; + }; + index: false; + }, + { + name: "totalApprovedAmount"; + type: "u64"; + index: false; + }, + { + name: "bidWall"; + type: { + option: "publicKey"; + }; + index: false; + }, + { + name: "bidWallAmount"; + type: "u64"; + index: false; + }, + { + name: "tokensMinted"; + type: "u64"; + index: false; + }, + ]; + }, + { + name: "LaunchFinalizedEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "launch"; + type: "publicKey"; + index: false; + }, + { + name: "performancePackage"; + type: "publicKey"; + index: false; + }, + { + name: "mintGovernor"; + type: "publicKey"; + index: false; + }, + { + name: "mintGovernorNewAdmin"; + type: "publicKey"; + index: false; + }, + { + name: "ppMintAuthority"; + type: "publicKey"; + index: false; + }, + { + name: "daoMintAuthority"; + type: "publicKey"; + index: false; + }, + ]; + }, + { + name: "LaunchRefundedEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "launch"; + type: "publicKey"; + index: false; + }, + { + name: "funder"; + type: "publicKey"; + index: false; + }, + { + name: "usdcRefunded"; + type: "u64"; + index: false; + }, + { + name: "fundingRecord"; + type: "publicKey"; + index: false; + }, + ]; + }, + { + name: "LaunchClaimEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "launch"; + type: "publicKey"; + index: false; + }, + { + name: "funder"; + type: "publicKey"; + index: false; + }, + { + name: "tokensClaimed"; + type: "u64"; + index: false; + }, + { + name: "fundingRecord"; + type: "publicKey"; + index: false; + }, + ]; + }, + { + name: "LaunchCloseEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "launch"; + type: "publicKey"; + index: false; + }, + { + name: "newState"; + type: { + defined: "LaunchState"; + }; + index: false; + }, + ]; + }, + { + name: "LaunchClaimAdditionalTokenAllocationEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "launch"; + type: "publicKey"; + index: false; + }, + { + name: "additionalTokensAmount"; + type: "u64"; + index: false; + }, + { + name: "additionalTokensRecipient"; + type: "publicKey"; + index: false; + }, + ]; + }, + { + name: "LaunchExtendedEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "launch"; + type: "publicKey"; + index: false; + }, + { + name: "oldSecondsForLaunch"; + type: "u32"; + index: false; + }, + { + name: "newSecondsForLaunch"; + type: "u32"; + index: false; + }, + ]; + }, + ]; + errors: [ + { + code: 6000; + name: "InvalidAmount"; + msg: "Invalid amount"; + }, + { + code: 6001; + name: "SupplyNonZero"; + msg: "Supply must be zero"; + }, + { + code: 6002; + name: "InvalidSecondsForLaunch"; + msg: "Launch period must be between 1 hour and 2 weeks"; + }, + { + code: 6003; + name: "InsufficientFunds"; + msg: "Insufficient funds"; + }, + { + code: 6004; + name: "InvalidLaunchState"; + msg: "Invalid launch state"; + }, + { + code: 6005; + name: "LaunchPeriodNotOver"; + msg: "Launch period not over"; + }, + { + code: 6006; + name: "LaunchExpired"; + msg: "Launch is complete, no more funding allowed"; + }, + { + code: 6007; + name: "LaunchNotRefunding"; + msg: "Refund not available"; + }, + { + code: 6008; + name: "LaunchNotInitialized"; + msg: "Launch must be initialized to be started"; + }, + { + code: 6009; + name: "FreezeAuthoritySet"; + msg: "Freeze authority can't be set on launchpad tokens"; + }, + { + code: 6010; + name: "InvalidMonthlySpendingLimit"; + msg: "Monthly spending limit must be less than 1/6th of the minimum raise amount and cannot be 0"; + }, + { + code: 6011; + name: "InvalidMonthlySpendingLimitMembers"; + msg: "There can only be at most 10 monthly spending limit members"; + }, + { + code: 6012; + name: "InvalidPerformancePackageTokenAmount"; + msg: "Invalid performance package token amount"; + }, + { + code: 6013; + name: "InvalidPerformancePackageMinUnlockTime"; + msg: "Insiders must wait at least 12 months before unlocking"; + }, + { + code: 6014; + name: "LaunchAuthorityNotSet"; + msg: "Launch authority must be set to complete the launch until 2 days after closing"; + }, + { + code: 6015; + name: "FinalRaiseAmountTooLow"; + msg: "The final amount raised must be >= the minimum raise amount"; + }, + { + code: 6016; + name: "TokensAlreadyClaimed"; + msg: "Tokens already claimed"; + }, + { + code: 6017; + name: "MoneyAlreadyRefunded"; + msg: "USDC already refunded"; + }, + { + code: 6018; + name: "InvariantViolated"; + msg: "Invariant violated"; + }, + { + code: 6019; + name: "LaunchNotLive"; + msg: "Launch must be live to be closed"; + }, + { + code: 6020; + name: "InvalidMinimumRaiseAmount"; + msg: "Minimum raise amount too low for liquidity"; + }, + { + code: 6021; + name: "FinalRaiseAmountAlreadySet"; + msg: "Final raise amount already set"; + }, + { + code: 6022; + name: "TotalApprovedAmountTooLow"; + msg: "Total approved amount too low"; + }, + { + code: 6023; + name: "InvalidAdditionalTokensRecipient"; + msg: "Additional tokens recipient must be set when amount > 0"; + }, + { + code: 6024; + name: "NoAdditionalTokensRecipientSet"; + msg: "No additional tokens recipient set"; + }, + { + code: 6025; + name: "AdditionalTokensAlreadyClaimed"; + msg: "Additional tokens already claimed"; + }, + { + code: 6026; + name: "FundingRecordApprovalPeriodOver"; + msg: "Funding record approval period is over"; + }, + { + code: 6027; + name: "PerformancePackageAlreadyInitialized"; + msg: "Performance package already initialized"; + }, + { + code: 6028; + name: "InvalidDao"; + msg: "Invalid DAO"; + }, + { + code: 6029; + name: "InvalidAccumulatorActivationDelaySeconds"; + msg: "Accumulator activation delay must be less than the launch duration"; + }, + { + code: 6030; + name: "ExtendDurationExceedsMax"; + msg: "Extend duration would exceed maximum allowed launch duration"; + }, + { + code: 6031; + name: "InvalidMintAuthority"; + msg: "Mint authority does not match expected"; + }, + { + code: 6032; + name: "InvalidMeteoraAccount"; + msg: "Invalid Meteora account"; + }, + ]; +}; + +export const IDL: LaunchpadV8 = { + version: "0.8.0", + name: "launchpad_v8", + instructions: [ + { + name: "initializeLaunch", + accounts: [ + { + name: "launch", + isMut: true, + isSigner: false, + }, + { + name: "baseMint", + isMut: true, + isSigner: false, + }, + { + name: "tokenMetadata", + isMut: true, + isSigner: false, + }, + { + name: "launchSigner", + isMut: false, + isSigner: false, + }, + { + name: "quoteVault", + isMut: true, + isSigner: false, + }, + { + name: "baseVault", + isMut: true, + isSigner: false, + }, + { + name: "payer", + isMut: true, + isSigner: true, + }, + { + name: "launchAuthority", + isMut: false, + isSigner: false, + }, + { + name: "quoteMint", + isMut: false, + isSigner: false, + }, + { + name: "additionalTokensRecipient", + isMut: false, + isSigner: false, + isOptional: true, + }, + { + name: "mintGovernor", + isMut: true, + isSigner: false, + }, + { + name: "mintGovernorProgram", + isMut: false, + isSigner: false, + }, + { + name: "mintGovernorEventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "rent", + isMut: false, + isSigner: false, + }, + { + name: "tokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "associatedTokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + { + name: "tokenMetadataProgram", + isMut: false, + isSigner: false, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: "args", + type: { + defined: "InitializeLaunchArgs", + }, + }, + ], + }, + { + name: "startLaunch", + accounts: [ + { + name: "launch", + isMut: true, + isSigner: false, + }, + { + name: "launchAuthority", + isMut: false, + isSigner: true, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, + { + name: "fund", + accounts: [ + { + name: "launch", + isMut: true, + isSigner: false, + }, + { + name: "fundingRecord", + isMut: true, + isSigner: false, + }, + { + name: "launchQuoteVault", + isMut: true, + isSigner: false, + }, + { + name: "funder", + isMut: false, + isSigner: true, + }, + { + name: "payer", + isMut: true, + isSigner: true, + }, + { + name: "funderQuoteAccount", + isMut: true, + isSigner: false, + }, + { + name: "tokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: "amount", + type: "u64", + }, + ], + }, + { + name: "closeLaunch", + accounts: [ + { + name: "launch", + isMut: true, + isSigner: false, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, + { + name: "setFundingRecordApproval", + accounts: [ + { + name: "launch", + isMut: true, + isSigner: false, + }, + { + name: "fundingRecord", + isMut: true, + isSigner: false, + }, + { + name: "launchAuthority", + isMut: false, + isSigner: true, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: "approvedAmount", + type: "u64", + }, + ], + }, + { + name: "settleLaunch", + accounts: [ + { + name: "launch", + isMut: true, + isSigner: false, + }, + { + name: "launchAuthority", + isMut: false, + isSigner: true, + isOptional: true, + }, + { + name: "tokenMetadata", + isMut: true, + isSigner: false, + }, + { + name: "payer", + isMut: true, + isSigner: true, + }, + { + name: "launchSigner", + isMut: true, + isSigner: false, + }, + { + name: "launchQuoteVault", + isMut: true, + isSigner: false, + }, + { + name: "launchBaseVault", + isMut: true, + isSigner: false, + }, + { + name: "treasuryQuoteAccount", + isMut: true, + isSigner: false, + }, + { + name: "baseMint", + isMut: true, + isSigner: false, + }, + { + name: "quoteMint", + isMut: false, + isSigner: false, + }, + { + name: "daoOwnedLpPosition", + isMut: true, + isSigner: false, + }, + { + name: "futarchyAmmBaseVault", + isMut: true, + isSigner: false, + }, + { + name: "futarchyAmmQuoteVault", + isMut: true, + isSigner: false, + }, + { + name: "dao", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisig", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisigVault", + isMut: false, + isSigner: false, + }, + { + name: "spendingLimit", + isMut: true, + isSigner: false, + }, + { + name: "bidWall", + isMut: true, + isSigner: false, + }, + { + name: "bidWallQuoteTokenAccount", + isMut: true, + isSigner: false, + }, + { + name: "feeRecipient", + isMut: false, + isSigner: false, + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + { + name: "tokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "associatedTokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "staticAccounts", + accounts: [ + { + name: "futarchyProgram", + isMut: false, + isSigner: false, + }, + { + name: "tokenMetadataProgram", + isMut: false, + isSigner: false, + }, + { + name: "futarchyEventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "squadsProgram", + isMut: false, + isSigner: false, + }, + { + name: "squadsProgramConfig", + isMut: false, + isSigner: false, + }, + { + name: "squadsProgramConfigTreasury", + isMut: true, + isSigner: false, + }, + { + name: "bidWallProgram", + isMut: false, + isSigner: false, + }, + { + name: "bidWallEventAuthority", + isMut: false, + isSigner: false, + }, + ], + }, + { + 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, + }, + ], + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, + { + name: "claim", + accounts: [ + { + name: "launch", + isMut: true, + isSigner: false, + }, + { + name: "fundingRecord", + isMut: true, + isSigner: false, + }, + { + name: "launchSigner", + isMut: false, + isSigner: false, + }, + { + name: "baseMint", + isMut: false, + isSigner: false, + }, + { + name: "launchBaseVault", + isMut: true, + isSigner: false, + }, + { + name: "funder", + isMut: false, + isSigner: false, + }, + { + name: "funderTokenAccount", + isMut: true, + isSigner: false, + }, + { + name: "tokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, + { + name: "refund", + accounts: [ + { + name: "launch", + isMut: true, + isSigner: false, + }, + { + name: "fundingRecord", + isMut: true, + isSigner: false, + }, + { + name: "launchQuoteVault", + isMut: true, + isSigner: false, + }, + { + name: "launchSigner", + isMut: false, + isSigner: false, + }, + { + name: "funder", + isMut: false, + isSigner: false, + }, + { + name: "funderQuoteAccount", + isMut: true, + isSigner: false, + }, + { + name: "tokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, + { + name: "claimAdditionalTokenAllocation", + accounts: [ + { + name: "launch", + isMut: true, + isSigner: false, + }, + { + name: "payer", + isMut: true, + isSigner: true, + }, + { + name: "launchSigner", + isMut: false, + isSigner: false, + }, + { + name: "launchBaseVault", + isMut: true, + isSigner: false, + }, + { + name: "baseMint", + isMut: false, + isSigner: false, + }, + { + name: "additionalTokensRecipient", + isMut: false, + isSigner: false, + }, + { + name: "additionalTokensRecipientTokenAccount", + isMut: true, + isSigner: false, + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + { + name: "tokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "associatedTokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, + { + name: "finalizeLaunch", + accounts: [ + { + name: "launch", + isMut: true, + isSigner: false, + }, + { + name: "payer", + isMut: true, + isSigner: true, + }, + { + name: "launchSigner", + isMut: false, + isSigner: false, + }, + { + name: "baseMint", + isMut: false, + isSigner: false, + }, + { + name: "dao", + isMut: false, + isSigner: false, + }, + { + name: "squadsMultisig", + isMut: false, + isSigner: false, + }, + { + name: "squadsMultisigVault", + isMut: false, + isSigner: false, + }, + { + name: "performancePackageGrantee", + isMut: false, + isSigner: false, + }, + { + name: "mintGovernor", + isMut: true, + isSigner: false, + }, + { + name: "ppMintAuthority", + isMut: true, + isSigner: false, + }, + { + name: "daoMintAuthority", + isMut: true, + isSigner: false, + }, + { + name: "performancePackage", + isMut: true, + isSigner: false, + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + { + name: "tokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "squadsProgram", + isMut: false, + isSigner: false, + }, + { + name: "mintGovernorProgram", + isMut: false, + isSigner: false, + }, + { + name: "mintGovernorEventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "performancePackageV2Program", + isMut: false, + isSigner: false, + }, + { + name: "performancePackageV2EventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, + { + name: "extendLaunch", + accounts: [ + { + name: "launch", + isMut: true, + isSigner: false, + }, + { + name: "admin", + isMut: false, + isSigner: true, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: "args", + type: { + defined: "ExtendLaunchArgs", + }, + }, + ], + }, + ], + accounts: [ + { + name: "fundingRecord", + type: { + kind: "struct", + fields: [ + { + name: "pdaBump", + docs: ["The PDA bump."], + type: "u8", + }, + { + name: "funder", + docs: ["The funder."], + type: "publicKey", + }, + { + name: "launch", + docs: ["The launch."], + type: "publicKey", + }, + { + name: "committedAmount", + docs: ["The amount of USDC that has been committed by the funder."], + type: "u64", + }, + { + name: "isTokensClaimed", + docs: ["Whether the tokens have been claimed."], + type: "bool", + }, + { + name: "isUsdcRefunded", + docs: ["Whether the USDC has been refunded."], + type: "bool", + }, + { + name: "approvedAmount", + docs: [ + "The amount of USDC that the launch authority has approved for the funder.", + "If zero, the funder has not been approved for any amount.", + ], + type: "u64", + }, + { + name: "committedAmountAccumulator", + docs: [ + "Running integral of committed_amount over time (committed_amount * seconds).", + ], + type: "u128", + }, + { + name: "lastAccumulatorUpdate", + docs: ["Unix timestamp of the last accumulator update."], + type: "i64", + }, + ], + }, + }, + { + name: "launch", + type: { + kind: "struct", + fields: [ + { + name: "pdaBump", + docs: ["The PDA bump."], + type: "u8", + }, + { + name: "minimumRaiseAmount", + docs: [ + "The minimum amount of USDC that must be raised, otherwise", + "everyone can get their USDC back.", + ], + type: "u64", + }, + { + name: "monthlySpendingLimitAmount", + docs: [ + "The monthly spending limit the DAO allocates to the team. Must be", + "less than 1/6th of the minimum raise amount (so 6 months of burn).", + ], + type: "u64", + }, + { + name: "monthlySpendingLimitMembers", + docs: [ + "The wallets that have access to the monthly spending limit.", + ], + type: { + vec: "publicKey", + }, + }, + { + name: "launchAuthority", + docs: ["The account that can start the launch."], + type: "publicKey", + }, + { + name: "launchSigner", + docs: ["The launch signer address."], + type: "publicKey", + }, + { + name: "launchSignerPdaBump", + docs: ["The PDA bump for the launch signer."], + type: "u8", + }, + { + name: "launchQuoteVault", + docs: [ + "The USDC vault that will hold the USDC raised until the launch is over.", + ], + type: "publicKey", + }, + { + name: "launchBaseVault", + docs: ["The token vault, used to send tokens to the AMM."], + type: "publicKey", + }, + { + name: "baseMint", + docs: [ + "The token that will be minted to funders and that will control the DAO.", + ], + type: "publicKey", + }, + { + name: "quoteMint", + docs: ["The USDC mint."], + type: "publicKey", + }, + { + name: "unixTimestampStarted", + docs: ["The unix timestamp when the launch was started."], + type: { + option: "i64", + }, + }, + { + name: "unixTimestampClosed", + docs: [ + "The unix timestamp when the launch stopped taking new contributions.", + ], + type: { + option: "i64", + }, + }, + { + name: "totalCommittedAmount", + docs: ["The amount of USDC that has been committed by the users."], + type: "u64", + }, + { + name: "state", + docs: ["The state of the launch."], + type: { + defined: "LaunchState", + }, + }, + { + name: "seqNum", + docs: [ + "The sequence number of this launch. Useful for sorting events.", + ], + type: "u64", + }, + { + name: "secondsForLaunch", + docs: ["The number of seconds that the launch will be live for."], + type: "u32", + }, + { + name: "dao", + docs: ["The DAO, if the launch is complete."], + type: { + option: "publicKey", + }, + }, + { + name: "daoVault", + docs: [ + "The DAO treasury that USDC / LP is sent to, if the launch is complete.", + ], + type: { + option: "publicKey", + }, + }, + { + name: "performancePackageGrantee", + docs: [ + "The address that will receive the performance package tokens.", + ], + type: "publicKey", + }, + { + name: "performancePackageTokenAmount", + docs: [ + "The amount of tokens to be granted to the performance package grantee.", + ], + type: "u64", + }, + { + name: "monthsUntilInsidersCanUnlock", + docs: [ + "The number of months that insiders must wait before unlocking their tokens.", + ], + type: "u8", + }, + { + name: "teamAddress", + docs: ["The initial address used to sponsor team proposals."], + type: "publicKey", + }, + { + name: "totalApprovedAmount", + docs: [ + "The amount of USDC that the launch authority has approved across all funders.", + ], + type: "u64", + }, + { + name: "additionalTokensAmount", + docs: [ + "The amount of additional tokens to be minted on a successful launch.", + ], + type: "u64", + }, + { + name: "additionalTokensRecipient", + docs: [ + "The token account that will receive the additional tokens.", + ], + type: { + option: "publicKey", + }, + }, + { + name: "additionalTokensClaimed", + docs: ["Are the additional tokens claimed."], + type: "bool", + }, + { + name: "unixTimestampCompleted", + docs: ["The unix timestamp when the launch was completed."], + type: { + option: "i64", + }, + }, + { + name: "isFinalized", + docs: ["Whether the launch has been finalized."], + type: "bool", + }, + { + name: "accumulatorActivationDelaySeconds", + docs: [ + "Number of seconds after launch start before the funding accumulator", + "begins tracking.", + ], + type: "u32", + }, + { + name: "hasBidWall", + docs: ["Whether the launch has a bid wall."], + type: "bool", + }, + { + name: "mintGovernor", + docs: ["The MintGovernor PDA that owns the SPL mint authority."], + type: "publicKey", + }, + ], + }, + }, + ], + types: [ + { + name: "CommonFields", + type: { + kind: "struct", + fields: [ + { + name: "slot", + type: "u64", + }, + { + name: "unixTimestamp", + type: "i64", + }, + { + name: "launchSeqNum", + type: "u64", + }, + ], + }, + }, + { + name: "ExtendLaunchArgs", + type: { + kind: "struct", + fields: [ + { + name: "durationSeconds", + type: "u32", + }, + ], + }, + }, + { + name: "InitializeLaunchArgs", + type: { + kind: "struct", + fields: [ + { + name: "minimumRaiseAmount", + type: "u64", + }, + { + name: "monthlySpendingLimitAmount", + type: "u64", + }, + { + name: "monthlySpendingLimitMembers", + type: { + vec: "publicKey", + }, + }, + { + name: "secondsForLaunch", + type: "u32", + }, + { + name: "tokenName", + type: "string", + }, + { + name: "tokenSymbol", + type: "string", + }, + { + name: "tokenUri", + type: "string", + }, + { + name: "performancePackageGrantee", + type: "publicKey", + }, + { + name: "performancePackageTokenAmount", + type: "u64", + }, + { + name: "monthsUntilInsidersCanUnlock", + type: "u8", + }, + { + name: "teamAddress", + type: "publicKey", + }, + { + name: "additionalTokensAmount", + type: "u64", + }, + { + name: "accumulatorActivationDelaySeconds", + type: "u32", + }, + { + name: "hasBidWall", + type: "bool", + }, + ], + }, + }, + { + name: "LaunchState", + type: { + kind: "enum", + variants: [ + { + name: "Initialized", + }, + { + name: "Live", + }, + { + name: "Closed", + }, + { + name: "Complete", + }, + { + name: "Refunding", + }, + ], + }, + }, + ], + events: [ + { + name: "LaunchInitializedEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "launch", + type: "publicKey", + index: false, + }, + { + name: "minimumRaiseAmount", + type: "u64", + index: false, + }, + { + name: "launchAuthority", + type: "publicKey", + index: false, + }, + { + name: "launchSigner", + type: "publicKey", + index: false, + }, + { + name: "launchSignerPdaBump", + type: "u8", + index: false, + }, + { + name: "launchUsdcVault", + type: "publicKey", + index: false, + }, + { + name: "launchTokenVault", + type: "publicKey", + index: false, + }, + { + name: "performancePackageGrantee", + type: "publicKey", + index: false, + }, + { + name: "performancePackageTokenAmount", + type: "u64", + index: false, + }, + { + name: "monthsUntilInsidersCanUnlock", + type: "u8", + index: false, + }, + { + name: "monthlySpendingLimitAmount", + type: "u64", + index: false, + }, + { + name: "monthlySpendingLimitMembers", + type: { + vec: "publicKey", + }, + index: false, + }, + { + name: "baseMint", + type: "publicKey", + index: false, + }, + { + name: "quoteMint", + type: "publicKey", + index: false, + }, + { + name: "pdaBump", + type: "u8", + index: false, + }, + { + name: "secondsForLaunch", + type: "u32", + index: false, + }, + { + name: "additionalTokensAmount", + type: "u64", + index: false, + }, + { + name: "additionalTokensRecipient", + type: { + option: "publicKey", + }, + index: false, + }, + { + name: "accumulatorActivationDelaySeconds", + type: "u32", + index: false, + }, + { + name: "hasBidWall", + type: "bool", + index: false, + }, + { + name: "mintGovernor", + type: "publicKey", + index: false, + }, + ], + }, + { + name: "LaunchStartedEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "launch", + type: "publicKey", + index: false, + }, + { + name: "launchAuthority", + type: "publicKey", + index: false, + }, + { + name: "slotStarted", + type: "u64", + index: false, + }, + ], + }, + { + name: "LaunchFundedEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "fundingRecord", + type: "publicKey", + index: false, + }, + { + name: "launch", + type: "publicKey", + index: false, + }, + { + name: "funder", + type: "publicKey", + index: false, + }, + { + name: "amount", + type: "u64", + index: false, + }, + { + name: "totalCommittedByFunder", + type: "u64", + index: false, + }, + { + name: "totalCommitted", + type: "u64", + index: false, + }, + { + name: "committedAmountAccumulator", + type: "u128", + index: false, + }, + ], + }, + { + name: "FundingRecordApprovalSetEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "launch", + type: "publicKey", + index: false, + }, + { + name: "fundingRecord", + type: "publicKey", + index: false, + }, + { + name: "funder", + type: "publicKey", + index: false, + }, + { + name: "approvedAmount", + type: "u64", + index: false, + }, + { + name: "totalApproved", + type: "u64", + index: false, + }, + ], + }, + { + name: "LaunchSettledEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "launch", + type: "publicKey", + index: false, + }, + { + name: "finalState", + type: { + defined: "LaunchState", + }, + index: false, + }, + { + name: "totalCommitted", + type: "u64", + index: false, + }, + { + name: "dao", + type: { + option: "publicKey", + }, + index: false, + }, + { + name: "daoTreasury", + type: { + option: "publicKey", + }, + index: false, + }, + { + name: "totalApprovedAmount", + type: "u64", + index: false, + }, + { + name: "bidWall", + type: { + option: "publicKey", + }, + index: false, + }, + { + name: "bidWallAmount", + type: "u64", + index: false, + }, + { + name: "tokensMinted", + type: "u64", + index: false, + }, + ], + }, + { + name: "LaunchFinalizedEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "launch", + type: "publicKey", + index: false, + }, + { + name: "performancePackage", + type: "publicKey", + index: false, + }, + { + name: "mintGovernor", + type: "publicKey", + index: false, + }, + { + name: "mintGovernorNewAdmin", + type: "publicKey", + index: false, + }, + { + name: "ppMintAuthority", + type: "publicKey", + index: false, + }, + { + name: "daoMintAuthority", + type: "publicKey", + index: false, + }, + ], + }, + { + name: "LaunchRefundedEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "launch", + type: "publicKey", + index: false, + }, + { + name: "funder", + type: "publicKey", + index: false, + }, + { + name: "usdcRefunded", + type: "u64", + index: false, + }, + { + name: "fundingRecord", + type: "publicKey", + index: false, + }, + ], + }, + { + name: "LaunchClaimEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "launch", + type: "publicKey", + index: false, + }, + { + name: "funder", + type: "publicKey", + index: false, + }, + { + name: "tokensClaimed", + type: "u64", + index: false, + }, + { + name: "fundingRecord", + type: "publicKey", + index: false, + }, + ], + }, + { + name: "LaunchCloseEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "launch", + type: "publicKey", + index: false, + }, + { + name: "newState", + type: { + defined: "LaunchState", + }, + index: false, + }, + ], + }, + { + name: "LaunchClaimAdditionalTokenAllocationEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "launch", + type: "publicKey", + index: false, + }, + { + name: "additionalTokensAmount", + type: "u64", + index: false, + }, + { + name: "additionalTokensRecipient", + type: "publicKey", + index: false, + }, + ], + }, + { + name: "LaunchExtendedEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "launch", + type: "publicKey", + index: false, + }, + { + name: "oldSecondsForLaunch", + type: "u32", + index: false, + }, + { + name: "newSecondsForLaunch", + type: "u32", + index: false, + }, + ], + }, + ], + errors: [ + { + code: 6000, + name: "InvalidAmount", + msg: "Invalid amount", + }, + { + code: 6001, + name: "SupplyNonZero", + msg: "Supply must be zero", + }, + { + code: 6002, + name: "InvalidSecondsForLaunch", + msg: "Launch period must be between 1 hour and 2 weeks", + }, + { + code: 6003, + name: "InsufficientFunds", + msg: "Insufficient funds", + }, + { + code: 6004, + name: "InvalidLaunchState", + msg: "Invalid launch state", + }, + { + code: 6005, + name: "LaunchPeriodNotOver", + msg: "Launch period not over", + }, + { + code: 6006, + name: "LaunchExpired", + msg: "Launch is complete, no more funding allowed", + }, + { + code: 6007, + name: "LaunchNotRefunding", + msg: "Refund not available", + }, + { + code: 6008, + name: "LaunchNotInitialized", + msg: "Launch must be initialized to be started", + }, + { + code: 6009, + name: "FreezeAuthoritySet", + msg: "Freeze authority can't be set on launchpad tokens", + }, + { + code: 6010, + name: "InvalidMonthlySpendingLimit", + msg: "Monthly spending limit must be less than 1/6th of the minimum raise amount and cannot be 0", + }, + { + code: 6011, + name: "InvalidMonthlySpendingLimitMembers", + msg: "There can only be at most 10 monthly spending limit members", + }, + { + code: 6012, + name: "InvalidPerformancePackageTokenAmount", + msg: "Invalid performance package token amount", + }, + { + code: 6013, + name: "InvalidPerformancePackageMinUnlockTime", + msg: "Insiders must wait at least 12 months before unlocking", + }, + { + code: 6014, + name: "LaunchAuthorityNotSet", + msg: "Launch authority must be set to complete the launch until 2 days after closing", + }, + { + code: 6015, + name: "FinalRaiseAmountTooLow", + msg: "The final amount raised must be >= the minimum raise amount", + }, + { + code: 6016, + name: "TokensAlreadyClaimed", + msg: "Tokens already claimed", + }, + { + code: 6017, + name: "MoneyAlreadyRefunded", + msg: "USDC already refunded", + }, + { + code: 6018, + name: "InvariantViolated", + msg: "Invariant violated", + }, + { + code: 6019, + name: "LaunchNotLive", + msg: "Launch must be live to be closed", + }, + { + code: 6020, + name: "InvalidMinimumRaiseAmount", + msg: "Minimum raise amount too low for liquidity", + }, + { + code: 6021, + name: "FinalRaiseAmountAlreadySet", + msg: "Final raise amount already set", + }, + { + code: 6022, + name: "TotalApprovedAmountTooLow", + msg: "Total approved amount too low", + }, + { + code: 6023, + name: "InvalidAdditionalTokensRecipient", + msg: "Additional tokens recipient must be set when amount > 0", + }, + { + code: 6024, + name: "NoAdditionalTokensRecipientSet", + msg: "No additional tokens recipient set", + }, + { + code: 6025, + name: "AdditionalTokensAlreadyClaimed", + msg: "Additional tokens already claimed", + }, + { + code: 6026, + name: "FundingRecordApprovalPeriodOver", + msg: "Funding record approval period is over", + }, + { + code: 6027, + name: "PerformancePackageAlreadyInitialized", + msg: "Performance package already initialized", + }, + { + code: 6028, + name: "InvalidDao", + msg: "Invalid DAO", + }, + { + code: 6029, + name: "InvalidAccumulatorActivationDelaySeconds", + msg: "Accumulator activation delay must be less than the launch duration", + }, + { + code: 6030, + name: "ExtendDurationExceedsMax", + msg: "Extend duration would exceed maximum allowed launch duration", + }, + { + code: 6031, + name: "InvalidMintAuthority", + msg: "Mint authority does not match expected", + }, + { + code: 6032, + name: "InvalidMeteoraAccount", + msg: "Invalid Meteora account", + }, + ], +}; diff --git a/sdk/sync-types.sh b/sdk/sync-types.sh index 7e99ab46..14088280 100755 --- a/sdk/sync-types.sh +++ b/sdk/sync-types.sh @@ -8,6 +8,7 @@ cp "$TYPES_DIR/conditional_vault.ts" ./src/conditional_vault/v0.4/ cp "$TYPES_DIR/futarchy.ts" ./src/futarchy/v0.6/types/ cp "$TYPES_DIR/launchpad.ts" ./src/launchpad/v0.6/types/ cp "$TYPES_DIR/launchpad_v7.ts" ./src/launchpad/v0.7/types/ +cp "$TYPES_DIR/launchpad_v8.ts" ./src/launchpad/v0.8/types/ cp "$TYPES_DIR/liquidation.ts" ./src/liquidation/v0.7/types/ cp "$TYPES_DIR/mint_governor.ts" ./src/mint_governor/v0.7/types/ cp "$TYPES_DIR/performance_package_v2.ts" ./src/performance_package_v2/v0.7/types/ diff --git a/tests/integration/launchpad_v8_full_lifecycle.test.ts b/tests/integration/launchpad_v8_full_lifecycle.test.ts new file mode 100644 index 00000000..1e2e0389 --- /dev/null +++ b/tests/integration/launchpad_v8_full_lifecycle.test.ts @@ -0,0 +1,451 @@ +import { + Keypair, + PublicKey, + TransactionMessage, + VersionedTransaction, +} from "@solana/web3.js"; +import { assert } from "chai"; +import { + MAINNET_USDC, + LAUNCHPAD_V0_8_PROGRAM_ID, + LAUNCHPAD_V0_8_MAINNET_METEORA_CONFIG, +} from "@metadaoproject/programs"; +import { + LaunchpadClient, + getFundingRecordAddr, +} from "@metadaoproject/programs/launchpad/v0.8"; +import * as multisig from "@sqds/multisig"; +import BN from "bn.js"; +import { initializeMintWithSeeds } from "../launchpad_v8/utils.js"; +import { createLookupTableForTransaction } from "../utils.js"; + +export default async function suite() { + before(async function () { + 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 [poolCreatorAuthority] = PublicKey.findProgramAddressSync( + [Buffer.from("damm_pool_creator_authority")], + LAUNCHPAD_V0_8_PROGRAM_ID, + ); + + dynamicConfig.data.set( + poolCreatorAuthority.toBuffer(), + poolCreatorAuthorityOffset, + ); + dynamicConfig.data.set([1], configTypeOffset); + + this.context.setAccount( + LAUNCHPAD_V0_8_MAINNET_METEORA_CONFIG, + dynamicConfig, + ); + }); + + it("full lifecycle: init → start → fund → close → approve → settle → finalize → claim → refund → claim_additional", async function () { + const launchpadClient: LaunchpadClient = this.launchpad_v8; + + // Create funders and authorities + const funder1 = Keypair.generate(); + const funder2 = Keypair.generate(); + const funder3 = Keypair.generate(); + const launchAuthority = Keypair.generate(); + const additionalTokensRecipient = Keypair.generate(); + + const minRaise = new BN(300_000 * 10 ** 6); // 300k USDC + const launchPeriod = 60 * 60 * 24 * 2; // 2 days + const monthlySpendingLimitAmount = new BN(25_000 * 10 ** 6); + const performancePackageTokenAmount = new BN(5_000_000 * 10 ** 6); // 5M tokens + const additionalTokensAmount = new BN(1_000_000 * 10 ** 6); // 1M tokens + + // ===================== + // Setup: Create mint and derive addresses + // ===================== + const result = await initializeMintWithSeeds( + this.banksClient, + launchpadClient, + this.payer, + ); + + const META = result.tokenMint; + const launch = result.launch; + const launchSigner = result.launchSigner; + + // Setup USDC accounts for funders + await this.createTokenAccount(MAINNET_USDC, funder1.publicKey); + await this.createTokenAccount(MAINNET_USDC, funder2.publicKey); + await this.createTokenAccount(MAINNET_USDC, funder3.publicKey); + + await this.transfer( + MAINNET_USDC, + this.payer, + funder1.publicKey, + 500_000_000000, + ); + await this.transfer( + MAINNET_USDC, + this.payer, + funder2.publicKey, + 200_000_000000, + ); + await this.transfer( + MAINNET_USDC, + this.payer, + funder3.publicKey, + 400_000_000000, + ); + + // ===================== + // 1. initialize_launch + // ===================== + await launchpadClient + .initializeLaunchIx({ + tokenName: "META", + tokenSymbol: "META", + tokenUri: "https://example.com", + minimumRaiseAmount: minRaise, + secondsForLaunch: launchPeriod, + baseMint: META, + quoteMint: MAINNET_USDC, + monthlySpendingLimitAmount, + monthlySpendingLimitMembers: [this.payer.publicKey], + performancePackageGrantee: this.payer.publicKey, + performancePackageTokenAmount, + monthsUntilInsidersCanUnlock: 24, + teamAddress: PublicKey.default, + launchAuthority: launchAuthority.publicKey, + additionalTokensRecipient: additionalTokensRecipient.publicKey, + additionalTokensAmount, + hasBidWall: false, + }) + .rpc(); + + // Verify: tokens minted at init, MintGovernor setup + // Supply = TOKENS_TO_PARTICIPANTS + TOKENS_TO_FUTARCHY_LIQUIDITY + TOKENS_TO_DAMM_V2_LIQUIDITY + additional_tokens + // = 10M + 2M + 900k + 1M = 13.9M + let mint = await this.getMint(META); + const expectedInitSupply = + (10_000_000 + 2_000_000 + 900_000 + 1_000_000) * 10 ** 6; + assert.equal(Number(mint.supply), expectedInitSupply); + + let launchAccount = await launchpadClient.fetchLaunch(launch); + assert.deepEqual(launchAccount.state, { initialized: {} }); + + const mintGovernorAddr = launchpadClient.getMintGovernorAddress({ + baseMint: META, + launchSigner, + }); + const mintGovernorAccount = + await launchpadClient.mintGovernorClient.fetchMintGovernor( + mintGovernorAddr, + ); + assert.ok(mintGovernorAccount.admin.equals(launchSigner)); + + // ===================== + // 2. start_launch + // ===================== + await launchpadClient + .startLaunchIx({ + launch, + launchAuthority: launchAuthority.publicKey, + }) + .signers([launchAuthority]) + .rpc(); + + launchAccount = await launchpadClient.fetchLaunch(launch); + assert.deepEqual(launchAccount.state, { live: {} }); + + // ===================== + // 3. fund (multiple funders) + // ===================== + // Funder1: 500k USDC + await launchpadClient + .fundIx({ + launch, + amount: new BN(500_000_000000), + funder: funder1.publicKey, + }) + .signers([funder1]) + .rpc(); + + // Funder2 (payer): 200k USDC + await launchpadClient + .fundIx({ + launch, + amount: new BN(200_000_000000), + funder: funder2.publicKey, + }) + .signers([funder2]) + .rpc(); + + // Funder3: 400k USDC + await launchpadClient + .fundIx({ + launch, + amount: new BN(400_000_000000), + funder: funder3.publicKey, + }) + .signers([funder3]) + .rpc(); + + launchAccount = await launchpadClient.fetchLaunch(launch); + assert.equal( + launchAccount.totalCommittedAmount.toString(), + new BN(1_100_000_000000).toString(), + ); + + // ===================== + // 4. close_launch + // ===================== + await this.advanceBySeconds(launchPeriod + 1); + + await launchpadClient.closeLaunchIx({ launch }).rpc(); + + launchAccount = await launchpadClient.fetchLaunch(launch); + assert.deepEqual(launchAccount.state, { closed: {} }); + + // ===================== + // 5. set_funding_record_approval (each funder, partial approvals) + // ===================== + // Approve 250k of funder1's 500k + await launchpadClient + .setFundingRecordApprovalIx({ + launch, + funder: funder1.publicKey, + launchAuthority: launchAuthority.publicKey, + approvedAmount: new BN(250_000_000000), + }) + .signers([launchAuthority]) + .rpc(); + + // Approve 100k of funder2's 200k + await launchpadClient + .setFundingRecordApprovalIx({ + launch, + funder: funder2.publicKey, + launchAuthority: launchAuthority.publicKey, + approvedAmount: new BN(100_000_000000), + }) + .signers([launchAuthority]) + .rpc(); + + // Approve 150k of funder3's 400k + await launchpadClient + .setFundingRecordApprovalIx({ + launch, + funder: funder3.publicKey, + launchAuthority: launchAuthority.publicKey, + approvedAmount: new BN(150_000_000000), + }) + .signers([launchAuthority]) + .rpc(); + + // Total approved: 250k + 100k + 150k = 500k (above 300k minimum) + + // ===================== + // 6. settle_launch → verify minting, DAO creation + // ===================== + const settleTx = await launchpadClient + .settleLaunchIx({ + launch, + baseMint: META, + launchAuthority: launchAuthority.publicKey, + }) + .signers([launchAuthority]) + .transaction(); + + const lut = await createLookupTableForTransaction(settleTx, this); + + const settleMessage = new TransactionMessage({ + payerKey: this.payer.publicKey, + recentBlockhash: (await this.banksClient.getLatestBlockhash())[0], + instructions: settleTx.instructions, + }).compileToV0Message([lut]); + + const settleVersionedTx = new VersionedTransaction(settleMessage); + settleVersionedTx.sign([this.payer, launchAuthority]); + + await this.banksClient.processTransaction(settleVersionedTx); + + launchAccount = await launchpadClient.fetchLaunch(launch); + assert.deepEqual(launchAccount.state, { complete: {} }); + assert.isNotNull(launchAccount.dao); + assert.isNotNull(launchAccount.daoVault); + assert.isNotNull(launchAccount.unixTimestampCompleted); + + // Supply unchanged from init (tokens were minted at initialize_launch) + mint = await this.getMint(META); + assert.equal(Number(mint.supply), expectedInitSupply); + + // Verify USDC distribution: 80% to treasury (no bid wall) + // Total approved = 500k, usdc_to_lp = 500k / 5 = 100k, usdc_to_dao = 400k + const treasuryUSDCBalance = await this.getTokenBalance( + MAINNET_USDC, + launchAccount.daoVault, + ); + assert.equal( + treasuryUSDCBalance.toString(), + new BN(400_000_000000).toString(), + ); + + // ===================== + // 7. finalize_launch → verify PP v2 setup, MintGovernor admin transfer + // ===================== + assert.equal(launchAccount.isFinalized, false); + + await launchpadClient + .finalizeLaunchIx({ + launch, + baseMint: META, + performancePackageGrantee: launchAccount.performancePackageGrantee, + }) + .rpc(); + + launchAccount = await launchpadClient.fetchLaunch(launch); + assert.equal(launchAccount.isFinalized, true); + + // Verify MintGovernor admin transferred to DAO squads vault + const daoAddr = launchpadClient.getLaunchDaoAddress({ launch }); + const [squadsMultisig] = multisig.getMultisigPda({ createKey: daoAddr }); + const [squadsMultisigVault] = multisig.getVaultPda({ + multisigPda: squadsMultisig, + index: 0, + }); + + const updatedMintGovernor = + await launchpadClient.mintGovernorClient.fetchMintGovernor( + mintGovernorAddr, + ); + assert.ok(updatedMintGovernor.admin.equals(squadsMultisigVault)); + + // Verify PP v2 was initialized + const performancePackageAddr = + launchpadClient.getLaunchPerformancePackageAddress({ launch }); + const ppAccount = + await launchpadClient.performancePackageV2.fetchPerformancePackage( + performancePackageAddr, + ); + assert.isNotNull(ppAccount); + assert.ok(ppAccount.authority.equals(squadsMultisigVault)); + assert.ok( + ppAccount.recipient.equals(launchAccount.performancePackageGrantee), + ); + + // Verify DAO MintAuthority was created for the squads_multisig_vault + const daoMintAuthorityAddr = launchpadClient.getMintAuthorityAddress({ + mintGovernor: mintGovernorAddr, + authorizedMinter: squadsMultisigVault, + }); + const daoMintAuthorityAccount = + await launchpadClient.mintGovernorClient.fetchMintAuthority( + daoMintAuthorityAddr, + ); + assert.ok( + daoMintAuthorityAccount.authorizedMinter.equals(squadsMultisigVault), + ); + assert.isNull(daoMintAuthorityAccount.maxTotal); + + // ===================== + // 8. claim (each funder) + // ===================== + await launchpadClient + .claimIx({ launch, baseMint: META, funder: funder1.publicKey }) + .rpc(); + await launchpadClient + .claimIx({ launch, baseMint: META, funder: funder2.publicKey }) + .rpc(); + await launchpadClient + .claimIx({ launch, baseMint: META, funder: funder3.publicKey }) + .rpc(); + + // Verify token distributions proportional to approved amounts + // Total approved = 500k, TOKENS_TO_PARTICIPANTS = 10M + // Funder1: 250k/500k * 10M = 5M tokens + // Funder2: 100k/500k * 10M = 2M tokens + // Funder3: 150k/500k * 10M = 3M tokens + const funder1Balance = await this.getTokenBalance(META, funder1.publicKey); + const funder2Balance = await this.getTokenBalance(META, funder2.publicKey); + const funder3Balance = await this.getTokenBalance(META, funder3.publicKey); + + assert.equal(funder1Balance, 5_000_000_000000n); + assert.equal(funder2Balance, 2_000_000_000000n); + assert.equal(funder3Balance, 3_000_000_000000n); + + // ===================== + // 9. refund (over-committed funders get back excess USDC) + // ===================== + const preRefundFunder1Quote = await this.getTokenBalance( + MAINNET_USDC, + funder1.publicKey, + ); + const preRefundFunder2Quote = await this.getTokenBalance( + MAINNET_USDC, + funder2.publicKey, + ); + const preRefundFunder3Quote = await this.getTokenBalance( + MAINNET_USDC, + funder3.publicKey, + ); + + await launchpadClient.refundIx({ launch, funder: funder1.publicKey }).rpc(); + await launchpadClient.refundIx({ launch, funder: funder2.publicKey }).rpc(); + await launchpadClient.refundIx({ launch, funder: funder3.publicKey }).rpc(); + + const postRefundFunder1Quote = await this.getTokenBalance( + MAINNET_USDC, + funder1.publicKey, + ); + const postRefundFunder2Quote = await this.getTokenBalance( + MAINNET_USDC, + funder2.publicKey, + ); + const postRefundFunder3Quote = await this.getTokenBalance( + MAINNET_USDC, + funder3.publicKey, + ); + + // Funder1: committed 500k, approved 250k → refund 250k + assert.equal( + postRefundFunder1Quote - preRefundFunder1Quote, + 250_000_000000n, + ); + // Funder2: committed 200k, approved 100k → refund 100k + assert.equal( + postRefundFunder2Quote - preRefundFunder2Quote, + 100_000_000000n, + ); + // Funder3: committed 400k, approved 150k → refund 250k + assert.equal( + postRefundFunder3Quote - preRefundFunder3Quote, + 250_000_000000n, + ); + + // ===================== + // 10. claim_additional_token_allocation + // ===================== + await launchpadClient + .claimAdditionalTokenAllocationIx({ + launch, + baseMint: META, + additionalTokensRecipient: additionalTokensRecipient.publicKey, + }) + .rpc(); + + const additionalRecipientBalance = await this.getTokenBalance( + META, + additionalTokensRecipient.publicKey, + ); + assert.equal( + additionalRecipientBalance.toString(), + additionalTokensAmount.toString(), + ); + + launchAccount = await launchpadClient.fetchLaunch(launch); + assert.isTrue(launchAccount.additionalTokensClaimed); + }); +} diff --git a/tests/integration/launchpad_v8_tranche_lifecycle.test.ts b/tests/integration/launchpad_v8_tranche_lifecycle.test.ts new file mode 100644 index 00000000..05e326c6 --- /dev/null +++ b/tests/integration/launchpad_v8_tranche_lifecycle.test.ts @@ -0,0 +1,447 @@ +import { + ComputeBudgetProgram, + Keypair, + PublicKey, + TransactionMessage, + VersionedTransaction, +} from "@solana/web3.js"; +import { assert } from "chai"; +import { + MAINNET_USDC, + LAUNCHPAD_V0_8_PROGRAM_ID, + LAUNCHPAD_V0_8_MAINNET_METEORA_CONFIG, +} from "@metadaoproject/programs"; +import { LaunchpadClient } from "@metadaoproject/programs/launchpad/v0.8"; +import BN from "bn.js"; +import { initializeMintWithSeeds } from "../launchpad_v8/utils.js"; +import { createLookupTableForTransaction, expectError } from "../utils.js"; + +// Hardcoded from programs/v08_launchpad/src/lib.rs:30-48 (not SDK-exported). +const PRICE_SCALE = new BN("1000000000000"); // 1e12 +const TOKENS_TO_PARTICIPANTS = new BN(10_000_000_000_000); // 10M tokens × 1e6 +const PP_TWAP_MIN_DURATION_SEC = 3 * 30 * 24 * 60 * 60; // 7_776_000 + +export default async function suite() { + before(async function () { + const dynamicConfig = await this.banksClient.getAccount( + new PublicKey("4mPQ4VuvvtYL3CeMPt14Uj1CLpBWcVdJoLoTH9ea4Kod"), + ); + + const poolCreatorAuthorityOffset = 8 + 32; + const configTypeOffset = 8 + 32 + 32 + 128 + 1 + 1; + + const [poolCreatorAuthority] = PublicKey.findProgramAddressSync( + [Buffer.from("damm_pool_creator_authority")], + LAUNCHPAD_V0_8_PROGRAM_ID, + ); + + dynamicConfig.data.set( + poolCreatorAuthority.toBuffer(), + poolCreatorAuthorityOffset, + ); + dynamicConfig.data.set([1], configTypeOffset); + + this.context.setAccount( + LAUNCHPAD_V0_8_MAINNET_METEORA_CONFIG, + dynamicConfig, + ); + }); + + it("trades the AMM up through every tranche and unlocks the full performance package", async function () { + // 620 cranking swaps + 5 unlock cycles. Bankrun is fast but JS RPC + // overhead per tx still adds up; give it 5 minutes. + this.timeout(5 * 60 * 1000); + + const launchpadClient: LaunchpadClient = this.launchpad_v8; + const futarchyClient = launchpadClient.futarchyClient; + + const funder1 = Keypair.generate(); + const funder2 = Keypair.generate(); + const funder3 = Keypair.generate(); + const launchAuthority = Keypair.generate(); + const grantee = Keypair.generate(); + const additionalTokensRecipient = Keypair.generate(); + + const minRaise = new BN(300_000 * 10 ** 6); + const launchPeriod = 60 * 60 * 24 * 2; + const monthlySpendingLimitAmount = new BN(25_000 * 10 ** 6); + const performancePackageTokenAmount = new BN(5_000_000 * 10 ** 6); // 5M + const additionalTokensAmount = new BN(1_000_000 * 10 ** 6); + const monthsUntilInsidersCanUnlock = 24; + const totalApproved = new BN(500_000 * 10 ** 6); // 250 + 100 + 150 + + const launchPrice = totalApproved + .mul(PRICE_SCALE) + .div(TOKENS_TO_PARTICIPANTS); + + // ============================================================ + // Phase A — Lifecycle (init → … → claim_additional) + // Mirrors launchpad_v8_full_lifecycle.test.ts:50-446 without the + // intermediate assertions; those are owned by that test and the + // per-instruction unit tests. + // ============================================================ + + const result = await initializeMintWithSeeds( + this.banksClient, + launchpadClient, + this.payer, + ); + const META = result.tokenMint; + const launch = result.launch; + const launchSigner = result.launchSigner; + + await this.createTokenAccount(MAINNET_USDC, funder1.publicKey); + await this.createTokenAccount(MAINNET_USDC, funder2.publicKey); + await this.createTokenAccount(MAINNET_USDC, funder3.publicKey); + + await this.transfer( + MAINNET_USDC, + this.payer, + funder1.publicKey, + 500_000_000000, + ); + await this.transfer( + MAINNET_USDC, + this.payer, + funder2.publicKey, + 200_000_000000, + ); + await this.transfer( + MAINNET_USDC, + this.payer, + funder3.publicKey, + 400_000_000000, + ); + + await launchpadClient + .initializeLaunchIx({ + tokenName: "META", + tokenSymbol: "META", + tokenUri: "https://example.com", + minimumRaiseAmount: minRaise, + secondsForLaunch: launchPeriod, + baseMint: META, + quoteMint: MAINNET_USDC, + monthlySpendingLimitAmount, + monthlySpendingLimitMembers: [this.payer.publicKey], + performancePackageGrantee: grantee.publicKey, + performancePackageTokenAmount, + monthsUntilInsidersCanUnlock, + teamAddress: PublicKey.default, + launchAuthority: launchAuthority.publicKey, + additionalTokensRecipient: additionalTokensRecipient.publicKey, + additionalTokensAmount, + hasBidWall: false, + }) + .rpc(); + + await launchpadClient + .startLaunchIx({ launch, launchAuthority: launchAuthority.publicKey }) + .signers([launchAuthority]) + .rpc(); + + await launchpadClient + .fundIx({ + launch, + amount: new BN(500_000_000000), + funder: funder1.publicKey, + }) + .signers([funder1]) + .rpc(); + await launchpadClient + .fundIx({ + launch, + amount: new BN(200_000_000000), + funder: funder2.publicKey, + }) + .signers([funder2]) + .rpc(); + await launchpadClient + .fundIx({ + launch, + amount: new BN(400_000_000000), + funder: funder3.publicKey, + }) + .signers([funder3]) + .rpc(); + + await this.advanceBySeconds(launchPeriod + 1); + await launchpadClient.closeLaunchIx({ launch }).rpc(); + + await launchpadClient + .setFundingRecordApprovalIx({ + launch, + funder: funder1.publicKey, + launchAuthority: launchAuthority.publicKey, + approvedAmount: new BN(250_000_000000), + }) + .signers([launchAuthority]) + .rpc(); + await launchpadClient + .setFundingRecordApprovalIx({ + launch, + funder: funder2.publicKey, + launchAuthority: launchAuthority.publicKey, + approvedAmount: new BN(100_000_000000), + }) + .signers([launchAuthority]) + .rpc(); + await launchpadClient + .setFundingRecordApprovalIx({ + launch, + funder: funder3.publicKey, + launchAuthority: launchAuthority.publicKey, + approvedAmount: new BN(150_000_000000), + }) + .signers([launchAuthority]) + .rpc(); + + const settleTx = await launchpadClient + .settleLaunchIx({ + launch, + baseMint: META, + launchAuthority: launchAuthority.publicKey, + }) + .signers([launchAuthority]) + .transaction(); + + const lut = await createLookupTableForTransaction(settleTx, this); + + const settleMessage = new TransactionMessage({ + payerKey: this.payer.publicKey, + recentBlockhash: (await this.banksClient.getLatestBlockhash())[0], + instructions: settleTx.instructions, + }).compileToV0Message([lut]); + + const settleVersionedTx = new VersionedTransaction(settleMessage); + settleVersionedTx.sign([this.payer, launchAuthority]); + + await this.banksClient.processTransaction(settleVersionedTx); + + let launchAccount = await launchpadClient.fetchLaunch(launch); + const dao = launchAccount.dao!; + + await launchpadClient + .finalizeLaunchIx({ + launch, + baseMint: META, + performancePackageGrantee: launchAccount.performancePackageGrantee, + }) + .rpc(); + + await launchpadClient + .claimIx({ launch, baseMint: META, funder: funder1.publicKey }) + .rpc(); + await launchpadClient + .claimIx({ launch, baseMint: META, funder: funder2.publicKey }) + .rpc(); + await launchpadClient + .claimIx({ launch, baseMint: META, funder: funder3.publicKey }) + .rpc(); + + await launchpadClient.refundIx({ launch, funder: funder1.publicKey }).rpc(); + await launchpadClient.refundIx({ launch, funder: funder2.publicKey }).rpc(); + await launchpadClient.refundIx({ launch, funder: funder3.publicKey }).rpc(); + + await launchpadClient + .claimAdditionalTokenAllocationIx({ + launch, + baseMint: META, + additionalTokensRecipient: additionalTokensRecipient.publicKey, + }) + .rpc(); + + // Capture the post-finalize addresses we'll need below. + const mintGovernorAddr = launchpadClient.getMintGovernorAddress({ + baseMint: META, + launchSigner, + }); + const performancePackageAddr = + launchpadClient.getLaunchPerformancePackageAddress({ launch }); + const ppMintAuthorityAddr = launchpadClient.getMintAuthorityAddress({ + mintGovernor: mintGovernorAddr, + authorizedMinter: performancePackageAddr, + }); + + // ============================================================ + // Phase B — Pre-unlock invariants + // ============================================================ + + // Mint authority is now the mint_governor PDA, not the launch_signer. + const mintAcc = await this.getMint(META); + assert.ok(mintAcc.mintAuthority.equals(mintGovernorAddr)); + + // start_unlock fails before min_unlock_timestamp. + const earlyStartCallbacks = expectError( + "UnlockTimestampNotReached", + "start_unlock should fail before min_unlock_timestamp", + ); + await launchpadClient.performancePackageV2 + .startUnlockIx({ + performancePackage: performancePackageAddr, + signer: grantee.publicKey, + dao, + }) + .signers([grantee]) + .rpc() + .then(earlyStartCallbacks[0], earlyStartCallbacks[1]); + + // ============================================================ + // Phase C — Whale buy + advance to min_unlock_timestamp + // ============================================================ + + // Past twap_start_delay (1 day) so update_twap actually moves the aggregator + // (programs/futarchy/src/state/futarchy_amm.rs:393-396). + await this.advanceBySeconds(24 * 60 * 60 + 60); + + const whale = Keypair.generate(); + await this.createTokenAccount(MAINNET_USDC, whale.publicKey); + await this.transfer( + MAINNET_USDC, + this.payer, + whale.publicKey, + 1_000_000_000_000, // $1M + ); + + // ~$1M buy → spot price ≈ 121× launch_price (CPMM math against + // the 100k USDC + 2M META reserves seeded by settle_launch). + await futarchyClient + .spotSwapIx({ + dao, + baseMint: META, + swapType: "buy", + inputAmount: new BN(1_000_000_000_000), + trader: whale.publicKey, + }) + .signers([whale]) + .rpc(); + + // Advance the rest of the way to min_unlock_timestamp (24 months). + await this.advanceBySeconds( + monthsUntilInsidersCanUnlock * 30 * 24 * 60 * 60, + ); + + // ============================================================ + // Phase D — Cranker + 5 unlock cycles at 2/4/8/16/32× + // ============================================================ + + const cranker = Keypair.generate(); + await this.createTokenAccount(MAINNET_USDC, cranker.publicKey); + await this.transfer( + MAINNET_USDC, + this.payer, + cranker.publicKey, + 100_000_000_000, // $100k — way more than the 620 × $10 ≈ $6 200 we'll spend. + ); + + // Walk last_observation up to targetMultiplier × launch_price by issuing + // tiny buy swaps spaced ≥61 s apart. Each update_twap moves the + // observation by at most launch_price/20 (programs/futarchy/src/state/ + // futarchy_amm.rs:339-449). Since the whale buy left last_price ≫ 32×, + // every crank advances by exactly the cap. + // + // Each iteration adds a unique ComputeBudget instruction so the swap + // signatures don't collide (bankrun rejects duplicates). + let crankNonce = 0; + const crankObservationTo = async (targetMultiplier: number) => { + const target = launchPrice.muln(targetMultiplier); + while (true) { + const daoAcc = await futarchyClient.getDao(dao); + const obs = daoAcc.amm.state.spot.spot.oracle.lastObservation as BN; + if (obs.gte(target)) return; + await this.advanceBySeconds(61); + await futarchyClient + .spotSwapIx({ + dao, + baseMint: META, + swapType: "buy", + inputAmount: new BN(10_000_000), // 10 USDC + trader: cranker.publicKey, + }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ + units: 200_000 + crankNonce++, + }), + ]) + .signers([cranker]) + .rpc(); + } + }; + + const granteeAta = await this.createTokenAccount(META, grantee.publicKey); + + const ppAmount = performancePackageTokenAmount; + const fifth = ppAmount.divn(5); // 1M × 1e6 + + const runUnlockCycle = async ( + targetMultiplier: number, + expectedCumulative: BN, + ) => { + await crankObservationTo(targetMultiplier); + + await launchpadClient.performancePackageV2 + .startUnlockIx({ + performancePackage: performancePackageAddr, + signer: grantee.publicKey, + dao, + }) + .signers([grantee]) + .rpc(); + + // No swaps fire during this wait, so last_observation stays pinned at + // targetMultiplier × launch_price. The effective_aggregator extrapolation + // (programs/performance_package_v2/src/state/performance_package.rs:87-92) + // makes the TWAP between start_unlock and complete_unlock equal to + // targetMultiplier × launch_price. + await this.advanceBySeconds(PP_TWAP_MIN_DURATION_SEC + 60); + + await launchpadClient.performancePackageV2 + .completeUnlockIx({ + performancePackage: performancePackageAddr, + mintGovernor: mintGovernorAddr, + mintAuthority: ppMintAuthorityAddr, + mint: META, + recipient: grantee.publicKey, + signer: grantee.publicKey, + dao, + }) + .signers([grantee]) + .rpc(); + + const granteeBalance = await this.getTokenBalance( + META, + grantee.publicKey, + ); + assert.equal(granteeBalance.toString(), expectedCumulative.toString()); + + const pp = + await launchpadClient.performancePackageV2.fetchPerformancePackage( + performancePackageAddr, + ); + assert.equal( + pp.totalRewardsPaidOut.toString(), + expectedCumulative.toString(), + ); + assert.deepEqual(pp.status, { locked: {} }); + assert.equal(pp.oracleReader.futarchyTwap.startValue.toString(), "0"); + assert.equal(pp.oracleReader.futarchyTwap.endValue.toString(), "0"); + }; + + await runUnlockCycle(2, fifth.muln(1)); + await runUnlockCycle(4, fifth.muln(2)); + await runUnlockCycle(8, fifth.muln(3)); + await runUnlockCycle(16, fifth.muln(4)); + await runUnlockCycle(32, fifth.muln(5)); + + // ============================================================ + // Phase E — Cap assertion + // ============================================================ + + const ppAuthority = + await launchpadClient.mintGovernorClient.fetchMintAuthority( + ppMintAuthorityAddr, + ); + assert.equal(ppAuthority.totalMinted.toString(), ppAmount.toString()); + assert.equal(ppAuthority.maxTotal.toString(), ppAmount.toString()); + }); +} diff --git a/tests/launchpad_v8/main.test.ts b/tests/launchpad_v8/main.test.ts new file mode 100644 index 00000000..4251df08 --- /dev/null +++ b/tests/launchpad_v8/main.test.ts @@ -0,0 +1,92 @@ +import { PublicKey } from "@solana/web3.js"; +import { + LAUNCHPAD_V0_8_PROGRAM_ID, + LAUNCHPAD_V0_8_MAINNET_METEORA_CONFIG, + MAINNET_USDC, +} from "@metadaoproject/programs"; +import BN from "bn.js"; +import initializeLaunch from "./unit/initializeLaunch.test.js"; +import startLaunch from "./unit/startLaunch.test.js"; +import fund from "./unit/fund.test.js"; +import closeLaunch from "./unit/closeLaunch.test.js"; +import setFundingRecordApproval from "./unit/setFundingRecordApproval.test.js"; +import settleLaunch from "./unit/settleLaunch.test.js"; +import claim from "./unit/claim.test.js"; +import refund from "./unit/refund.test.js"; +import claimAdditionalTokenAllocation from "./unit/claimAdditionalTokenAllocation.test.js"; +import finalizeLaunch from "./unit/finalizeLaunch.test.js"; +import extendLaunch from "./unit/extendLaunch.test.js"; + +export default function suite() { + before(async function () { + 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 [poolCreatorAuthority] = PublicKey.findProgramAddressSync( + [Buffer.from("damm_pool_creator_authority")], + LAUNCHPAD_V0_8_PROGRAM_ID, + ); + + dynamicConfig.data.set( + poolCreatorAuthority.toBuffer(), + poolCreatorAuthorityOffset, + ); + dynamicConfig.data.set([1], configTypeOffset); + + this.context.setAccount( + LAUNCHPAD_V0_8_MAINNET_METEORA_CONFIG, + dynamicConfig, + ); + + this.setupBasicLaunch = async ({ + baseMint, + founders, + launchAuthority, + }: { + baseMint: PublicKey; + founders: PublicKey[]; + launchAuthority: PublicKey; + }) => { + await this.launchpad_v8 + .initializeLaunchIx({ + tokenName: "META", + tokenSymbol: "META", + tokenUri: "https://example.com", + minimumRaiseAmount: new BN(100_000 * 10 ** 6), // 100k + secondsForLaunch: 60 * 60 * 24 * 4, // 4 days + baseMint, + quoteMint: MAINNET_USDC, + monthlySpendingLimitAmount: new BN(10_000 * 10 ** 6), // 10k burn + monthlySpendingLimitMembers: founders, + performancePackageGrantee: founders[0], + performancePackageTokenAmount: new BN(5_000_000 * 10 ** 6), // 5M + monthsUntilInsidersCanUnlock: 24, // 2 years + teamAddress: PublicKey.default, + launchAuthority: launchAuthority, + hasBidWall: false, + }) + .rpc(); + }; + }); + + describe("#initialize_launch", initializeLaunch); + describe("#start_launch", startLaunch); + describe("#fund", fund); + describe("#close_launch", closeLaunch); + describe("#set_funding_record_approval", setFundingRecordApproval); + describe("#settle_launch", settleLaunch); + describe("#claim", claim); + describe("#refund", refund); + describe( + "#claim_additional_token_allocation", + claimAdditionalTokenAllocation, + ); + describe("#finalize_launch", finalizeLaunch); + describe("#extend_launch", extendLaunch); +} diff --git a/tests/launchpad_v8/unit/claim.test.ts b/tests/launchpad_v8/unit/claim.test.ts new file mode 100644 index 00000000..848de520 --- /dev/null +++ b/tests/launchpad_v8/unit/claim.test.ts @@ -0,0 +1,202 @@ +import { + Keypair, + PublicKey, + TransactionMessage, + VersionedTransaction, +} from "@solana/web3.js"; +import { assert } from "chai"; +import { + LaunchpadClient, + getFundingRecordAddr, +} from "@metadaoproject/programs/launchpad/v0.8"; +import BN from "bn.js"; +import { initializeMintWithSeeds } from "../utils.js"; +import { createLookupTableForTransaction } from "../../utils.js"; + +export default function suite() { + let launchpadClient: LaunchpadClient; + let META: PublicKey; + let launch: PublicKey; + let launchSigner: PublicKey; + let launchAuthority: Keypair; + + const secondsForLaunch = 60 * 60 * 24 * 4; // 4 days + + before(async function () { + launchpadClient = this.launchpad_v8; + }); + + async function settleViaLut( + context: any, + client: LaunchpadClient, + params: { + launch: PublicKey; + baseMint: PublicKey; + launchAuthority: PublicKey; + }, + additionalSigners: Keypair[] = [], + ) { + const settleTx = await client + .settleLaunchIx({ + launch: params.launch, + baseMint: params.baseMint, + launchAuthority: params.launchAuthority, + }) + .transaction(); + + const lut = await createLookupTableForTransaction(settleTx, context); + + const message = new TransactionMessage({ + payerKey: context.payer.publicKey, + recentBlockhash: (await context.banksClient.getLatestBlockhash())[0], + instructions: settleTx.instructions, + }).compileToV0Message([lut]); + + const tx = new VersionedTransaction(message); + tx.sign([context.payer, ...additionalSigners]); + + await context.banksClient.processTransaction(tx); + } + + async function setupFundCloseApproveSettle( + context: any, + client: LaunchpadClient, + opts: { + launch: PublicKey; + baseMint: PublicKey; + launchAuthority: Keypair; + fundAmount: BN; + }, + ) { + // Fund + await client + .fundIx({ + launch: opts.launch, + amount: opts.fundAmount, + payer: context.payer.publicKey, + }) + .rpc(); + + // Advance past launch period + await context.advanceBySeconds(secondsForLaunch + 1); + + // Close + await client.closeLaunchIx({ launch: opts.launch }).rpc(); + + // Approve + await client + .setFundingRecordApprovalIx({ + launch: opts.launch, + approvedAmount: opts.fundAmount, + funder: context.payer.publicKey, + launchAuthority: opts.launchAuthority.publicKey, + }) + .signers([opts.launchAuthority]) + .rpc(); + + // Settle + await settleViaLut( + context, + client, + { + launch: opts.launch, + baseMint: opts.baseMint, + launchAuthority: opts.launchAuthority.publicKey, + }, + [opts.launchAuthority], + ); + } + + beforeEach(async function () { + const result = await initializeMintWithSeeds( + this.banksClient, + this.launchpad_v8, + this.payer, + ); + + META = result.tokenMint; + launch = result.launch; + launchSigner = result.launchSigner; + launchAuthority = new Keypair(); + + await this.setupBasicLaunch({ + baseMint: META, + founders: [this.payer.publicKey], + launchAuthority: launchAuthority.publicKey, + }); + + await launchpadClient + .startLaunchIx({ + launch, + launchAuthority: launchAuthority.publicKey, + }) + .signers([launchAuthority]) + .rpc(); + + // Create funder's base token ATA + await this.createTokenAccount(META, this.payer.publicKey); + }); + + it("successfully claims tokens after launch completion", async function () { + const fundAmount = new BN(150_000 * 10 ** 6); // 150k USDC + + await setupFundCloseApproveSettle(this, launchpadClient, { + launch, + baseMint: META, + launchAuthority, + fundAmount, + }); + + // Verify launch is complete + const launchAccount = await launchpadClient.fetchLaunch(launch); + assert.deepEqual(launchAccount.state, { complete: {} }); + + // Initial token balance should be 0 + const initialBalance = await this.getTokenBalance( + META, + this.payer.publicKey, + ); + assert.equal(initialBalance.toString(), "0"); + + // Claim + await launchpadClient.claimIx({ launch, baseMint: META }).rpc(); + + // Sole funder gets all TOKENS_TO_PARTICIPANTS (10M tokens) + const finalBalance = await this.getTokenBalance(META, this.payer.publicKey); + const expectedTokens = new BN(10_000_000 * 10 ** 6); + assert.equal(finalBalance.toString(), expectedTokens.toString()); + + // Verify funding record state + const [fundingRecord] = getFundingRecordAddr( + launchpadClient.getProgramId(), + launch, + this.payer.publicKey, + ); + const fundingRecordAccount = + await launchpadClient.fetchFundingRecord(fundingRecord); + assert.equal(fundingRecordAccount.isTokensClaimed, true); + assert.equal(fundingRecordAccount.isUsdcRefunded, false); + assert.ok(fundingRecordAccount.funder.equals(this.payer.publicKey)); + assert.ok(fundingRecordAccount.launch.equals(launch)); + }); + + it("fails when launch is not complete", async function () { + const fundAmount = new BN(150_000 * 10 ** 6); + + // Fund but don't close/approve/settle + await launchpadClient + .fundIx({ + launch, + amount: fundAmount, + payer: this.payer.publicKey, + }) + .rpc(); + + try { + await launchpadClient.claimIx({ launch, baseMint: META }).rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "InvalidLaunchState"); + } + }); +} diff --git a/tests/launchpad_v8/unit/claimAdditionalTokenAllocation.test.ts b/tests/launchpad_v8/unit/claimAdditionalTokenAllocation.test.ts new file mode 100644 index 00000000..ca5a8ca6 --- /dev/null +++ b/tests/launchpad_v8/unit/claimAdditionalTokenAllocation.test.ts @@ -0,0 +1,245 @@ +import { + ComputeBudgetProgram, + Keypair, + PublicKey, + TransactionMessage, + VersionedTransaction, +} from "@solana/web3.js"; +import { assert } from "chai"; +import { LaunchpadClient } from "@metadaoproject/programs/launchpad/v0.8"; +import { MAINNET_USDC } from "@metadaoproject/programs"; +import BN from "bn.js"; +import { initializeMintWithSeeds } from "../utils.js"; +import { createLookupTableForTransaction } from "../../utils.js"; + +export default function suite() { + let launchpadClient: LaunchpadClient; + let META: PublicKey; + let launch: PublicKey; + let launchSigner: PublicKey; + let launchAuthority: Keypair; + + const secondsForLaunch = 60 * 60 * 24 * 4; // 4 days + + before(async function () { + launchpadClient = this.launchpad_v8; + }); + + async function settleViaLut( + context: any, + client: LaunchpadClient, + params: { + launch: PublicKey; + baseMint: PublicKey; + launchAuthority: PublicKey; + }, + additionalSigners: Keypair[] = [], + ) { + const settleTx = await client + .settleLaunchIx({ + launch: params.launch, + baseMint: params.baseMint, + launchAuthority: params.launchAuthority, + }) + .transaction(); + + const lut = await createLookupTableForTransaction(settleTx, context); + + const message = new TransactionMessage({ + payerKey: context.payer.publicKey, + recentBlockhash: (await context.banksClient.getLatestBlockhash())[0], + instructions: settleTx.instructions, + }).compileToV0Message([lut]); + + const tx = new VersionedTransaction(message); + tx.sign([context.payer, ...additionalSigners]); + + await context.banksClient.processTransaction(tx); + } + + async function setupFundCloseApproveSettle( + context: any, + client: LaunchpadClient, + opts: { + launch: PublicKey; + baseMint: PublicKey; + launchAuthority: Keypair; + fundAmount: BN; + }, + ) { + await client + .fundIx({ + launch: opts.launch, + amount: opts.fundAmount, + payer: context.payer.publicKey, + }) + .rpc(); + + await context.advanceBySeconds(secondsForLaunch + 1); + + await client.closeLaunchIx({ launch: opts.launch }).rpc(); + + await client + .setFundingRecordApprovalIx({ + launch: opts.launch, + approvedAmount: opts.fundAmount, + funder: context.payer.publicKey, + launchAuthority: opts.launchAuthority.publicKey, + }) + .signers([opts.launchAuthority]) + .rpc(); + + await settleViaLut( + context, + client, + { + launch: opts.launch, + baseMint: opts.baseMint, + launchAuthority: opts.launchAuthority.publicKey, + }, + [opts.launchAuthority], + ); + } + + beforeEach(async function () { + const result = await initializeMintWithSeeds( + this.banksClient, + this.launchpad_v8, + this.payer, + ); + + META = result.tokenMint; + launch = result.launch; + launchSigner = result.launchSigner; + launchAuthority = new Keypair(); + }); + + it("sets and claims additional token allocation successfully, and only once", async function () { + const additionalTokensRecipient = new Keypair(); + const additionalTokensAmount = new BN(1_000_000 * 10 ** 6); // 1M tokens + + // Initialize with additional tokens recipient and amount + await launchpadClient + .initializeLaunchIx({ + tokenName: "META", + tokenSymbol: "META", + tokenUri: "https://example.com", + minimumRaiseAmount: new BN(100_000 * 10 ** 6), + secondsForLaunch, + baseMint: META, + quoteMint: MAINNET_USDC, + monthlySpendingLimitAmount: new BN(10_000 * 10 ** 6), + monthlySpendingLimitMembers: [this.payer.publicKey], + performancePackageGrantee: this.payer.publicKey, + performancePackageTokenAmount: new BN(5_000_000 * 10 ** 6), + monthsUntilInsidersCanUnlock: 24, + teamAddress: PublicKey.default, + launchAuthority: launchAuthority.publicKey, + additionalTokensRecipient: additionalTokensRecipient.publicKey, + additionalTokensAmount, + hasBidWall: false, + }) + .rpc(); + + await launchpadClient + .startLaunchIx({ + launch, + launchAuthority: launchAuthority.publicKey, + }) + .signers([launchAuthority]) + .rpc(); + + await this.createTokenAccount(META, this.payer.publicKey); + + await setupFundCloseApproveSettle(this, launchpadClient, { + launch, + baseMint: META, + launchAuthority, + fundAmount: new BN(150_000 * 10 ** 6), + }); + + // Verify launch is complete with additional tokens unclaimed + const launchAccount = await launchpadClient.fetchLaunch(launch); + assert.deepEqual(launchAccount.state, { complete: {} }); + assert.equal(launchAccount.additionalTokensClaimed, false); + + // Claim additional tokens + await launchpadClient + .claimAdditionalTokenAllocationIx({ + launch, + baseMint: META, + additionalTokensRecipient: additionalTokensRecipient.publicKey, + }) + .rpc(); + + // Verify tokens transferred to recipient + const recipientBalance = await this.getTokenBalance( + META, + additionalTokensRecipient.publicKey, + ); + assert.equal( + recipientBalance.toString(), + additionalTokensAmount.toString(), + ); + + // Verify state updated + const updatedLaunch = await launchpadClient.fetchLaunch(launch); + assert.equal(updatedLaunch.additionalTokensClaimed, true); + + // Try to claim again — should fail + try { + await launchpadClient + .claimAdditionalTokenAllocationIx({ + launch, + baseMint: META, + additionalTokensRecipient: additionalTokensRecipient.publicKey, + }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_001 }), + ]) + .rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "AdditionalTokensAlreadyClaimed"); + } + }); + + it("fails to claim additional token allocation if the launch doesn't have one", async function () { + // Standard launch without additional tokens + await this.setupBasicLaunch({ + baseMint: META, + founders: [this.payer.publicKey], + launchAuthority: launchAuthority.publicKey, + }); + + await launchpadClient + .startLaunchIx({ + launch, + launchAuthority: launchAuthority.publicKey, + }) + .signers([launchAuthority]) + .rpc(); + + await this.createTokenAccount(META, this.payer.publicKey); + + await setupFundCloseApproveSettle(this, launchpadClient, { + launch, + baseMint: META, + launchAuthority, + fundAmount: new BN(150_000 * 10 ** 6), + }); + + try { + await launchpadClient + .claimAdditionalTokenAllocationIx({ + launch, + baseMint: META, + additionalTokensRecipient: this.payer.publicKey, + }) + .rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "NoAdditionalTokensRecipientSet"); + } + }); +} diff --git a/tests/launchpad_v8/unit/closeLaunch.test.ts b/tests/launchpad_v8/unit/closeLaunch.test.ts new file mode 100644 index 00000000..eeb3c246 --- /dev/null +++ b/tests/launchpad_v8/unit/closeLaunch.test.ts @@ -0,0 +1,190 @@ +import { Keypair, PublicKey, ComputeBudgetProgram } from "@solana/web3.js"; +import { assert } from "chai"; +import { LaunchpadClient } from "@metadaoproject/programs/launchpad/v0.8"; +import { MAINNET_USDC } from "@metadaoproject/programs"; +import { BN } from "bn.js"; +import { initializeMintWithSeeds } from "../utils.js"; +import { expectError } from "../../utils.js"; + +export default function suite() { + let launchpadClient: LaunchpadClient; + let META: PublicKey; + let launch: PublicKey; + let launchSigner: PublicKey; + let launchAuthority: Keypair; + + before(async function () { + launchpadClient = this.launchpad_v8; + }); + + beforeEach(async function () { + const result = await initializeMintWithSeeds( + this.banksClient, + this.launchpad_v8, + this.payer, + ); + + META = result.tokenMint; + launch = result.launch; + launchSigner = result.launchSigner; + launchAuthority = new Keypair(); + + await this.setupBasicLaunch({ + baseMint: META, + founders: [this.payer.publicKey], + launchAuthority: launchAuthority.publicKey, + }); + + await launchpadClient + .startLaunchIx({ + launch, + launchAuthority: launchAuthority.publicKey, + }) + .signers([launchAuthority]) + .rpc(); + }); + + it("successfully closes launch after sufficient time when minimum raise is met", async function () { + const fundAmount = new BN(150_000 * 10 ** 6); + await launchpadClient + .fundIx({ + launch, + amount: fundAmount, + payer: this.payer.publicKey, + }) + .rpc(); + + const launchAccount = await launchpadClient.fetchLaunch(launch); + const secondsForLaunch = launchAccount.secondsForLaunch; + + await this.advanceBySeconds(secondsForLaunch + 100); + + await launchpadClient.closeLaunchIx({ launch }).rpc(); + + const updatedLaunch = await launchpadClient.fetchLaunch(launch); + assert.deepEqual(updatedLaunch.state, { closed: {} }); + assert.isNotNull(updatedLaunch.unixTimestampClosed); + }); + + it("successfully closes launch after sufficient time when minimum raise is not met", async function () { + const fundAmount = new BN(100 * 10 ** 6); + await launchpadClient + .fundIx({ + launch, + amount: fundAmount, + payer: this.payer.publicKey, + }) + .rpc(); + + const launchAccount = await launchpadClient.fetchLaunch(launch); + const secondsForLaunch = launchAccount.secondsForLaunch; + + await this.advanceBySeconds(secondsForLaunch + 100); + + await launchpadClient.closeLaunchIx({ launch }).rpc(); + + const updatedLaunch = await launchpadClient.fetchLaunch(launch); + assert.deepEqual(updatedLaunch.state, { refunding: {} }); + assert.isNotNull(updatedLaunch.unixTimestampClosed); + }); + + it("fails to close launch before sufficient time has passed", async function () { + const fundAmount = new BN(150_000 * 10 ** 6); + await launchpadClient + .fundIx({ + launch, + amount: fundAmount, + payer: this.payer.publicKey, + }) + .rpc(); + + await this.advanceBySeconds(60 * 60); + + const callbacks = expectError( + "LaunchPeriodNotOver", + "Should have rejected closing before launch period ended", + ); + + await launchpadClient + .closeLaunchIx({ launch }) + .rpc() + .then(callbacks[0], callbacks[1]); + + const launchAccount = await launchpadClient.fetchLaunch(launch); + assert.deepEqual(launchAccount.state, { live: {} }); + assert.isNull(launchAccount.unixTimestampClosed); + }); + + it("fails to close launch when launch has already been closed", async function () { + const fundAmount = new BN(150_000 * 10 ** 6); + await launchpadClient + .fundIx({ + launch, + amount: fundAmount, + payer: this.payer.publicKey, + }) + .rpc(); + + const launchAccount = await launchpadClient.fetchLaunch(launch); + const secondsForLaunch = launchAccount.secondsForLaunch; + + await this.advanceBySeconds(secondsForLaunch + 100); + await launchpadClient.closeLaunchIx({ launch }).rpc(); + + const callbacks = expectError( + "LaunchNotLive", + "Should have rejected closing an already closed launch", + ); + + await launchpadClient + .closeLaunchIx({ launch }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_001 }), + ]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("fails to close launch when launch is still in Initialized state", async function () { + const result = await initializeMintWithSeeds( + this.banksClient, + this.launchpad_v8, + this.payer, + ); + const newLaunch = result.launch; + const newMETA = result.tokenMint; + + await launchpadClient + .initializeLaunchIx({ + tokenName: "META2", + tokenSymbol: "META2", + tokenUri: "https://example.com", + minimumRaiseAmount: new BN(100_000 * 10 ** 6), + secondsForLaunch: 60 * 60 * 24 * 4, + baseMint: newMETA, + quoteMint: MAINNET_USDC, + monthlySpendingLimitAmount: new BN(10_000 * 10 ** 6), + monthlySpendingLimitMembers: [this.payer.publicKey], + performancePackageGrantee: this.payer.publicKey, + performancePackageTokenAmount: new BN(5_000_000 * 10 ** 6), + monthsUntilInsidersCanUnlock: 24, + teamAddress: PublicKey.default, + launchAuthority: launchAuthority.publicKey, + hasBidWall: false, + }) + .rpc(); + + const callbacks = expectError( + "LaunchNotLive", + "Should have rejected closing an initialized (not started) launch", + ); + + await launchpadClient + .closeLaunchIx({ launch: newLaunch }) + .rpc() + .then(callbacks[0], callbacks[1]); + + const launchAccount = await launchpadClient.fetchLaunch(newLaunch); + assert.deepEqual(launchAccount.state, { initialized: {} }); + }); +} diff --git a/tests/launchpad_v8/unit/extendLaunch.test.ts b/tests/launchpad_v8/unit/extendLaunch.test.ts new file mode 100644 index 00000000..6443c89d --- /dev/null +++ b/tests/launchpad_v8/unit/extendLaunch.test.ts @@ -0,0 +1,146 @@ +import { Keypair, PublicKey, ComputeBudgetProgram } from "@solana/web3.js"; +import { assert } from "chai"; +import { LaunchpadClient } from "@metadaoproject/programs/launchpad/v0.8"; +import { BN } from "bn.js"; +import { initializeMintWithSeeds } from "../utils.js"; +import { expectError } from "../../utils.js"; + +export default function suite() { + let launchpadClient: LaunchpadClient; + let META: PublicKey; + let launch: PublicKey; + let launchSigner: PublicKey; + let launchAuthority: Keypair; + + before(async function () { + launchpadClient = this.launchpad_v8; + }); + + beforeEach(async function () { + const result = await initializeMintWithSeeds( + this.banksClient, + this.launchpad_v8, + this.payer, + ); + + META = result.tokenMint; + launch = result.launch; + launchSigner = result.launchSigner; + launchAuthority = new Keypair(); + + await this.setupBasicLaunch({ + baseMint: META, + founders: [this.payer.publicKey], + launchAuthority: launchAuthority.publicKey, + }); + + await launchpadClient + .startLaunchIx({ + launch, + launchAuthority: launchAuthority.publicKey, + }) + .signers([launchAuthority]) + .rpc(); + }); + + it("successfully extends a live launch", async function () { + const launchBefore = await launchpadClient.fetchLaunch(launch); + const originalSeconds = launchBefore.secondsForLaunch; + + const extensionSeconds = 60 * 60 * 24; // 1 day + + await launchpadClient + .extendLaunchIx({ + launch, + durationSeconds: extensionSeconds, + admin: this.payer.publicKey, + }) + .rpc(); + + const launchAfter = await launchpadClient.fetchLaunch(launch); + assert.equal( + launchAfter.secondsForLaunch, + originalSeconds + extensionSeconds, + ); + }); + + it("funders can still fund after original deadline if extended", async function () { + const launchAccount = await launchpadClient.fetchLaunch(launch); + const originalSeconds = launchAccount.secondsForLaunch; + + const extensionSeconds = 60 * 60 * 24 * 2; // 2 days + + await launchpadClient + .extendLaunchIx({ + launch, + durationSeconds: extensionSeconds, + admin: this.payer.publicKey, + }) + .rpc(); + + // Advance past the original deadline but before the new one + await this.advanceBySeconds(originalSeconds + 100); + + const fundAmount = new BN(100 * 10 ** 6); + await launchpadClient + .fundIx({ + launch, + amount: fundAmount, + payer: this.payer.publicKey, + }) + .rpc(); + + const updatedLaunch = await launchpadClient.fetchLaunch(launch); + assert.deepEqual(updatedLaunch.state, { live: {} }); + }); + + it("close_launch respects new extended deadline", async function () { + const launchAccount = await launchpadClient.fetchLaunch(launch); + const originalSeconds = launchAccount.secondsForLaunch; + + const extensionSeconds = 60 * 60 * 24 * 2; // 2 days + + await launchpadClient + .extendLaunchIx({ + launch, + durationSeconds: extensionSeconds, + admin: this.payer.publicKey, + }) + .rpc(); + + const fundAmount = new BN(150_000 * 10 ** 6); + await launchpadClient + .fundIx({ + launch, + amount: fundAmount, + payer: this.payer.publicKey, + }) + .rpc(); + + // Advance past original deadline but before new deadline — close should fail + await this.advanceBySeconds(originalSeconds + 100); + + const callbacks = expectError( + "LaunchPeriodNotOver", + "Should have rejected closing before extended deadline", + ); + + await launchpadClient + .closeLaunchIx({ launch }) + .rpc() + .then(callbacks[0], callbacks[1]); + + // Advance past the new extended deadline — close should succeed + await this.advanceBySeconds(extensionSeconds); + + await launchpadClient + .closeLaunchIx({ launch }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_001 }), + ]) + .rpc(); + + const updatedLaunch = await launchpadClient.fetchLaunch(launch); + assert.deepEqual(updatedLaunch.state, { closed: {} }); + }); +} diff --git a/tests/launchpad_v8/unit/finalizeLaunch.test.ts b/tests/launchpad_v8/unit/finalizeLaunch.test.ts new file mode 100644 index 00000000..5d6d2514 --- /dev/null +++ b/tests/launchpad_v8/unit/finalizeLaunch.test.ts @@ -0,0 +1,289 @@ +import { + Keypair, + PublicKey, + TransactionMessage, + VersionedTransaction, + ComputeBudgetProgram, +} from "@solana/web3.js"; +import * as multisig from "@sqds/multisig"; +import { assert } from "chai"; +import { LaunchpadClient } from "@metadaoproject/programs/launchpad/v0.8"; +import BN from "bn.js"; +import { initializeMintWithSeeds } from "../utils.js"; +import { createLookupTableForTransaction } from "../../utils.js"; + +export default function suite() { + let launchpadClient: LaunchpadClient; + let META: PublicKey; + let launch: PublicKey; + let launchSigner: PublicKey; + let launchAuthority: Keypair; + + const secondsForLaunch = 60 * 60 * 24 * 4; // 4 days + + before(async function () { + launchpadClient = this.launchpad_v8; + }); + + async function settleViaLut( + context: any, + client: LaunchpadClient, + params: { + launch: PublicKey; + baseMint: PublicKey; + launchAuthority: PublicKey; + }, + additionalSigners: Keypair[] = [], + ) { + const settleTx = await client + .settleLaunchIx({ + launch: params.launch, + baseMint: params.baseMint, + launchAuthority: params.launchAuthority, + }) + .transaction(); + + const lut = await createLookupTableForTransaction(settleTx, context); + + const message = new TransactionMessage({ + payerKey: context.payer.publicKey, + recentBlockhash: (await context.banksClient.getLatestBlockhash())[0], + instructions: settleTx.instructions, + }).compileToV0Message([lut]); + + const tx = new VersionedTransaction(message); + tx.sign([context.payer, ...additionalSigners]); + + await context.banksClient.processTransaction(tx); + } + + async function setupFundCloseApproveSettle( + context: any, + client: LaunchpadClient, + opts: { + launch: PublicKey; + baseMint: PublicKey; + launchAuthority: Keypair; + fundAmount: BN; + }, + ) { + // Fund + await client + .fundIx({ + launch: opts.launch, + amount: opts.fundAmount, + payer: context.payer.publicKey, + }) + .rpc(); + + // Advance past launch period + await context.advanceBySeconds(secondsForLaunch + 1); + + // Close + await client.closeLaunchIx({ launch: opts.launch }).rpc(); + + // Approve + await client + .setFundingRecordApprovalIx({ + launch: opts.launch, + approvedAmount: opts.fundAmount, + funder: context.payer.publicKey, + launchAuthority: opts.launchAuthority.publicKey, + }) + .signers([opts.launchAuthority]) + .rpc(); + + // Settle + await settleViaLut( + context, + client, + { + launch: opts.launch, + baseMint: opts.baseMint, + launchAuthority: opts.launchAuthority.publicKey, + }, + [opts.launchAuthority], + ); + } + + beforeEach(async function () { + const result = await initializeMintWithSeeds( + this.banksClient, + this.launchpad_v8, + this.payer, + ); + + META = result.tokenMint; + launch = result.launch; + launchSigner = result.launchSigner; + launchAuthority = new Keypair(); + + await this.setupBasicLaunch({ + baseMint: META, + founders: [this.payer.publicKey], + launchAuthority: launchAuthority.publicKey, + }); + + await launchpadClient + .startLaunchIx({ + launch, + launchAuthority: launchAuthority.publicKey, + }) + .signers([launchAuthority]) + .rpc(); + }); + + it("finalizes launch with PP v2 setup and MintGovernor admin transfer to DAO", async function () { + const fundAmount = new BN(150_000 * 10 ** 6); // 150k USDC + + await setupFundCloseApproveSettle(this, launchpadClient, { + launch, + baseMint: META, + launchAuthority, + fundAmount, + }); + + // Verify launch is Complete before finalize + let launchAccount = await launchpadClient.fetchLaunch(launch); + assert.deepEqual(launchAccount.state, { complete: {} }); + assert.equal(launchAccount.isFinalized, false); + + // Finalize + await launchpadClient + .finalizeLaunchIx({ + launch, + baseMint: META, + performancePackageGrantee: launchAccount.performancePackageGrantee, + }) + .rpc(); + + // Reload launch state + launchAccount = await launchpadClient.fetchLaunch(launch); + assert.equal(launchAccount.isFinalized, true); + + // Derive expected addresses + const mintGovernorAddr = launchpadClient.getMintGovernorAddress({ + baseMint: META, + launchSigner, + }); + const daoAddr = launchpadClient.getLaunchDaoAddress({ launch }); + const [squadsMultisig] = multisig.getMultisigPda({ createKey: daoAddr }); + const [squadsMultisigVault] = multisig.getVaultPda({ + multisigPda: squadsMultisig, + index: 0, + }); + + // Verify MintGovernor admin transferred to DAO (squads_multisig_vault) + const mintGovernorAccount = + await launchpadClient.mintGovernorClient.fetchMintGovernor( + mintGovernorAddr, + ); + assert.ok(mintGovernorAccount.admin.equals(squadsMultisigVault)); + + // Verify PP v2 MintAuthority was created for the performance package + const performancePackageAddr = + launchpadClient.getLaunchPerformancePackageAddress({ launch }); + const ppMintAuthorityAddr = launchpadClient.getMintAuthorityAddress({ + mintGovernor: mintGovernorAddr, + authorizedMinter: performancePackageAddr, + }); + const ppMintAuthorityAccount = + await launchpadClient.mintGovernorClient.fetchMintAuthority( + ppMintAuthorityAddr, + ); + assert.ok( + ppMintAuthorityAccount.authorizedMinter.equals(performancePackageAddr), + ); + // max_total = performance_package_token_amount (5M tokens) + const expectedPPMaxTotal = new BN(5_000_000 * 10 ** 6); + assert.equal( + ppMintAuthorityAccount.maxTotal.toString(), + expectedPPMaxTotal.toString(), + ); + + // Verify PP v2 account was initialized + const ppAccount = + await launchpadClient.performancePackageV2.fetchPerformancePackage( + performancePackageAddr, + ); + assert.isNotNull(ppAccount); + + // PP authority should be squads_multisig_vault (DAO controls it) + assert.ok(ppAccount.authority.equals(squadsMultisigVault)); + + // PP recipient should be the performance_package_grantee + assert.ok( + ppAccount.recipient.equals(launchAccount.performancePackageGrantee), + ); + + // Verify DAO MintAuthority was created for the squads_multisig_vault + const daoMintAuthorityAddr = launchpadClient.getMintAuthorityAddress({ + mintGovernor: mintGovernorAddr, + authorizedMinter: squadsMultisigVault, + }); + const daoMintAuthorityAccount = + await launchpadClient.mintGovernorClient.fetchMintAuthority( + daoMintAuthorityAddr, + ); + assert.ok( + daoMintAuthorityAccount.authorizedMinter.equals(squadsMultisigVault), + ); + assert.isNull(daoMintAuthorityAccount.maxTotal); + }); + + it("fails when launch state is not Complete", async function () { + // Launch is in Live state — don't fund/close/settle + // The DAO constraint fires before validate() since launch.dao is None + try { + await launchpadClient + .finalizeLaunchIx({ + launch, + baseMint: META, + performancePackageGrantee: this.payer.publicKey, + }) + .rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "InvalidDao"); + } + }); + + it("can finalize only once", async function () { + const fundAmount = new BN(150_000 * 10 ** 6); + + await setupFundCloseApproveSettle(this, launchpadClient, { + launch, + baseMint: META, + launchAuthority, + fundAmount, + }); + + const launchAccount = await launchpadClient.fetchLaunch(launch); + + // First finalize succeeds + await launchpadClient + .finalizeLaunchIx({ + launch, + baseMint: META, + performancePackageGrantee: launchAccount.performancePackageGrantee, + }) + .rpc(); + + // Second finalize fails + try { + await launchpadClient + .finalizeLaunchIx({ + launch, + baseMint: META, + performancePackageGrantee: launchAccount.performancePackageGrantee, + }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ]) + .rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "PerformancePackageAlreadyInitialized"); + } + }); +} diff --git a/tests/launchpad_v8/unit/fund.test.ts b/tests/launchpad_v8/unit/fund.test.ts new file mode 100644 index 00000000..ffd38bf0 --- /dev/null +++ b/tests/launchpad_v8/unit/fund.test.ts @@ -0,0 +1,591 @@ +import { Keypair, PublicKey, ComputeBudgetProgram } from "@solana/web3.js"; +import { assert } from "chai"; +import { + LaunchpadClient, + getFundingRecordAddr, +} from "@metadaoproject/programs/launchpad/v0.8"; +import { MAINNET_USDC } from "@metadaoproject/programs"; +import { getAccount } from "spl-token-bankrun"; +import { BN } from "bn.js"; +import { getAssociatedTokenAddressSync } from "@solana/spl-token"; +import { initializeMintWithSeeds } from "../utils.js"; +import { expectError } from "../../utils.js"; + +export default function suite() { + let launchpadClient: LaunchpadClient; + let META: PublicKey; + let launch: PublicKey; + let launchSigner: PublicKey; + let launchAuthority: Keypair; + let quoteVault: PublicKey; + + before(async function () { + launchpadClient = this.launchpad_v8; + }); + + beforeEach(async function () { + const result = await initializeMintWithSeeds( + this.banksClient, + this.launchpad_v8, + this.payer, + ); + + META = result.tokenMint; + launch = result.launch; + launchSigner = result.launchSigner; + launchAuthority = new Keypair(); + + quoteVault = getAssociatedTokenAddressSync( + MAINNET_USDC, + launchSigner, + true, + ); + + await this.setupBasicLaunch({ + baseMint: META, + founders: [this.payer.publicKey], + launchAuthority: launchAuthority.publicKey, + }); + }); + + it("fails to fund the launch before it's started", async function () { + const fundAmount = new BN(100 * 10 ** 6); + + const callbacks = expectError( + "InvalidLaunchState", + "Should have rejected funding before launch started", + ); + + await launchpadClient + .fundIx({ + launch, + amount: fundAmount, + payer: this.payer.publicKey, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("successfully funds the launch", async function () { + await launchpadClient + .startLaunchIx({ + launch, + launchAuthority: launchAuthority.publicKey, + }) + .signers([launchAuthority]) + .rpc(); + + const fundAmount = new BN(100 * 10 ** 6); + + await launchpadClient + .fundIx({ + launch, + amount: fundAmount, + payer: this.payer.publicKey, + }) + .rpc(); + + const launchAccount = await launchpadClient.fetchLaunch(launch); + assert.equal( + launchAccount.totalCommittedAmount.toString(), + fundAmount.toString(), + ); + + const usdcVaultAccount = await getAccount(this.banksClient, quoteVault); + assert.equal(usdcVaultAccount.amount.toString(), fundAmount.toString()); + + const [fundingRecord, pdaBump] = getFundingRecordAddr( + launchpadClient.getProgramId(), + launch, + this.payer.publicKey, + ); + + const fundingRecordAccount = + await launchpadClient.fetchFundingRecord(fundingRecord); + assert.equal( + fundingRecordAccount.committedAmount.toString(), + fundAmount.toString(), + ); + assert.equal(fundingRecordAccount.pdaBump, pdaBump); + assert.ok(fundingRecordAccount.funder.equals(this.payer.publicKey)); + assert.ok(fundingRecordAccount.launch.equals(launch)); + assert.isFalse(fundingRecordAccount.isTokensClaimed); + assert.isFalse(fundingRecordAccount.isUsdcRefunded); + assert.equal(fundingRecordAccount.approvedAmount.toString(), "0"); + }); + + it("two different funders get independent funding records", async function () { + await launchpadClient + .startLaunchIx({ + launch, + launchAuthority: launchAuthority.publicKey, + }) + .signers([launchAuthority]) + .rpc(); + + const funder2 = new Keypair(); + await this.createTokenAccount(MAINNET_USDC, funder2.publicKey); + await this.transfer( + MAINNET_USDC, + this.payer, + funder2.publicKey, + 500 * 10 ** 6, + ); + + const amount1 = new BN(100 * 10 ** 6); + const amount2 = new BN(300 * 10 ** 6); + + // Funder 1 (payer) + await launchpadClient + .fundIx({ + launch, + amount: amount1, + payer: this.payer.publicKey, + }) + .rpc(); + + // Funder 2 + await launchpadClient + .fundIx({ + launch, + amount: amount2, + funder: funder2.publicKey, + payer: this.payer.publicKey, + }) + .signers([funder2]) + .rpc(); + + // Each funding record is independent + const [fr1] = getFundingRecordAddr( + launchpadClient.getProgramId(), + launch, + this.payer.publicKey, + ); + const [fr2] = getFundingRecordAddr( + launchpadClient.getProgramId(), + launch, + funder2.publicKey, + ); + + const frAccount1 = await launchpadClient.fetchFundingRecord(fr1); + const frAccount2 = await launchpadClient.fetchFundingRecord(fr2); + + assert.equal(frAccount1.committedAmount.toString(), amount1.toString()); + assert.ok(frAccount1.funder.equals(this.payer.publicKey)); + + assert.equal(frAccount2.committedAmount.toString(), amount2.toString()); + assert.ok(frAccount2.funder.equals(funder2.publicKey)); + + // Launch total is the sum + const launchAccount = await launchpadClient.fetchLaunch(launch); + assert.equal( + launchAccount.totalCommittedAmount.toString(), + amount1.add(amount2).toString(), + ); + + const usdcVaultAccount = await getAccount(this.banksClient, quoteVault); + assert.equal( + usdcVaultAccount.amount.toString(), + amount1.add(amount2).toString(), + ); + }); + + it("successfully funds the launch multiple times", async function () { + await launchpadClient + .startLaunchIx({ + launch, + launchAuthority: launchAuthority.publicKey, + }) + .signers([launchAuthority]) + .rpc(); + + const fundAmount1 = new BN(100 * 10 ** 6); + const fundAmount2 = new BN(200 * 10 ** 6); + const totalAmount = fundAmount1.add(fundAmount2); + + await launchpadClient + .fundIx({ + launch, + amount: fundAmount1, + payer: this.payer.publicKey, + }) + .rpc(); + + await launchpadClient + .fundIx({ + launch, + amount: fundAmount2, + payer: this.payer.publicKey, + }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_001 }), + ]) + .rpc(); + + const launchAccount = await launchpadClient.fetchLaunch(launch); + assert.equal( + launchAccount.totalCommittedAmount.toString(), + totalAmount.toString(), + ); + + const usdcVaultAccount = await getAccount(this.banksClient, quoteVault); + assert.equal(usdcVaultAccount.amount.toString(), totalAmount.toString()); + + const [fundingRecord] = getFundingRecordAddr( + launchpadClient.getProgramId(), + launch, + this.payer.publicKey, + ); + + const fundingRecordAccount = + await launchpadClient.fetchFundingRecord(fundingRecord); + assert.equal( + fundingRecordAccount.committedAmount.toString(), + totalAmount.toString(), + ); + }); + + it("fails to fund the launch at the exact boundary second", async function () { + await launchpadClient + .startLaunchIx({ + launch, + launchAuthority: launchAuthority.publicKey, + }) + .signers([launchAuthority]) + .rpc(); + + const launchAccount = await launchpadClient.fetchLaunch(launch); + const secondsForLaunch = launchAccount.secondsForLaunch; + + // Advance to the exact expiration boundary + await this.advanceBySeconds(secondsForLaunch); + + const fundAmount = new BN(100 * 10 ** 6); + + const callbacks = expectError( + "LaunchExpired", + "Should have rejected funding at exact boundary", + ); + + await launchpadClient + .fundIx({ + launch, + amount: fundAmount, + payer: this.payer.publicKey, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("fails to fund the launch after time expires", async function () { + await launchpadClient + .startLaunchIx({ + launch, + launchAuthority: launchAuthority.publicKey, + }) + .signers([launchAuthority]) + .rpc(); + + const launchAccount = await launchpadClient.fetchLaunch(launch); + const secondsForLaunch = launchAccount.secondsForLaunch; + + // Advance past the launch period + await this.advanceBySeconds(secondsForLaunch + 10); + + const fundAmount = new BN(100 * 10 ** 6); + + const callbacks = expectError( + "LaunchExpired", + "Should have rejected funding after expiration", + ); + + await launchpadClient + .fundIx({ + launch, + amount: fundAmount, + payer: this.payer.publicKey, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("accumulator starts at 0 and last_accumulator_update is set on first fund", async function () { + await launchpadClient + .startLaunchIx({ + launch, + launchAuthority: launchAuthority.publicKey, + }) + .signers([launchAuthority]) + .rpc(); + + const clock = await this.banksClient.getClock(); + + const fundAmount = new BN(100 * 10 ** 6); + + await launchpadClient + .fundIx({ + launch, + amount: fundAmount, + payer: this.payer.publicKey, + }) + .rpc(); + + const [fundingRecord] = getFundingRecordAddr( + launchpadClient.getProgramId(), + launch, + this.payer.publicKey, + ); + + const fundingRecordAccount = + await launchpadClient.fetchFundingRecord(fundingRecord); + assert.equal( + fundingRecordAccount.committedAmountAccumulator.toString(), + "0", + ); + assert.equal( + fundingRecordAccount.lastAccumulatorUpdate.toString(), + clock.unixTimestamp.toString(), + ); + }); + + it("accumulator correctly sums across multiple time intervals", async function () { + await launchpadClient + .startLaunchIx({ + launch, + launchAuthority: launchAuthority.publicKey, + }) + .signers([launchAuthority]) + .rpc(); + + const fundAmount1 = new BN(100 * 10 ** 6); + const fundAmount2 = new BN(200 * 10 ** 6); + + // First fund at t=0 + await launchpadClient + .fundIx({ + launch, + amount: fundAmount1, + payer: this.payer.publicKey, + }) + .rpc(); + + const clock1 = await this.banksClient.getClock(); + + // Advance 60 seconds + const elapsed1 = 60; + await this.advanceBySeconds(elapsed1); + + // Second fund at t=60 + await launchpadClient + .fundIx({ + launch, + amount: fundAmount2, + payer: this.payer.publicKey, + }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_001 }), + ]) + .rpc(); + + const [fundingRecord] = getFundingRecordAddr( + launchpadClient.getProgramId(), + launch, + this.payer.publicKey, + ); + + const fundingRecordAccount = + await launchpadClient.fetchFundingRecord(fundingRecord); + + // Accumulator = fundAmount1 * elapsed1 = 100_000_000 * 60 = 6_000_000_000 + const expectedAccumulator = new BN(100 * 10 ** 6).muln(elapsed1); + assert.equal( + fundingRecordAccount.committedAmountAccumulator.toString(), + expectedAccumulator.toString(), + ); + assert.equal( + fundingRecordAccount.committedAmount.toString(), + fundAmount1.add(fundAmount2).toString(), + ); + }); + + it("accumulator stays 0 during activation delay period", async function () { + // Create a launch with accumulator activation delay + const delayResult = await initializeMintWithSeeds( + this.banksClient, + this.launchpad_v8, + this.payer, + ); + const delayMeta = delayResult.tokenMint; + const delayLaunch = delayResult.launch; + const delayLaunchSigner = delayResult.launchSigner; + const delayLaunchAuthority = new Keypair(); + + const secondsForLaunch = 60 * 60 * 24 * 4; // 4 days + const delaySeconds = 3600; // 1 hour delay + + await launchpadClient + .initializeLaunchIx({ + tokenName: "DELAY", + tokenSymbol: "DELAY", + tokenUri: "https://example.com", + minimumRaiseAmount: new BN(100_000 * 10 ** 6), + secondsForLaunch, + baseMint: delayMeta, + quoteMint: MAINNET_USDC, + monthlySpendingLimitAmount: new BN(10_000 * 10 ** 6), + monthlySpendingLimitMembers: [this.payer.publicKey], + performancePackageGrantee: this.payer.publicKey, + performancePackageTokenAmount: new BN(5_000_000 * 10 ** 6), + monthsUntilInsidersCanUnlock: 24, + teamAddress: PublicKey.default, + launchAuthority: delayLaunchAuthority.publicKey, + accumulatorActivationDelaySeconds: delaySeconds, + hasBidWall: false, + }) + .rpc(); + + await launchpadClient + .startLaunchIx({ + launch: delayLaunch, + launchAuthority: delayLaunchAuthority.publicKey, + }) + .signers([delayLaunchAuthority]) + .rpc(); + + const fundAmount = new BN(100 * 10 ** 6); + + // Fund at t=0 (within delay period) + await launchpadClient + .fundIx({ + launch: delayLaunch, + amount: fundAmount, + payer: this.payer.publicKey, + }) + .rpc(); + + // Advance 30 minutes (still within 1 hour delay) + await this.advanceBySeconds(1800); + + // Fund again still within delay period + await launchpadClient + .fundIx({ + launch: delayLaunch, + amount: fundAmount, + payer: this.payer.publicKey, + }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_001 }), + ]) + .rpc(); + + const [fundingRecord] = getFundingRecordAddr( + launchpadClient.getProgramId(), + delayLaunch, + this.payer.publicKey, + ); + + const fundingRecordAccount = + await launchpadClient.fetchFundingRecord(fundingRecord); + + // Accumulator should remain 0 because both funds happened during the delay period + assert.equal( + fundingRecordAccount.committedAmountAccumulator.toString(), + "0", + ); + }); + + it("accumulator only counts time after activation delay", async function () { + // Create a launch with accumulator activation delay + const delayResult = await initializeMintWithSeeds( + this.banksClient, + this.launchpad_v8, + this.payer, + ); + const delayMeta = delayResult.tokenMint; + const delayLaunch = delayResult.launch; + const delayLaunchSigner = delayResult.launchSigner; + const delayLaunchAuthority = new Keypair(); + + const secondsForLaunch = 60 * 60 * 24 * 4; // 4 days + const delaySeconds = 3600; // 1 hour delay + + await launchpadClient + .initializeLaunchIx({ + tokenName: "DELAY2", + tokenSymbol: "DELAY2", + tokenUri: "https://example.com", + minimumRaiseAmount: new BN(100_000 * 10 ** 6), + secondsForLaunch, + baseMint: delayMeta, + quoteMint: MAINNET_USDC, + monthlySpendingLimitAmount: new BN(10_000 * 10 ** 6), + monthlySpendingLimitMembers: [this.payer.publicKey], + performancePackageGrantee: this.payer.publicKey, + performancePackageTokenAmount: new BN(5_000_000 * 10 ** 6), + monthsUntilInsidersCanUnlock: 24, + teamAddress: PublicKey.default, + launchAuthority: delayLaunchAuthority.publicKey, + accumulatorActivationDelaySeconds: delaySeconds, + hasBidWall: false, + }) + .rpc(); + + await launchpadClient + .startLaunchIx({ + launch: delayLaunch, + launchAuthority: delayLaunchAuthority.publicKey, + }) + .signers([delayLaunchAuthority]) + .rpc(); + + const launchAccount = await launchpadClient.fetchLaunch(delayLaunch); + const startTime = launchAccount.unixTimestampStarted.toNumber(); + const activationTimestamp = startTime + delaySeconds; + + const fundAmount = new BN(100 * 10 ** 6); + + // Fund at t=0 (before activation) + await launchpadClient + .fundIx({ + launch: delayLaunch, + amount: fundAmount, + payer: this.payer.publicKey, + }) + .rpc(); + + // Advance past the activation delay + 120 seconds + const timeAfterActivation = 120; + await this.advanceBySeconds(delaySeconds + timeAfterActivation); + + // Fund again after activation delay + await launchpadClient + .fundIx({ + launch: delayLaunch, + amount: fundAmount, + payer: this.payer.publicKey, + }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_001 }), + ]) + .rpc(); + + const [fundingRecord] = getFundingRecordAddr( + launchpadClient.getProgramId(), + delayLaunch, + this.payer.publicKey, + ); + + const fundingRecordAccount = + await launchpadClient.fetchFundingRecord(fundingRecord); + + // The accumulator should only count time after the activation timestamp. + // period_start = max(last_accumulator_update=startTime, activation_timestamp) = activation_timestamp + // elapsed = (startTime + delaySeconds + timeAfterActivation) - activation_timestamp = timeAfterActivation = 120 + // accumulator = fundAmount * 120 = 100_000_000 * 120 = 12_000_000_000 + const expectedAccumulator = new BN(100 * 10 ** 6).muln(timeAfterActivation); + assert.equal( + fundingRecordAccount.committedAmountAccumulator.toString(), + expectedAccumulator.toString(), + ); + }); +} diff --git a/tests/launchpad_v8/unit/initializeLaunch.test.ts b/tests/launchpad_v8/unit/initializeLaunch.test.ts new file mode 100644 index 00000000..cdb73fb2 --- /dev/null +++ b/tests/launchpad_v8/unit/initializeLaunch.test.ts @@ -0,0 +1,358 @@ +import { + PublicKey, + Keypair, + SystemProgram, + Transaction, + Signer, +} from "@solana/web3.js"; +import { assert } from "chai"; +import { + FutarchyClient, + MAINNET_USDC, + MPL_TOKEN_METADATA_PROGRAM_ID, +} from "@metadaoproject/programs"; +import { + LaunchpadClient, + getLaunchAddr, + getLaunchSignerAddr, +} from "@metadaoproject/programs/launchpad/v0.8"; +import { + getMintGovernorAddr, + getMintAuthorityAddr, +} from "@metadaoproject/programs/mint_governor/v0.7"; +import { getMetadataAddr } from "@metadaoproject/programs"; +import { BN } from "bn.js"; +import { getAssociatedTokenAddressSync } from "@solana/spl-token"; +import * as token from "@solana/spl-token"; +import { initializeMintWithSeeds } from "../utils.js"; +import { expectError } from "../../utils.js"; + +export default function suite() { + let futarchyClient: FutarchyClient; + let launchpadClient: LaunchpadClient; + let META: PublicKey; + let launch: PublicKey; + let launchSigner: PublicKey; + let launchAuthority: Signer; + + before(async function () { + futarchyClient = this.futarchy; + launchpadClient = this.launchpad_v8; + }); + + beforeEach(async function () { + const result = await initializeMintWithSeeds( + this.banksClient, + this.launchpad_v8, + this.payer, + ); + + META = result.tokenMint; + launch = result.launch; + launchSigner = result.launchSigner; + launchAuthority = new Keypair(); + }); + + it("initializes a launch with valid parameters", async function () { + const minRaise = new BN(1000_000000); // 1000 USDC + const secondsForLaunch = 60 * 60 * 24 * 7; // 1 week + const monthlySpend = new BN(100_000000); + const recipientAddress = Keypair.generate().publicKey; + const premineAmount = new BN(500_000_000); + + const [, pdaBump] = getLaunchAddr(launchpadClient.getProgramId(), META); + const [, launchSignerPdaBump] = getLaunchSignerAddr( + launchpadClient.getProgramId(), + launch, + ); + + await launchpadClient + .initializeLaunchIx({ + tokenName: "META", + tokenSymbol: "META", + tokenUri: "https://example.com", + minimumRaiseAmount: minRaise, + secondsForLaunch: secondsForLaunch, + baseMint: META, + quoteMint: MAINNET_USDC, + monthlySpendingLimitAmount: monthlySpend, + monthlySpendingLimitMembers: [this.payer.publicKey], + performancePackageGrantee: recipientAddress, + performancePackageTokenAmount: premineAmount, + monthsUntilInsidersCanUnlock: 18, + teamAddress: PublicKey.default, + launchAuthority: launchAuthority.publicKey, + hasBidWall: true, + }) + .rpc(); + + const storedLaunch = await launchpadClient.fetchLaunch(launch); + + // Core launch fields + assert.equal( + storedLaunch.minimumRaiseAmount.toString(), + minRaise.toString(), + ); + assert.ok(storedLaunch.launchAuthority.equals(launchAuthority.publicKey)); + assert.ok(storedLaunch.launchSigner.equals(launchSigner)); + assert.equal(storedLaunch.launchSignerPdaBump, launchSignerPdaBump); + assert.ok( + storedLaunch.launchQuoteVault.equals( + token.getAssociatedTokenAddressSync(MAINNET_USDC, launchSigner, true), + ), + ); + assert.ok( + storedLaunch.launchBaseVault.equals( + token.getAssociatedTokenAddressSync(META, launchSigner, true), + ), + ); + assert.ok(storedLaunch.baseMint.equals(META)); + assert.equal(storedLaunch.pdaBump, pdaBump); + assert.equal(storedLaunch.totalCommittedAmount.toString(), "0"); + assert.equal(storedLaunch.seqNum.toString(), "0"); + assert.exists(storedLaunch.state.initialized); + assert.isNull(storedLaunch.unixTimestampStarted); + assert.isNull(storedLaunch.dao); + assert.equal(storedLaunch.accumulatorActivationDelaySeconds, 0); + assert.isTrue(storedLaunch.hasBidWall); + assert.isFalse(storedLaunch.isFinalized); + + // MintGovernor PDA stored on launch + const mintGovernorClient = launchpadClient.mintGovernorClient; + const [expectedMintGovernor] = getMintGovernorAddr({ + programId: mintGovernorClient.programId, + mint: META, + createKey: launchSigner, + }); + assert.ok(storedLaunch.mintGovernor.equals(expectedMintGovernor)); + + // MintGovernor initialized with admin = launch_signer + const mintGovernorAccount = + await mintGovernorClient.fetchMintGovernor(expectedMintGovernor); + assert.ok(mintGovernorAccount.admin.equals(launchSigner)); + assert.ok(mintGovernorAccount.mint.equals(META)); + + // total_supply = TOKENS_TO_PARTICIPANTS + TOKENS_TO_FUTARCHY_LIQUIDITY + TOKENS_TO_DAMM_V2_LIQUIDITY + additional_tokens_amount + // = 10_000_000 + 2_000_000 + 900_000 + 0 = 12_900_000 tokens (scaled by 10^6) + const expectedTotalSupply = new BN("12900000000000"); // 12_900_000 * 1_000_000 + + // SPL mint authority is the MintGovernor PDA + const mintInfo = await this.getMint(META); + assert.ok(mintInfo.mintAuthority.equals(expectedMintGovernor)); + + // Tokens are minted during initialize_launch (before governor takes over) + assert.equal(mintInfo.supply.toString(), expectedTotalSupply.toString()); + const baseVaultBalance = await this.getTokenBalance(META, launchSigner); + assert.equal(baseVaultBalance.toString(), expectedTotalSupply.toString()); + }); + + it("fails when monthly spending limit members contains duplicates", async function () { + const minRaise = new BN(1000_000000); + const secondsForLaunch = 60 * 60 * 24 * 7; + const monthlySpend = new BN(100_000000); + const recipientAddress = Keypair.generate().publicKey; + const premineAmount = new BN(500_000_000); + + const callbacks = expectError( + "InvalidMonthlySpendingLimitMembers", + "Should have rejected duplicate monthly spending limit members", + ); + + await launchpadClient + .initializeLaunchIx({ + tokenName: "META", + tokenSymbol: "META", + tokenUri: "https://example.com", + minimumRaiseAmount: minRaise, + secondsForLaunch: secondsForLaunch, + baseMint: META, + quoteMint: MAINNET_USDC, + monthlySpendingLimitAmount: monthlySpend, + monthlySpendingLimitMembers: [ + this.payer.publicKey, + this.payer.publicKey, + ], + performancePackageGrantee: recipientAddress, + performancePackageTokenAmount: premineAmount, + monthsUntilInsidersCanUnlock: 18, + teamAddress: PublicKey.default, + launchAuthority: launchAuthority.publicKey, + hasBidWall: false, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("fails when monthly spending limit members is empty", async function () { + const minRaise = new BN(1000_000000); + const secondsForLaunch = 60 * 60 * 24 * 7; + const monthlySpend = new BN(100_000000); + const recipientAddress = Keypair.generate().publicKey; + const premineAmount = new BN(500_000_000); + + const callbacks = expectError( + "InvalidMonthlySpendingLimitMembers", + "Should have rejected empty monthly spending limit members", + ); + + await launchpadClient + .initializeLaunchIx({ + tokenName: "META", + tokenSymbol: "META", + tokenUri: "https://example.com", + minimumRaiseAmount: minRaise, + secondsForLaunch: secondsForLaunch, + baseMint: META, + quoteMint: MAINNET_USDC, + monthlySpendingLimitAmount: monthlySpend, + monthlySpendingLimitMembers: [], + performancePackageGrantee: recipientAddress, + performancePackageTokenAmount: premineAmount, + monthsUntilInsidersCanUnlock: 18, + teamAddress: PublicKey.default, + launchAuthority: launchAuthority.publicKey, + hasBidWall: false, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("rejects accumulator activation delay >= seconds_for_launch", async function () { + const minRaise = new BN(1000_000000); + const secondsForLaunch = 60 * 60 * 24 * 7; + const monthlySpend = new BN(100_000000); + const recipientAddress = Keypair.generate().publicKey; + const premineAmount = new BN(500_000_000); + + const callbacks = expectError( + "InvalidAccumulatorActivationDelaySeconds", + "Should have rejected accumulator activation delay >= seconds_for_launch", + ); + + await launchpadClient + .initializeLaunchIx({ + tokenName: "META", + tokenSymbol: "META", + tokenUri: "https://example.com", + minimumRaiseAmount: minRaise, + secondsForLaunch: secondsForLaunch, + baseMint: META, + quoteMint: MAINNET_USDC, + monthlySpendingLimitAmount: monthlySpend, + monthlySpendingLimitMembers: [this.payer.publicKey], + performancePackageGrantee: recipientAddress, + performancePackageTokenAmount: premineAmount, + monthsUntilInsidersCanUnlock: 18, + teamAddress: PublicKey.default, + launchAuthority: launchAuthority.publicKey, + accumulatorActivationDelaySeconds: secondsForLaunch, + hasBidWall: false, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("fails when launch signer is faked", async function () { + const minRaise = new BN(1000_000000); + const secondsForLaunch = 60 * 60 * 24 * 7; + const fakeLaunchSigner = Keypair.generate(); + const monthlySpend = new BN(100_000000); + const recipientAddress = Keypair.generate().publicKey; + const premineAmount = new BN(500_000_000); + + const fakeSignerFrom = Keypair.generate(); + const fakeSignerFromPubkey = fakeSignerFrom.publicKey; + + META = await PublicKey.createWithSeed( + fakeSignerFrom.publicKey, + "fake-launch-signer", + token.TOKEN_PROGRAM_ID, + ); + + const rent = await this.banksClient.getRent(); + + const lamports = Number(await rent.minimumBalance(BigInt(token.MINT_SIZE))); + + const tx = new Transaction().add( + SystemProgram.createAccountWithSeed({ + fromPubkey: this.payer.publicKey, + newAccountPubkey: META, + basePubkey: fakeSignerFromPubkey, + seed: "fake-launch-signer", + lamports: lamports, + space: token.MINT_SIZE, + programId: token.TOKEN_PROGRAM_ID, + }), + token.createInitializeMint2Instruction( + META, + 6, + fakeLaunchSigner.publicKey, + null, + ), + ); + tx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + tx.feePayer = this.payer.publicKey; + tx.sign(this.payer, fakeSignerFrom); + + await this.banksClient.processTransaction(tx); + + const [tokenMetadata] = getMetadataAddr(META); + + const callbacks = expectError( + "ConstraintSeeds", + "Should have rejected faked launch signer", + ); + + await launchpadClient + .initializeLaunchIx({ + tokenName: "META", + tokenSymbol: "META", + tokenUri: "https://example.com", + minimumRaiseAmount: minRaise, + secondsForLaunch: secondsForLaunch, + baseMint: META, + quoteMint: MAINNET_USDC, + monthlySpendingLimitAmount: monthlySpend, + monthlySpendingLimitMembers: [this.payer.publicKey], + performancePackageGrantee: recipientAddress, + performancePackageTokenAmount: premineAmount, + monthsUntilInsidersCanUnlock: 18, + teamAddress: PublicKey.default, + launchAuthority: launchAuthority.publicKey, + hasBidWall: false, + }) + .accounts({ + launch, + launchSigner: fakeLaunchSigner.publicKey, + quoteVault: token.getAssociatedTokenAddressSync( + MAINNET_USDC, + fakeLaunchSigner.publicKey, + true, + ), + baseVault: token.getAssociatedTokenAddressSync( + META, + fakeLaunchSigner.publicKey, + true, + ), + launchAuthority: launchAuthority.publicKey, + quoteMint: MAINNET_USDC, + baseMint: META, + tokenMetadata, + tokenMetadataProgram: MPL_TOKEN_METADATA_PROGRAM_ID, + }) + .preInstructions([ + token.createAssociatedTokenAccountIdempotentInstruction( + this.payer.publicKey, + getAssociatedTokenAddressSync( + MAINNET_USDC, + fakeLaunchSigner.publicKey, + true, + ), + fakeLaunchSigner.publicKey, + MAINNET_USDC, + ), + ]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); +} diff --git a/tests/launchpad_v8/unit/refund.test.ts b/tests/launchpad_v8/unit/refund.test.ts new file mode 100644 index 00000000..3c6e4550 --- /dev/null +++ b/tests/launchpad_v8/unit/refund.test.ts @@ -0,0 +1,233 @@ +import { + Keypair, + PublicKey, + TransactionMessage, + VersionedTransaction, +} from "@solana/web3.js"; +import { assert } from "chai"; +import { + LaunchpadClient, + getFundingRecordAddr, +} from "@metadaoproject/programs/launchpad/v0.8"; +import { MAINNET_USDC } from "@metadaoproject/programs"; +import BN from "bn.js"; +import { initializeMintWithSeeds } from "../utils.js"; +import { createLookupTableForTransaction } from "../../utils.js"; + +export default function suite() { + let launchpadClient: LaunchpadClient; + let META: PublicKey; + let launch: PublicKey; + let launchSigner: PublicKey; + let launchAuthority: Keypair; + + const secondsForLaunch = 60 * 60 * 24 * 4; // 4 days + + before(async function () { + launchpadClient = this.launchpad_v8; + }); + + async function settleViaLut( + context: any, + client: LaunchpadClient, + params: { + launch: PublicKey; + baseMint: PublicKey; + launchAuthority: PublicKey | null; + }, + additionalSigners: Keypair[] = [], + ) { + const settleTx = await client + .settleLaunchIx({ + launch: params.launch, + baseMint: params.baseMint, + launchAuthority: params.launchAuthority, + }) + .transaction(); + + const lut = await createLookupTableForTransaction(settleTx, context); + + const message = new TransactionMessage({ + payerKey: context.payer.publicKey, + recentBlockhash: (await context.banksClient.getLatestBlockhash())[0], + instructions: settleTx.instructions, + }).compileToV0Message([lut]); + + const tx = new VersionedTransaction(message); + tx.sign([context.payer, ...additionalSigners]); + + await context.banksClient.processTransaction(tx); + } + + beforeEach(async function () { + const result = await initializeMintWithSeeds( + this.banksClient, + this.launchpad_v8, + this.payer, + ); + + META = result.tokenMint; + launch = result.launch; + launchSigner = result.launchSigner; + launchAuthority = new Keypair(); + + await this.setupBasicLaunch({ + baseMint: META, + founders: [this.payer.publicKey], + launchAuthority: launchAuthority.publicKey, + }); + + await launchpadClient + .startLaunchIx({ + launch, + launchAuthority: launchAuthority.publicKey, + }) + .signers([launchAuthority]) + .rpc(); + + await this.createTokenAccount(META, this.payer.publicKey); + }); + + it("allows refunds when launch is in refunding state", async function () { + // Fund below minimum raise so close_launch sets Refunding + const fundAmount = new BN(50_000 * 10 ** 6); // 50k USDC (below 100k minimum) + + await launchpadClient + .fundIx({ + launch, + amount: fundAmount, + payer: this.payer.publicKey, + }) + .rpc(); + + // Advance past launch period and close + await this.advanceBySeconds(secondsForLaunch + 1); + await launchpadClient.closeLaunchIx({ launch }).rpc(); + + // Verify launch is in Refunding state + let launchAccount = await launchpadClient.fetchLaunch(launch); + assert.deepEqual(launchAccount.state, { refunding: {} }); + + const initialUsdcBalance = await this.getTokenBalance( + MAINNET_USDC, + this.payer.publicKey, + ); + + // Refund + await launchpadClient.refundIx({ launch }).rpc(); + + const finalUsdcBalance = await this.getTokenBalance( + MAINNET_USDC, + this.payer.publicKey, + ); + + // Full committed amount refunded + assert.equal( + (finalUsdcBalance - initialUsdcBalance).toString(), + fundAmount.toString(), + ); + + // Verify funding record + const [fundingRecord] = getFundingRecordAddr( + launchpadClient.getProgramId(), + launch, + this.payer.publicKey, + ); + const fundingRecordAccount = + await launchpadClient.fetchFundingRecord(fundingRecord); + assert.equal(fundingRecordAccount.isUsdcRefunded, true); + }); + + it("works for oversubscribed launches", async function () { + // Fund more than minimum + const fundAmount = new BN(200_000 * 10 ** 6); // 200k USDC + + await launchpadClient + .fundIx({ + launch, + amount: fundAmount, + payer: this.payer.publicKey, + }) + .rpc(); + + // Advance past launch period and close + await this.advanceBySeconds(secondsForLaunch + 1); + await launchpadClient.closeLaunchIx({ launch }).rpc(); + + // Approve only 150k of the 200k + const approvedAmount = new BN(150_000 * 10 ** 6); + await launchpadClient + .setFundingRecordApprovalIx({ + launch, + approvedAmount, + funder: this.payer.publicKey, + launchAuthority: launchAuthority.publicKey, + }) + .signers([launchAuthority]) + .rpc(); + + // Settle — transitions to Complete + await settleViaLut( + this, + launchpadClient, + { + launch, + baseMint: META, + launchAuthority: launchAuthority.publicKey, + }, + [launchAuthority], + ); + + const launchAccount = await launchpadClient.fetchLaunch(launch); + assert.deepEqual(launchAccount.state, { complete: {} }); + + const initialUsdcBalance = await this.getTokenBalance( + MAINNET_USDC, + this.payer.publicKey, + ); + + // Refund — should get committed - approved = 50k + await launchpadClient.refundIx({ launch }).rpc(); + + const finalUsdcBalance = await this.getTokenBalance( + MAINNET_USDC, + this.payer.publicKey, + ); + + const expectedRefund = fundAmount.sub(approvedAmount); // 200k - 150k = 50k + assert.equal( + (finalUsdcBalance - initialUsdcBalance).toString(), + expectedRefund.toString(), + ); + + // Verify funding record + const [fundingRecord] = getFundingRecordAddr( + launchpadClient.getProgramId(), + launch, + this.payer.publicKey, + ); + const fundingRecordAccount = + await launchpadClient.fetchFundingRecord(fundingRecord); + assert.equal(fundingRecordAccount.isUsdcRefunded, true); + }); + + it("fails when launch is not in refunding or complete state", async function () { + // Fund but don't close — launch is still in Funding state + const fundAmount = new BN(150_000 * 10 ** 6); + + await launchpadClient + .fundIx({ + launch, + amount: fundAmount, + payer: this.payer.publicKey, + }) + .rpc(); + + try { + await launchpadClient.refundIx({ launch }).rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "LaunchNotRefunding"); + } + }); +} diff --git a/tests/launchpad_v8/unit/setFundingRecordApproval.test.ts b/tests/launchpad_v8/unit/setFundingRecordApproval.test.ts new file mode 100644 index 00000000..ede91d2b --- /dev/null +++ b/tests/launchpad_v8/unit/setFundingRecordApproval.test.ts @@ -0,0 +1,406 @@ +import { + Keypair, + PublicKey, + TransactionMessage, + VersionedTransaction, +} from "@solana/web3.js"; +import { assert } from "chai"; +import { + LaunchpadClient, + getFundingRecordAddr, +} from "@metadaoproject/programs/launchpad/v0.8"; +import { MAINNET_USDC } from "@metadaoproject/programs"; +import { BN } from "bn.js"; +import { initializeMintWithSeeds } from "../utils.js"; +import { expectError, createLookupTableForTransaction } from "../../utils.js"; + +export default function suite() { + let launchpadClient: LaunchpadClient; + let META: PublicKey; + let launch: PublicKey; + let launchSigner: PublicKey; + let launchAuthority: Keypair; + + const secondsForLaunch = 60 * 60 * 24 * 4; // 4 days + + const funder1 = Keypair.generate(); + const funder2 = Keypair.generate(); + const funder3 = Keypair.generate(); + const funder4 = Keypair.generate(); + + const funder1Amount = new BN(30_000 * 10 ** 6); // 30,000 USDC + const funder2Amount = new BN(40_000 * 10 ** 6); // 40,000 USDC + const funder3Amount = new BN(20_000 * 10 ** 6); // 20,000 USDC + const funder4Amount = new BN(50_000 * 10 ** 6); // 50,000 USDC + + async function fundLaunch() { + await launchpadClient + .fundIx({ launch, amount: funder1Amount, funder: funder1.publicKey }) + .signers([funder1]) + .rpc(); + await launchpadClient + .fundIx({ launch, amount: funder2Amount, funder: funder2.publicKey }) + .signers([funder2]) + .rpc(); + await launchpadClient + .fundIx({ launch, amount: funder3Amount, funder: funder3.publicKey }) + .signers([funder3]) + .rpc(); + await launchpadClient + .fundIx({ launch, amount: funder4Amount, funder: funder4.publicKey }) + .signers([funder4]) + .rpc(); + } + + before(async function () { + launchpadClient = this.launchpad_v8; + + await this.createTokenAccount(MAINNET_USDC, funder1.publicKey); + await this.createTokenAccount(MAINNET_USDC, funder2.publicKey); + await this.createTokenAccount(MAINNET_USDC, funder3.publicKey); + await this.createTokenAccount(MAINNET_USDC, funder4.publicKey); + }); + + beforeEach(async function () { + const result = await initializeMintWithSeeds( + this.banksClient, + this.launchpad_v8, + this.payer, + ); + + META = result.tokenMint; + launch = result.launch; + launchSigner = result.launchSigner; + launchAuthority = new Keypair(); + + await this.setupBasicLaunch({ + baseMint: META, + founders: [this.payer.publicKey], + launchAuthority: launchAuthority.publicKey, + }); + + await launchpadClient + .startLaunchIx({ + launch, + launchAuthority: launchAuthority.publicKey, + }) + .signers([launchAuthority]) + .rpc(); + + // Mint USDC to funders (10x for oversubscription cases) + await this.transfer( + MAINNET_USDC, + this.payer, + funder1.publicKey, + funder1Amount.toNumber() * 10, + ); + await this.transfer( + MAINNET_USDC, + this.payer, + funder2.publicKey, + funder2Amount.toNumber() * 10, + ); + await this.transfer( + MAINNET_USDC, + this.payer, + funder3.publicKey, + funder3Amount.toNumber() * 10, + ); + await this.transfer( + MAINNET_USDC, + this.payer, + funder4.publicKey, + funder4Amount.toNumber() * 10, + ); + }); + + it("can set funding record approval for full, partial, and zero amounts", async function () { + await fundLaunch(); + await this.advanceBySeconds(secondsForLaunch + 1); + await launchpadClient.closeLaunchIx({ launch }).rpc(); + + // Set funder1's approval to full amount + await launchpadClient + .setFundingRecordApprovalIx({ + launch, + approvedAmount: funder1Amount, + funder: funder1.publicKey, + launchAuthority: launchAuthority.publicKey, + }) + .signers([launchAuthority]) + .rpc(); + + const fundingRecord1 = launchpadClient.getFundingRecordAddress({ + launch, + funder: funder1.publicKey, + }); + + let fundingRecord1Account = + await launchpadClient.getFundingRecord(fundingRecord1); + assert.equal( + fundingRecord1Account.approvedAmount.toString(), + funder1Amount.toString(), + ); + + let launchAccount = await launchpadClient.fetchLaunch(launch); + assert.equal( + launchAccount.totalApprovedAmount.toString(), + funder1Amount.toString(), + ); + + // Update to partial amount (half) + await launchpadClient + .setFundingRecordApprovalIx({ + launch, + approvedAmount: funder1Amount.div(new BN(2)), + funder: funder1.publicKey, + launchAuthority: launchAuthority.publicKey, + }) + .signers([launchAuthority]) + .rpc(); + + fundingRecord1Account = + await launchpadClient.getFundingRecord(fundingRecord1); + assert.equal( + fundingRecord1Account.approvedAmount.toString(), + funder1Amount.div(new BN(2)).toString(), + ); + + launchAccount = await launchpadClient.fetchLaunch(launch); + assert.equal( + launchAccount.totalApprovedAmount.toString(), + funder1Amount.div(new BN(2)).toString(), + ); + + // Update to zero + await launchpadClient + .setFundingRecordApprovalIx({ + launch, + approvedAmount: new BN(0), + funder: funder1.publicKey, + launchAuthority: launchAuthority.publicKey, + }) + .signers([launchAuthority]) + .rpc(); + + fundingRecord1Account = + await launchpadClient.getFundingRecord(fundingRecord1); + assert.equal(fundingRecord1Account.approvedAmount.toString(), "0"); + + launchAccount = await launchpadClient.fetchLaunch(launch); + assert.equal(launchAccount.totalApprovedAmount.toString(), "0"); + }); + + it("correctly updates the launch account total approved amount", async function () { + await fundLaunch(); + await this.advanceBySeconds(secondsForLaunch + 1); + await launchpadClient.closeLaunchIx({ launch }).rpc(); + + // Approve funder1 + await launchpadClient + .setFundingRecordApprovalIx({ + launch, + approvedAmount: funder1Amount, + funder: funder1.publicKey, + launchAuthority: launchAuthority.publicKey, + }) + .signers([launchAuthority]) + .rpc(); + + const fundingRecord1 = launchpadClient.getFundingRecordAddress({ + launch, + funder: funder1.publicKey, + }); + const fundingRecord1Account = + await launchpadClient.getFundingRecord(fundingRecord1); + assert.equal( + fundingRecord1Account.approvedAmount.toString(), + funder1Amount.toString(), + ); + + let launchAccount = await launchpadClient.fetchLaunch(launch); + assert.equal( + launchAccount.totalApprovedAmount.toString(), + funder1Amount.toString(), + ); + + // Approve funder2 + await launchpadClient + .setFundingRecordApprovalIx({ + launch, + approvedAmount: funder2Amount, + funder: funder2.publicKey, + launchAuthority: launchAuthority.publicKey, + }) + .signers([launchAuthority]) + .rpc(); + + const fundingRecord2 = launchpadClient.getFundingRecordAddress({ + launch, + funder: funder2.publicKey, + }); + const fundingRecord2Account = + await launchpadClient.getFundingRecord(fundingRecord2); + assert.equal( + fundingRecord2Account.approvedAmount.toString(), + funder2Amount.toString(), + ); + + launchAccount = await launchpadClient.fetchLaunch(launch); + assert.equal( + launchAccount.totalApprovedAmount.toString(), + funder1Amount.add(funder2Amount).toString(), + ); + }); + + it("can't set funding record approval before the launch period ends", async function () { + await fundLaunch(); + + const callbacks = expectError("InvalidLaunchState", "Invalid launch state"); + + await launchpadClient + .setFundingRecordApprovalIx({ + launch, + approvedAmount: funder1Amount, + funder: funder1.publicKey, + launchAuthority: launchAuthority.publicKey, + }) + .signers([launchAuthority]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("can't set funding record approval after the funding record approval period ends (2 days after launch is closed)", async function () { + await fundLaunch(); + await this.advanceBySeconds(secondsForLaunch + 1); + await launchpadClient.closeLaunchIx({ launch }).rpc(); + + // Advance exactly 2 days (the boundary) + await this.advanceBySeconds(60 * 60 * 24 * 2); + + const callbacks = expectError( + "FundingRecordApprovalPeriodOver", + "Funding record approval period is over", + ); + + await launchpadClient + .setFundingRecordApprovalIx({ + launch, + approvedAmount: funder1Amount, + funder: funder1.publicKey, + launchAuthority: launchAuthority.publicKey, + }) + .signers([launchAuthority]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("can't set funding record approval after the launch is completed", async function () { + await fundLaunch(); + await this.advanceBySeconds(secondsForLaunch + 1); + await launchpadClient.closeLaunchIx({ launch }).rpc(); + + // Approve all funders + await launchpadClient + .setFundingRecordApprovalIx({ + launch, + approvedAmount: funder1Amount, + funder: funder1.publicKey, + launchAuthority: launchAuthority.publicKey, + }) + .signers([launchAuthority]) + .rpc(); + + await launchpadClient + .setFundingRecordApprovalIx({ + launch, + approvedAmount: funder2Amount, + funder: funder2.publicKey, + launchAuthority: launchAuthority.publicKey, + }) + .signers([launchAuthority]) + .rpc(); + + await launchpadClient + .setFundingRecordApprovalIx({ + launch, + approvedAmount: funder3Amount, + funder: funder3.publicKey, + launchAuthority: launchAuthority.publicKey, + }) + .signers([launchAuthority]) + .rpc(); + + await launchpadClient + .setFundingRecordApprovalIx({ + launch, + approvedAmount: funder4Amount, + funder: funder4.publicKey, + launchAuthority: launchAuthority.publicKey, + }) + .signers([launchAuthority]) + .rpc(); + + // Settle launch to reach Complete state + const settleTx = await launchpadClient + .settleLaunchIx({ + launch, + baseMint: META, + launchAuthority: launchAuthority.publicKey, + }) + .transaction(); + + const lut = await createLookupTableForTransaction(settleTx, this); + + const message = new TransactionMessage({ + payerKey: this.payer.publicKey, + recentBlockhash: (await this.banksClient.getLatestBlockhash())[0], + instructions: settleTx.instructions, + }).compileToV0Message([lut]); + + const tx = new VersionedTransaction(message); + tx.sign([this.payer, launchAuthority]); + + await this.banksClient.processTransaction(tx); + + // Verify launch is now Complete + const launchAccount = await launchpadClient.fetchLaunch(launch); + assert.deepEqual(launchAccount.state, { complete: {} }); + + // Try to set funding record approval after completion + const callbacks = expectError("InvalidLaunchState", "Invalid launch state"); + + await launchpadClient + .setFundingRecordApprovalIx({ + launch, + approvedAmount: funder1Amount, + funder: funder1.publicKey, + launchAuthority: launchAuthority.publicKey, + }) + .signers([launchAuthority]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("can't set funding record approval to an amount greater than the committed amount", async function () { + await fundLaunch(); + await this.advanceBySeconds(secondsForLaunch + 1); + await launchpadClient.closeLaunchIx({ launch }).rpc(); + + const callbacks = expectError( + "InsufficientFunds", + "Failed to set funding record approval to an amount greater than the committed amount", + ); + + await launchpadClient + .setFundingRecordApprovalIx({ + launch, + approvedAmount: funder1Amount.add(new BN(1)), + funder: funder1.publicKey, + launchAuthority: launchAuthority.publicKey, + }) + .signers([launchAuthority]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); +} diff --git a/tests/launchpad_v8/unit/settleLaunch.test.ts b/tests/launchpad_v8/unit/settleLaunch.test.ts new file mode 100644 index 00000000..1bffb8a4 --- /dev/null +++ b/tests/launchpad_v8/unit/settleLaunch.test.ts @@ -0,0 +1,679 @@ +import { + Keypair, + PublicKey, + TransactionMessage, + VersionedTransaction, + ComputeBudgetProgram, +} from "@solana/web3.js"; +import { assert } from "chai"; +import { getMetadataAddr, MAINNET_USDC } from "@metadaoproject/programs"; +import { LaunchpadClient } from "@metadaoproject/programs/launchpad/v0.8"; +import BN from "bn.js"; +import { deserializeMetadata } from "@metaplex-foundation/mpl-token-metadata"; +import { + fromWeb3JsPublicKey, + toWeb3JsPublicKey, +} from "@metaplex-foundation/umi-web3js-adapters"; +import { initializeMintWithSeeds } from "../utils.js"; +import { createLookupTableForTransaction } from "../../utils.js"; + +export default function suite() { + let launchpadClient: LaunchpadClient; + let META: PublicKey; + let launch: PublicKey; + let launchSigner: PublicKey; + let launchAuthority: Keypair; + + const minRaise = new BN(100_000 * 10 ** 6); // 100k USDC + const secondsForLaunch = 60 * 60 * 24 * 4; // 4 days + + const funder1 = Keypair.generate(); + const funder2 = Keypair.generate(); + + before(async function () { + launchpadClient = this.launchpad_v8; + + await this.createTokenAccount(MAINNET_USDC, funder1.publicKey); + await this.createTokenAccount(MAINNET_USDC, funder2.publicKey); + }); + + async function settleViaLut( + context: any, + client: LaunchpadClient, + params: { + launch: PublicKey; + baseMint: PublicKey; + launchAuthority: PublicKey | null; + }, + additionalSigners: Keypair[] = [], + ) { + const settleTx = await client + .settleLaunchIx({ + launch: params.launch, + baseMint: params.baseMint, + launchAuthority: params.launchAuthority, + }) + .transaction(); + + const lut = await createLookupTableForTransaction(settleTx, context); + + const message = new TransactionMessage({ + payerKey: context.payer.publicKey, + recentBlockhash: (await context.banksClient.getLatestBlockhash())[0], + instructions: settleTx.instructions, + }).compileToV0Message([lut]); + + const tx = new VersionedTransaction(message); + tx.sign([context.payer, ...additionalSigners]); + + await context.banksClient.processTransaction(tx); + } + + async function trySettleViaLut( + context: any, + client: LaunchpadClient, + params: { + launch: PublicKey; + baseMint: PublicKey; + launchAuthority: PublicKey | null; + }, + additionalSigners: Keypair[] = [], + ) { + const settleTx = await client + .settleLaunchIx({ + launch: params.launch, + baseMint: params.baseMint, + launchAuthority: params.launchAuthority, + }) + .transaction(); + + const lut = await createLookupTableForTransaction(settleTx, context); + + const message = new TransactionMessage({ + payerKey: context.payer.publicKey, + recentBlockhash: (await context.banksClient.getLatestBlockhash())[0], + instructions: settleTx.instructions, + }).compileToV0Message([lut]); + + const tx = new VersionedTransaction(message); + tx.sign([context.payer, ...additionalSigners]); + + return context.banksClient.tryProcessTransaction(tx); + } + + async function setupFundCloseApprove( + context: any, + client: LaunchpadClient, + opts: { + launch: PublicKey; + baseMint: PublicKey; + launchAuthority: Keypair; + fundAmount: BN; + approveAmount?: BN; + hasBidWall?: boolean; + }, + ) { + // Fund + await client + .fundIx({ + launch: opts.launch, + amount: opts.fundAmount, + payer: context.payer.publicKey, + }) + .rpc(); + + // Advance past launch period + await context.advanceBySeconds(secondsForLaunch + 1); + + // Close + await client.closeLaunchIx({ launch: opts.launch }).rpc(); + + // Approve + const approveAmount = + opts.approveAmount !== undefined ? opts.approveAmount : opts.fundAmount; + await client + .setFundingRecordApprovalIx({ + launch: opts.launch, + approvedAmount: approveAmount, + funder: context.payer.publicKey, + launchAuthority: opts.launchAuthority.publicKey, + }) + .signers([opts.launchAuthority]) + .rpc(); + } + + describe("happy path", function () { + beforeEach(async function () { + const result = await initializeMintWithSeeds( + this.banksClient, + this.launchpad_v8, + this.payer, + ); + + META = result.tokenMint; + launch = result.launch; + launchSigner = result.launchSigner; + launchAuthority = new Keypair(); + + await this.setupBasicLaunch({ + baseMint: META, + founders: [this.payer.publicKey], + launchAuthority: launchAuthority.publicKey, + }); + + await launchpadClient + .startLaunchIx({ + launch, + launchAuthority: launchAuthority.publicKey, + }) + .signers([launchAuthority]) + .rpc(); + }); + + it("settles launch with DAO creation, token minting, liquidity, metadata transfer, and USDC distribution", async function () { + const fundAmount = new BN(150_000 * 10 ** 6); // 150k USDC + + const [tokenMetadata] = getMetadataAddr(META); + + // Verify metadata authority is launch_signer before settle + let rawStoredMetadata = await this.banksClient.getAccount(tokenMetadata); + let storedMetadata = deserializeMetadata({ + ...rawStoredMetadata, + publicKey: fromWeb3JsPublicKey(tokenMetadata), + owner: fromWeb3JsPublicKey(rawStoredMetadata.owner), + lamports: { + basisPoints: BigInt(rawStoredMetadata.lamports), + identifier: "SOL", + decimals: 9, + }, + rentEpoch: rawStoredMetadata.rentEpoch + ? BigInt(rawStoredMetadata.rentEpoch) + : undefined, + }); + assert.ok( + toWeb3JsPublicKey(storedMetadata.updateAuthority).equals(launchSigner), + ); + + await setupFundCloseApprove(this, launchpadClient, { + launch, + baseMint: META, + launchAuthority, + fundAmount, + approveAmount: fundAmount, + }); + + await settleViaLut( + this, + launchpadClient, + { + launch, + baseMint: META, + launchAuthority: launchAuthority.publicKey, + }, + [launchAuthority], + ); + + // Verify launch state + const launchAccount = await launchpadClient.fetchLaunch(launch); + assert.deepEqual(launchAccount.state, { complete: {} }); + assert.isNotNull(launchAccount.dao); + assert.isNotNull(launchAccount.daoVault); + assert.isNotNull(launchAccount.unixTimestampCompleted); + + // Verify USDC distribution: 80% to treasury + const treasuryUSDCBalance = await this.getTokenBalance( + MAINNET_USDC, + launchAccount.daoVault, + ); + assert.equal( + treasuryUSDCBalance.toString(), + fundAmount.muln(8).divn(10).toString(), + ); + + // Verify token supply: 10M + 2M + 900k = 12,900,000 + // (performance package tokens are minted separately) + const mint = await this.getMint(META); + const expectedSupply = (10_000_000 + 2_000_000 + 900_000) * 10 ** 6; + assert.equal(Number(mint.supply), expectedSupply); + + // Verify metadata authority transferred to dao_vault + rawStoredMetadata = await this.banksClient.getAccount(tokenMetadata); + storedMetadata = deserializeMetadata({ + ...rawStoredMetadata, + publicKey: fromWeb3JsPublicKey(tokenMetadata), + owner: fromWeb3JsPublicKey(rawStoredMetadata.owner), + lamports: { + basisPoints: BigInt(rawStoredMetadata.lamports), + identifier: "SOL", + decimals: 9, + }, + rentEpoch: rawStoredMetadata.rentEpoch + ? BigInt(rawStoredMetadata.rentEpoch) + : undefined, + }); + assert.ok( + toWeb3JsPublicKey(storedMetadata.updateAuthority).equals( + launchAccount.daoVault, + ), + ); + + // Verify MintGovernor admin is still launch_signer (not yet transferred to DAO) + const mintGovernorAddr = launchpadClient.getMintGovernorAddress({ + baseMint: META, + launchSigner, + }); + const mintGovernorAccount = + await launchpadClient.mintGovernorClient.fetchMintGovernor( + mintGovernorAddr, + ); + assert.ok(mintGovernorAccount.admin.equals(launchSigner)); + }); + }); + + describe("USDC allocation", function () { + beforeEach(async function () { + const result = await initializeMintWithSeeds( + this.banksClient, + this.launchpad_v8, + this.payer, + ); + + META = result.tokenMint; + launch = result.launch; + launchSigner = result.launchSigner; + launchAuthority = new Keypair(); + }); + + it("sends all USDC to treasury when hasBidWall is false", async function () { + await launchpadClient + .initializeLaunchIx({ + tokenName: "META", + tokenSymbol: "META", + tokenUri: "https://example.com", + minimumRaiseAmount: minRaise, + secondsForLaunch, + baseMint: META, + quoteMint: MAINNET_USDC, + monthlySpendingLimitAmount: new BN(10_000 * 10 ** 6), + monthlySpendingLimitMembers: [this.payer.publicKey], + performancePackageGrantee: this.payer.publicKey, + performancePackageTokenAmount: new BN(5_000_000 * 10 ** 6), + monthsUntilInsidersCanUnlock: 24, + teamAddress: PublicKey.default, + launchAuthority: launchAuthority.publicKey, + hasBidWall: false, + }) + .rpc(); + + await launchpadClient + .startLaunchIx({ + launch, + launchAuthority: launchAuthority.publicKey, + }) + .signers([launchAuthority]) + .rpc(); + + const fundAmount = new BN(200_000 * 10 ** 6); // 200k USDC (2x minimum) + + await setupFundCloseApprove(this, launchpadClient, { + launch, + baseMint: META, + launchAuthority, + fundAmount, + }); + + await settleViaLut( + this, + launchpadClient, + { + launch, + baseMint: META, + launchAuthority: launchAuthority.publicKey, + }, + [launchAuthority], + ); + + const launchAccount = await launchpadClient.fetchLaunch(launch); + + // usdc_to_lp = 200k / 5 = 40k + // usdc_to_dao = 200k - 40k = 160k (all goes to treasury, no bid wall) + const treasuryUSDCBalance = await this.getTokenBalance( + MAINNET_USDC, + launchAccount.daoVault, + ); + assert.equal( + treasuryUSDCBalance.toString(), + fundAmount.muln(4).divn(5).toString(), + ); + }); + + it("initializes bid wall when hasBidWall is true and funding exceeds 1.25x", async function () { + await launchpadClient + .initializeLaunchIx({ + tokenName: "META", + tokenSymbol: "META", + tokenUri: "https://example.com", + minimumRaiseAmount: minRaise, + secondsForLaunch, + baseMint: META, + quoteMint: MAINNET_USDC, + monthlySpendingLimitAmount: new BN(10_000 * 10 ** 6), + monthlySpendingLimitMembers: [this.payer.publicKey], + performancePackageGrantee: this.payer.publicKey, + performancePackageTokenAmount: new BN(5_000_000 * 10 ** 6), + monthsUntilInsidersCanUnlock: 24, + teamAddress: PublicKey.default, + launchAuthority: launchAuthority.publicKey, + hasBidWall: true, + }) + .rpc(); + + await launchpadClient + .startLaunchIx({ + launch, + launchAuthority: launchAuthority.publicKey, + }) + .signers([launchAuthority]) + .rpc(); + + // Fund 200k (2x minimum of 100k, well above 1.25x threshold) + const fundAmount = new BN(200_000 * 10 ** 6); + + await setupFundCloseApprove(this, launchpadClient, { + launch, + baseMint: META, + launchAuthority, + fundAmount, + }); + + await settleViaLut( + this, + launchpadClient, + { + launch, + baseMint: META, + launchAuthority: launchAuthority.publicKey, + }, + [launchAuthority], + ); + + const launchAccount = await launchpadClient.fetchLaunch(launch); + assert.deepEqual(launchAccount.state, { complete: {} }); + + // usdc_to_lp = 200k / 5 = 40k + // usdc_to_dao = 200k - 40k = 160k + // usdc_to_dao_treasury = min(160k, 100k) = 100k + // usdc_to_bid_wall = 160k - 100k = 60k + const treasuryUSDCBalance = await this.getTokenBalance( + MAINNET_USDC, + launchAccount.daoVault, + ); + assert.equal(treasuryUSDCBalance.toString(), minRaise.toString()); + + // Verify bid wall was initialized with USDC + const bidWallAddr = launchpadClient.bidWall.getBidWallAddress({ + baseMint: META, + creator: launchSigner, + nonce: new BN(0), + }); + const bidWallAccount = + await launchpadClient.bidWall.fetchBidWall(bidWallAddr); + assert.isNotNull(bidWallAccount); + }); + + it("does not initialize bid wall when funding equals minimum raise", async function () { + await launchpadClient + .initializeLaunchIx({ + tokenName: "META", + tokenSymbol: "META", + tokenUri: "https://example.com", + minimumRaiseAmount: minRaise, + secondsForLaunch, + baseMint: META, + quoteMint: MAINNET_USDC, + monthlySpendingLimitAmount: new BN(10_000 * 10 ** 6), + monthlySpendingLimitMembers: [this.payer.publicKey], + performancePackageGrantee: this.payer.publicKey, + performancePackageTokenAmount: new BN(5_000_000 * 10 ** 6), + monthsUntilInsidersCanUnlock: 24, + teamAddress: PublicKey.default, + launchAuthority: launchAuthority.publicKey, + hasBidWall: true, + }) + .rpc(); + + await launchpadClient + .startLaunchIx({ + launch, + launchAuthority: launchAuthority.publicKey, + }) + .signers([launchAuthority]) + .rpc(); + + // Fund exactly minimum raise + await setupFundCloseApprove(this, launchpadClient, { + launch, + baseMint: META, + launchAuthority, + fundAmount: minRaise, + }); + + await settleViaLut( + this, + launchpadClient, + { + launch, + baseMint: META, + launchAuthority: launchAuthority.publicKey, + }, + [launchAuthority], + ); + + const launchAccount = await launchpadClient.fetchLaunch(launch); + assert.deepEqual(launchAccount.state, { complete: {} }); + + // usdc_to_lp = 100k / 5 = 20k + // usdc_to_dao = 100k - 20k = 80k + // usdc_to_dao_treasury = min(80k, 100k) = 80k + // usdc_to_bid_wall = 80k - 80k = 0 (no bid wall) + const treasuryUSDCBalance = await this.getTokenBalance( + MAINNET_USDC, + launchAccount.daoVault, + ); + assert.equal( + treasuryUSDCBalance.toString(), + minRaise.muln(4).divn(5).toString(), + ); + + // Bid wall should not be initialized + const bidWallAddr = launchpadClient.bidWall.getBidWallAddress({ + baseMint: META, + creator: launchSigner, + nonce: new BN(0), + }); + const bidWallRawAccount = await this.banksClient.getAccount(bidWallAddr); + assert.isNull(bidWallRawAccount); + }); + + it("does not initialize bid wall at exactly 1.25x boundary", async function () { + await launchpadClient + .initializeLaunchIx({ + tokenName: "META", + tokenSymbol: "META", + tokenUri: "https://example.com", + minimumRaiseAmount: minRaise, + secondsForLaunch, + baseMint: META, + quoteMint: MAINNET_USDC, + monthlySpendingLimitAmount: new BN(10_000 * 10 ** 6), + monthlySpendingLimitMembers: [this.payer.publicKey], + performancePackageGrantee: this.payer.publicKey, + performancePackageTokenAmount: new BN(5_000_000 * 10 ** 6), + monthsUntilInsidersCanUnlock: 24, + teamAddress: PublicKey.default, + launchAuthority: launchAuthority.publicKey, + hasBidWall: true, + }) + .rpc(); + + await launchpadClient + .startLaunchIx({ + launch, + launchAuthority: launchAuthority.publicKey, + }) + .signers([launchAuthority]) + .rpc(); + + // Fund exactly 1.25x minimum = 125k + const fundAmount = minRaise.muln(5).divn(4); + + await setupFundCloseApprove(this, launchpadClient, { + launch, + baseMint: META, + launchAuthority, + fundAmount, + }); + + await settleViaLut( + this, + launchpadClient, + { + launch, + baseMint: META, + launchAuthority: launchAuthority.publicKey, + }, + [launchAuthority], + ); + + const launchAccount = await launchpadClient.fetchLaunch(launch); + assert.deepEqual(launchAccount.state, { complete: {} }); + + // usdc_to_lp = 125k / 5 = 25k + // usdc_to_dao = 125k - 25k = 100k + // usdc_to_dao_treasury = min(100k, 100k) = 100k + // usdc_to_bid_wall = 100k - 100k = 0 (no bid wall) + const treasuryUSDCBalance = await this.getTokenBalance( + MAINNET_USDC, + launchAccount.daoVault, + ); + assert.equal(treasuryUSDCBalance.toString(), minRaise.toString()); + + // Bid wall should not be initialized + const bidWallAddr = launchpadClient.bidWall.getBidWallAddress({ + baseMint: META, + creator: launchSigner, + nonce: new BN(0), + }); + const bidWallRawAccount = await this.banksClient.getAccount(bidWallAddr); + assert.isNull(bidWallRawAccount); + }); + }); + + describe("refunding", function () { + beforeEach(async function () { + const result = await initializeMintWithSeeds( + this.banksClient, + this.launchpad_v8, + this.payer, + ); + + META = result.tokenMint; + launch = result.launch; + launchSigner = result.launchSigner; + launchAuthority = new Keypair(); + + await this.setupBasicLaunch({ + baseMint: META, + founders: [this.payer.publicKey], + launchAuthority: launchAuthority.publicKey, + }); + + await launchpadClient + .startLaunchIx({ + launch, + launchAuthority: launchAuthority.publicKey, + }) + .signers([launchAuthority]) + .rpc(); + }); + + it("transitions to refunding when total approved amount is below minimum raise", async function () { + // Fund enough to get past close_launch (>= minimum raise) + const fundAmount = new BN(150_000 * 10 ** 6); + await launchpadClient + .fundIx({ + launch, + amount: fundAmount, + payer: this.payer.publicKey, + }) + .rpc(); + + await this.advanceBySeconds(secondsForLaunch + 1); + await launchpadClient.closeLaunchIx({ launch }).rpc(); + + // Approve only a small portion (below minimum raise) + await launchpadClient + .setFundingRecordApprovalIx({ + launch, + approvedAmount: new BN(50_000 * 10 ** 6), // 50k, below 100k minimum + funder: this.payer.publicKey, + launchAuthority: launchAuthority.publicKey, + }) + .signers([launchAuthority]) + .rpc(); + + // Wait past 2-day approval window so launch authority doesn't need to sign + await this.advanceBySeconds(60 * 60 * 24 * 2 + 1); + + await settleViaLut(this, launchpadClient, { + launch, + baseMint: META, + launchAuthority: null, + }); + + const launchAccount = await launchpadClient.fetchLaunch(launch); + assert.deepEqual(launchAccount.state, { refunding: {} }); + assert.isNull(launchAccount.dao); + assert.isNull(launchAccount.daoVault); + assert.isNull(launchAccount.unixTimestampCompleted); + + // Tokens were minted during initialize_launch, supply is 12.9M + // (no additional tokens minted during settlement for refunding launches) + const mint = await this.getMint(META); + const expectedSupply = (10_000_000 + 2_000_000 + 900_000) * 10 ** 6; + assert.equal(Number(mint.supply), expectedSupply); + }); + + it("fails when launch is in refunding state", async function () { + // Fund below minimum so close_launch sets Refunding + const fundAmount = new BN(100 * 10 ** 6); // 100 USDC, way below 100k minimum + await launchpadClient + .fundIx({ + launch, + amount: fundAmount, + payer: this.payer.publicKey, + }) + .rpc(); + + await this.advanceBySeconds(secondsForLaunch + 1); + await launchpadClient.closeLaunchIx({ launch }).rpc(); + + // Launch is now in Refunding state (set by close_launch) + const launchAccount = await launchpadClient.fetchLaunch(launch); + assert.deepEqual(launchAccount.state, { refunding: {} }); + + // Try to settle — should fail + const result = await trySettleViaLut(this, launchpadClient, { + launch, + baseMint: META, + launchAuthority: null, + }); + + assert.isTrue( + result.meta.logMessages.some((log: string) => + log.includes("InvalidLaunchState"), + ), + ); + }); + }); +} diff --git a/tests/launchpad_v8/unit/startLaunch.test.ts b/tests/launchpad_v8/unit/startLaunch.test.ts new file mode 100644 index 00000000..ed5027f0 --- /dev/null +++ b/tests/launchpad_v8/unit/startLaunch.test.ts @@ -0,0 +1,59 @@ +import { PublicKey, Keypair, Signer } from "@solana/web3.js"; +import { assert } from "chai"; +import { LaunchpadClient } from "@metadaoproject/programs/launchpad/v0.8"; +import { initializeMintWithSeeds } from "../utils.js"; + +export default function suite() { + let launchpadClient: LaunchpadClient; + let META: PublicKey; + let launch: PublicKey; + let launchAuthority: Signer; + + before(async function () { + launchpadClient = this.launchpad_v8; + }); + + beforeEach(async function () { + const result = await initializeMintWithSeeds( + this.banksClient, + this.launchpad_v8, + this.payer, + ); + + META = result.tokenMint; + launch = result.launch; + launchAuthority = new Keypair(); + + await this.setupBasicLaunch({ + baseMint: META, + founders: [this.payer.publicKey], + launchAuthority: launchAuthority.publicKey, + }); + }); + + it("starts launch correctly", async function () { + // Check initial state + let launchAccount = await launchpadClient.fetchLaunch(launch); + assert.isNull(launchAccount.unixTimestampStarted); + assert.exists(launchAccount.state.initialized); + + const clock = await this.banksClient.getClock(); + + await launchpadClient + .startLaunchIx({ + launch, + launchAuthority: launchAuthority.publicKey, + }) + .signers([launchAuthority]) + .rpc(); + + launchAccount = await launchpadClient.fetchLaunch(launch); + + assert.exists(launchAccount.state.live); + assert.equal( + launchAccount.unixTimestampStarted.toString(), + clock.unixTimestamp.toString(), + ); + assert.equal(launchAccount.seqNum.toString(), "1"); + }); +} diff --git a/tests/launchpad_v8/utils.ts b/tests/launchpad_v8/utils.ts new file mode 100644 index 00000000..01824c5c --- /dev/null +++ b/tests/launchpad_v8/utils.ts @@ -0,0 +1,58 @@ +import { PublicKey, Signer, SystemProgram, Transaction } from "@solana/web3.js"; +import * as token from "@solana/spl-token"; +import { BanksClient } from "solana-bankrun"; +import { + LaunchpadClient, + getLaunchAddr, + getLaunchSignerAddr, +} from "@metadaoproject/programs/launchpad/v0.8"; + +export async function initializeMintWithSeeds( + banksClient: BanksClient, + launchpadClient: LaunchpadClient, + payer: Signer, +): Promise<{ + tokenMint: PublicKey; + launch: PublicKey; + launchSigner: PublicKey; +}> { + const seed = Math.random().toString(36).substring(2, 15); + const tokenMint = await PublicKey.createWithSeed( + payer.publicKey, + seed, + token.TOKEN_PROGRAM_ID, + ); + + const [launch] = getLaunchAddr(launchpadClient.getProgramId(), tokenMint); + const [launchSigner] = getLaunchSignerAddr( + launchpadClient.getProgramId(), + launch, + ); + + const rent = await banksClient.getRent(); + const lamports = Number(rent.minimumBalance(BigInt(token.MINT_SIZE))); + + const tx = new Transaction().add( + SystemProgram.createAccountWithSeed({ + fromPubkey: payer.publicKey, + newAccountPubkey: tokenMint, + basePubkey: payer.publicKey, + seed, + lamports: lamports, + space: token.MINT_SIZE, + programId: token.TOKEN_PROGRAM_ID, + }), + token.createInitializeMint2Instruction(tokenMint, 6, launchSigner, null), + ); + tx.recentBlockhash = (await banksClient.getLatestBlockhash())[0]; + tx.feePayer = payer.publicKey; + tx.sign(payer); + + await banksClient.processTransaction(tx); + + return { + tokenMint, + launch, + launchSigner, + }; +} diff --git a/tests/main.test.ts b/tests/main.test.ts index 3e5a879b..7b69d89b 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -2,6 +2,7 @@ import conditionalVault from "./conditionalVault/main.test.js"; import futarchy from "./futarchy/main.test.js"; import launchpad from "./launchpad/main.test.js"; import launchpad_v7 from "./launchpad_v7/main.test.js"; +import launchpad_v8 from "./launchpad_v8/main.test.js"; import priceBasedPerformancePackage from "./priceBasedPerformancePackage/main.test.js"; import bidWall from "./bidWall/main.test.js"; import mintGovernor from "./mintGovernor/main.test.js"; @@ -41,6 +42,7 @@ import { sha256, } from "@metadaoproject/programs"; import { LaunchpadClient as LaunchpadClientV6 } from "@metadaoproject/programs/launchpad/v0.6"; +import { LaunchpadClient as LaunchpadClientV8 } from "@metadaoproject/programs/launchpad/v0.8"; import { PublicKey, @@ -78,6 +80,8 @@ 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 fullLaunch_v8 from "./integration/launchpad_v8_full_lifecycle.test.js"; +import trancheLifecycle_v8 from "./integration/launchpad_v8_tranche_lifecycle.test.js"; import { BN } from "bn.js"; const ONE_BUCK_PRICE = PriceMath.getAmmPrice(1, 6, 6); @@ -89,6 +93,7 @@ export interface TestContext { conditionalVault: ConditionalVaultClient; futarchy: FutarchyClient; launchpad_v7: LaunchpadClientV7; + launchpad_v8: LaunchpadClientV8; launchpad_v6: LaunchpadClientV6; priceBasedPerformancePackage: PriceBasedPerformancePackageClient; bidWall: BidWallClient; @@ -255,6 +260,9 @@ before(async function () { this.launchpad_v7 = LaunchpadClientV7.createClient({ provider: provider as any, }); + this.launchpad_v8 = LaunchpadClientV8.createClient({ + provider: provider as any, + }); this.launchpad_v6 = LaunchpadClientV6.createClient({ provider: provider as any, }); @@ -741,6 +749,7 @@ before(async function () { describe("launchpad", launchpad); describe("launchpad_v7", launchpad_v7); +describe("launchpad_v8", launchpad_v8); describe("price_based_performance_package", priceBasedPerformancePackage); describe("conditional_vault", conditionalVault); describe("futarchy", futarchy); @@ -752,4 +761,6 @@ 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("full launch v8", fullLaunch_v8); + describe("full launch v8 - tranche lifecycle", trancheLifecycle_v8); }); diff --git a/tests/utils.ts b/tests/utils.ts index 26a19704..4792adfe 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -115,16 +115,30 @@ export async function createLookupTableForTransaction( recentSlot: slot - 1n, }); - // Extract all unique accounts from the transaction - const accountsToAdd = transaction.instructions.map((instruction) => + // Extract all unique accounts from the transaction (deduplicate by base58) + const accountsToAdd = transaction.instructions.flatMap((instruction) => instruction.keys.map((key) => key.pubkey), ); - const uniqueAccounts = [...new Set(accountsToAdd.flat())] as PublicKey[]; + const seen = new Set(); + const uniqueAccounts: PublicKey[] = []; + for (const key of accountsToAdd) { + const b58 = key.toBase58(); + if (!seen.has(b58)) { + seen.add(b58); + uniqueAccounts.push(key); + } + } console.log("uniqueAccounts", uniqueAccounts.length); // Add any additional addresses - const allAddresses = [...uniqueAccounts, ...additionalAddresses]; - const finalUniqueAddresses = [...new Set(allAddresses)] as PublicKey[]; + for (const key of additionalAddresses) { + const b58 = key.toBase58(); + if (!seen.has(b58)) { + seen.add(b58); + uniqueAccounts.push(key); + } + } + const finalUniqueAddresses = uniqueAccounts; // Create the lookup table const createLutTx = new Transaction().add(createTableIx);