diff --git a/.github/workflows/generate-verifiable-builds.yaml b/.github/workflows/generate-verifiable-builds.yaml index 78680bf5..205121b3 100644 --- a/.github/workflows/generate-verifiable-builds.yaml +++ b/.github/workflows/generate-verifiable-builds.yaml @@ -201,4 +201,21 @@ jobs: uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4 with: default_author: github_actions - message: 'Update gated_mint verifiable build' \ No newline at end of file + message: 'Update gated_mint verifiable build' + generate-verifiable-relaunch: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - uses: metadaoproject/anchor-verifiable-build@6d8fc1999ea4b7ff701e8b166903b398741e1c50 # v0.4 + with: + program: relaunch + anchor-version: '0.29.0' + solana-cli-version: '1.17.31' + features: 'production' + - run: 'git pull --rebase' + - run: cp target/deploy/relaunch.so ./verifiable-builds + - name: Commit verifiable build back to mainline + uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4 + with: + default_author: github_actions + message: 'Update relaunch verifiable build' \ No newline at end of file diff --git a/.gitignore b/.gitignore index 63b3efe5..f021870a 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,5 @@ scripts/*.js # Test ledger accounts - used when dumping accounts from mainnet to localnet test-ledger-accounts # Surfpool -.surfpool/ \ No newline at end of file +.surfpool/ +vibes/ \ No newline at end of file diff --git a/Anchor.toml b/Anchor.toml index 1faca827..85dedd1d 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -17,6 +17,7 @@ liquidation = "LiQnowFbFQdYyZhF4pUbpsrZCjxRTQ1upKJxZ2VXjde" mint_governor = "gvnr27cVeyW3AVf3acL7VCJ5WjGAphytnsgcK1feHyH" performance_package_v2 = "pPV2pfrxnmstSb9j7kEeCLny5BGj6SNwCWGd6xbGGzz" price_based_performance_package = "pbPPQH7jyKoSLu8QYs3rSY3YkDRXEBojKbTgnUg7NDS" +relaunch = "vaMpdXN2P3Z5v8y6GtAU5NzCUjxtphnRVpvqu37Spik" [registry] url = "https://api.apr.dev" @@ -93,3 +94,19 @@ program = "./tests/fixtures/openbook_twap.so" [[test.genesis]] address = "metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s" program = "./tests/fixtures/mpl_token_metadata.so" + +[[test.genesis]] +address = "pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA" +program = "./tests/fixtures/pump_amm.so" + +[[test.genesis]] +address = "pfeeUxB6jkeY1Hxd7CsFCAjcbHA9rWtchMGdZ6VojVZ" +program = "./tests/fixtures/pump_fees.so" + +[[test.genesis]] +address = "whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc" +program = "./tests/fixtures/whirlpool.so" + +[[test.genesis]] +address = "675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8" +program = "./tests/fixtures/raydium_amm.so" diff --git a/CLAUDE.md b/CLAUDE.md index 13a1b7ca..89b46049 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -191,20 +191,20 @@ Tests use `solana-bankrun` for deterministic testing without external RPC: - `advanceBySlots()` - Simulate time progression - Time constants: `TEN_SECONDS_IN_SLOTS`, `ONE_MINUTE_IN_SLOTS`, `HOUR_IN_SLOTS`, `DAY_IN_SLOTS` -**Getting unique transaction signatures:** When testing error cases that call the same instruction multiple times (e.g., verifying an action fails after state changes), add a `ComputeBudgetProgram.setComputeUnitLimit()` instruction with incrementing values to produce different transaction signatures: +**Getting unique transaction signatures:** When testing error cases that call the same instruction multiple times (e.g., verifying an action fails after state changes), add a `ComputeBudgetProgram.setComputeUnitPrice()` instruction to make the transaction hash unique, so the retry isn't rejected as a duplicate of the earlier byte-identical transaction: ```typescript -// First call (200_000), second call (200_001), etc. +// If the same call site needs several unique retries, increment microLamports (1, 2, ...). await client .someIx({ ... }) .postInstructions([ - ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), ]) .signers([signer]) .rpc(); ``` -Do NOT use `advanceBySlots()` for this purpose - it changes the clock which may affect time-dependent tests. +Do NOT use `setComputeUnitLimit()` for this — reserve it for genuinely raising a transaction's compute budget. Do NOT use `advanceBySlots()` either - it changes the clock which may affect time-dependent tests. **Isolating tests during development:** When writing or editing tests, ALWAYS add `.only` to the `describe`/`it` block you're working on before running. This keeps feedback fast and output clean. Once your changes pass, remove `.only` and run the full suite (`anchor test --skip-build`) to confirm nothing else broke. diff --git a/Cargo.lock b/Cargo.lock index 83881e26..53ab3c65 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1770,6 +1770,17 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "relaunch" +version = "0.1.0" +dependencies = [ + "anchor-lang", + "anchor-spl", + "futarchy", + "solana-security-txt", + "squads-multisig-program", +] + [[package]] name = "rustc-hash" version = "1.1.0" diff --git a/README.md b/README.md index d14e0bcc..5daba823 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Programs for unruggable capital formation and market-driven governance. | program | tag | program ID | | ----------------- | ---- | -------------------------------------------- | +| relaunch | v0.1.0 | vaMpdXN2P3Z5v8y6GtAU5NzCUjxtphnRVpvqu37Spik | | gated_mint | v0.1.0 | GaTEjZy6eMdHg2BcL8dk3iE78jkJ9sPtyw1q2tMNi8PA | | launchpad | v0.8.0 | moonDJUoHteKkGATejA5bdJVwJ6V6Dg74gyqyJTx73n | | launchpad | v0.7.0 | moontUzsdepotRGe5xsfip7vLPTJnVuafqdUWexVnPM | diff --git a/package.json b/package.json index 6a7dd00b..e6df7b64 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "redeem-launch": "NODE_OPTIONS=\"--no-deprecation\" tsx --tsconfig tsconfig.json scripts/redeemLaunch.ts", "setup-futarchy-amm": "NODE_OPTIONS=\"--no-deprecation\" tsx --tsconfig tsconfig.json scripts/setupFutarchyAmm.ts", "initialize-dao": "NODE_OPTIONS=\"--no-deprecation\" tsx --tsconfig tsconfig.json scripts/v0.6/initializeDao.ts", + "relaunch-create-alt": "NODE_OPTIONS=\"--no-deprecation\" tsx --tsconfig tsconfig.json scripts/relaunch/createAlt.ts", "prepare": "husky" }, "lint-staged": { diff --git a/programs/relaunch/Cargo.toml b/programs/relaunch/Cargo.toml new file mode 100644 index 00000000..c99ab068 --- /dev/null +++ b/programs/relaunch/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "relaunch" +version = "0.1.0" +description = "Created with Anchor" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "relaunch" + +[features] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +cpi = ["no-entrypoint"] +default = [] +production = [] + +[dependencies] +anchor-lang = { version = "=0.29.0", features = ["init-if-needed", "event-cpi"] } +anchor-spl = { version = "=0.29.0", features = ["metadata"] } +solana-security-txt = "=1.1.1" +futarchy = { path = "../futarchy", features = ["cpi"] } +squads-multisig-program = { git = "https://github.com/Squads-Protocol/v4", package = "squads-multisig-program", rev = "6d5235da621a2e9b7379ea358e48760e981053be", features = ["cpi"] } diff --git a/programs/relaunch/Xargo.toml b/programs/relaunch/Xargo.toml new file mode 100644 index 00000000..475fb71e --- /dev/null +++ b/programs/relaunch/Xargo.toml @@ -0,0 +1,2 @@ +[target.bpfel-unknown-unknown.dependencies.std] +features = [] diff --git a/programs/relaunch/src/constants.rs b/programs/relaunch/src/constants.rs new file mode 100644 index 00000000..352cb895 --- /dev/null +++ b/programs/relaunch/src/constants.rs @@ -0,0 +1,132 @@ +pub const TOKEN_SCALE: u64 = 1_000_000; + +pub const PRICE_SCALE: u128 = 1_000_000_000_000; + +/// 12.5M tokens with 6 decimals, distributed pro-rata to depositors. +pub const TOKENS_TO_DEPOSITORS: u64 = 12_500_000 * TOKEN_SCALE; +/// 12.5M tokens with 6 decimals, paired with all recovered USDC in the +/// futarchy AMM. +pub const TOKENS_TO_FUTARCHY_LIQUIDITY: u64 = 12_500_000 * TOKEN_SCALE; +/// 1.5M tokens with 6 decimals, staked to create a proposal (launchpad's value). +pub const PROPOSAL_MIN_STAKE_TOKENS: u64 = 1_500_000 * TOKEN_SCALE; + +/// 1 year. +pub const MAX_SECONDS_FOR_DEPOSITS: u32 = 60 * 60 * 24 * 365; + +pub mod wsol_mint { + use anchor_lang::prelude::declare_id; + + declare_id!("So11111111111111111111111111111111111111112"); +} + +pub mod usdc_mint { + use anchor_lang::prelude::declare_id; + + declare_id!("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"); +} + +// pump + +/// Seed of the pump pool-authority PDA, derived under the pump bonding-curve +/// program: `["pool-authority", mint]`. The canonical PumpSwap pool for a +/// mint has this PDA as its `creator`. +pub const PUMP_POOL_AUTHORITY_SEED: &[u8] = b"pool-authority"; + +/// Seed of PumpSwap pool PDAs, derived under pump_amm: +/// `["pool", index_le_u16, creator, base_mint, quote_mint]`. +pub const PUMP_POOL_SEED: &[u8] = b"pool"; + +/// The pump bonding-curve program. The pool-authority PDA that creates +/// canonical PumpSwap pools at graduation is derived under this program. +pub mod pump_program { + use anchor_lang::prelude::declare_id; + + declare_id!("6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P"); +} + +/// PumpSwap — the AMM that canonical graduation pools live on. +pub mod pump_amm_program { + use anchor_lang::prelude::declare_id; + + declare_id!("pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA"); +} + +/// The pump fee program that PumpSwap consults for its dynamic fee tiers. +pub mod pump_fees_program { + use anchor_lang::prelude::declare_id; + + declare_id!("pfeeUxB6jkeY1Hxd7CsFCAjcbHA9rWtchMGdZ6VojVZ"); +} + +/// pump_amm's global config: the `["global_config"]` PDA under pump_amm. +pub mod pump_amm_global_config { + use anchor_lang::prelude::declare_id; + + declare_id!("ADyA8hdefvWN2dbGGWFotbzWxrAvLW83WG6QCVXvJKqw"); +} + +/// pump_amm's event authority: the `["__event_authority"]` PDA under pump_amm. +pub mod pump_amm_event_authority { + use anchor_lang::prelude::declare_id; + + declare_id!("GS4CU59F31iL7aR2Q8zVS8DRrcRnXX1yjQ66TqNVQnaR"); +} + +/// The fee config the pump fee program keeps for pump_amm: the +/// `["fee_config", pump_amm program id]` PDA under pump_fees. +pub mod pump_amm_fee_config { + use anchor_lang::prelude::declare_id; + + declare_id!("5PHirr8joyTMp9JMm6nW7hNDVyEYdkzDqazxPD7RaTjx"); +} + +// Raydium + +/// Raydium's legacy "Standard" AMM v4, where pre-PumpSwap pump graduations +/// live. +pub mod raydium_amm_program { + use anchor_lang::prelude::declare_id; + + declare_id!("675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8"); +} + +/// The global authority PDA over every AMM v4 vault: `["amm authority"]`. +pub mod raydium_amm_authority { + use anchor_lang::prelude::declare_id; + + declare_id!("5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1"); +} + +/// OpenBook v1. Stored as `market_program` by every orderbook-era AMM v4 +/// pool. All pump graduations have this as an orderbook. +pub mod openbook_program { + use anchor_lang::prelude::declare_id; + + declare_id!("srmqPvymJeFKQ4zGQed1GFppgkRHL9kaELCbyksJtPX"); +} + +/// A Raydium source pool must have at least this much LP burned +/// (`lp_amount - lp_mint.supply`, raw units, 9 decimals). +pub const RAYDIUM_MIN_BURNED_LP: u64 = 4_000_000_000_000; + +// Orca + +pub mod whirlpool_program { + use anchor_lang::prelude::declare_id; + + declare_id!("whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc"); +} + +/// Orca Whirlpool SOL/USDC 0.04% — the pinned venue for the WSOL→USDC swap leg. +pub mod usdc_swap_pool { + use anchor_lang::prelude::declare_id; + + declare_id!("Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE"); +} + +/// SPL Memo, required by whirlpool's v2 instructions. +pub mod memo_program { + use anchor_lang::prelude::declare_id; + + declare_id!("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr"); +} diff --git a/programs/relaunch/src/error.rs b/programs/relaunch/src/error.rs new file mode 100644 index 00000000..1a1417a6 --- /dev/null +++ b/programs/relaunch/src/error.rs @@ -0,0 +1,67 @@ +use anchor_lang::prelude::*; + +#[error_code] +pub enum RelaunchError { + #[msg("New mint supply must be zero")] + SupplyNonZero, + #[msg("New mint must not have a freeze authority")] + FreezeAuthoritySet, + #[msg("Source pool is not the canonical PumpSwap pool for the old mint")] + SourcePoolNotCanonical, + #[msg("Source quote mint does not match the source pool's quote mint")] + SourcePoolQuoteMintMismatch, + #[msg("Source quote mint must be WSOL or USDC")] + InvalidQuoteMint, + #[msg("Old mint carries a Token-2022 extension outside the metadata allowlist")] + ForbiddenOldMintExtension, + #[msg("Threshold must be between 1 and 10000 bps")] + InvalidThresholdBps, + #[msg("Deposit period must be at most 1 year")] + InvalidSecondsForDeposits, + #[msg("Monthly spending limit amount and members must both be set or both be empty")] + InvalidMonthlySpendingLimit, + #[msg("There can be at most 10 monthly spending limit members, without duplicates")] + InvalidMonthlySpendingLimitMembers, + #[msg("Relaunch must be in the Initialized state")] + RelaunchNotInitialized, + #[msg("Relaunch must be in the Live state")] + RelaunchNotLive, + #[msg("Deposit window has closed")] + DepositWindowClosed, + #[msg("Amount must be greater than zero")] + InvalidAmount, + #[msg("Insufficient balance")] + InsufficientFunds, + #[msg("Deposit window is still open")] + DepositWindowStillOpen, + #[msg("Relaunch must be in the SellPending state")] + RelaunchNotSellPending, + #[msg("Grace period has not elapsed")] + GracePeriodStillActive, + #[msg("Relaunch must be in the Failed state")] + RelaunchNotFailed, + #[msg("Deposit record has already been claimed")] + AlreadyClaimed, + #[msg("Grace period has elapsed")] + GracePeriodElapsed, + #[msg("Relaunch must be in the Sold state")] + RelaunchNotSold, + #[msg("Swap output is below the minimum output amount")] + SlippageExceeded, + #[msg("Relaunch must be in the Swapped state")] + RelaunchNotSwapped, + #[msg("Relaunch must be in the Complete state")] + RelaunchNotComplete, + #[msg("Casting overflow. If you're seeing this, please report this")] + CastingOverflow, + #[msg("Source pool LP mint must be supplied for Raydium sources only and match the pool's stored LP mint")] + SourcePoolLpMintMismatch, + #[msg("Source pool's burned LP is below the required floor")] + SourcePoolLpNotBurned, + #[msg("Source pool's status does not permit swaps")] + SourcePoolSwapsDisabled, + #[msg("Source pool was not created in the orderbook era")] + SourcePoolWrongEra, + #[msg("Instruction does not match the relaunch's source venue")] + WrongSourceVenue, +} diff --git a/programs/relaunch/src/events.rs b/programs/relaunch/src/events.rs new file mode 100644 index 00000000..e51fa422 --- /dev/null +++ b/programs/relaunch/src/events.rs @@ -0,0 +1,142 @@ +use anchor_lang::prelude::*; + +use crate::state::{RelaunchState, SourceVenue}; + +#[derive(AnchorSerialize, AnchorDeserialize)] +pub struct CommonFields { + pub slot: u64, + pub unix_timestamp: i64, + pub relaunch_seq_num: u64, +} + +impl CommonFields { + pub fn new(clock: &Clock, relaunch_seq_num: u64) -> Self { + Self { + slot: clock.slot, + unix_timestamp: clock.unix_timestamp, + relaunch_seq_num, + } + } +} + +#[event] +pub struct RelaunchInitializedEvent { + pub common: CommonFields, + pub relaunch: Pubkey, + pub admin: Pubkey, + pub new_mint: Pubkey, + pub old_mint: Pubkey, + pub source_pool: Pubkey, + pub source_quote_mint: Pubkey, + pub relaunch_signer: Pubkey, + pub relaunch_signer_bump: u8, + pub old_token_vault: Pubkey, + pub new_token_vault: Pubkey, + pub source_quote_vault: Pubkey, + pub usdc_vault: Pubkey, + pub threshold_bps: u16, + pub old_supply_snapshot: u64, + pub seconds_for_deposits: u32, + pub grace_period_seconds: u32, + pub monthly_spending_limit_amount: u64, + pub monthly_spending_limit_members: Vec, + pub team_address: Pubkey, + pub pda_bump: u8, + pub source_venue: SourceVenue, +} + +#[event] +pub struct DepositsStartedEvent { + pub common: CommonFields, + pub relaunch: Pubkey, + pub admin: Pubkey, +} + +#[event] +pub struct TokensDepositedEvent { + pub common: CommonFields, + pub relaunch: Pubkey, + pub depositor: Pubkey, + pub deposit_record: Pubkey, + pub amount: u64, + pub total_deposited: u64, + pub total_deposited_by_depositor: u64, + pub deposit_record_seq_num: u64, +} + +#[event] +pub struct DepositsClosedEvent { + pub common: CommonFields, + pub relaunch: Pubkey, + pub new_state: RelaunchState, +} + +#[event] +pub struct RelaunchMarkedFailedEvent { + pub common: CommonFields, + pub relaunch: Pubkey, +} + +#[event] +pub struct SellExecutedEvent { + pub common: CommonFields, + pub relaunch: Pubkey, + pub base_sold: u64, + pub quote_recovered: u64, + pub new_state: RelaunchState, +} + +#[event] +pub struct UsdcSwapExecutedEvent { + pub common: CommonFields, + pub relaunch: Pubkey, + pub wsol_sold: u64, + pub usdc_recovered: u64, +} + +#[event] +pub struct RefundClaimedEvent { + pub common: CommonFields, + pub relaunch: Pubkey, + pub depositor: Pubkey, + pub deposit_record: Pubkey, + pub amount_refunded: u64, + pub deposit_record_seq_num: u64, +} + +#[event] +pub struct RelaunchCompletedEvent { + pub common: CommonFields, + pub relaunch: Pubkey, + pub dao: Pubkey, + pub dao_vault: Pubkey, + pub usdc_recovered: u64, + pub twap_initial_observation: u128, + pub usdc_to_lp: u64, + pub usdc_to_treasury: u64, +} + +#[event] +pub struct TokensClaimedEvent { + pub common: CommonFields, + pub relaunch: Pubkey, + pub depositor: Pubkey, + pub deposit_record: Pubkey, + pub amount_claimed: u64, + pub deposit_record_seq_num: u64, +} + +#[event] +pub struct TokensDepositedViaBuyEvent { + pub common: CommonFields, + pub relaunch: Pubkey, + pub depositor: Pubkey, + pub deposit_record: Pubkey, + /// The old tokens bought and credited (the measured old-vault delta). + pub amount: u64, + /// The quote consumed by the buy, inclusive of pump's fees. + pub quote_spent: u64, + pub total_deposited: u64, + pub total_deposited_by_depositor: u64, + pub deposit_record_seq_num: u64, +} diff --git a/programs/relaunch/src/instructions/claim.rs b/programs/relaunch/src/instructions/claim.rs new file mode 100644 index 00000000..b1c425cf --- /dev/null +++ b/programs/relaunch/src/instructions/claim.rs @@ -0,0 +1,113 @@ +use anchor_lang::prelude::*; +use anchor_spl::token::{self, Mint, Token, TokenAccount, Transfer}; + +use crate::error::RelaunchError; +use crate::events::{CommonFields, TokensClaimedEvent}; +use crate::state::{DepositRecord, Relaunch, RelaunchState}; +use crate::TOKENS_TO_DEPOSITORS; + +#[event_cpi] +#[derive(Accounts)] +pub struct Claim<'info> { + #[account( + mut, + has_one = new_mint, + has_one = new_token_vault, + has_one = relaunch_signer, + )] + pub relaunch: Box>, + + #[account( + mut, + has_one = relaunch, + has_one = depositor, + seeds = [b"deposit_record", relaunch.key().as_ref(), depositor.key().as_ref()], + bump = deposit_record.pda_bump + )] + pub deposit_record: Box>, + + pub new_mint: Box>, + + #[account(mut)] + pub new_token_vault: Box>, + + /// CHECK: just a signer + pub relaunch_signer: UncheckedAccount<'info>, + + /// CHECK: the claim recipient; not required to sign, so anyone can crank + /// claims for any depositor. + pub depositor: UncheckedAccount<'info>, + + #[account( + mut, + associated_token::mint = new_mint, + associated_token::authority = depositor, + )] + pub depositor_token_account: Box>, + + pub token_program: Program<'info, Token>, +} + +impl Claim<'_> { + pub fn validate(&self) -> Result<()> { + require!( + self.relaunch.state == RelaunchState::Complete, + RelaunchError::RelaunchNotComplete + ); + + require!(!self.deposit_record.claimed, RelaunchError::AlreadyClaimed); + + Ok(()) + } + + pub fn handle(ctx: Context) -> Result<()> { + let relaunch_key = ctx.accounts.relaunch.key(); + + let seeds = &[ + b"relaunch_signer", + relaunch_key.as_ref(), + &[ctx.accounts.relaunch.relaunch_signer_bump], + ]; + let signer = &[&seeds[..]]; + + // The depositor's floor pro-rata share of the depositor bucket; + // rounding dust stays in the vault. + let amount_claimed = u64::try_from( + ctx.accounts.deposit_record.amount_deposited as u128 * TOKENS_TO_DEPOSITORS as u128 + / ctx.accounts.relaunch.total_deposited as u128, + ) + .map_err(|_| RelaunchError::CastingOverflow)?; + + let deposit_record = &mut ctx.accounts.deposit_record; + deposit_record.claimed = true; + deposit_record.seq_num += 1; + + token::transfer( + CpiContext::new_with_signer( + ctx.accounts.token_program.to_account_info(), + Transfer { + from: ctx.accounts.new_token_vault.to_account_info(), + to: ctx.accounts.depositor_token_account.to_account_info(), + authority: ctx.accounts.relaunch_signer.to_account_info(), + }, + signer, + ), + amount_claimed, + )?; + + let relaunch = &mut ctx.accounts.relaunch; + relaunch.seq_num += 1; + + let clock = Clock::get()?; + emit_cpi!(TokensClaimedEvent { + common: CommonFields::new(&clock, ctx.accounts.relaunch.seq_num), + relaunch: relaunch_key, + depositor: ctx.accounts.depositor.key(), + deposit_record: ctx.accounts.deposit_record.key(), + amount_claimed, + deposit_record_seq_num: ctx.accounts.deposit_record.seq_num, + }); + + Ok(()) + } +} diff --git a/programs/relaunch/src/instructions/claim_refund.rs b/programs/relaunch/src/instructions/claim_refund.rs new file mode 100644 index 00000000..c683583d --- /dev/null +++ b/programs/relaunch/src/instructions/claim_refund.rs @@ -0,0 +1,108 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface; + +use crate::error::RelaunchError; +use crate::events::{CommonFields, RefundClaimedEvent}; +use crate::state::{DepositRecord, Relaunch, RelaunchState}; + +#[event_cpi] +#[derive(Accounts)] +pub struct ClaimRefund<'info> { + #[account( + mut, + has_one = old_mint, + has_one = old_token_vault, + has_one = relaunch_signer, + )] + pub relaunch: Box>, + + #[account( + mut, + has_one = relaunch, + has_one = depositor, + seeds = [b"deposit_record", relaunch.key().as_ref(), depositor.key().as_ref()], + bump = deposit_record.pda_bump + )] + pub deposit_record: Box>, + + #[account(mint::token_program = old_token_program)] + pub old_mint: Box>, + + #[account(mut)] + pub old_token_vault: Box>, + + /// CHECK: just a signer + pub relaunch_signer: UncheckedAccount<'info>, + + /// CHECK: the refund recipient; not required to sign, so anyone can crank + /// refunds for any depositor. + pub depositor: UncheckedAccount<'info>, + + #[account( + mut, + associated_token::mint = old_mint, + associated_token::authority = depositor, + associated_token::token_program = old_token_program, + )] + pub depositor_token_account: Box>, + + pub old_token_program: Interface<'info, token_interface::TokenInterface>, +} + +impl ClaimRefund<'_> { + pub fn validate(&self) -> Result<()> { + require!( + self.relaunch.state == RelaunchState::Failed, + RelaunchError::RelaunchNotFailed + ); + + require!(!self.deposit_record.claimed, RelaunchError::AlreadyClaimed); + + Ok(()) + } + + pub fn handle(ctx: Context) -> Result<()> { + let relaunch_key = ctx.accounts.relaunch.key(); + + let seeds = &[ + b"relaunch_signer", + relaunch_key.as_ref(), + &[ctx.accounts.relaunch.relaunch_signer_bump], + ]; + let signer = &[&seeds[..]]; + + let deposit_record = &mut ctx.accounts.deposit_record; + deposit_record.claimed = true; + deposit_record.seq_num += 1; + + token_interface::transfer_checked( + CpiContext::new_with_signer( + ctx.accounts.old_token_program.to_account_info(), + token_interface::TransferChecked { + from: ctx.accounts.old_token_vault.to_account_info(), + mint: ctx.accounts.old_mint.to_account_info(), + to: ctx.accounts.depositor_token_account.to_account_info(), + authority: ctx.accounts.relaunch_signer.to_account_info(), + }, + signer, + ), + ctx.accounts.deposit_record.amount_deposited, + ctx.accounts.old_mint.decimals, + )?; + + let relaunch = &mut ctx.accounts.relaunch; + relaunch.seq_num += 1; + + let clock = Clock::get()?; + emit_cpi!(RefundClaimedEvent { + common: CommonFields::new(&clock, ctx.accounts.relaunch.seq_num), + relaunch: relaunch_key, + depositor: ctx.accounts.depositor.key(), + deposit_record: ctx.accounts.deposit_record.key(), + amount_refunded: ctx.accounts.deposit_record.amount_deposited, + deposit_record_seq_num: ctx.accounts.deposit_record.seq_num, + }); + + Ok(()) + } +} diff --git a/programs/relaunch/src/instructions/close_deposits.rs b/programs/relaunch/src/instructions/close_deposits.rs new file mode 100644 index 00000000..6a440a73 --- /dev/null +++ b/programs/relaunch/src/instructions/close_deposits.rs @@ -0,0 +1,58 @@ +use anchor_lang::prelude::*; + +use crate::error::RelaunchError; +use crate::events::{CommonFields, DepositsClosedEvent}; +use crate::state::{Relaunch, RelaunchState}; + +#[event_cpi] +#[derive(Accounts)] +pub struct CloseDeposits<'info> { + #[account(mut)] + pub relaunch: Account<'info, Relaunch>, +} + +impl CloseDeposits<'_> { + pub fn validate(&self) -> Result<()> { + require!( + self.relaunch.state == RelaunchState::Live, + RelaunchError::RelaunchNotLive + ); + + let clock = Clock::get()?; + require_gte!( + clock.unix_timestamp, + self.relaunch.unix_timestamp_started.unwrap() + + self.relaunch.seconds_for_deposits as i64, + RelaunchError::DepositWindowStillOpen + ); + + Ok(()) + } + + pub fn handle(ctx: Context) -> Result<()> { + let relaunch = &mut ctx.accounts.relaunch; + let clock = Clock::get()?; + + // threshold_bps × old_supply_snapshot overflows u64 for supplies + // above ~1.8e15 raw units, so the threshold math runs in u128. + let threshold = + relaunch.threshold_bps as u128 * relaunch.old_supply_snapshot as u128 / 10_000; + + relaunch.state = if relaunch.total_deposited as u128 >= threshold { + RelaunchState::SellPending + } else { + RelaunchState::Failed + }; + relaunch.unix_timestamp_closed = Some(clock.unix_timestamp); + + relaunch.seq_num += 1; + + emit_cpi!(DepositsClosedEvent { + common: CommonFields::new(&clock, ctx.accounts.relaunch.seq_num), + relaunch: ctx.accounts.relaunch.key(), + new_state: ctx.accounts.relaunch.state, + }); + + Ok(()) + } +} diff --git a/programs/relaunch/src/instructions/complete_relaunch.rs b/programs/relaunch/src/instructions/complete_relaunch.rs new file mode 100644 index 00000000..1c2e1c1f --- /dev/null +++ b/programs/relaunch/src/instructions/complete_relaunch.rs @@ -0,0 +1,319 @@ +use anchor_lang::prelude::*; +use anchor_spl::associated_token::{get_associated_token_address, AssociatedToken}; +use anchor_spl::metadata::{ + mpl_token_metadata::ID as MPL_TOKEN_METADATA_PROGRAM_ID, update_metadata_accounts_v2, Metadata, + UpdateMetadataAccountsV2, +}; +use anchor_spl::token::{ + self, spl_token::instruction::AuthorityType, Mint, SetAuthority, Token, TokenAccount, +}; + +use futarchy::program::Futarchy; +use futarchy::{ + InitialSpendingLimit, InitializeDaoParams, ProvideLiquidityParams, SEED_AMM_POSITION, SEED_DAO, +}; + +use crate::error::RelaunchError; +use crate::events::{CommonFields, RelaunchCompletedEvent}; +use crate::state::{Relaunch, RelaunchState}; +use crate::{ + usdc_mint, PRICE_SCALE, PROPOSAL_MIN_STAKE_TOKENS, TOKENS_TO_FUTARCHY_LIQUIDITY, +}; + +#[event_cpi] +#[derive(Accounts)] +pub struct CompleteRelaunch<'info> { + #[account( + mut, + has_one = relaunch_signer, + has_one = new_mint, + has_one = new_token_vault, + has_one = usdc_vault, + )] + pub relaunch: Box>, + + #[account(mut)] + pub payer: Signer<'info>, + + /// CHECK: the vault authority; signs the futarchy CPIs and the authority + /// handoffs. + pub relaunch_signer: UncheckedAccount<'info>, + + /// The DAO's base mint; its mint authority moves to the Squads vault. + #[account(mut)] + pub new_mint: Box>, + + /// The DAO's quote mint. + #[account(address = usdc_mint::id())] + pub usdc_mint: Box>, + + #[account(mut)] + pub new_token_vault: Box>, + + #[account(mut)] + pub usdc_vault: Box>, + + /// CHECK: the new token's metadata; its update authority moves to the + /// Squads vault. + #[account( + mut, + seeds = [b"metadata", MPL_TOKEN_METADATA_PROGRAM_ID.as_ref(), new_mint.key().as_ref()], + seeds::program = MPL_TOKEN_METADATA_PROGRAM_ID, + bump + )] + pub token_metadata: UncheckedAccount<'info>, + + /// CHECK: initialized by the futarchy program; the nonce seed matches the + /// hardcoded `nonce: 0` CPI param. + #[account( + mut, + seeds = [SEED_DAO, relaunch_signer.key().as_ref(), 0_u64.to_le_bytes().as_ref()], + bump, + seeds::program = futarchy_program, + )] + pub dao: UncheckedAccount<'info>, + + /// CHECK: initialized by the futarchy program; the DAO's AMM base ATA. + #[account(mut, address = get_associated_token_address(&dao.key(), &new_mint.key()))] + pub futarchy_amm_base_vault: UncheckedAccount<'info>, + + /// CHECK: initialized by the futarchy program; the DAO's AMM quote ATA. + #[account(mut, address = get_associated_token_address(&dao.key(), &usdc_mint.key()))] + pub futarchy_amm_quote_vault: UncheckedAccount<'info>, + + /// CHECK: initialized by the futarchy program; the Squads-vault-owned LP + /// position. + #[account( + mut, + seeds = [SEED_AMM_POSITION, dao.key().as_ref(), squads_multisig_vault.key().as_ref()], + bump, + seeds::program = futarchy_program, + )] + pub amm_position: UncheckedAccount<'info>, + + /// CHECK: initialized by squads via the futarchy CPI. + #[account( + mut, + seeds = [squads_multisig_program::SEED_PREFIX, squads_multisig_program::SEED_MULTISIG, dao.key().as_ref()], + bump, + seeds::program = squads_program, + )] + pub squads_multisig: UncheckedAccount<'info>, + + /// CHECK: the DAO treasury that receives the mint and metadata + /// authorities. + #[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 = squads_program, + )] + pub squads_multisig_vault: UncheckedAccount<'info>, + + /// CHECK: initialized by squads when a spending limit is configured. + #[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 = squads_program, + )] + pub spending_limit: UncheckedAccount<'info>, + + /// CHECK: checked by squads. + #[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. + #[account(mut)] + pub squads_program_config_treasury: UncheckedAccount<'info>, + + pub futarchy_program: Program<'info, Futarchy>, + /// CHECK: the futarchy program's event-CPI authority PDA. + #[account(seeds = [b"__event_authority"], bump, seeds::program = futarchy_program)] + pub futarchy_event_authority: UncheckedAccount<'info>, + pub squads_program: Program<'info, squads_multisig_program::program::SquadsMultisigProgram>, + pub token_metadata_program: Program<'info, Metadata>, + pub token_program: Program<'info, Token>, + pub associated_token_program: Program<'info, AssociatedToken>, + pub system_program: Program<'info, System>, +} + +impl CompleteRelaunch<'_> { + pub fn validate(&self) -> Result<()> { + require!( + self.relaunch.state == RelaunchState::Swapped, + RelaunchError::RelaunchNotSwapped + ); + + Ok(()) + } + + pub fn handle(ctx: Context) -> Result<()> { + let relaunch_key = ctx.accounts.relaunch.key(); + + let seeds = &[ + b"relaunch_signer", + relaunch_key.as_ref(), + &[ctx.accounts.relaunch.relaunch_signer_bump], + ]; + let signer = &[&seeds[..]]; + + let usdc_recovered = ctx.accounts.relaunch.usdc_recovered; + // LP the vault's live balance rather than the recorded proceeds so + // stray donations end up in the pool instead of stranding. + let usdc_to_lp = ctx.accounts.usdc_vault.amount; + let price_1e12 = + (usdc_to_lp as u128 * PRICE_SCALE) / (TOKENS_TO_FUTARCHY_LIQUIDITY as u128); + + ctx.accounts.initialize_dao(price_1e12, signer)?; + ctx.accounts + .provide_futarchy_amm_liquidity(usdc_to_lp, signer)?; + ctx.accounts.transfer_mint_authority_to_dao(signer)?; + ctx.accounts.transfer_metadata_authority_to_dao(signer)?; + + let clock = Clock::get()?; + let relaunch = &mut ctx.accounts.relaunch; + relaunch.dao = Some(ctx.accounts.dao.key()); + relaunch.dao_vault = Some(ctx.accounts.squads_multisig_vault.key()); + relaunch.state = RelaunchState::Complete; + relaunch.unix_timestamp_completed = Some(clock.unix_timestamp); + relaunch.seq_num += 1; + + emit_cpi!(RelaunchCompletedEvent { + common: CommonFields::new(&clock, relaunch.seq_num), + relaunch: relaunch_key, + dao: ctx.accounts.dao.key(), + dao_vault: ctx.accounts.squads_multisig_vault.key(), + usdc_recovered, + twap_initial_observation: price_1e12, + usdc_to_lp, + usdc_to_treasury: 0, + }); + + Ok(()) + } + + #[inline(never)] + fn initialize_dao(&self, price_1e12: u128, signer: &[&[&[u8]]]) -> Result<()> { + // A zero/empty config means the DAO launches without a Squads + // spending limit. + let initial_spending_limit = if self.relaunch.monthly_spending_limit_amount == 0 { + None + } else { + Some(InitialSpendingLimit { + amount_per_month: self.relaunch.monthly_spending_limit_amount, + members: self.relaunch.monthly_spending_limit_members.clone(), + }) + }; + + futarchy::cpi::initialize_dao( + CpiContext::new_with_signer( + self.futarchy_program.to_account_info(), + futarchy::cpi::accounts::InitializeDao { + dao: self.dao.to_account_info(), + dao_creator: self.relaunch_signer.to_account_info(), + payer: self.payer.to_account_info(), + system_program: self.system_program.to_account_info(), + base_mint: self.new_mint.to_account_info(), + quote_mint: self.usdc_mint.to_account_info(), + event_authority: self.futarchy_event_authority.to_account_info(), + program: self.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.squads_program.to_account_info(), + squads_program_config: self.squads_program_config.to_account_info(), + squads_program_config_treasury: self + .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(), + }, + signer, + ), + InitializeDaoParams { + twap_initial_observation: price_1e12, + twap_max_observation_change_per_update: price_1e12 / 20, + twap_start_delay_seconds: 24 * 60 * 60, + min_quote_futarchic_liquidity: 1, + min_base_futarchic_liquidity: 1, + base_to_stake: PROPOSAL_MIN_STAKE_TOKENS, + pass_threshold_bps: 300, + seconds_per_proposal: 3 * 24 * 60 * 60, + nonce: 0, + initial_spending_limit, + team_sponsored_pass_threshold_bps: -300, + team_address: self.relaunch.team_address, + }, + ) + } + + #[inline(never)] + fn provide_futarchy_amm_liquidity(&self, usdc_to_lp: u64, signer: &[&[&[u8]]]) -> Result<()> { + futarchy::cpi::provide_liquidity( + CpiContext::new_with_signer( + self.futarchy_program.to_account_info(), + futarchy::cpi::accounts::ProvideLiquidity { + dao: self.dao.to_account_info(), + liquidity_provider: self.relaunch_signer.to_account_info(), + liquidity_provider_base_account: self.new_token_vault.to_account_info(), + liquidity_provider_quote_account: self.usdc_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.amm_position.to_account_info(), + token_program: self.token_program.to_account_info(), + program: self.futarchy_program.to_account_info(), + event_authority: self.futarchy_event_authority.to_account_info(), + }, + signer, + ), + ProvideLiquidityParams { + quote_amount: usdc_to_lp, + max_base_amount: TOKENS_TO_FUTARCHY_LIQUIDITY, + min_liquidity: 0, + position_authority: self.squads_multisig_vault.key(), + }, + ) + } + + #[inline(never)] + fn transfer_mint_authority_to_dao(&self, signer: &[&[&[u8]]]) -> Result<()> { + token::set_authority( + CpiContext::new_with_signer( + self.token_program.to_account_info(), + SetAuthority { + account_or_mint: self.new_mint.to_account_info(), + current_authority: self.relaunch_signer.to_account_info(), + }, + signer, + ), + AuthorityType::MintTokens, + Some(self.squads_multisig_vault.key()), + ) + } + + #[inline(never)] + fn transfer_metadata_authority_to_dao(&self, signer: &[&[&[u8]]]) -> Result<()> { + update_metadata_accounts_v2( + CpiContext::new_with_signer( + self.token_metadata_program.to_account_info(), + UpdateMetadataAccountsV2 { + metadata: self.token_metadata.to_account_info(), + update_authority: self.relaunch_signer.to_account_info(), + }, + signer, + ), + Some(self.squads_multisig_vault.key()), + None, + None, + None, + ) + } +} diff --git a/programs/relaunch/src/instructions/deposit.rs b/programs/relaunch/src/instructions/deposit.rs new file mode 100644 index 00000000..e38efd0f --- /dev/null +++ b/programs/relaunch/src/instructions/deposit.rs @@ -0,0 +1,121 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface; + +use crate::error::RelaunchError; +use crate::events::{CommonFields, TokensDepositedEvent}; +use crate::state::{DepositRecord, Relaunch, RelaunchState}; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct DepositArgs { + pub amount: u64, +} + +#[event_cpi] +#[derive(Accounts)] +pub struct Deposit<'info> { + #[account( + mut, + has_one = old_mint, + has_one = old_token_vault, + )] + pub relaunch: Box>, + + #[account( + init_if_needed, + payer = payer, + space = 8 + DepositRecord::INIT_SPACE, + seeds = [b"deposit_record", relaunch.key().as_ref(), depositor.key().as_ref()], + bump + )] + pub deposit_record: Box>, + + #[account(mint::token_program = old_token_program)] + pub old_mint: Box>, + + #[account(mut)] + pub old_token_vault: Box>, + + pub depositor: Signer<'info>, + + #[account( + mut, + associated_token::mint = old_mint, + associated_token::authority = depositor, + associated_token::token_program = old_token_program, + )] + pub depositor_token_account: Box>, + + #[account(mut)] + pub payer: Signer<'info>, + + pub old_token_program: Interface<'info, token_interface::TokenInterface>, + pub system_program: Program<'info, System>, +} + +impl Deposit<'_> { + pub fn validate(&self, args: &DepositArgs) -> Result<()> { + require!( + self.relaunch.state == RelaunchState::Live, + RelaunchError::RelaunchNotLive + ); + + let clock = Clock::get()?; + require_gt!( + self.relaunch.unix_timestamp_started.unwrap() + + self.relaunch.seconds_for_deposits as i64, + clock.unix_timestamp, + RelaunchError::DepositWindowClosed + ); + + require_gt!(args.amount, 0, RelaunchError::InvalidAmount); + + require_gte!( + self.depositor_token_account.amount, + args.amount, + RelaunchError::InsufficientFunds + ); + + Ok(()) + } + + pub fn handle(ctx: Context, args: DepositArgs) -> Result<()> { + token_interface::transfer_checked( + CpiContext::new( + ctx.accounts.old_token_program.to_account_info(), + token_interface::TransferChecked { + from: ctx.accounts.depositor_token_account.to_account_info(), + mint: ctx.accounts.old_mint.to_account_info(), + to: ctx.accounts.old_token_vault.to_account_info(), + authority: ctx.accounts.depositor.to_account_info(), + }, + ), + args.amount, + ctx.accounts.old_mint.decimals, + )?; + + ctx.accounts.deposit_record.credit( + ctx.accounts.relaunch.key(), + ctx.accounts.depositor.key(), + args.amount, + ctx.bumps.deposit_record, + ); + + let relaunch = &mut ctx.accounts.relaunch; + relaunch.total_deposited += args.amount; + relaunch.seq_num += 1; + + let clock = Clock::get()?; + emit_cpi!(TokensDepositedEvent { + common: CommonFields::new(&clock, ctx.accounts.relaunch.seq_num), + relaunch: ctx.accounts.relaunch.key(), + depositor: ctx.accounts.depositor.key(), + deposit_record: ctx.accounts.deposit_record.key(), + amount: args.amount, + total_deposited: ctx.accounts.relaunch.total_deposited, + total_deposited_by_depositor: ctx.accounts.deposit_record.amount_deposited, + deposit_record_seq_num: ctx.accounts.deposit_record.seq_num, + }); + + Ok(()) + } +} diff --git a/programs/relaunch/src/instructions/deposit_via_buy.rs b/programs/relaunch/src/instructions/deposit_via_buy.rs new file mode 100644 index 00000000..fe4d6615 --- /dev/null +++ b/programs/relaunch/src/instructions/deposit_via_buy.rs @@ -0,0 +1,312 @@ +use anchor_lang::prelude::*; +use anchor_spl::associated_token::AssociatedToken; +use anchor_spl::token::{self, Mint, Token, TokenAccount}; +use anchor_spl::token_interface; + +use crate::error::RelaunchError; +use crate::events::{CommonFields, TokensDepositedViaBuyEvent}; +use crate::pump_amm; +use crate::state::{DepositRecord, Relaunch, RelaunchState, SourceVenue}; +use crate::{ + pump_amm_event_authority, pump_amm_fee_config, pump_amm_global_config, pump_amm_program, + pump_fees_program, +}; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct DepositViaBuyArgs { + /// The exact amount of old tokens to buy off the source pool (pump's buy + /// is exact-output). + pub base_out: u64, + /// The depositor's live slippage cap on the quote spent, inclusive of + /// pump's fees. + pub max_quote_in: u64, +} + +#[event_cpi] +#[derive(Accounts)] +pub struct DepositViaBuy<'info> { + #[account( + mut, + has_one = old_mint, + has_one = source_quote_mint, + has_one = source_pool, + has_one = relaunch_signer, + has_one = old_token_vault, + has_one = source_quote_vault, + )] + pub relaunch: Box>, + + #[account( + init_if_needed, + payer = payer, + space = 8 + DepositRecord::INIT_SPACE, + seeds = [b"deposit_record", relaunch.key().as_ref(), depositor.key().as_ref()], + bump + )] + pub deposit_record: Box>, + + pub depositor: Signer<'info>, + + #[account(mut)] + pub payer: Signer<'info>, + + /// CHECK: the vault authority that signs the buy; pump_amm requires the + /// user account writable. + #[account(mut)] + pub relaunch_signer: UncheckedAccount<'info>, + + #[account(mint::token_program = base_token_program)] + pub old_mint: Box>, + + pub source_quote_mint: Box>, + + #[account(mut)] + pub old_token_vault: Box>, + + #[account(mut)] + pub source_quote_vault: Box>, + + #[account( + mut, + token::mint = source_quote_mint, + token::authority = depositor, + )] + pub depositor_quote_account: Box>, + + /// CHECK: fingerprint-validated at init and pinned by has_one + #[account(mut)] + pub source_pool: UncheckedAccount<'info>, + + /// CHECK: fixed address + #[account(address = pump_amm_global_config::id())] + pub pump_global_config: UncheckedAccount<'info>, + + /// CHECK: pump_amm requires membership in its global config + pub protocol_fee_recipient: UncheckedAccount<'info>, + + /// CHECK: pump_amm checks this ATA + #[account(mut)] + pub protocol_fee_recipient_token_account: UncheckedAccount<'info>, + + /// CHECK: pump_amm checks this against the pool's stored field + #[account(mut)] + pub pool_base_token_account: UncheckedAccount<'info>, + + /// CHECK: pump_amm checks this against the pool's stored field + #[account(mut)] + pub pool_quote_token_account: UncheckedAccount<'info>, + + /// CHECK: pump_amm derives this from the pool's coin_creator + #[account(mut)] + pub coin_creator_vault_ata: UncheckedAccount<'info>, + + /// CHECK: pump_amm derives this from the pool's coin_creator + pub coin_creator_vault_authority: UncheckedAccount<'info>, + + /// CHECK: pump_amm address-checks this PDA + pub global_volume_accumulator: UncheckedAccount<'info>, + + /// CHECK: pump_amm address-checks this PDA; created for `relaunch_signer` + /// on the first buy + #[account(mut)] + pub user_volume_accumulator: UncheckedAccount<'info>, + + /// CHECK: fixed address + #[account(address = pump_amm_fee_config::id())] + pub pump_fee_config: UncheckedAccount<'info>, + + /// CHECK: fixed address + #[account(address = pump_fees_program::id())] + pub pump_fee_program: UncheckedAccount<'info>, + + /// CHECK: pump_amm address-checks this PDA (it need not exist) + pub pool_v2: UncheckedAccount<'info>, + + /// CHECK: pump_amm requires membership in its global config's buyback list + pub buyback_fee_recipient: UncheckedAccount<'info>, + + /// CHECK: pump_amm checks this ATA + #[account(mut)] + pub buyback_fee_recipient_token_account: UncheckedAccount<'info>, + + /// CHECK: fixed address + #[account(address = pump_amm_event_authority::id())] + pub pump_event_authority: UncheckedAccount<'info>, + + /// CHECK: fixed address + #[account(address = pump_amm_program::id())] + pub pump_amm_program: UncheckedAccount<'info>, + + pub base_token_program: Interface<'info, token_interface::TokenInterface>, + pub quote_token_program: Program<'info, Token>, + pub associated_token_program: Program<'info, AssociatedToken>, + pub system_program: Program<'info, System>, +} + +impl DepositViaBuy<'_> { + pub fn validate(&self, args: &DepositViaBuyArgs) -> Result<()> { + require!( + self.relaunch.state == RelaunchState::Live, + RelaunchError::RelaunchNotLive + ); + + require!( + self.relaunch.source_venue == SourceVenue::PumpSwap, + RelaunchError::WrongSourceVenue + ); + + let clock = Clock::get()?; + require_gt!( + self.relaunch.unix_timestamp_started.unwrap() + + self.relaunch.seconds_for_deposits as i64, + clock.unix_timestamp, + RelaunchError::DepositWindowClosed + ); + + require_gt!(args.base_out, 0, RelaunchError::InvalidAmount); + require_gt!(args.max_quote_in, 0, RelaunchError::InvalidAmount); + + require_gte!( + self.depositor_quote_account.amount, + args.max_quote_in, + RelaunchError::InsufficientFunds + ); + + Ok(()) + } + + pub fn handle(ctx: Context, args: DepositViaBuyArgs) -> Result<()> { + let relaunch_key = ctx.accounts.relaunch.key(); + + let seeds = &[ + b"relaunch_signer", + relaunch_key.as_ref(), + &[ctx.accounts.relaunch.relaunch_signer_bump], + ]; + let signer = &[&seeds[..]]; + + let old_before = ctx.accounts.old_token_vault.amount; + let quote_vault_before = ctx.accounts.source_quote_vault.amount; + + token::transfer_checked( + CpiContext::new( + ctx.accounts.quote_token_program.to_account_info(), + token::TransferChecked { + from: ctx.accounts.depositor_quote_account.to_account_info(), + mint: ctx.accounts.source_quote_mint.to_account_info(), + to: ctx.accounts.source_quote_vault.to_account_info(), + authority: ctx.accounts.depositor.to_account_info(), + }, + ), + args.max_quote_in, + ctx.accounts.source_quote_mint.decimals, + )?; + + if ctx.accounts.user_volume_accumulator.data_is_empty() { + pump_amm::init_user_volume_accumulator(pump_amm::InitUserVolumeAccumulator { + payer: ctx.accounts.payer.to_account_info(), + user: ctx.accounts.relaunch_signer.to_account_info(), + user_volume_accumulator: ctx.accounts.user_volume_accumulator.to_account_info(), + system_program: ctx.accounts.system_program.to_account_info(), + event_authority: ctx.accounts.pump_event_authority.to_account_info(), + program: ctx.accounts.pump_amm_program.to_account_info(), + })?; + } + + pump_amm::buy( + pump_amm::Buy { + pool: ctx.accounts.source_pool.to_account_info(), + user: ctx.accounts.relaunch_signer.to_account_info(), + global_config: ctx.accounts.pump_global_config.to_account_info(), + base_mint: ctx.accounts.old_mint.to_account_info(), + quote_mint: ctx.accounts.source_quote_mint.to_account_info(), + user_base_token_account: ctx.accounts.old_token_vault.to_account_info(), + user_quote_token_account: ctx.accounts.source_quote_vault.to_account_info(), + pool_base_token_account: ctx.accounts.pool_base_token_account.to_account_info(), + pool_quote_token_account: ctx.accounts.pool_quote_token_account.to_account_info(), + protocol_fee_recipient: ctx.accounts.protocol_fee_recipient.to_account_info(), + protocol_fee_recipient_token_account: ctx + .accounts + .protocol_fee_recipient_token_account + .to_account_info(), + base_token_program: ctx.accounts.base_token_program.to_account_info(), + quote_token_program: ctx.accounts.quote_token_program.to_account_info(), + system_program: ctx.accounts.system_program.to_account_info(), + associated_token_program: ctx.accounts.associated_token_program.to_account_info(), + event_authority: ctx.accounts.pump_event_authority.to_account_info(), + program: ctx.accounts.pump_amm_program.to_account_info(), + coin_creator_vault_ata: ctx.accounts.coin_creator_vault_ata.to_account_info(), + coin_creator_vault_authority: ctx + .accounts + .coin_creator_vault_authority + .to_account_info(), + global_volume_accumulator: ctx.accounts.global_volume_accumulator.to_account_info(), + user_volume_accumulator: ctx.accounts.user_volume_accumulator.to_account_info(), + fee_config: ctx.accounts.pump_fee_config.to_account_info(), + fee_program: ctx.accounts.pump_fee_program.to_account_info(), + pool_v2: ctx.accounts.pool_v2.to_account_info(), + buyback_fee_recipient: ctx.accounts.buyback_fee_recipient.to_account_info(), + buyback_fee_recipient_token_account: ctx + .accounts + .buyback_fee_recipient_token_account + .to_account_info(), + }, + args.base_out, + args.max_quote_in, + // Volume accumulators feed pump's user-incentive rewards, which + // the relaunch_signer PDA could never claim — don't track. + false, + signer, + )?; + + ctx.accounts.old_token_vault.reload()?; + ctx.accounts.source_quote_vault.reload()?; + + let tokens_bought = ctx.accounts.old_token_vault.amount - old_before; + let quote_refund = ctx.accounts.source_quote_vault.amount - quote_vault_before; + let quote_spent = args.max_quote_in - quote_refund; + + if quote_refund > 0 { + token::transfer_checked( + CpiContext::new_with_signer( + ctx.accounts.quote_token_program.to_account_info(), + token::TransferChecked { + from: ctx.accounts.source_quote_vault.to_account_info(), + mint: ctx.accounts.source_quote_mint.to_account_info(), + to: ctx.accounts.depositor_quote_account.to_account_info(), + authority: ctx.accounts.relaunch_signer.to_account_info(), + }, + signer, + ), + quote_refund, + ctx.accounts.source_quote_mint.decimals, + )?; + } + + ctx.accounts.deposit_record.credit( + relaunch_key, + ctx.accounts.depositor.key(), + tokens_bought, + ctx.bumps.deposit_record, + ); + + let relaunch = &mut ctx.accounts.relaunch; + relaunch.total_deposited += tokens_bought; + relaunch.seq_num += 1; + + let clock = Clock::get()?; + emit_cpi!(TokensDepositedViaBuyEvent { + common: CommonFields::new(&clock, ctx.accounts.relaunch.seq_num), + relaunch: relaunch_key, + depositor: ctx.accounts.depositor.key(), + deposit_record: ctx.accounts.deposit_record.key(), + amount: tokens_bought, + quote_spent, + total_deposited: ctx.accounts.relaunch.total_deposited, + total_deposited_by_depositor: ctx.accounts.deposit_record.amount_deposited, + deposit_record_seq_num: ctx.accounts.deposit_record.seq_num, + }); + + Ok(()) + } +} diff --git a/programs/relaunch/src/instructions/deposit_via_buy_raydium.rs b/programs/relaunch/src/instructions/deposit_via_buy_raydium.rs new file mode 100644 index 00000000..56671b78 --- /dev/null +++ b/programs/relaunch/src/instructions/deposit_via_buy_raydium.rs @@ -0,0 +1,229 @@ +use anchor_lang::prelude::*; +use anchor_spl::token::{self, Mint, Token, TokenAccount}; + +use crate::error::RelaunchError; +use crate::events::{CommonFields, TokensDepositedViaBuyEvent}; +use crate::raydium_amm; +use crate::state::{DepositRecord, Relaunch, RelaunchState, SourceVenue}; +use crate::{raydium_amm_authority, raydium_amm_program}; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct DepositViaBuyRaydiumArgs { + /// The exact amount of old tokens to buy off the source pool + /// (swap_base_out_v2 is exact-output). + pub base_out: u64, + /// The depositor's live slippage cap on the quote spent, inclusive of + /// the AMM's 25 bps fee. + pub max_quote_in: u64, +} + +#[event_cpi] +#[derive(Accounts)] +pub struct DepositViaBuyRaydium<'info> { + #[account( + mut, + has_one = source_quote_mint, + has_one = source_pool, + has_one = relaunch_signer, + has_one = old_token_vault, + has_one = source_quote_vault, + )] + pub relaunch: Box>, + + #[account( + init_if_needed, + payer = payer, + space = 8 + DepositRecord::INIT_SPACE, + seeds = [b"deposit_record", relaunch.key().as_ref(), depositor.key().as_ref()], + bump + )] + pub deposit_record: Box>, + + pub depositor: Signer<'info>, + + #[account(mut)] + pub payer: Signer<'info>, + + /// CHECK: the vault authority that signs the buy and the refund + pub relaunch_signer: UncheckedAccount<'info>, + + pub source_quote_mint: Box>, + + #[account(mut)] + pub old_token_vault: Box>, + + #[account(mut)] + pub source_quote_vault: Box>, + + #[account( + mut, + token::mint = source_quote_mint, + token::authority = depositor, + )] + pub depositor_quote_account: Box>, + + /// CHECK: fingerprint-validated at init and pinned by has_one + #[account(mut)] + pub source_pool: UncheckedAccount<'info>, + + /// CHECK: fixed address + #[account(address = raydium_amm_authority::id())] + pub amm_authority: UncheckedAccount<'info>, + + /// CHECK: pinned against the pool's stored field in validate() + #[account(mut)] + pub amm_coin_vault: UncheckedAccount<'info>, + + /// CHECK: pinned against the pool's stored field in validate() + #[account(mut)] + pub amm_pc_vault: UncheckedAccount<'info>, + + /// CHECK: fixed address + #[account(address = raydium_amm_program::id())] + pub raydium_amm_program: UncheckedAccount<'info>, + + pub token_program: Program<'info, Token>, + pub system_program: Program<'info, System>, +} + +impl DepositViaBuyRaydium<'_> { + pub fn validate(&self, args: &DepositViaBuyRaydiumArgs) -> Result<()> { + require!( + self.relaunch.state == RelaunchState::Live, + RelaunchError::RelaunchNotLive + ); + + require!( + self.relaunch.source_venue == SourceVenue::RaydiumAmmV4, + RelaunchError::WrongSourceVenue + ); + + let clock = Clock::get()?; + require_gt!( + self.relaunch.unix_timestamp_started.unwrap() + + self.relaunch.seconds_for_deposits as i64, + clock.unix_timestamp, + RelaunchError::DepositWindowClosed + ); + + require_gt!(args.base_out, 0, RelaunchError::InvalidAmount); + require_gt!(args.max_quote_in, 0, RelaunchError::InvalidAmount); + + require_gte!( + self.depositor_quote_account.amount, + args.max_quote_in, + RelaunchError::InsufficientFunds + ); + + // Pin the vaults to the pool's stored fields. + let pool = raydium_amm::RaydiumPool::try_parse(&self.source_pool.try_borrow_data()?)?; + require_keys_eq!( + self.amm_coin_vault.key(), + pool.coin_vault, + RelaunchError::SourcePoolNotCanonical + ); + require_keys_eq!( + self.amm_pc_vault.key(), + pool.pc_vault, + RelaunchError::SourcePoolNotCanonical + ); + + Ok(()) + } + + pub fn handle(ctx: Context, args: DepositViaBuyRaydiumArgs) -> Result<()> { + let relaunch_key = ctx.accounts.relaunch.key(); + + let seeds = &[ + b"relaunch_signer", + relaunch_key.as_ref(), + &[ctx.accounts.relaunch.relaunch_signer_bump], + ]; + let signer = &[&seeds[..]]; + + let old_before = ctx.accounts.old_token_vault.amount; + let quote_vault_before = ctx.accounts.source_quote_vault.amount; + + token::transfer_checked( + CpiContext::new( + ctx.accounts.token_program.to_account_info(), + token::TransferChecked { + from: ctx.accounts.depositor_quote_account.to_account_info(), + mint: ctx.accounts.source_quote_mint.to_account_info(), + to: ctx.accounts.source_quote_vault.to_account_info(), + authority: ctx.accounts.depositor.to_account_info(), + }, + ), + args.max_quote_in, + ctx.accounts.source_quote_mint.decimals, + )?; + + // Exact-out: the CPI pulls only the input the buy needs from the + // quote vault, leaving the rest for the refund below. + raydium_amm::swap_base_out_v2( + raydium_amm::Swap { + token_program: ctx.accounts.token_program.to_account_info(), + amm: ctx.accounts.source_pool.to_account_info(), + amm_authority: ctx.accounts.amm_authority.to_account_info(), + amm_coin_vault: ctx.accounts.amm_coin_vault.to_account_info(), + amm_pc_vault: ctx.accounts.amm_pc_vault.to_account_info(), + user_source_token_account: ctx.accounts.source_quote_vault.to_account_info(), + user_destination_token_account: ctx.accounts.old_token_vault.to_account_info(), + user_source_owner: ctx.accounts.relaunch_signer.to_account_info(), + }, + args.max_quote_in, + args.base_out, + signer, + )?; + + ctx.accounts.old_token_vault.reload()?; + ctx.accounts.source_quote_vault.reload()?; + + let tokens_bought = ctx.accounts.old_token_vault.amount - old_before; + let quote_refund = ctx.accounts.source_quote_vault.amount - quote_vault_before; + let quote_spent = args.max_quote_in - quote_refund; + + if quote_refund > 0 { + token::transfer_checked( + CpiContext::new_with_signer( + ctx.accounts.token_program.to_account_info(), + token::TransferChecked { + from: ctx.accounts.source_quote_vault.to_account_info(), + mint: ctx.accounts.source_quote_mint.to_account_info(), + to: ctx.accounts.depositor_quote_account.to_account_info(), + authority: ctx.accounts.relaunch_signer.to_account_info(), + }, + signer, + ), + quote_refund, + ctx.accounts.source_quote_mint.decimals, + )?; + } + + ctx.accounts.deposit_record.credit( + relaunch_key, + ctx.accounts.depositor.key(), + tokens_bought, + ctx.bumps.deposit_record, + ); + + let relaunch = &mut ctx.accounts.relaunch; + relaunch.total_deposited += tokens_bought; + relaunch.seq_num += 1; + + let clock = Clock::get()?; + emit_cpi!(TokensDepositedViaBuyEvent { + common: CommonFields::new(&clock, ctx.accounts.relaunch.seq_num), + relaunch: relaunch_key, + depositor: ctx.accounts.depositor.key(), + deposit_record: ctx.accounts.deposit_record.key(), + amount: tokens_bought, + quote_spent, + total_deposited: ctx.accounts.relaunch.total_deposited, + total_deposited_by_depositor: ctx.accounts.deposit_record.amount_deposited, + deposit_record_seq_num: ctx.accounts.deposit_record.seq_num, + }); + + Ok(()) + } +} diff --git a/programs/relaunch/src/instructions/execute_sell.rs b/programs/relaunch/src/instructions/execute_sell.rs new file mode 100644 index 00000000..cb414abd --- /dev/null +++ b/programs/relaunch/src/instructions/execute_sell.rs @@ -0,0 +1,218 @@ +use anchor_lang::prelude::*; +use anchor_spl::associated_token::AssociatedToken; +use anchor_spl::token::{Mint, Token, TokenAccount}; +use anchor_spl::token_interface; + +use crate::error::RelaunchError; +use crate::events::{CommonFields, SellExecutedEvent}; +use crate::pump_amm; +use crate::state::{Relaunch, RelaunchState, SourceVenue}; +use crate::{ + pump_amm_event_authority, pump_amm_fee_config, pump_amm_global_config, pump_amm_program, + pump_fees_program, usdc_mint, +}; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct ExecuteSellArgs { + /// The admin's live, client-computed slippage floor on the sell proceeds. + pub min_quote_out: u64, +} + +#[event_cpi] +#[derive(Accounts)] +pub struct ExecuteSell<'info> { + #[account( + mut, + has_one = admin, + has_one = old_mint, + has_one = source_quote_mint, + has_one = source_pool, + has_one = relaunch_signer, + has_one = old_token_vault, + has_one = source_quote_vault, + )] + pub relaunch: Box>, + + pub admin: Signer<'info>, + + /// CHECK: the vault authority that signs the sell; pump_amm requires the + /// user account writable. + #[account(mut)] + pub relaunch_signer: UncheckedAccount<'info>, + + #[account(mint::token_program = base_token_program)] + pub old_mint: Box>, + + pub source_quote_mint: Box>, + + #[account(mut)] + pub old_token_vault: Box>, + + #[account(mut)] + pub source_quote_vault: Box>, + + /// CHECK: fingerprint-validated at init and pinned by has_one; pump_amm + /// rechecks its internal consistency. + #[account(mut)] + pub source_pool: UncheckedAccount<'info>, + + /// CHECK: fixed address + #[account(address = pump_amm_global_config::id())] + pub pump_global_config: UncheckedAccount<'info>, + + /// CHECK: pump_amm requires membership in its global config + pub protocol_fee_recipient: UncheckedAccount<'info>, + + /// CHECK: pump_amm checks this ATA + #[account(mut)] + pub protocol_fee_recipient_token_account: UncheckedAccount<'info>, + + /// CHECK: pump_amm checks this against the pool's stored field + #[account(mut)] + pub pool_base_token_account: UncheckedAccount<'info>, + + /// CHECK: pump_amm checks this against the pool's stored field + #[account(mut)] + pub pool_quote_token_account: UncheckedAccount<'info>, + + /// CHECK: pump_amm derives this from the pool's coin_creator + #[account(mut)] + pub coin_creator_vault_ata: UncheckedAccount<'info>, + + /// CHECK: pump_amm derives this from the pool's coin_creator + pub coin_creator_vault_authority: UncheckedAccount<'info>, + + /// CHECK: fixed address + #[account(address = pump_amm_fee_config::id())] + pub pump_fee_config: UncheckedAccount<'info>, + + /// CHECK: fixed address + #[account(address = pump_fees_program::id())] + pub pump_fee_program: UncheckedAccount<'info>, + + /// CHECK: pump_amm address-checks this PDA (it need not exist) + pub pool_v2: UncheckedAccount<'info>, + + /// CHECK: pump_amm requires membership in its global config's buyback list + pub buyback_fee_recipient: UncheckedAccount<'info>, + + /// CHECK: pump_amm checks this ATA + #[account(mut)] + pub buyback_fee_recipient_token_account: UncheckedAccount<'info>, + + /// CHECK: fixed address + #[account(address = pump_amm_event_authority::id())] + pub pump_event_authority: UncheckedAccount<'info>, + + /// CHECK: fixed address + #[account(address = pump_amm_program::id())] + pub pump_amm_program: UncheckedAccount<'info>, + + pub base_token_program: Interface<'info, token_interface::TokenInterface>, + pub quote_token_program: Program<'info, Token>, + pub associated_token_program: Program<'info, AssociatedToken>, + pub system_program: Program<'info, System>, +} + +impl ExecuteSell<'_> { + pub fn validate(&self, _args: &ExecuteSellArgs) -> Result<()> { + require!( + self.relaunch.state == RelaunchState::SellPending, + RelaunchError::RelaunchNotSellPending + ); + + require!( + self.relaunch.source_venue == SourceVenue::PumpSwap, + RelaunchError::WrongSourceVenue + ); + + let clock = Clock::get()?; + require_gte!( + self.relaunch.unix_timestamp_closed.unwrap() + + self.relaunch.grace_period_seconds as i64, + clock.unix_timestamp, + RelaunchError::GracePeriodElapsed + ); + + Ok(()) + } + + pub fn handle(ctx: Context, args: ExecuteSellArgs) -> Result<()> { + let relaunch_key = ctx.accounts.relaunch.key(); + + let seeds = &[ + b"relaunch_signer", + relaunch_key.as_ref(), + &[ctx.accounts.relaunch.relaunch_signer_bump], + ]; + let signer = &[&seeds[..]]; + + let base_sold = ctx.accounts.old_token_vault.amount; + let quote_before = ctx.accounts.source_quote_vault.amount; + + pump_amm::sell( + pump_amm::Sell { + pool: ctx.accounts.source_pool.to_account_info(), + user: ctx.accounts.relaunch_signer.to_account_info(), + global_config: ctx.accounts.pump_global_config.to_account_info(), + base_mint: ctx.accounts.old_mint.to_account_info(), + quote_mint: ctx.accounts.source_quote_mint.to_account_info(), + user_base_token_account: ctx.accounts.old_token_vault.to_account_info(), + user_quote_token_account: ctx.accounts.source_quote_vault.to_account_info(), + pool_base_token_account: ctx.accounts.pool_base_token_account.to_account_info(), + pool_quote_token_account: ctx.accounts.pool_quote_token_account.to_account_info(), + protocol_fee_recipient: ctx.accounts.protocol_fee_recipient.to_account_info(), + protocol_fee_recipient_token_account: ctx + .accounts + .protocol_fee_recipient_token_account + .to_account_info(), + base_token_program: ctx.accounts.base_token_program.to_account_info(), + quote_token_program: ctx.accounts.quote_token_program.to_account_info(), + system_program: ctx.accounts.system_program.to_account_info(), + associated_token_program: ctx.accounts.associated_token_program.to_account_info(), + event_authority: ctx.accounts.pump_event_authority.to_account_info(), + program: ctx.accounts.pump_amm_program.to_account_info(), + coin_creator_vault_ata: ctx.accounts.coin_creator_vault_ata.to_account_info(), + coin_creator_vault_authority: ctx + .accounts + .coin_creator_vault_authority + .to_account_info(), + fee_config: ctx.accounts.pump_fee_config.to_account_info(), + fee_program: ctx.accounts.pump_fee_program.to_account_info(), + pool_v2: ctx.accounts.pool_v2.to_account_info(), + buyback_fee_recipient: ctx.accounts.buyback_fee_recipient.to_account_info(), + buyback_fee_recipient_token_account: ctx + .accounts + .buyback_fee_recipient_token_account + .to_account_info(), + }, + base_sold, + args.min_quote_out, + signer, + )?; + + ctx.accounts.source_quote_vault.reload()?; + let quote_recovered = ctx.accounts.source_quote_vault.amount - quote_before; + + let relaunch = &mut ctx.accounts.relaunch; + relaunch.quote_recovered = quote_recovered; + if relaunch.source_quote_mint == usdc_mint::id() { + relaunch.usdc_recovered = quote_recovered; + relaunch.state = RelaunchState::Swapped; + } else { + relaunch.state = RelaunchState::Sold; + } + relaunch.seq_num += 1; + + let clock = Clock::get()?; + emit_cpi!(SellExecutedEvent { + common: CommonFields::new(&clock, ctx.accounts.relaunch.seq_num), + relaunch: relaunch_key, + base_sold, + quote_recovered, + new_state: ctx.accounts.relaunch.state, + }); + + Ok(()) + } +} diff --git a/programs/relaunch/src/instructions/execute_sell_raydium.rs b/programs/relaunch/src/instructions/execute_sell_raydium.rs new file mode 100644 index 00000000..b85362e8 --- /dev/null +++ b/programs/relaunch/src/instructions/execute_sell_raydium.rs @@ -0,0 +1,152 @@ +use anchor_lang::prelude::*; +use anchor_spl::token::{Token, TokenAccount}; + +use crate::error::RelaunchError; +use crate::events::{CommonFields, SellExecutedEvent}; +use crate::raydium_amm; +use crate::state::{Relaunch, RelaunchState, SourceVenue}; +use crate::{raydium_amm_authority, raydium_amm_program}; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct ExecuteSellRaydiumArgs { + /// The admin's live, client-computed slippage floor on the sell proceeds. + pub min_quote_out: u64, +} + +#[event_cpi] +#[derive(Accounts)] +pub struct ExecuteSellRaydium<'info> { + #[account( + mut, + has_one = admin, + has_one = source_pool, + has_one = relaunch_signer, + has_one = old_token_vault, + has_one = source_quote_vault, + )] + pub relaunch: Box>, + + pub admin: Signer<'info>, + + /// CHECK: the vault authority that signs the sell + pub relaunch_signer: UncheckedAccount<'info>, + + #[account(mut)] + pub old_token_vault: Box>, + + #[account(mut)] + pub source_quote_vault: Box>, + + /// CHECK: fingerprint-validated at init and pinned by has_one; the AMM + /// rechecks its internal consistency. + #[account(mut)] + pub source_pool: UncheckedAccount<'info>, + + /// CHECK: fixed address + #[account(address = raydium_amm_authority::id())] + pub amm_authority: UncheckedAccount<'info>, + + /// CHECK: pinned against the pool's stored field in validate() + #[account(mut)] + pub amm_coin_vault: UncheckedAccount<'info>, + + /// CHECK: pinned against the pool's stored field in validate() + #[account(mut)] + pub amm_pc_vault: UncheckedAccount<'info>, + + /// CHECK: fixed address + #[account(address = raydium_amm_program::id())] + pub raydium_amm_program: UncheckedAccount<'info>, + + pub token_program: Program<'info, Token>, +} + +impl ExecuteSellRaydium<'_> { + pub fn validate(&self, _args: &ExecuteSellRaydiumArgs) -> Result<()> { + require!( + self.relaunch.state == RelaunchState::SellPending, + RelaunchError::RelaunchNotSellPending + ); + + require!( + self.relaunch.source_venue == SourceVenue::RaydiumAmmV4, + RelaunchError::WrongSourceVenue + ); + + let clock = Clock::get()?; + require_gte!( + self.relaunch.unix_timestamp_closed.unwrap() + + self.relaunch.grace_period_seconds as i64, + clock.unix_timestamp, + RelaunchError::GracePeriodElapsed + ); + + // Pin the vaults to the pool's stored fields. + let pool = raydium_amm::RaydiumPool::try_parse(&self.source_pool.try_borrow_data()?)?; + require_keys_eq!( + self.amm_coin_vault.key(), + pool.coin_vault, + RelaunchError::SourcePoolNotCanonical + ); + require_keys_eq!( + self.amm_pc_vault.key(), + pool.pc_vault, + RelaunchError::SourcePoolNotCanonical + ); + + Ok(()) + } + + pub fn handle(ctx: Context, args: ExecuteSellRaydiumArgs) -> Result<()> { + let relaunch_key = ctx.accounts.relaunch.key(); + + let seeds = &[ + b"relaunch_signer", + relaunch_key.as_ref(), + &[ctx.accounts.relaunch.relaunch_signer_bump], + ]; + let signer = &[&seeds[..]]; + + let base_sold = ctx.accounts.old_token_vault.amount; + let quote_before = ctx.accounts.source_quote_vault.amount; + + raydium_amm::swap_base_in_v2( + raydium_amm::Swap { + token_program: ctx.accounts.token_program.to_account_info(), + amm: ctx.accounts.source_pool.to_account_info(), + amm_authority: ctx.accounts.amm_authority.to_account_info(), + amm_coin_vault: ctx.accounts.amm_coin_vault.to_account_info(), + amm_pc_vault: ctx.accounts.amm_pc_vault.to_account_info(), + user_source_token_account: ctx.accounts.old_token_vault.to_account_info(), + user_destination_token_account: ctx + .accounts + .source_quote_vault + .to_account_info(), + user_source_owner: ctx.accounts.relaunch_signer.to_account_info(), + }, + base_sold, + args.min_quote_out, + signer, + )?; + + ctx.accounts.source_quote_vault.reload()?; + let quote_recovered = ctx.accounts.source_quote_vault.amount - quote_before; + + // Raydium sources are WSOL-quoted, so the sell always lands in Sold. + let relaunch = &mut ctx.accounts.relaunch; + relaunch.quote_recovered = quote_recovered; + relaunch.state = RelaunchState::Sold; + relaunch.seq_num += 1; + + let clock = Clock::get()?; + emit_cpi!(SellExecutedEvent { + common: CommonFields::new(&clock, ctx.accounts.relaunch.seq_num), + relaunch: relaunch_key, + base_sold, + quote_recovered, + new_state: ctx.accounts.relaunch.state, + }); + + Ok(()) + } +} diff --git a/programs/relaunch/src/instructions/execute_usdc_swap.rs b/programs/relaunch/src/instructions/execute_usdc_swap.rs new file mode 100644 index 00000000..1aa41a06 --- /dev/null +++ b/programs/relaunch/src/instructions/execute_usdc_swap.rs @@ -0,0 +1,164 @@ +use anchor_lang::prelude::*; +use anchor_spl::token::{Token, TokenAccount}; + +use crate::error::RelaunchError; +use crate::events::{CommonFields, UsdcSwapExecutedEvent}; +use crate::state::{Relaunch, RelaunchState}; +use crate::whirlpool; +use crate::{memo_program, usdc_mint, usdc_swap_pool, whirlpool_program, wsol_mint}; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct ExecuteUsdcSwapArgs { + /// The admin's live, client-computed slippage floor on the swap output. + pub min_usdc_out: u64, +} + +#[event_cpi] +#[derive(Accounts)] +pub struct ExecuteUsdcSwap<'info> { + #[account( + mut, + has_one = admin, + has_one = relaunch_signer, + has_one = source_quote_vault, + has_one = usdc_vault, + )] + pub relaunch: Box>, + + pub admin: Signer<'info>, + + /// CHECK: the vault authority that signs the swap. + pub relaunch_signer: UncheckedAccount<'info>, + + /// The WSOL vault holding the sell proceeds; `Sold` only occurs for + /// WSOL-quoted sources. + #[account(mut)] + pub source_quote_vault: Box>, + + #[account(mut)] + pub usdc_vault: Box>, + + /// CHECK: pinned to the program's swap-venue constant; whirlpool rechecks + /// its internal consistency. + #[account(mut, address = usdc_swap_pool::id())] + pub whirlpool: UncheckedAccount<'info>, + + /// CHECK: fixed address; the pinned pool's token A. + #[account(address = wsol_mint::id())] + pub wsol_mint: UncheckedAccount<'info>, + + /// CHECK: fixed address; the pinned pool's token B. + #[account(address = usdc_mint::id())] + pub usdc_mint: UncheckedAccount<'info>, + + /// CHECK: whirlpool checks this against the pool's stored field + #[account(mut)] + pub whirlpool_wsol_vault: UncheckedAccount<'info>, + + /// CHECK: whirlpool checks this against the pool's stored field + #[account(mut)] + pub whirlpool_usdc_vault: UncheckedAccount<'info>, + + /// CHECK: whirlpool validates the tick-array sequence + #[account(mut)] + pub tick_array_0: UncheckedAccount<'info>, + + /// CHECK: whirlpool validates the tick-array sequence + #[account(mut)] + pub tick_array_1: UncheckedAccount<'info>, + + /// CHECK: whirlpool validates the tick-array sequence + #[account(mut)] + pub tick_array_2: UncheckedAccount<'info>, + + /// CHECK: whirlpool checks this PDA + #[account(mut)] + pub oracle: UncheckedAccount<'info>, + + /// CHECK: fixed address + #[account(address = memo_program::id())] + pub memo_program: UncheckedAccount<'info>, + + /// CHECK: fixed address + #[account(address = whirlpool_program::id())] + pub whirlpool_program: UncheckedAccount<'info>, + + pub token_program: Program<'info, Token>, +} + +impl ExecuteUsdcSwap<'_> { + pub fn validate(&self, _args: &ExecuteUsdcSwapArgs) -> Result<()> { + require!( + self.relaunch.state == RelaunchState::Sold, + RelaunchError::RelaunchNotSold + ); + + Ok(()) + } + + pub fn handle(ctx: Context, args: ExecuteUsdcSwapArgs) -> Result<()> { + let relaunch_key = ctx.accounts.relaunch.key(); + + let seeds = &[ + b"relaunch_signer", + relaunch_key.as_ref(), + &[ctx.accounts.relaunch.relaunch_signer_bump], + ]; + let signer = &[&seeds[..]]; + + let wsol_sold = ctx.accounts.source_quote_vault.amount; + let usdc_before = ctx.accounts.usdc_vault.amount; + + whirlpool::swap_v2( + whirlpool::SwapV2 { + token_program_a: ctx.accounts.token_program.to_account_info(), + token_program_b: ctx.accounts.token_program.to_account_info(), + memo_program: ctx.accounts.memo_program.to_account_info(), + token_authority: ctx.accounts.relaunch_signer.to_account_info(), + whirlpool: ctx.accounts.whirlpool.to_account_info(), + token_mint_a: ctx.accounts.wsol_mint.to_account_info(), + token_mint_b: ctx.accounts.usdc_mint.to_account_info(), + token_owner_account_a: ctx.accounts.source_quote_vault.to_account_info(), + token_vault_a: ctx.accounts.whirlpool_wsol_vault.to_account_info(), + token_owner_account_b: ctx.accounts.usdc_vault.to_account_info(), + token_vault_b: ctx.accounts.whirlpool_usdc_vault.to_account_info(), + tick_array_0: ctx.accounts.tick_array_0.to_account_info(), + tick_array_1: ctx.accounts.tick_array_1.to_account_info(), + tick_array_2: ctx.accounts.tick_array_2.to_account_info(), + oracle: ctx.accounts.oracle.to_account_info(), + }, + wsol_sold, + args.min_usdc_out, + whirlpool::MIN_SQRT_PRICE, + true, // amount specified is input + true, // a→b: WSOL → USDC + signer, + )?; + + ctx.accounts.usdc_vault.reload()?; + let usdc_recovered = ctx.accounts.usdc_vault.amount - usdc_before; + + // Whirlpool enforces min_usdc_out internally; re-check the measured + // delta so the floor doesn't rest on the external program. + require_gte!( + usdc_recovered, + args.min_usdc_out, + RelaunchError::SlippageExceeded + ); + + let relaunch = &mut ctx.accounts.relaunch; + relaunch.usdc_recovered = usdc_recovered; + relaunch.state = RelaunchState::Swapped; + relaunch.seq_num += 1; + + let clock = Clock::get()?; + emit_cpi!(UsdcSwapExecutedEvent { + common: CommonFields::new(&clock, ctx.accounts.relaunch.seq_num), + relaunch: relaunch_key, + wsol_sold, + usdc_recovered, + }); + + Ok(()) + } +} diff --git a/programs/relaunch/src/instructions/initialize_relaunch.rs b/programs/relaunch/src/instructions/initialize_relaunch.rs new file mode 100644 index 00000000..05061917 --- /dev/null +++ b/programs/relaunch/src/instructions/initialize_relaunch.rs @@ -0,0 +1,465 @@ +use anchor_lang::prelude::*; +use anchor_spl::associated_token::AssociatedToken; +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, +}; +use anchor_spl::token::{ + self, spl_token::instruction::AuthorityType, Mint, MintTo, SetAuthority, Token, TokenAccount, +}; +use anchor_spl::token_2022::spl_token_2022::{ + extension::{BaseStateWithExtensions, ExtensionType, StateWithExtensions}, + state::Mint as MintWithExtensions, +}; +use anchor_spl::token_interface; + +use crate::error::RelaunchError; +use crate::events::{CommonFields, RelaunchInitializedEvent}; +use crate::pump_amm; +use crate::raydium_amm; +use crate::state::{Relaunch, RelaunchState, SourceVenue}; +use crate::{ + openbook_program, pump_amm_program, pump_program, raydium_amm_program, usdc_mint, wsol_mint, + MAX_SECONDS_FOR_DEPOSITS, PUMP_POOL_AUTHORITY_SEED, PUMP_POOL_SEED, RAYDIUM_MIN_BURNED_LP, + TOKENS_TO_DEPOSITORS, TOKENS_TO_FUTARCHY_LIQUIDITY, +}; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct InitializeRelaunchArgs { + pub token_name: String, + pub token_symbol: String, + pub token_uri: String, + pub seconds_for_deposits: u32, + pub grace_period_seconds: u32, + pub threshold_bps: u16, + pub monthly_spending_limit_amount: u64, + pub monthly_spending_limit_members: Vec, + pub team_address: Pubkey, +} + +#[event_cpi] +#[derive(Accounts)] +pub struct InitializeRelaunch<'info> { + #[account( + init, + payer = payer, + space = 8 + Relaunch::INIT_SPACE, + seeds = [b"relaunch", new_mint.key().as_ref()], + bump + )] + pub relaunch: Box>, + + #[account( + mut, + mint::decimals = 6, + mint::authority = mint_authority, + )] + pub new_mint: Box>, + + /// Proof that the initializer controls the new mint: must sign, and the + /// handler CPIs `set_authority` to hand minting to `relaunch_signer`. + pub mint_authority: Signer<'info>, + + /// CHECK: PDA that signs CPIs and owns the vaults + #[account( + seeds = [b"relaunch_signer", relaunch.key().as_ref()], + bump + )] + pub relaunch_signer: UncheckedAccount<'info>, + + #[account(mint::token_program = old_token_program)] + pub old_mint: Box>, + + /// CHECK: fingerprint-checked in validate() + pub source_pool: UncheckedAccount<'info>, + + pub source_quote_mint: Box>, + + /// The source pool's LP mint: required for Raydium sources + pub source_pool_lp_mint: Option>>, + + #[account(address = usdc_mint::id())] + pub usdc_mint: Box>, + + #[account( + init_if_needed, + payer = payer, + associated_token::mint = old_mint, + associated_token::authority = relaunch_signer, + associated_token::token_program = old_token_program, + )] + pub old_token_vault: Box>, + + #[account( + init_if_needed, + payer = payer, + associated_token::mint = new_mint, + associated_token::authority = relaunch_signer, + )] + pub new_token_vault: Box>, + + #[account( + init_if_needed, + payer = payer, + associated_token::mint = source_quote_mint, + associated_token::authority = relaunch_signer, + )] + pub source_quote_vault: Box>, + + /// The same account as `source_quote_vault` for USDC-quoted sources, in + /// which case the `init_if_needed` is a no-op revalidation. + #[account( + init_if_needed, + payer = payer, + associated_token::mint = usdc_mint, + associated_token::authority = relaunch_signer, + )] + pub usdc_vault: Box>, + + /// CHECK: This is the token metadata + #[account( + mut, + seeds = [b"metadata", MPL_TOKEN_METADATA_PROGRAM_ID.as_ref(), new_mint.key().as_ref()], + seeds::program = MPL_TOKEN_METADATA_PROGRAM_ID, + bump + )] + pub token_metadata: UncheckedAccount<'info>, + + /// CHECK: The initializer; gains the sell/swap monopoly during the grace + /// period. Not required to sign, mirroring launchpad's launch_authority. + pub admin: UncheckedAccount<'info>, + + #[account(mut)] + pub payer: Signer<'info>, + + pub rent: Sysvar<'info, Rent>, + + pub old_token_program: Interface<'info, token_interface::TokenInterface>, + 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 InitializeRelaunch<'_> { + pub fn validate(&self, args: &InitializeRelaunchArgs) -> Result<()> { + require_eq!(self.new_mint.supply, 0, RelaunchError::SupplyNonZero); + + require!( + self.new_mint.freeze_authority.is_none(), + RelaunchError::FreezeAuthoritySet + ); + + if *self.source_pool.owner == pump_amm_program::id() { + self.validate_pump_source()?; + } else if *self.source_pool.owner == raydium_amm_program::id() { + self.validate_raydium_source()?; + } else { + return err!(RelaunchError::SourcePoolNotCanonical); + } + + // Old mints may carry only mint-embedded metadata extensions; anything + // else (transfer fees, hooks, ...) is rejected rather than assumed safe. + let old_mint_info = self.old_mint.to_account_info(); + if *old_mint_info.owner == anchor_spl::token_2022::ID { + let old_mint_data = old_mint_info.try_borrow_data()?; + let old_mint_state = StateWithExtensions::::unpack(&old_mint_data)?; + for extension in old_mint_state.get_extension_types()? { + require!( + matches!( + extension, + ExtensionType::MetadataPointer | ExtensionType::TokenMetadata + ), + RelaunchError::ForbiddenOldMintExtension + ); + } + } + + require_gt!(args.threshold_bps, 0, RelaunchError::InvalidThresholdBps); + require_gte!( + 10_000, + args.threshold_bps, + RelaunchError::InvalidThresholdBps + ); + + require_gte!( + MAX_SECONDS_FOR_DEPOSITS, + args.seconds_for_deposits, + RelaunchError::InvalidSecondsForDeposits + ); + + // A zero amount with no members means the DAO launches without a + // spending limit; a config missing either half is invalid. + require!( + (args.monthly_spending_limit_amount == 0) + == args.monthly_spending_limit_members.is_empty(), + RelaunchError::InvalidMonthlySpendingLimit + ); + + require_gte!( + futarchy::MAX_SPENDING_LIMIT_MEMBERS, + args.monthly_spending_limit_members.len(), + RelaunchError::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, + RelaunchError::InvalidMonthlySpendingLimitMembers + ); + + Ok(()) + } + + fn validate_pump_source(&self) -> Result<()> { + require!( + self.source_pool_lp_mint.is_none(), + RelaunchError::SourcePoolLpMintMismatch + ); + + require!( + self.source_quote_mint.key() == wsol_mint::id() + || self.source_quote_mint.key() == usdc_mint::id(), + RelaunchError::InvalidQuoteMint + ); + + let pool = pump_amm::PumpSwapPool::try_parse(&self.source_pool.try_borrow_data()?)?; + + require_eq!(pool.index, 0, RelaunchError::SourcePoolNotCanonical); + + require_keys_eq!( + pool.base_mint, + self.old_mint.key(), + RelaunchError::SourcePoolNotCanonical + ); + + let (pool_authority, _) = Pubkey::find_program_address( + &[PUMP_POOL_AUTHORITY_SEED, self.old_mint.key().as_ref()], + &pump_program::id(), + ); + require_keys_eq!( + pool.creator, + pool_authority, + RelaunchError::SourcePoolNotCanonical + ); + + require_keys_eq!( + pool.quote_mint, + self.source_quote_mint.key(), + RelaunchError::SourcePoolQuoteMintMismatch + ); + + // The fields above are also the pool PDA's seeds, so re-deriving the + // address re-checks them without relying on pump_amm keeping stored + // fields consistent with seeds. + let (canonical_pool, _) = Pubkey::find_program_address( + &[ + PUMP_POOL_SEED, + &0u16.to_le_bytes(), + pool_authority.as_ref(), + self.old_mint.key().as_ref(), + self.source_quote_mint.key().as_ref(), + ], + &pump_amm_program::id(), + ); + require_keys_eq!( + self.source_pool.key(), + canonical_pool, + RelaunchError::SourcePoolNotCanonical + ); + + Ok(()) + } + + /// No PDA provenance exists on AMM v4, so canonicality is owner + shape, + /// the right pair, swappability, the orderbook-era mark, and a migration's + /// worth of burned LP. + fn validate_raydium_source(&self) -> Result<()> { + require_keys_eq!( + self.source_quote_mint.key(), + wsol_mint::id(), + RelaunchError::InvalidQuoteMint + ); + + let pool = raydium_amm::RaydiumPool::try_parse(&self.source_pool.try_borrow_data()?)?; + + // The right pair in either orientation + let expected = (self.old_mint.key(), self.source_quote_mint.key()); + require!( + (pool.coin_mint, pool.pc_mint) == expected + || (pool.pc_mint, pool.coin_mint) == expected, + RelaunchError::SourcePoolNotCanonical + ); + + // The AMM's own swap_permission(): 1 (Initialized), 6 (SwapOnly), + // 7 (WaitingTrade). + require!( + matches!(pool.status, 1 | 6 | 7), + RelaunchError::SourcePoolSwapsDisabled + ); + + // The orderbook fingerprint: pools created since Raydium removed the + // orderbook path store the system program here, so no decoy created + // today can pass. + require_keys_eq!( + pool.market_program, + openbook_program::id(), + RelaunchError::SourcePoolWrongEra + ); + + // burned = lp_amount - supply is monotone: deposits and withdrawals + // move both terms monotonically, so only external burns increase it. + let lp_mint = match &self.source_pool_lp_mint { + Some(lp_mint) => lp_mint, + None => return err!(RelaunchError::SourcePoolLpMintMismatch), + }; + require_keys_eq!( + lp_mint.key(), + pool.lp_mint, + RelaunchError::SourcePoolLpMintMismatch + ); + require_gte!( + pool.lp_amount.saturating_sub(lp_mint.supply), + RAYDIUM_MIN_BURNED_LP, + RelaunchError::SourcePoolLpNotBurned + ); + + Ok(()) + } + + pub fn handle(ctx: Context, args: InitializeRelaunchArgs) -> Result<()> { + let relaunch_key = ctx.accounts.relaunch.key(); + + // validate() already required the owner to be one of the two venues. + let source_venue = if *ctx.accounts.source_pool.owner == raydium_amm_program::id() { + SourceVenue::RaydiumAmmV4 + } else { + SourceVenue::PumpSwap + }; + + let seeds = &[ + b"relaunch_signer", + relaunch_key.as_ref(), + &[ctx.bumps.relaunch_signer], + ]; + let signer = &[&seeds[..]]; + + token::set_authority( + CpiContext::new( + ctx.accounts.token_program.to_account_info(), + SetAuthority { + account_or_mint: ctx.accounts.new_mint.to_account_info(), + current_authority: ctx.accounts.mint_authority.to_account_info(), + }, + ), + AuthorityType::MintTokens, + Some(ctx.accounts.relaunch_signer.key()), + )?; + + create_metadata_accounts_v3( + CpiContext::new( + ctx.accounts.token_metadata_program.to_account_info(), + CreateMetadataAccountsV3 { + metadata: ctx.accounts.token_metadata.to_account_info(), + mint: ctx.accounts.new_mint.to_account_info(), + mint_authority: ctx.accounts.relaunch_signer.to_account_info(), + payer: ctx.accounts.payer.to_account_info(), + update_authority: ctx.accounts.relaunch_signer.to_account_info(), + system_program: ctx.accounts.system_program.to_account_info(), + rent: ctx.accounts.rent.to_account_info(), + }, + ) + .with_signer(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, + )?; + + token::mint_to( + CpiContext::new_with_signer( + ctx.accounts.token_program.to_account_info(), + MintTo { + mint: ctx.accounts.new_mint.to_account_info(), + to: ctx.accounts.new_token_vault.to_account_info(), + authority: ctx.accounts.relaunch_signer.to_account_info(), + }, + signer, + ), + TOKENS_TO_DEPOSITORS + TOKENS_TO_FUTARCHY_LIQUIDITY, + )?; + + let old_supply_snapshot = ctx.accounts.old_mint.supply; + + ctx.accounts.relaunch.set_inner(Relaunch { + admin: ctx.accounts.admin.key(), + new_mint: ctx.accounts.new_mint.key(), + old_mint: ctx.accounts.old_mint.key(), + source_pool: ctx.accounts.source_pool.key(), + source_quote_mint: ctx.accounts.source_quote_mint.key(), + relaunch_signer: ctx.accounts.relaunch_signer.key(), + relaunch_signer_bump: ctx.bumps.relaunch_signer, + old_token_vault: ctx.accounts.old_token_vault.key(), + new_token_vault: ctx.accounts.new_token_vault.key(), + source_quote_vault: ctx.accounts.source_quote_vault.key(), + usdc_vault: ctx.accounts.usdc_vault.key(), + threshold_bps: args.threshold_bps, + old_supply_snapshot, + seconds_for_deposits: args.seconds_for_deposits, + grace_period_seconds: args.grace_period_seconds, + monthly_spending_limit_amount: args.monthly_spending_limit_amount, + monthly_spending_limit_members: args.monthly_spending_limit_members.clone(), + team_address: args.team_address, + state: RelaunchState::Initialized, + total_deposited: 0, + quote_recovered: 0, + usdc_recovered: 0, + unix_timestamp_started: None, + unix_timestamp_closed: None, + unix_timestamp_completed: None, + dao: None, + dao_vault: None, + seq_num: 0, + pda_bump: ctx.bumps.relaunch, + source_venue, + }); + + let clock = Clock::get()?; + emit_cpi!(RelaunchInitializedEvent { + common: CommonFields::new(&clock, 0), + relaunch: relaunch_key, + admin: ctx.accounts.admin.key(), + new_mint: ctx.accounts.new_mint.key(), + old_mint: ctx.accounts.old_mint.key(), + source_pool: ctx.accounts.source_pool.key(), + source_quote_mint: ctx.accounts.source_quote_mint.key(), + relaunch_signer: ctx.accounts.relaunch_signer.key(), + relaunch_signer_bump: ctx.bumps.relaunch_signer, + old_token_vault: ctx.accounts.old_token_vault.key(), + new_token_vault: ctx.accounts.new_token_vault.key(), + source_quote_vault: ctx.accounts.source_quote_vault.key(), + usdc_vault: ctx.accounts.usdc_vault.key(), + threshold_bps: args.threshold_bps, + old_supply_snapshot, + seconds_for_deposits: args.seconds_for_deposits, + grace_period_seconds: args.grace_period_seconds, + monthly_spending_limit_amount: args.monthly_spending_limit_amount, + monthly_spending_limit_members: args.monthly_spending_limit_members, + team_address: args.team_address, + pda_bump: ctx.bumps.relaunch, + source_venue, + }); + + Ok(()) + } +} diff --git a/programs/relaunch/src/instructions/mark_failed.rs b/programs/relaunch/src/instructions/mark_failed.rs new file mode 100644 index 00000000..a3274f4e --- /dev/null +++ b/programs/relaunch/src/instructions/mark_failed.rs @@ -0,0 +1,47 @@ +use anchor_lang::prelude::*; + +use crate::error::RelaunchError; +use crate::events::{CommonFields, RelaunchMarkedFailedEvent}; +use crate::state::{Relaunch, RelaunchState}; + +#[event_cpi] +#[derive(Accounts)] +pub struct MarkFailed<'info> { + #[account(mut)] + pub relaunch: Account<'info, Relaunch>, +} + +impl MarkFailed<'_> { + pub fn validate(&self) -> Result<()> { + require!( + self.relaunch.state == RelaunchState::SellPending, + RelaunchError::RelaunchNotSellPending + ); + + let clock = Clock::get()?; + require_gt!( + clock.unix_timestamp, + self.relaunch.unix_timestamp_closed.unwrap() + + self.relaunch.grace_period_seconds as i64, + RelaunchError::GracePeriodStillActive + ); + + Ok(()) + } + + pub fn handle(ctx: Context) -> Result<()> { + let relaunch = &mut ctx.accounts.relaunch; + let clock = Clock::get()?; + + relaunch.state = RelaunchState::Failed; + + relaunch.seq_num += 1; + + emit_cpi!(RelaunchMarkedFailedEvent { + common: CommonFields::new(&clock, ctx.accounts.relaunch.seq_num), + relaunch: ctx.accounts.relaunch.key(), + }); + + Ok(()) + } +} diff --git a/programs/relaunch/src/instructions/mod.rs b/programs/relaunch/src/instructions/mod.rs new file mode 100644 index 00000000..59424879 --- /dev/null +++ b/programs/relaunch/src/instructions/mod.rs @@ -0,0 +1,27 @@ +pub mod claim; +pub mod claim_refund; +pub mod close_deposits; +pub mod complete_relaunch; +pub mod deposit; +pub mod deposit_via_buy; +pub mod deposit_via_buy_raydium; +pub mod execute_sell; +pub mod execute_sell_raydium; +pub mod execute_usdc_swap; +pub mod initialize_relaunch; +pub mod mark_failed; +pub mod start_deposits; + +pub use claim::*; +pub use claim_refund::*; +pub use close_deposits::*; +pub use complete_relaunch::*; +pub use deposit::*; +pub use deposit_via_buy::*; +pub use deposit_via_buy_raydium::*; +pub use execute_sell::*; +pub use execute_sell_raydium::*; +pub use execute_usdc_swap::*; +pub use initialize_relaunch::*; +pub use mark_failed::*; +pub use start_deposits::*; diff --git a/programs/relaunch/src/instructions/start_deposits.rs b/programs/relaunch/src/instructions/start_deposits.rs new file mode 100644 index 00000000..9e6a71f1 --- /dev/null +++ b/programs/relaunch/src/instructions/start_deposits.rs @@ -0,0 +1,46 @@ +use anchor_lang::prelude::*; + +use crate::error::RelaunchError; +use crate::events::{CommonFields, DepositsStartedEvent}; +use crate::state::{Relaunch, RelaunchState}; + +#[event_cpi] +#[derive(Accounts)] +pub struct StartDeposits<'info> { + #[account( + mut, + has_one = admin, + )] + pub relaunch: Account<'info, Relaunch>, + + pub admin: Signer<'info>, +} + +impl StartDeposits<'_> { + pub fn validate(&self) -> Result<()> { + require!( + self.relaunch.state == RelaunchState::Initialized, + RelaunchError::RelaunchNotInitialized + ); + + Ok(()) + } + + pub fn handle(ctx: Context) -> Result<()> { + let relaunch = &mut ctx.accounts.relaunch; + let clock = Clock::get()?; + + relaunch.state = RelaunchState::Live; + relaunch.unix_timestamp_started = Some(clock.unix_timestamp); + + relaunch.seq_num += 1; + + emit_cpi!(DepositsStartedEvent { + common: CommonFields::new(&clock, ctx.accounts.relaunch.seq_num), + relaunch: ctx.accounts.relaunch.key(), + admin: ctx.accounts.admin.key(), + }); + + Ok(()) + } +} diff --git a/programs/relaunch/src/lib.rs b/programs/relaunch/src/lib.rs new file mode 100644 index 00000000..e4513406 --- /dev/null +++ b/programs/relaunch/src/lib.rs @@ -0,0 +1,114 @@ +//! A smart contract that relaunches existing tokens as futarchic DAOs. +use anchor_lang::prelude::*; + +pub mod constants; +pub mod error; +pub mod events; +pub mod instructions; +pub mod pump_amm; +pub mod raydium_amm; +pub mod state; +pub mod whirlpool; + +pub use constants::*; +pub use state::*; + +use instructions::*; + +#[cfg(not(feature = "no-entrypoint"))] +use solana_security_txt::security_txt; + +#[cfg(not(feature = "no-entrypoint"))] +security_txt! { + name: "relaunch", + project_url: "https://metadao.fi", + contacts: "telegram:metaproph3t,telegram:kollan_house", + source_code: "https://github.com/metaDAOproject/programs", + source_release: "v0.1.0", + policy: "The market will decide whether we pay a bug bounty.", + acknowledgements: "DCF = (CF1 / (1 + r)^1) + (CF2 / (1 + r)^2) + ... (CFn / (1 + r)^n)" +} + +declare_id!("vaMpdXN2P3Z5v8y6GtAU5NzCUjxtphnRVpvqu37Spik"); + +#[program] +pub mod relaunch { + use super::*; + + #[access_control(ctx.accounts.validate(&args))] + pub fn initialize_relaunch( + ctx: Context, + args: InitializeRelaunchArgs, + ) -> Result<()> { + InitializeRelaunch::handle(ctx, args) + } + + #[access_control(ctx.accounts.validate())] + pub fn start_deposits(ctx: Context) -> Result<()> { + StartDeposits::handle(ctx) + } + + #[access_control(ctx.accounts.validate(&args))] + pub fn deposit(ctx: Context, args: DepositArgs) -> Result<()> { + Deposit::handle(ctx, args) + } + + #[access_control(ctx.accounts.validate(&args))] + pub fn deposit_via_buy(ctx: Context, args: DepositViaBuyArgs) -> Result<()> { + DepositViaBuy::handle(ctx, args) + } + + #[access_control(ctx.accounts.validate(&args))] + pub fn deposit_via_buy_raydium( + ctx: Context, + args: DepositViaBuyRaydiumArgs, + ) -> Result<()> { + DepositViaBuyRaydium::handle(ctx, args) + } + + #[access_control(ctx.accounts.validate())] + pub fn close_deposits(ctx: Context) -> Result<()> { + CloseDeposits::handle(ctx) + } + + #[access_control(ctx.accounts.validate(&args))] + pub fn execute_sell(ctx: Context, args: ExecuteSellArgs) -> Result<()> { + ExecuteSell::handle(ctx, args) + } + + #[access_control(ctx.accounts.validate(&args))] + pub fn execute_sell_raydium( + ctx: Context, + args: ExecuteSellRaydiumArgs, + ) -> Result<()> { + ExecuteSellRaydium::handle(ctx, args) + } + + #[access_control(ctx.accounts.validate(&args))] + pub fn execute_usdc_swap( + ctx: Context, + args: ExecuteUsdcSwapArgs, + ) -> Result<()> { + ExecuteUsdcSwap::handle(ctx, args) + } + + #[access_control(ctx.accounts.validate())] + pub fn complete_relaunch(ctx: Context) -> Result<()> { + CompleteRelaunch::handle(ctx) + } + + #[access_control(ctx.accounts.validate())] + pub fn claim(ctx: Context) -> Result<()> { + Claim::handle(ctx) + } + + #[access_control(ctx.accounts.validate())] + pub fn mark_failed(ctx: Context) -> Result<()> { + MarkFailed::handle(ctx) + } + + #[access_control(ctx.accounts.validate())] + pub fn claim_refund(ctx: Context) -> Result<()> { + ClaimRefund::handle(ctx) + } +} diff --git a/programs/relaunch/src/pump_amm.rs b/programs/relaunch/src/pump_amm.rs new file mode 100644 index 00000000..68636931 --- /dev/null +++ b/programs/relaunch/src/pump_amm.rs @@ -0,0 +1,308 @@ +//! Types for reading pump_amm accounts, plus hand-built CPI builders — +//! pump_amm ships no Anchor-0.29 crate. Discriminators and args come from +//! pump_amm's IDL; the deployed program is newer than that IDL and requires a +//! remaining-accounts tail past the published list — the `pool_v2` PDA plus +//! one buyback fee recipient with its quote ATA (verified against live +//! mainnet swaps 2026-08-04). +use anchor_lang::prelude::*; +use anchor_lang::solana_program::instruction::{AccountMeta, Instruction}; +use anchor_lang::solana_program::program::{invoke, invoke_signed}; + +use crate::error::RelaunchError; +use crate::pump_amm_program; + +pub const POOL_DISCRIMINATOR: [u8; 8] = [241, 154, 109, 4, 17, 177, 109, 188]; + +pub const SELL_DISCRIMINATOR: [u8; 8] = [51, 230, 133, 164, 1, 127, 131, 173]; + +pub const BUY_DISCRIMINATOR: [u8; 8] = [102, 6, 61, 18, 1, 218, 235, 234]; + +pub const INIT_USER_VOLUME_ACCUMULATOR_DISCRIMINATOR: [u8; 8] = + [94, 6, 202, 115, 255, 96, 232, 183]; + +/// The prefix of pump_amm's `Pool` account that canonicality validation +/// reads; trailing fields are ignored. +#[derive(AnchorDeserialize)] +pub struct PumpSwapPool { + pub pool_bump: u8, + pub index: u16, + pub creator: Pubkey, + pub base_mint: Pubkey, + pub quote_mint: Pubkey, +} + +impl PumpSwapPool { + pub fn try_parse(data: &[u8]) -> Result { + require!( + data.len() > POOL_DISCRIMINATOR.len() && data[..8] == POOL_DISCRIMINATOR, + RelaunchError::SourcePoolNotCanonical + ); + Self::deserialize(&mut &data[8..]) + .map_err(|_| error!(RelaunchError::SourcePoolNotCanonical)) + } +} + +/// The accounts of pump_amm's `sell`, in instruction order. +pub struct Sell<'info> { + pub pool: AccountInfo<'info>, + pub user: AccountInfo<'info>, + pub global_config: AccountInfo<'info>, + pub base_mint: AccountInfo<'info>, + pub quote_mint: AccountInfo<'info>, + pub user_base_token_account: AccountInfo<'info>, + pub user_quote_token_account: AccountInfo<'info>, + pub pool_base_token_account: AccountInfo<'info>, + pub pool_quote_token_account: AccountInfo<'info>, + pub protocol_fee_recipient: AccountInfo<'info>, + pub protocol_fee_recipient_token_account: AccountInfo<'info>, + pub base_token_program: AccountInfo<'info>, + pub quote_token_program: AccountInfo<'info>, + pub system_program: AccountInfo<'info>, + pub associated_token_program: AccountInfo<'info>, + pub event_authority: AccountInfo<'info>, + pub program: AccountInfo<'info>, + pub coin_creator_vault_ata: AccountInfo<'info>, + pub coin_creator_vault_authority: AccountInfo<'info>, + pub fee_config: AccountInfo<'info>, + pub fee_program: AccountInfo<'info>, + // remaining-accounts tail + pub pool_v2: AccountInfo<'info>, + pub buyback_fee_recipient: AccountInfo<'info>, + pub buyback_fee_recipient_token_account: AccountInfo<'info>, +} + +pub fn sell( + accounts: Sell, + base_amount_in: u64, + min_quote_amount_out: u64, + signer_seeds: &[&[&[u8]]], +) -> Result<()> { + let mut data = Vec::with_capacity(24); + data.extend_from_slice(&SELL_DISCRIMINATOR); + data.extend_from_slice(&base_amount_in.to_le_bytes()); + data.extend_from_slice(&min_quote_amount_out.to_le_bytes()); + + let metas = vec![ + AccountMeta::new(accounts.pool.key(), false), + AccountMeta::new(accounts.user.key(), true), + AccountMeta::new_readonly(accounts.global_config.key(), false), + AccountMeta::new_readonly(accounts.base_mint.key(), false), + AccountMeta::new_readonly(accounts.quote_mint.key(), false), + AccountMeta::new(accounts.user_base_token_account.key(), false), + AccountMeta::new(accounts.user_quote_token_account.key(), false), + AccountMeta::new(accounts.pool_base_token_account.key(), false), + AccountMeta::new(accounts.pool_quote_token_account.key(), false), + AccountMeta::new_readonly(accounts.protocol_fee_recipient.key(), false), + AccountMeta::new(accounts.protocol_fee_recipient_token_account.key(), false), + AccountMeta::new_readonly(accounts.base_token_program.key(), false), + AccountMeta::new_readonly(accounts.quote_token_program.key(), false), + AccountMeta::new_readonly(accounts.system_program.key(), false), + AccountMeta::new_readonly(accounts.associated_token_program.key(), false), + AccountMeta::new_readonly(accounts.event_authority.key(), false), + AccountMeta::new_readonly(accounts.program.key(), false), + AccountMeta::new(accounts.coin_creator_vault_ata.key(), false), + AccountMeta::new_readonly(accounts.coin_creator_vault_authority.key(), false), + AccountMeta::new_readonly(accounts.fee_config.key(), false), + AccountMeta::new_readonly(accounts.fee_program.key(), false), + AccountMeta::new_readonly(accounts.pool_v2.key(), false), + AccountMeta::new_readonly(accounts.buyback_fee_recipient.key(), false), + AccountMeta::new(accounts.buyback_fee_recipient_token_account.key(), false), + ]; + + let account_infos = [ + accounts.pool, + accounts.user, + accounts.global_config, + accounts.base_mint, + accounts.quote_mint, + accounts.user_base_token_account, + accounts.user_quote_token_account, + accounts.pool_base_token_account, + accounts.pool_quote_token_account, + accounts.protocol_fee_recipient, + accounts.protocol_fee_recipient_token_account, + accounts.base_token_program, + accounts.quote_token_program, + accounts.system_program, + accounts.associated_token_program, + accounts.event_authority, + accounts.program, + accounts.coin_creator_vault_ata, + accounts.coin_creator_vault_authority, + accounts.fee_config, + accounts.fee_program, + accounts.pool_v2, + accounts.buyback_fee_recipient, + accounts.buyback_fee_recipient_token_account, + ]; + + invoke_signed( + &Instruction { + program_id: pump_amm_program::id(), + accounts: metas, + data, + }, + &account_infos, + signer_seeds, + )?; + + Ok(()) +} + +/// The accounts of pump_amm's `buy`, in instruction order. +pub struct Buy<'info> { + pub pool: AccountInfo<'info>, + pub user: AccountInfo<'info>, + pub global_config: AccountInfo<'info>, + pub base_mint: AccountInfo<'info>, + pub quote_mint: AccountInfo<'info>, + pub user_base_token_account: AccountInfo<'info>, + pub user_quote_token_account: AccountInfo<'info>, + pub pool_base_token_account: AccountInfo<'info>, + pub pool_quote_token_account: AccountInfo<'info>, + pub protocol_fee_recipient: AccountInfo<'info>, + pub protocol_fee_recipient_token_account: AccountInfo<'info>, + pub base_token_program: AccountInfo<'info>, + pub quote_token_program: AccountInfo<'info>, + pub system_program: AccountInfo<'info>, + pub associated_token_program: AccountInfo<'info>, + pub event_authority: AccountInfo<'info>, + pub program: AccountInfo<'info>, + pub coin_creator_vault_ata: AccountInfo<'info>, + pub coin_creator_vault_authority: AccountInfo<'info>, + pub global_volume_accumulator: AccountInfo<'info>, + pub user_volume_accumulator: AccountInfo<'info>, + pub fee_config: AccountInfo<'info>, + pub fee_program: AccountInfo<'info>, + // remaining-accounts tail + pub pool_v2: AccountInfo<'info>, + pub buyback_fee_recipient: AccountInfo<'info>, + pub buyback_fee_recipient_token_account: AccountInfo<'info>, +} + +pub fn buy( + accounts: Buy, + base_amount_out: u64, + max_quote_amount_in: u64, + track_volume: bool, + signer_seeds: &[&[&[u8]]], +) -> Result<()> { + let mut data = Vec::with_capacity(25); + data.extend_from_slice(&BUY_DISCRIMINATOR); + data.extend_from_slice(&base_amount_out.to_le_bytes()); + data.extend_from_slice(&max_quote_amount_in.to_le_bytes()); + data.push(track_volume as u8); + + let metas = vec![ + AccountMeta::new(accounts.pool.key(), false), + AccountMeta::new(accounts.user.key(), true), + AccountMeta::new_readonly(accounts.global_config.key(), false), + AccountMeta::new_readonly(accounts.base_mint.key(), false), + AccountMeta::new_readonly(accounts.quote_mint.key(), false), + AccountMeta::new(accounts.user_base_token_account.key(), false), + AccountMeta::new(accounts.user_quote_token_account.key(), false), + AccountMeta::new(accounts.pool_base_token_account.key(), false), + AccountMeta::new(accounts.pool_quote_token_account.key(), false), + AccountMeta::new_readonly(accounts.protocol_fee_recipient.key(), false), + AccountMeta::new(accounts.protocol_fee_recipient_token_account.key(), false), + AccountMeta::new_readonly(accounts.base_token_program.key(), false), + AccountMeta::new_readonly(accounts.quote_token_program.key(), false), + AccountMeta::new_readonly(accounts.system_program.key(), false), + AccountMeta::new_readonly(accounts.associated_token_program.key(), false), + AccountMeta::new_readonly(accounts.event_authority.key(), false), + AccountMeta::new_readonly(accounts.program.key(), false), + AccountMeta::new(accounts.coin_creator_vault_ata.key(), false), + AccountMeta::new_readonly(accounts.coin_creator_vault_authority.key(), false), + AccountMeta::new_readonly(accounts.global_volume_accumulator.key(), false), + AccountMeta::new(accounts.user_volume_accumulator.key(), false), + AccountMeta::new_readonly(accounts.fee_config.key(), false), + AccountMeta::new_readonly(accounts.fee_program.key(), false), + AccountMeta::new_readonly(accounts.pool_v2.key(), false), + AccountMeta::new_readonly(accounts.buyback_fee_recipient.key(), false), + AccountMeta::new(accounts.buyback_fee_recipient_token_account.key(), false), + ]; + + let account_infos = [ + accounts.pool, + accounts.user, + accounts.global_config, + accounts.base_mint, + accounts.quote_mint, + accounts.user_base_token_account, + accounts.user_quote_token_account, + accounts.pool_base_token_account, + accounts.pool_quote_token_account, + accounts.protocol_fee_recipient, + accounts.protocol_fee_recipient_token_account, + accounts.base_token_program, + accounts.quote_token_program, + accounts.system_program, + accounts.associated_token_program, + accounts.event_authority, + accounts.program, + accounts.coin_creator_vault_ata, + accounts.coin_creator_vault_authority, + accounts.global_volume_accumulator, + accounts.user_volume_accumulator, + accounts.fee_config, + accounts.fee_program, + accounts.pool_v2, + accounts.buyback_fee_recipient, + accounts.buyback_fee_recipient_token_account, + ]; + + invoke_signed( + &Instruction { + program_id: pump_amm_program::id(), + accounts: metas, + data, + }, + &account_infos, + signer_seeds, + )?; + + Ok(()) +} + +/// The accounts of pump_amm's `init_user_volume_accumulator`, in instruction +/// order. Buys require the user's volume accumulator PDA to exist; this +/// creates it (rent paid by `payer`). +pub struct InitUserVolumeAccumulator<'info> { + pub payer: AccountInfo<'info>, + pub user: AccountInfo<'info>, + pub user_volume_accumulator: AccountInfo<'info>, + pub system_program: AccountInfo<'info>, + pub event_authority: AccountInfo<'info>, + pub program: AccountInfo<'info>, +} + +pub fn init_user_volume_accumulator(accounts: InitUserVolumeAccumulator) -> Result<()> { + let metas = vec![ + AccountMeta::new(accounts.payer.key(), true), + AccountMeta::new_readonly(accounts.user.key(), false), + AccountMeta::new(accounts.user_volume_accumulator.key(), false), + AccountMeta::new_readonly(accounts.system_program.key(), false), + AccountMeta::new_readonly(accounts.event_authority.key(), false), + AccountMeta::new_readonly(accounts.program.key(), false), + ]; + + let account_infos = [ + accounts.payer, + accounts.user, + accounts.user_volume_accumulator, + accounts.system_program, + accounts.event_authority, + accounts.program, + ]; + + invoke( + &Instruction { + program_id: pump_amm_program::id(), + accounts: metas, + data: INIT_USER_VOLUME_ACCUMULATOR_DISCRIMINATOR.to_vec(), + }, + &account_infos, + )?; + + Ok(()) +} diff --git a/programs/relaunch/src/raydium_amm.rs b/programs/relaunch/src/raydium_amm.rs new file mode 100644 index 00000000..1d6730db --- /dev/null +++ b/programs/relaunch/src/raydium_amm.rs @@ -0,0 +1,155 @@ +//! Types for reading Raydium AMM v4 accounts, plus hand-built CPI builders — +//! AMM v4 is a native pre-Anchor program with no crate we can depend on. The +//! V2 swap instructions (tags 16/17) skip the dead orderbook entirely: 8 +//! accounts, pool + vaults only, direction inferred from the source/ +//! destination account mints (verified against live mainnet swaps and a +//! surfpool rehearsal 2026-08-12). +use anchor_lang::prelude::*; +use anchor_lang::solana_program::instruction::{AccountMeta, Instruction}; +use anchor_lang::solana_program::program::invoke_signed; + +use crate::error::RelaunchError; +use crate::raydium_amm_program; + +/// AmmInfo is a fixed-size packed struct the AMM casts zero-copy. +/// No discriminator, so the exact length is the shape check. +pub const AMM_INFO_LEN: usize = 752; + +pub const SWAP_BASE_IN_V2_TAG: u8 = 16; +pub const SWAP_BASE_OUT_V2_TAG: u8 = 17; + +/// The subset of AmmInfo that validation and the swap instructions read. +pub struct RaydiumPool { + pub status: u64, // @ 0 + pub coin_vault: Pubkey, // @ 336 + pub pc_vault: Pubkey, // @ 368 + pub coin_mint: Pubkey, // @ 400 (WSOL on pump-migration pools) + pub pc_mint: Pubkey, // @ 432 (the token on pump-migration pools) + pub lp_mint: Pubkey, // @ 464 + pub market_program: Pubkey, // @ 560 (the orderbook fingerprint) + pub lp_amount: u64, // @ 720 (LP ever minted; burns don't decrement) +} + +impl RaydiumPool { + pub fn try_parse(data: &[u8]) -> Result { + require_eq!( + data.len(), + AMM_INFO_LEN, + RelaunchError::SourcePoolNotCanonical + ); + Ok(Self { + status: read_u64(data, 0), + coin_vault: read_pubkey(data, 336), + pc_vault: read_pubkey(data, 368), + coin_mint: read_pubkey(data, 400), + pc_mint: read_pubkey(data, 432), + lp_mint: read_pubkey(data, 464), + market_program: read_pubkey(data, 560), + lp_amount: read_u64(data, 720), + }) + } +} + +fn read_u64(data: &[u8], offset: usize) -> u64 { + u64::from_le_bytes(data[offset..offset + 8].try_into().unwrap()) +} + +fn read_pubkey(data: &[u8], offset: usize) -> Pubkey { + Pubkey::new_from_array(data[offset..offset + 32].try_into().unwrap()) +} + +/// The accounts of AMM v4's V2 swap instructions, in instruction order, +/// identical for both legs. The AMM checks the vaults against pool state and +/// infers direction by matching the user source/destination mints against +/// coin/pc. +pub struct Swap<'info> { + pub token_program: AccountInfo<'info>, + pub amm: AccountInfo<'info>, + pub amm_authority: AccountInfo<'info>, + pub amm_coin_vault: AccountInfo<'info>, + pub amm_pc_vault: AccountInfo<'info>, + pub user_source_token_account: AccountInfo<'info>, + pub user_destination_token_account: AccountInfo<'info>, + pub user_source_owner: AccountInfo<'info>, +} + +/// Exact input, floor on output. The sell leg. +pub fn swap_base_in_v2( + accounts: Swap, + amount_in: u64, + minimum_amount_out: u64, + signer_seeds: &[&[&[u8]]], +) -> Result<()> { + invoke_swap( + accounts, + SWAP_BASE_IN_V2_TAG, + amount_in, + minimum_amount_out, + signer_seeds, + ) +} + +/// Exact output, cap on input. Only the needed input is pulled from the +/// source account. The buy leg. +pub fn swap_base_out_v2( + accounts: Swap, + max_amount_in: u64, + amount_out: u64, + signer_seeds: &[&[&[u8]]], +) -> Result<()> { + invoke_swap( + accounts, + SWAP_BASE_OUT_V2_TAG, + max_amount_in, + amount_out, + signer_seeds, + ) +} + +fn invoke_swap( + accounts: Swap, + tag: u8, + arg1: u64, + arg2: u64, + signer_seeds: &[&[&[u8]]], +) -> Result<()> { + let mut data = Vec::with_capacity(17); + data.push(tag); + data.extend_from_slice(&arg1.to_le_bytes()); + data.extend_from_slice(&arg2.to_le_bytes()); + + let metas = vec![ + AccountMeta::new_readonly(accounts.token_program.key(), false), + AccountMeta::new(accounts.amm.key(), false), + AccountMeta::new_readonly(accounts.amm_authority.key(), false), + AccountMeta::new(accounts.amm_coin_vault.key(), false), + AccountMeta::new(accounts.amm_pc_vault.key(), false), + AccountMeta::new(accounts.user_source_token_account.key(), false), + AccountMeta::new(accounts.user_destination_token_account.key(), false), + // Readonly signer. Raydium doesn't require the owner writable. + AccountMeta::new_readonly(accounts.user_source_owner.key(), true), + ]; + + let account_infos = [ + accounts.token_program, + accounts.amm, + accounts.amm_authority, + accounts.amm_coin_vault, + accounts.amm_pc_vault, + accounts.user_source_token_account, + accounts.user_destination_token_account, + accounts.user_source_owner, + ]; + + invoke_signed( + &Instruction { + program_id: raydium_amm_program::id(), + accounts: metas, + data, + }, + &account_infos, + signer_seeds, + )?; + + Ok(()) +} diff --git a/programs/relaunch/src/state/deposit_record.rs b/programs/relaunch/src/state/deposit_record.rs new file mode 100644 index 00000000..4d7f9867 --- /dev/null +++ b/programs/relaunch/src/state/deposit_record.rs @@ -0,0 +1,40 @@ +use anchor_lang::prelude::*; + +#[account] +#[derive(InitSpace)] +pub struct DepositRecord { + /// The relaunch this record belongs to. + pub relaunch: Pubkey, + /// The depositor. + pub depositor: Pubkey, + /// The amount of old tokens deposited, including tokens bought via + /// `deposit_via_buy`. + pub amount_deposited: u64, + /// Whether the record has been settled by `claim` / `claim_refund`. + pub claimed: bool, + /// The sequence number of this record. Useful for sorting events. + pub seq_num: u64, + /// The PDA bump. + pub pda_bump: u8, +} + +impl DepositRecord { + /// Credits a deposit, initializing the record on first use: a fresh + /// record holds the default pubkey, an existing one holds the + /// depositor's pubkey. + pub fn credit(&mut self, relaunch: Pubkey, depositor: Pubkey, amount: u64, pda_bump: u8) { + if self.depositor == depositor { + self.amount_deposited += amount; + self.seq_num += 1; + } else { + *self = DepositRecord { + relaunch, + depositor, + amount_deposited: amount, + claimed: false, + seq_num: 0, + pda_bump, + }; + } + } +} diff --git a/programs/relaunch/src/state/mod.rs b/programs/relaunch/src/state/mod.rs new file mode 100644 index 00000000..3ee33f40 --- /dev/null +++ b/programs/relaunch/src/state/mod.rs @@ -0,0 +1,5 @@ +pub mod deposit_record; +pub mod relaunch; + +pub use deposit_record::*; +pub use relaunch::*; diff --git a/programs/relaunch/src/state/relaunch.rs b/programs/relaunch/src/state/relaunch.rs new file mode 100644 index 00000000..0e939eb6 --- /dev/null +++ b/programs/relaunch/src/state/relaunch.rs @@ -0,0 +1,99 @@ +use anchor_lang::prelude::*; +use futarchy::MAX_SPENDING_LIMIT_MEMBERS; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy, PartialEq, Eq, InitSpace)] +pub enum RelaunchState { + Initialized, + Live, + SellPending, + Sold, + Swapped, + Complete, + Failed, +} + +/// The venue the source pool lives on, deciding how the pool was validated +/// at init and which sell/buy instructions apply. +#[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy, PartialEq, Eq, InitSpace)] +pub enum SourceVenue { + PumpSwap, + RaydiumAmmV4, +} + +#[account] +#[derive(InitSpace)] +pub struct Relaunch { + // identity & authority + /// The initializer; executes the sell + swap legs. + pub admin: Pubkey, + /// The token that will be distributed to depositors and that will control the DAO. + pub new_mint: Pubkey, + /// The token being relaunched. + pub old_mint: Pubkey, + /// The canonical PumpSwap pool for the old mint, validated at init. + pub source_pool: Pubkey, + /// The source pool's quote mint — WSOL or USDC. WSOL sources swap through + /// the `usdc_swap_pool` constant. + pub source_quote_mint: Pubkey, + + // signer & vaults (all ATAs of relaunch_signer) + /// The PDA that signs all CPIs and owns the vaults: `["relaunch_signer", relaunch]`. + pub relaunch_signer: Pubkey, + /// The PDA bump for the relaunch signer. + pub relaunch_signer_bump: u8, + /// The vault that escrows deposited old tokens. + pub old_token_vault: Pubkey, + /// The vault that holds the minted new tokens until claim / liquidity provision. + pub new_token_vault: Pubkey, + /// The vault that receives raw sell proceeds (WSOL or USDC). + pub source_quote_vault: Pubkey, + /// The vault that receives the swap-leg output; == `source_quote_vault` + /// for USDC sources. + pub usdc_vault: Pubkey, + + // config + /// The minimum participation, denominated in bps of old-token total supply. + pub threshold_bps: u16, + /// The old mint supply captured at init (threshold denominator). + pub old_supply_snapshot: u64, + /// The number of seconds that deposits will be open for. + pub seconds_for_deposits: u32, + /// The admin's window to sell after deposits close. + pub grace_period_seconds: u32, + + // DAO passthrough config (launchpad-style) + /// The monthly spending limit the DAO allocates to the team. Zero, with + /// no members, means the DAO launches without a spending limit. + pub monthly_spending_limit_amount: u64, + /// The wallets that have access to the monthly spending limit. + #[max_len(MAX_SPENDING_LIMIT_MEMBERS)] + pub monthly_spending_limit_members: Vec, + /// The initial address used to sponsor team proposals. + pub team_address: Pubkey, + + // progress + /// The state of the relaunch. + pub state: RelaunchState, + /// The amount of old tokens deposited across all depositors. + pub total_deposited: u64, + /// The raw sell proceeds, in the source quote asset. + pub quote_recovered: u64, + /// The post-swap USDC (== `quote_recovered` for USDC sources). + pub usdc_recovered: u64, + /// The unix timestamp when deposits were opened. + pub unix_timestamp_started: Option, + /// The unix timestamp when deposits were closed. + pub unix_timestamp_closed: Option, + /// The unix timestamp when the relaunch was completed. + pub unix_timestamp_completed: Option, + /// The DAO, if the relaunch is complete. + pub dao: Option, + /// The DAO's Squads multisig vault, if the relaunch is complete. + pub dao_vault: Option, + /// The sequence number of this relaunch used for sorting events. + pub seq_num: u64, + /// The PDA bump. + pub pda_bump: u8, + /// The venue of the source pool, set from the pool's owner at init. + pub source_venue: SourceVenue, +} diff --git a/programs/relaunch/src/whirlpool.rs b/programs/relaunch/src/whirlpool.rs new file mode 100644 index 00000000..5c61f205 --- /dev/null +++ b/programs/relaunch/src/whirlpool.rs @@ -0,0 +1,101 @@ +//! Hand-built CPI builder for Orca Whirlpool's `swap_v2` — the same pattern +//! as `pump_amm.rs`, since a generated client crate is not worth a new +//! dependency for a single instruction. +use anchor_lang::prelude::*; +use anchor_lang::solana_program::instruction::{AccountMeta, Instruction}; +use anchor_lang::solana_program::program::invoke_signed; + +use crate::whirlpool_program; + +pub const SWAP_V2_DISCRIMINATOR: [u8; 8] = [43, 4, 237, 11, 26, 201, 30, 98]; + +/// Whirlpool's global minimum sqrt price; passing it as the price limit of an +/// a→b swap means "no limit beyond the pool's own bounds". +pub const MIN_SQRT_PRICE: u128 = 4_295_048_016; + +/// The accounts of whirlpool's `swap_v2`, in instruction order. +pub struct SwapV2<'info> { + pub token_program_a: AccountInfo<'info>, + pub token_program_b: AccountInfo<'info>, + pub memo_program: AccountInfo<'info>, + pub token_authority: AccountInfo<'info>, + pub whirlpool: AccountInfo<'info>, + pub token_mint_a: AccountInfo<'info>, + pub token_mint_b: AccountInfo<'info>, + pub token_owner_account_a: AccountInfo<'info>, + pub token_vault_a: AccountInfo<'info>, + pub token_owner_account_b: AccountInfo<'info>, + pub token_vault_b: AccountInfo<'info>, + pub tick_array_0: AccountInfo<'info>, + pub tick_array_1: AccountInfo<'info>, + pub tick_array_2: AccountInfo<'info>, + pub oracle: AccountInfo<'info>, +} + +pub fn swap_v2( + accounts: SwapV2, + amount: u64, + other_amount_threshold: u64, + sqrt_price_limit: u128, + amount_specified_is_input: bool, + a_to_b: bool, + signer_seeds: &[&[&[u8]]], +) -> Result<()> { + let mut data = Vec::with_capacity(43); + data.extend_from_slice(&SWAP_V2_DISCRIMINATOR); + data.extend_from_slice(&amount.to_le_bytes()); + data.extend_from_slice(&other_amount_threshold.to_le_bytes()); + data.extend_from_slice(&sqrt_price_limit.to_le_bytes()); + data.push(amount_specified_is_input as u8); + data.push(a_to_b as u8); + // remaining_accounts_info: Option = None + data.push(0); + + let metas = vec![ + AccountMeta::new_readonly(accounts.token_program_a.key(), false), + AccountMeta::new_readonly(accounts.token_program_b.key(), false), + AccountMeta::new_readonly(accounts.memo_program.key(), false), + AccountMeta::new_readonly(accounts.token_authority.key(), true), + AccountMeta::new(accounts.whirlpool.key(), false), + AccountMeta::new_readonly(accounts.token_mint_a.key(), false), + AccountMeta::new_readonly(accounts.token_mint_b.key(), false), + AccountMeta::new(accounts.token_owner_account_a.key(), false), + AccountMeta::new(accounts.token_vault_a.key(), false), + AccountMeta::new(accounts.token_owner_account_b.key(), false), + AccountMeta::new(accounts.token_vault_b.key(), false), + AccountMeta::new(accounts.tick_array_0.key(), false), + AccountMeta::new(accounts.tick_array_1.key(), false), + AccountMeta::new(accounts.tick_array_2.key(), false), + AccountMeta::new(accounts.oracle.key(), false), + ]; + + let account_infos = [ + accounts.token_program_a, + accounts.token_program_b, + accounts.memo_program, + accounts.token_authority, + accounts.whirlpool, + accounts.token_mint_a, + accounts.token_mint_b, + accounts.token_owner_account_a, + accounts.token_vault_a, + accounts.token_owner_account_b, + accounts.token_vault_b, + accounts.tick_array_0, + accounts.tick_array_1, + accounts.tick_array_2, + accounts.oracle, + ]; + + invoke_signed( + &Instruction { + program_id: whirlpool_program::id(), + accounts: metas, + data, + }, + &account_infos, + signer_seeds, + )?; + + Ok(()) +} diff --git a/scripts/relaunch/createAlt.ts b/scripts/relaunch/createAlt.ts new file mode 100644 index 00000000..0c06e202 --- /dev/null +++ b/scripts/relaunch/createAlt.ts @@ -0,0 +1,481 @@ +// Creates the relaunch SDK's global frozen address lookup table. +// +// Usage (env: ANCHOR_PROVIDER_URL, ANCHOR_WALLET, PRIORITY_FEE_MICRO_LAMPORTS): +// yarn relaunch-create-alt # plan: print the list, no txs +// yarn relaunch-create-alt create # create table + extend + verify +// yarn relaunch-create-alt create --table # resume a partially extended table +// yarn relaunch-create-alt verify --table # compare on-chain contents to the plan +// yarn relaunch-create-alt freeze --table # verify, confirm, then freeze (PERMANENT) +// yarn relaunch-create-alt dump --table --out +// # write the frozen table's account data as a bankrun fixture; zeroes +// # last_extended_slot so all entries are active at bankrun's low slots +// +// The table authority is the script wallet until `freeze` removes it. Only +// freeze after `verify` passes and the address is pinned nowhere yet — once +// frozen the table can never be extended, closed, or edited. + +import * as anchor from "@coral-xyz/anchor"; +import { + AddressLookupTableProgram, + ComputeBudgetProgram, + Connection, + PublicKey, + SYSVAR_RENT_PUBKEY, + SystemProgram, + Transaction, +} from "@solana/web3.js"; +import { + ASSOCIATED_TOKEN_PROGRAM_ID, + NATIVE_MINT, + TOKEN_2022_PROGRAM_ID, + TOKEN_PROGRAM_ID, + getAssociatedTokenAddressSync, +} from "@solana/spl-token"; +import { + FUTARCHY_V0_6_PROGRAM_ID, + MAINNET_USDC, + MPL_TOKEN_METADATA_PROGRAM_ID, + PUMP_AMM_PROGRAM_ID, + PUMP_FEES_PROGRAM_ID, + RAYDIUM_AMM_AUTHORITY, + RAYDIUM_AMM_PROGRAM_ID, + RELAUNCH_V0_1_PROGRAM_ID, + SQUADS_PROGRAM_CONFIG, + SQUADS_PROGRAM_CONFIG_TREASURY, + SQUADS_PROGRAM_ID, + WHIRLPOOL_PROGRAM_ID, +} from "@metadaoproject/programs"; +import { + MEMO_PROGRAM_ID, + PUMP_AMM_EVENT_AUTHORITY, + PUMP_AMM_FEE_CONFIG, + PUMP_AMM_GLOBAL_CONFIG, + PUMP_AMM_GLOBAL_VOLUME_ACCUMULATOR, + USDC_SWAP_POOL, + getWhirlpoolTickArrayAddr, + getWhirlpoolOracleAddr, + parsePumpGlobalConfig, + parseWhirlpool, +} from "@metadaoproject/programs/relaunch"; +import * as readline from "readline/promises"; + +const MAINNET_GENESIS_HASH = "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d"; +const LOOKUP_TABLE_MAX_ADDRESSES = 256; +const ADDRESSES_PER_EXTEND = 20; +const TICK_ARRAYS_BELOW_CURRENT = 32; +// The array starting at tick 0 covers prices through 1000 * 1.0001^span, +// i.e. just past $1,000/SOL given the pool's 10^(9-6) decimal shift. +const TICK_BAND_TOP_START = 0; + +type Entry = { label: string; key: PublicKey }; + +async function buildAddressList(connection: Connection): Promise { + const [globalConfigInfo, poolInfo] = await connection.getMultipleAccountsInfo( + [PUMP_AMM_GLOBAL_CONFIG, USDC_SWAP_POOL], + ); + if (!globalConfigInfo) throw new Error("pump global config not found"); + if (!poolInfo) throw new Error("whirlpool USDC_SWAP_POOL not found"); + + const { protocolFeeRecipients, buybackFeeRecipients } = parsePumpGlobalConfig( + globalConfigInfo.data, + ); + const pool = parseWhirlpool(poolInfo.data); + + if ( + !pool.tokenMintA.equals(NATIVE_MINT) || + !pool.tokenMintB.equals(MAINNET_USDC) + ) { + throw new Error("USDC_SWAP_POOL mints are not WSOL/USDC"); + } + + const entries: Entry[] = []; + const push = (label: string, key: PublicKey) => entries.push({ label, key }); + + // Relaunch protocol + push("relaunch program", RELAUNCH_V0_1_PROGRAM_ID); + push( + "relaunch event authority", + PublicKey.findProgramAddressSync( + [Buffer.from("__event_authority")], + RELAUNCH_V0_1_PROGRAM_ID, + )[0], + ); + + // Core programs, sysvars, mints + push("system program", SystemProgram.programId); + push("token program", TOKEN_PROGRAM_ID); + push("token-2022 program", TOKEN_2022_PROGRAM_ID); + push("associated token program", ASSOCIATED_TOKEN_PROGRAM_ID); + push("memo program", MEMO_PROGRAM_ID); + push("rent sysvar", SYSVAR_RENT_PUBKEY); + push("mpl token metadata program", MPL_TOKEN_METADATA_PROGRAM_ID); + push("WSOL mint", NATIVE_MINT); + push("USDC mint", MAINNET_USDC); + + // pump statics + push("pump_amm program", PUMP_AMM_PROGRAM_ID); + push("pump fees program", PUMP_FEES_PROGRAM_ID); + push("pump global config", PUMP_AMM_GLOBAL_CONFIG); + push("pump event authority", PUMP_AMM_EVENT_AUTHORITY); + push("pump fee config", PUMP_AMM_FEE_CONFIG); + push("pump global volume accumulator", PUMP_AMM_GLOBAL_VOLUME_ACCUMULATOR); + + // fee recipients + their quote-mint ATAs (config-order snapshot) + const pushRecipients = (kind: string, recipients: PublicKey[]) => { + recipients.forEach((recipient, i) => { + push(`${kind} fee recipient #${i + 1}`, recipient); + push( + `${kind} fee recipient #${i + 1} WSOL ATA`, + getAssociatedTokenAddressSync(NATIVE_MINT, recipient, true), + ); + push( + `${kind} fee recipient #${i + 1} USDC ATA`, + getAssociatedTokenAddressSync(MAINNET_USDC, recipient, true), + ); + }); + }; + pushRecipients("protocol", protocolFeeRecipients); + pushRecipients("buyback", buybackFeeRecipients); + + // whirlpool statics + push("whirlpool program", WHIRLPOOL_PROGRAM_ID); + push("USDC_SWAP_POOL", USDC_SWAP_POOL); + push("USDC_SWAP_POOL WSOL vault", pool.tokenVaultA); + push("USDC_SWAP_POOL USDC vault", pool.tokenVaultB); + push("USDC_SWAP_POOL oracle", getWhirlpoolOracleAddr(USDC_SWAP_POOL)); + + // futarchy + squads statics + push("futarchy program", FUTARCHY_V0_6_PROGRAM_ID); + push( + "futarchy event authority", + PublicKey.findProgramAddressSync( + [Buffer.from("__event_authority")], + FUTARCHY_V0_6_PROGRAM_ID, + )[0], + ); + push("squads program", SQUADS_PROGRAM_ID); + push("squads program config", SQUADS_PROGRAM_CONFIG); + push("squads program config treasury", SQUADS_PROGRAM_CONFIG_TREASURY); + + // whirlpool tick-array band + const span = 88 * pool.tickSpacing; + const currentStart = Math.floor(pool.tickCurrentIndex / span) * span; + if (currentStart > TICK_BAND_TOP_START) { + throw new Error( + `pool tick ${pool.tickCurrentIndex} is above the band top ($1,000/SOL); revisit the band bounds`, + ); + } + for ( + let start = currentStart - TICK_ARRAYS_BELOW_CURRENT * span; + start <= TICK_BAND_TOP_START; + start += span + ) { + push( + `tick array [${start}, ${start + span})`, + getWhirlpoolTickArrayAddr(USDC_SWAP_POOL, start), + ); + } + + // raydium statics — the AMM v4 venue's two global keys (every other + // Raydium-side account is per-pool) + push("raydium amm v4 program", RAYDIUM_AMM_PROGRAM_ID); + push("raydium amm authority", RAYDIUM_AMM_AUTHORITY); + + const seen = new Set(); + for (const { label, key } of entries) { + const b58 = key.toBase58(); + if (seen.has(b58)) + throw new Error(`duplicate address in list: ${label} (${b58})`); + seen.add(b58); + } + if (entries.length > LOOKUP_TABLE_MAX_ADDRESSES) { + throw new Error( + `list has ${entries.length} entries, max is ${LOOKUP_TABLE_MAX_ADDRESSES}`, + ); + } + + return entries; +} + +function priorityFeeIx() { + return ComputeBudgetProgram.setComputeUnitPrice({ + microLamports: parseInt(process.env.PRIORITY_FEE_MICRO_LAMPORTS ?? "10000"), + }); +} + +async function fetchTableAddresses(connection: Connection, table: PublicKey) { + const res = await connection.getAddressLookupTable(table); + if (!res.value) throw new Error(`lookup table ${table.toBase58()} not found`); + return res.value; +} + +/// Compares the on-chain table against the computed list. `asPrefix` allows +/// the on-chain table to be an incomplete prefix (for resuming extends). +function compareAddresses( + onChain: PublicKey[], + expected: Entry[], + asPrefix: boolean, +): { matches: boolean; problems: string[] } { + const problems: string[] = []; + if ( + asPrefix + ? onChain.length > expected.length + : onChain.length !== expected.length + ) { + problems.push( + `length mismatch: on-chain ${onChain.length}, expected ${expected.length}`, + ); + } + const upTo = Math.min(onChain.length, expected.length); + for (let i = 0; i < upTo; i++) { + if (!onChain[i].equals(expected[i].key)) { + problems.push( + `index ${i}: on-chain ${onChain[i].toBase58()}, expected ${expected[i].key.toBase58()} (${expected[i].label})`, + ); + } + } + return { matches: problems.length === 0, problems }; +} + +async function extendTable( + provider: anchor.AnchorProvider, + table: PublicKey, + entries: Entry[], + alreadyExtended: number, +) { + for (let i = alreadyExtended; i < entries.length; i += ADDRESSES_PER_EXTEND) { + const chunk = entries.slice(i, i + ADDRESSES_PER_EXTEND); + const ix = AddressLookupTableProgram.extendLookupTable({ + lookupTable: table, + authority: provider.wallet.publicKey, + payer: provider.wallet.publicKey, + addresses: chunk.map((e) => e.key), + }); + const signature = await provider.sendAndConfirm( + new Transaction().add(priorityFeeIx(), ix), + ); + console.log( + `extended ${i}..${i + chunk.length - 1} (${chunk[0].label} .. ${chunk[chunk.length - 1].label}): ${signature}`, + ); + } +} + +async function main() { + const args = process.argv.slice(2); + const command = args.find((a) => !a.startsWith("--")) ?? "plan"; + const tableArg = args.includes("--table") + ? new PublicKey(args[args.indexOf("--table") + 1]) + : undefined; + + const provider = anchor.AnchorProvider.env(); + const connection = provider.connection; + + const genesisHash = await connection.getGenesisHash(); + if ( + genesisHash !== MAINNET_GENESIS_HASH && + !args.includes("--allow-non-mainnet") + ) { + throw new Error( + `RPC is not mainnet (genesis ${genesisHash}); pass --allow-non-mainnet to override`, + ); + } + + const entries = await buildAddressList(connection); + const rent = await connection.getMinimumBalanceForRentExemption( + 56 + 32 * entries.length, + ); + console.log( + `computed ${entries.length} addresses (${LOOKUP_TABLE_MAX_ADDRESSES - entries.length} slots spare); rent ${rent / 1e9} SOL\n`, + ); + + if (command === "plan") { + entries.forEach((e, i) => + console.log(String(i).padStart(3), e.key.toBase58(), e.label), + ); + console.log( + `\ndry run only. Next: yarn relaunch-create-alt create (then verify, then freeze).`, + ); + return; + } + + if (command === "create") { + let table = tableArg; + let alreadyExtended = 0; + if (table) { + const existing = await fetchTableAddresses(connection, table); + if (!existing.state.authority?.equals(provider.wallet.publicKey)) { + throw new Error( + "wallet is not the table authority (or table is frozen)", + ); + } + const { matches, problems } = compareAddresses( + existing.state.addresses, + entries, + true, + ); + if (!matches) { + throw new Error( + `existing table is not a prefix of the computed list:\n${problems.join("\n")}\n` + + `(if groups D/E/H changed on-chain since the table was created, start a fresh table)`, + ); + } + alreadyExtended = existing.state.addresses.length; + console.log(`resuming ${table.toBase58()} at index ${alreadyExtended}`); + } else { + const recentSlot = await connection.getSlot("finalized"); + const [createIx, tableAddr] = AddressLookupTableProgram.createLookupTable( + { + authority: provider.wallet.publicKey, + payer: provider.wallet.publicKey, + recentSlot, + }, + ); + table = tableAddr; + const signature = await provider.sendAndConfirm( + new Transaction().add(priorityFeeIx(), createIx), + ); + console.log(`created lookup table ${table.toBase58()}: ${signature}`); + } + + await extendTable(provider, table, entries, alreadyExtended); + + const final = await fetchTableAddresses(connection, table); + const { matches, problems } = compareAddresses( + final.state.addresses, + entries, + false, + ); + if (!matches) + throw new Error( + `post-extend verification FAILED:\n${problems.join("\n")}`, + ); + console.log( + `\nverified: ${table.toBase58()} holds all ${entries.length} addresses in order.\n` + + `Table is usable one slot after the last extend. When satisfied:\n` + + ` yarn relaunch-create-alt freeze --table ${table.toBase58()}`, + ); + return; + } + + if (command === "verify" || command === "freeze") { + if (!tableArg) throw new Error(`${command} requires --table
`); + const account = await fetchTableAddresses(connection, tableArg); + const { matches, problems } = compareAddresses( + account.state.addresses, + entries, + false, + ); + const frozen = !account.state.authority; + console.log( + `table ${tableArg.toBase58()}: ${account.state.addresses.length} addresses, ` + + (frozen ? "FROZEN" : `authority ${account.state.authority.toBase58()}`), + ); + if (!matches) { + console.log(`MISMATCH vs computed list:\n${problems.join("\n")}`); + if (command === "verify") process.exitCode = 1; + if (command === "freeze") + throw new Error("refusing to freeze a mismatched table"); + return; + } + console.log("contents match the computed list exactly."); + if (command === "verify") return; + + if (frozen) { + console.log("table is already frozen; nothing to do."); + return; + } + if (!args.includes("--yes")) { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + const answer = await rl.question( + "Freezing is PERMANENT: the table can never be extended, closed, or edited, " + + "and its rent is locked forever.\nType FREEZE to continue: ", + ); + rl.close(); + if (answer.trim() !== "FREEZE") { + console.log("aborted."); + return; + } + } + const freezeIx = AddressLookupTableProgram.freezeLookupTable({ + lookupTable: tableArg, + authority: provider.wallet.publicKey, + }); + const signature = await provider.sendAndConfirm( + new Transaction().add(priorityFeeIx(), freezeIx), + ); + const after = await fetchTableAddresses(connection, tableArg); + if (after.state.authority) + throw new Error( + "freeze transaction landed but table still has an authority?", + ); + console.log( + `frozen: ${signature}\n` + + `Pin ${tableArg.toBase58()} in sdk/src/constants.ts (RELAUNCH_V0_1_GLOBAL_ALT).`, + ); + return; + } + + if (command === "dump") { + if (!tableArg) throw new Error("dump requires --table
"); + const outIdx = args.indexOf("--out"); + if (outIdx === -1) throw new Error("dump requires --out "); + const outPath = args[outIdx + 1]; + + const info = await connection.getAccountInfo(tableArg); + if (!info) throw new Error(`lookup table ${tableArg.toBase58()} not found`); + if (!info.owner.equals(AddressLookupTableProgram.programId)) { + throw new Error( + `account is owned by ${info.owner.toBase58()}, not the lookup table program`, + ); + } + + const account = await fetchTableAddresses(connection, tableArg); + const { matches, problems } = compareAddresses( + account.state.addresses, + entries, + false, + ); + if (!matches) + throw new Error( + `table does not match the computed list:\n${problems.join("\n")}`, + ); + if (account.state.authority) { + throw new Error( + "table is not frozen; freeze it first so the fixture mirrors the final shape", + ); + } + + // Meta layout: u32 discriminant, u64 deactivation_slot, u64 + // last_extended_slot, u8 last_extended_slot_start_index, Option + // authority, u16 padding. Zero the last-extended fields: entries only + // activate on slots past last_extended_slot, and bankrun clocks start + // near slot 0 — far below the slot this table was extended at. + const data = Buffer.from(info.data); + data.writeBigUInt64LE(0n, 12); + data.writeUInt8(0, 20); + + const fs = await import("fs"); + fs.writeFileSync(outPath, data); + console.log( + `wrote ${data.length} bytes (${account.state.addresses.length} addresses, frozen) to ${outPath}\n` + + `load in bankrun with owner ${AddressLookupTableProgram.programId.toBase58()} ` + + `and lamports ${info.lamports} at address ${tableArg.toBase58()}`, + ); + return; + } + + throw new Error( + `unknown command "${command}" (expected plan | create | verify | freeze | dump)`, + ); +} + +main().then( + () => process.exit(), + (err) => { + console.error(err); + process.exit(1); + }, +); diff --git a/sdk/package.json b/sdk/package.json index 150579ac..5f01f725 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -18,6 +18,7 @@ "./mint_governor": "./dist/mint_governor/index.js", "./performance_package_v2": "./dist/performance_package_v2/index.js", "./price_based_performance_package": "./dist/price_based_performance_package/index.js", + "./relaunch": "./dist/relaunch/index.js", "./shared_liquidity_manager": "./dist/shared_liquidity_manager/index.js", "./amm/*": "./dist/amm/*/index.js", "./autocrat/*": "./dist/autocrat/*/index.js", @@ -30,6 +31,7 @@ "./mint_governor/*": "./dist/mint_governor/*/index.js", "./performance_package_v2/*": "./dist/performance_package_v2/*/index.js", "./price_based_performance_package/*": "./dist/price_based_performance_package/*/index.js", + "./relaunch/*": "./dist/relaunch/*/index.js", "./shared_liquidity_manager/*": "./dist/shared_liquidity_manager/*/index.js" }, "license": "BSL-1.0", diff --git a/sdk/src/constants.ts b/sdk/src/constants.ts index a9e431a8..260fc8a1 100644 --- a/sdk/src/constants.ts +++ b/sdk/src/constants.ts @@ -68,6 +68,19 @@ export const PERFORMANCE_PACKAGE_V2_PROGRAM_ID = new PublicKey( export const LIQUIDATION_V0_7_PROGRAM_ID = new PublicKey( "LiQnowFbFQdYyZhF4pUbpsrZCjxRTQ1upKJxZ2VXjde", ); +export const RELAUNCH_V0_1_PROGRAM_ID = new PublicKey( + "vaMpdXN2P3Z5v8y6GtAU5NzCUjxtphnRVpvqu37Spik", +); + +// The relaunch global frozen address lookup table (contents documented in +// vibes/relaunch-alt-contents.html plus the two Raydium AMM v4 statics, +// created by `yarn relaunch-create-alt`). +// PLACEHOLDER: this is the table from the 2026-08-12 surfpool dry run, which +// tests/fixtures/relaunch-global-alt mirrors byte-for-byte. Replace with the +// real table address once it is created and frozen on mainnet. +export const RELAUNCH_V0_1_GLOBAL_ALT = new PublicKey( + "HXH8J1qEhfXWoAgsFhstHgcLkRXqvheoiNuRbXM3VS13", +); export const MPL_TOKEN_METADATA_PROGRAM_ID = new PublicKey( "metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s", @@ -109,6 +122,24 @@ export const DEVNET_RAYDIUM_AUTHORITY = PublicKey.findProgramAddressSync( DEVNET_RAYDIUM_CP_SWAP_PROGRAM_ID, )[0]; +/// The pump bonding-curve program. Canonical PumpSwap pools have the +/// `["pool-authority", mint]` PDA of this program as their creator. +export const PUMP_PROGRAM_ID = new PublicKey( + "6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P", +); + +export const PUMP_AMM_PROGRAM_ID = new PublicKey( + "pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA", +); + +export const PUMP_FEES_PROGRAM_ID = new PublicKey( + "pfeeUxB6jkeY1Hxd7CsFCAjcbHA9rWtchMGdZ6VojVZ", +); + +export const WHIRLPOOL_PROGRAM_ID = new PublicKey( + "whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc", +); + export const DAMM_V2_PROGRAM_ID = new PublicKey( "cpamdpZCGKUy5JxQXB4dcpGPiikHawvSWAd6mEn1sGG", ); diff --git a/sdk/src/index.ts b/sdk/src/index.ts index b909c275..b55f0ce0 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -8,6 +8,7 @@ export * from "./liquidation/index.js"; export * from "./mint_governor/index.js"; export * from "./performance_package_v2/index.js"; export * from "./price_based_performance_package/index.js"; +export * from "./relaunch/index.js"; // Shared exports export * from "./utils.js"; diff --git a/sdk/src/relaunch/index.ts b/sdk/src/relaunch/index.ts new file mode 100644 index 00000000..727de0c6 --- /dev/null +++ b/sdk/src/relaunch/index.ts @@ -0,0 +1 @@ +export * from "./v0.1/index.js"; diff --git a/sdk/src/relaunch/v0.1/RelaunchClient.ts b/sdk/src/relaunch/v0.1/RelaunchClient.ts new file mode 100644 index 00000000..4d82a426 --- /dev/null +++ b/sdk/src/relaunch/v0.1/RelaunchClient.ts @@ -0,0 +1,1439 @@ +import { AnchorProvider, Program } from "@coral-xyz/anchor"; +import { + AccountInfo, + ComputeBudgetProgram, + Keypair, + PublicKey, + SystemProgram, + Transaction, + TransactionSignature, +} from "@solana/web3.js"; +import { + AccountLayout, + ASSOCIATED_TOKEN_PROGRAM_ID, + createAssociatedTokenAccountIdempotentInstruction, + createInitializeMint2Instruction, + createSyncNativeInstruction, + getAssociatedTokenAddressSync, + MINT_SIZE, + NATIVE_MINT, + TOKEN_PROGRAM_ID, +} from "@solana/spl-token"; +import BN from "bn.js"; +import * as multisig from "@sqds/multisig"; +import { + FUTARCHY_V0_6_PROGRAM_ID, + MAINNET_USDC, + MPL_TOKEN_METADATA_PROGRAM_ID, + PUMP_AMM_PROGRAM_ID, + PUMP_FEES_PROGRAM_ID, + RELAUNCH_V0_1_PROGRAM_ID, + SQUADS_PROGRAM_CONFIG, + SQUADS_PROGRAM_CONFIG_TREASURY, + SQUADS_PROGRAM_ID, + WHIRLPOOL_PROGRAM_ID, +} from "../../constants.js"; +import { getDaoAddr } from "../../futarchy/v0.6/index.js"; +import { + RelaunchProgram, + RelaunchIDL, + RelaunchAccount, + DepositRecordAccount, +} from "./types/index.js"; +import { + getRelaunchAddr, + getRelaunchSignerAddr, + getDepositRecordAddr, +} from "./pda.js"; +import { + fetchPumpPool, + getPumpCreatorVaultAuthorityAddr, + getPumpFeeRecipients, + getPumpPoolV2Addr, + getPumpUserVolumeAccumulatorAddr, + PUMP_AMM_EVENT_AUTHORITY, + PUMP_AMM_FEE_CONFIG, + PUMP_AMM_GLOBAL_CONFIG, + PUMP_AMM_GLOBAL_VOLUME_ACCUMULATOR, +} from "./pumpAmm.js"; +import { + fetchRaydiumPool, + parseRaydiumPool, + RAYDIUM_AMM_AUTHORITY, + RAYDIUM_AMM_PROGRAM_ID, +} from "./raydiumAmm.js"; +import { + fetchWhirlpool, + getWhirlpoolOracleAddr, + getWhirlpoolSwapTickArrayAddrs, + MEMO_PROGRAM_ID, + USDC_SWAP_POOL, +} from "./whirlpool.js"; +import { getEventAuthorityAddr, getMetadataAddr } from "../../pda.js"; + +export type CreateRelaunchClientParams = { + provider: AnchorProvider; + relaunchProgramId?: PublicKey; +}; + +function ceilDiv(a: bigint, b: bigint): bigint { + return (a + b - 1n) / b; +} + +export class RelaunchClient { + public readonly provider: AnchorProvider; + public readonly relaunchProgram: Program; + public readonly programId: PublicKey; + + constructor(provider: AnchorProvider, relaunchProgramId: PublicKey) { + this.provider = provider; + this.programId = relaunchProgramId; + this.relaunchProgram = new Program( + RelaunchIDL, + relaunchProgramId, + provider, + ); + } + + public static createClient( + createRelaunchClientParams: CreateRelaunchClientParams, + ): RelaunchClient { + let { provider, relaunchProgramId } = createRelaunchClientParams; + + return new RelaunchClient( + provider, + relaunchProgramId || RELAUNCH_V0_1_PROGRAM_ID, + ); + } + + public getProgramId(): PublicKey { + return this.programId; + } + + initializeRelaunchIx({ + newMint, + oldMint, + oldTokenProgram, + sourcePool, + // Required for Raydium sources (the pool's LP mint), null for PumpSwap. + sourcePoolLpMint = null, + sourceQuoteMint, + tokenName, + tokenSymbol, + tokenUri, + secondsForDeposits, + gracePeriodSeconds, + thresholdBps, + // Zero amount with no members initializes without a spending limit. + monthlySpendingLimitAmount = new BN(0), + monthlySpendingLimitMembers = [], + teamAddress, + mintAuthority = this.provider.publicKey, + admin = this.provider.publicKey, + payer = this.provider.publicKey, + }: { + newMint: PublicKey; + oldMint: PublicKey; + oldTokenProgram: PublicKey; + sourcePool: PublicKey; + sourcePoolLpMint?: PublicKey | null; + sourceQuoteMint: PublicKey; + tokenName: string; + tokenSymbol: string; + tokenUri: string; + secondsForDeposits: number; + gracePeriodSeconds: number; + thresholdBps: number; + monthlySpendingLimitAmount?: BN; + monthlySpendingLimitMembers?: PublicKey[]; + teamAddress: PublicKey; + mintAuthority?: PublicKey; + admin?: PublicKey; + payer?: PublicKey; + }) { + const relaunch = this.getRelaunchAddress({ newMint }); + const relaunchSigner = this.getRelaunchSignerAddress({ relaunch }); + + const oldTokenVault = getAssociatedTokenAddressSync( + oldMint, + relaunchSigner, + true, + oldTokenProgram, + ); + const newTokenVault = getAssociatedTokenAddressSync( + newMint, + relaunchSigner, + true, + ); + const sourceQuoteVault = getAssociatedTokenAddressSync( + sourceQuoteMint, + relaunchSigner, + true, + ); + const usdcVault = getAssociatedTokenAddressSync( + MAINNET_USDC, + relaunchSigner, + true, + ); + const [tokenMetadata] = getMetadataAddr(newMint); + + return this.relaunchProgram.methods + .initializeRelaunch({ + tokenName, + tokenSymbol, + tokenUri, + secondsForDeposits, + gracePeriodSeconds, + thresholdBps, + monthlySpendingLimitAmount, + monthlySpendingLimitMembers, + teamAddress, + }) + .accounts({ + relaunch, + newMint, + mintAuthority, + relaunchSigner, + oldMint, + sourcePool, + sourcePoolLpMint, + sourceQuoteMint, + usdcMint: MAINNET_USDC, + oldTokenVault, + newTokenVault, + sourceQuoteVault, + usdcVault, + tokenMetadata, + admin, + payer, + oldTokenProgram, + tokenProgram: TOKEN_PROGRAM_ID, + tokenMetadataProgram: MPL_TOKEN_METADATA_PROGRAM_ID, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 400_000 }), + ]); + } + + startDepositsIx({ + relaunch, + admin = this.provider.publicKey, + }: { + relaunch: PublicKey; + admin?: PublicKey; + }) { + return this.relaunchProgram.methods.startDeposits().accounts({ + relaunch, + admin, + }); + } + + depositIx({ + relaunch, + oldMint, + oldTokenProgram, + amount, + depositor = this.provider.publicKey, + payer = this.provider.publicKey, + }: { + relaunch: PublicKey; + oldMint: PublicKey; + oldTokenProgram: PublicKey; + amount: BN; + depositor?: PublicKey; + payer?: PublicKey; + }) { + const relaunchSigner = this.getRelaunchSignerAddress({ relaunch }); + const depositRecord = this.getDepositRecordAddress({ + relaunch, + depositor, + }); + + const oldTokenVault = getAssociatedTokenAddressSync( + oldMint, + relaunchSigner, + true, + oldTokenProgram, + ); + const depositorTokenAccount = getAssociatedTokenAddressSync( + oldMint, + depositor, + false, + oldTokenProgram, + ); + + return this.relaunchProgram.methods.deposit({ amount }).accounts({ + relaunch, + depositRecord, + oldMint, + oldTokenVault, + depositor, + depositorTokenAccount, + payer, + oldTokenProgram, + }); + } + + closeDepositsIx({ relaunch }: { relaunch: PublicKey }) { + return this.relaunchProgram.methods.closeDeposits().accounts({ + relaunch, + }); + } + + depositViaBuyIx({ + relaunch, + oldMint, + oldTokenProgram, + sourceQuoteMint, + sourcePool, + poolBaseTokenAccount, + poolQuoteTokenAccount, + coinCreator, + protocolFeeRecipient, + buybackFeeRecipient, + baseOut, + maxQuoteIn, + depositor = this.provider.publicKey, + payer = this.provider.publicKey, + depositorQuoteAccount = getAssociatedTokenAddressSync( + sourceQuoteMint, + depositor, + ), + }: { + relaunch: PublicKey; + oldMint: PublicKey; + oldTokenProgram: PublicKey; + sourceQuoteMint: PublicKey; + sourcePool: PublicKey; + poolBaseTokenAccount: PublicKey; + poolQuoteTokenAccount: PublicKey; + coinCreator: PublicKey; + protocolFeeRecipient: PublicKey; + buybackFeeRecipient: PublicKey; + baseOut: BN; + maxQuoteIn: BN; + depositor?: PublicKey; + payer?: PublicKey; + depositorQuoteAccount?: PublicKey; + }) { + const relaunchSigner = this.getRelaunchSignerAddress({ relaunch }); + const depositRecord = this.getDepositRecordAddress({ + relaunch, + depositor, + }); + + const oldTokenVault = getAssociatedTokenAddressSync( + oldMint, + relaunchSigner, + true, + oldTokenProgram, + ); + const sourceQuoteVault = getAssociatedTokenAddressSync( + sourceQuoteMint, + relaunchSigner, + true, + ); + const coinCreatorVaultAuthority = + getPumpCreatorVaultAuthorityAddr(coinCreator); + + return this.relaunchProgram.methods + .depositViaBuy({ baseOut, maxQuoteIn }) + .accounts({ + relaunch, + depositRecord, + depositor, + payer, + relaunchSigner, + oldMint, + sourceQuoteMint, + oldTokenVault, + sourceQuoteVault, + depositorQuoteAccount, + sourcePool, + pumpGlobalConfig: PUMP_AMM_GLOBAL_CONFIG, + protocolFeeRecipient, + protocolFeeRecipientTokenAccount: getAssociatedTokenAddressSync( + sourceQuoteMint, + protocolFeeRecipient, + true, + ), + poolBaseTokenAccount, + poolQuoteTokenAccount, + coinCreatorVaultAta: getAssociatedTokenAddressSync( + sourceQuoteMint, + coinCreatorVaultAuthority, + true, + ), + coinCreatorVaultAuthority, + globalVolumeAccumulator: PUMP_AMM_GLOBAL_VOLUME_ACCUMULATOR, + userVolumeAccumulator: getPumpUserVolumeAccumulatorAddr(relaunchSigner), + pumpFeeConfig: PUMP_AMM_FEE_CONFIG, + pumpFeeProgram: PUMP_FEES_PROGRAM_ID, + poolV2: getPumpPoolV2Addr(oldMint), + buybackFeeRecipient, + buybackFeeRecipientTokenAccount: getAssociatedTokenAddressSync( + sourceQuoteMint, + buybackFeeRecipient, + true, + ), + pumpEventAuthority: PUMP_AMM_EVENT_AUTHORITY, + pumpAmmProgram: PUMP_AMM_PROGRAM_ID, + baseTokenProgram: oldTokenProgram, + quoteTokenProgram: TOKEN_PROGRAM_ID, + associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, + systemProgram: SystemProgram.programId, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]); + } + + depositViaBuyRaydiumIx({ + relaunch, + oldMint, + sourceQuoteMint, + sourcePool, + ammCoinVault, + ammPcVault, + baseOut, + maxQuoteIn, + depositor = this.provider.publicKey, + payer = this.provider.publicKey, + depositorQuoteAccount = getAssociatedTokenAddressSync( + sourceQuoteMint, + depositor, + ), + }: { + relaunch: PublicKey; + oldMint: PublicKey; + sourceQuoteMint: PublicKey; + sourcePool: PublicKey; + ammCoinVault: PublicKey; + ammPcVault: PublicKey; + baseOut: BN; + maxQuoteIn: BN; + depositor?: PublicKey; + payer?: PublicKey; + depositorQuoteAccount?: PublicKey; + }) { + const relaunchSigner = this.getRelaunchSignerAddress({ relaunch }); + const depositRecord = this.getDepositRecordAddress({ + relaunch, + depositor, + }); + + const oldTokenVault = getAssociatedTokenAddressSync( + oldMint, + relaunchSigner, + true, + ); + const sourceQuoteVault = getAssociatedTokenAddressSync( + sourceQuoteMint, + relaunchSigner, + true, + ); + + return this.relaunchProgram.methods + .depositViaBuyRaydium({ baseOut, maxQuoteIn }) + .accounts({ + relaunch, + depositRecord, + depositor, + payer, + relaunchSigner, + sourceQuoteMint, + oldTokenVault, + sourceQuoteVault, + depositorQuoteAccount, + sourcePool, + ammAuthority: RAYDIUM_AMM_AUTHORITY, + ammCoinVault, + ammPcVault, + raydiumAmmProgram: RAYDIUM_AMM_PROGRAM_ID, + tokenProgram: TOKEN_PROGRAM_ID, + systemProgram: SystemProgram.programId, + }); + } + + executeSellIx({ + relaunch, + oldMint, + oldTokenProgram, + sourceQuoteMint, + sourcePool, + poolBaseTokenAccount, + poolQuoteTokenAccount, + coinCreator, + protocolFeeRecipient, + buybackFeeRecipient, + minQuoteOut, + admin = this.provider.publicKey, + }: { + relaunch: PublicKey; + oldMint: PublicKey; + oldTokenProgram: PublicKey; + sourceQuoteMint: PublicKey; + sourcePool: PublicKey; + poolBaseTokenAccount: PublicKey; + poolQuoteTokenAccount: PublicKey; + coinCreator: PublicKey; + protocolFeeRecipient: PublicKey; + buybackFeeRecipient: PublicKey; + minQuoteOut: BN; + admin?: PublicKey; + }) { + const relaunchSigner = this.getRelaunchSignerAddress({ relaunch }); + + const oldTokenVault = getAssociatedTokenAddressSync( + oldMint, + relaunchSigner, + true, + oldTokenProgram, + ); + const sourceQuoteVault = getAssociatedTokenAddressSync( + sourceQuoteMint, + relaunchSigner, + true, + ); + const coinCreatorVaultAuthority = + getPumpCreatorVaultAuthorityAddr(coinCreator); + + return this.relaunchProgram.methods + .executeSell({ minQuoteOut }) + .accounts({ + relaunch, + admin, + relaunchSigner, + oldMint, + sourceQuoteMint, + oldTokenVault, + sourceQuoteVault, + sourcePool, + pumpGlobalConfig: PUMP_AMM_GLOBAL_CONFIG, + protocolFeeRecipient, + protocolFeeRecipientTokenAccount: getAssociatedTokenAddressSync( + sourceQuoteMint, + protocolFeeRecipient, + true, + ), + poolBaseTokenAccount, + poolQuoteTokenAccount, + coinCreatorVaultAta: getAssociatedTokenAddressSync( + sourceQuoteMint, + coinCreatorVaultAuthority, + true, + ), + coinCreatorVaultAuthority, + pumpFeeConfig: PUMP_AMM_FEE_CONFIG, + pumpFeeProgram: PUMP_FEES_PROGRAM_ID, + poolV2: getPumpPoolV2Addr(oldMint), + buybackFeeRecipient, + buybackFeeRecipientTokenAccount: getAssociatedTokenAddressSync( + sourceQuoteMint, + buybackFeeRecipient, + true, + ), + pumpEventAuthority: PUMP_AMM_EVENT_AUTHORITY, + pumpAmmProgram: PUMP_AMM_PROGRAM_ID, + baseTokenProgram: oldTokenProgram, + quoteTokenProgram: TOKEN_PROGRAM_ID, + associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, + systemProgram: SystemProgram.programId, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 250_000 }), + ]); + } + + executeSellRaydiumIx({ + relaunch, + oldMint, + sourceQuoteMint, + sourcePool, + ammCoinVault, + ammPcVault, + minQuoteOut, + admin = this.provider.publicKey, + }: { + relaunch: PublicKey; + oldMint: PublicKey; + sourceQuoteMint: PublicKey; + sourcePool: PublicKey; + ammCoinVault: PublicKey; + ammPcVault: PublicKey; + minQuoteOut: BN; + admin?: PublicKey; + }) { + const relaunchSigner = this.getRelaunchSignerAddress({ relaunch }); + + const oldTokenVault = getAssociatedTokenAddressSync( + oldMint, + relaunchSigner, + true, + ); + const sourceQuoteVault = getAssociatedTokenAddressSync( + sourceQuoteMint, + relaunchSigner, + true, + ); + + return this.relaunchProgram.methods + .executeSellRaydium({ minQuoteOut }) + .accounts({ + relaunch, + admin, + relaunchSigner, + oldTokenVault, + sourceQuoteVault, + sourcePool, + ammAuthority: RAYDIUM_AMM_AUTHORITY, + ammCoinVault, + ammPcVault, + raydiumAmmProgram: RAYDIUM_AMM_PROGRAM_ID, + tokenProgram: TOKEN_PROGRAM_ID, + }); + } + + executeUsdcSwapIx({ + relaunch, + whirlpoolWsolVault, + whirlpoolUsdcVault, + tickArrays, + minUsdcOut, + whirlpool = USDC_SWAP_POOL, + admin = this.provider.publicKey, + }: { + relaunch: PublicKey; + whirlpoolWsolVault: PublicKey; + whirlpoolUsdcVault: PublicKey; + tickArrays: [PublicKey, PublicKey, PublicKey]; + minUsdcOut: BN; + whirlpool?: PublicKey; + admin?: PublicKey; + }) { + const relaunchSigner = this.getRelaunchSignerAddress({ relaunch }); + + const sourceQuoteVault = getAssociatedTokenAddressSync( + NATIVE_MINT, + relaunchSigner, + true, + ); + const usdcVault = getAssociatedTokenAddressSync( + MAINNET_USDC, + relaunchSigner, + true, + ); + + return this.relaunchProgram.methods + .executeUsdcSwap({ minUsdcOut }) + .accounts({ + relaunch, + admin, + relaunchSigner, + sourceQuoteVault, + usdcVault, + whirlpool, + wsolMint: NATIVE_MINT, + usdcMint: MAINNET_USDC, + whirlpoolWsolVault, + whirlpoolUsdcVault, + tickArray0: tickArrays[0], + tickArray1: tickArrays[1], + tickArray2: tickArrays[2], + oracle: getWhirlpoolOracleAddr(whirlpool), + memoProgram: MEMO_PROGRAM_ID, + whirlpoolProgram: WHIRLPOOL_PROGRAM_ID, + tokenProgram: TOKEN_PROGRAM_ID, + }); + } + + // Swaps the whole WSOL vault to USDC as the admin (the provider wallet), + // deriving the whirlpool account set from the pinned pool's live state. + // When minUsdcOut is not given, it is computed from the pool's spot price + // minus slippageBps (which must also cover the swap fee + price impact). + async executeUsdcSwap({ + relaunch, + minUsdcOut, + slippageBps = 100, + }: { + relaunch: PublicKey; + minUsdcOut?: BN; + slippageBps?: number; + }): Promise { + const whirlpool = await fetchWhirlpool(this.provider.connection); + + if (minUsdcOut === undefined) { + const relaunchSigner = this.getRelaunchSignerAddress({ relaunch }); + const wsolIn = await this.fetchTokenBalance( + getAssociatedTokenAddressSync(NATIVE_MINT, relaunchSigner, true), + ); + // Spot price in USDC-raw per WSOL-raw is (sqrtPrice / 2^64)^2. + const spotOut = + (wsolIn * whirlpool.sqrtPrice * whirlpool.sqrtPrice) >> 128n; + const floor = (spotOut * (10_000n - BigInt(slippageBps))) / 10_000n; + minUsdcOut = new BN(floor.toString()); + } + + return this.executeUsdcSwapIx({ + relaunch, + whirlpoolWsolVault: whirlpool.tokenVaultA, + whirlpoolUsdcVault: whirlpool.tokenVaultB, + tickArrays: getWhirlpoolSwapTickArrayAddrs( + USDC_SWAP_POOL, + whirlpool.tickCurrentIndex, + whirlpool.tickSpacing, + true, + ), + minUsdcOut, + }).rpc(); + } + + completeRelaunchIx({ + relaunch, + newMint, + payer = this.provider.publicKey, + }: { + relaunch: PublicKey; + newMint: PublicKey; + payer?: PublicKey; + }) { + const relaunchSigner = this.getRelaunchSignerAddress({ relaunch }); + + const newTokenVault = getAssociatedTokenAddressSync( + newMint, + relaunchSigner, + true, + ); + const usdcVault = getAssociatedTokenAddressSync( + MAINNET_USDC, + relaunchSigner, + true, + ); + const [tokenMetadata] = getMetadataAddr(newMint); + + const [dao] = getDaoAddr({ nonce: new BN(0), daoCreator: relaunchSigner }); + const [futarchyEventAuthority] = getEventAuthorityAddr( + FUTARCHY_V0_6_PROGRAM_ID, + ); + + const [multisigPda] = multisig.getMultisigPda({ createKey: dao }); + const [multisigVault] = multisig.getVaultPda({ multisigPda, index: 0 }); + const [spendingLimit] = multisig.getSpendingLimitPda({ + multisigPda, + createKey: dao, + }); + + const [ammPosition] = PublicKey.findProgramAddressSync( + [Buffer.from("amm_position"), dao.toBuffer(), multisigVault.toBuffer()], + FUTARCHY_V0_6_PROGRAM_ID, + ); + + return this.relaunchProgram.methods + .completeRelaunch() + .accounts({ + relaunch, + payer, + relaunchSigner, + newMint, + usdcMint: MAINNET_USDC, + newTokenVault, + usdcVault, + tokenMetadata, + dao, + futarchyAmmBaseVault: getAssociatedTokenAddressSync(newMint, dao, true), + futarchyAmmQuoteVault: getAssociatedTokenAddressSync( + MAINNET_USDC, + dao, + true, + ), + ammPosition, + squadsMultisig: multisigPda, + squadsMultisigVault: multisigVault, + spendingLimit, + squadsProgramConfig: SQUADS_PROGRAM_CONFIG, + squadsProgramConfigTreasury: SQUADS_PROGRAM_CONFIG_TREASURY, + futarchyProgram: FUTARCHY_V0_6_PROGRAM_ID, + futarchyEventAuthority, + squadsProgram: SQUADS_PROGRAM_ID, + tokenMetadataProgram: MPL_TOKEN_METADATA_PROGRAM_ID, + tokenProgram: TOKEN_PROGRAM_ID, + associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, + systemProgram: SystemProgram.programId, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 500_000 }), + ]); + } + + // Completes the relaunch as any cranker (the provider wallet), reading the + // new mint from the stored relaunch. + async completeRelaunch({ + relaunch, + }: { + relaunch: PublicKey; + }): Promise { + const storedRelaunch = await this.fetchRelaunch(relaunch); + if (storedRelaunch === null) { + throw new Error(`relaunch ${relaunch.toBase58()} does not exist`); + } + + return this.completeRelaunchIx({ + relaunch, + newMint: storedRelaunch.newMint, + }).rpc(); + } + + markFailedIx({ relaunch }: { relaunch: PublicKey }) { + return this.relaunchProgram.methods.markFailed().accounts({ + relaunch, + }); + } + + claimRefundIx({ + relaunch, + oldMint, + oldTokenProgram, + depositor = this.provider.publicKey, + }: { + relaunch: PublicKey; + oldMint: PublicKey; + oldTokenProgram: PublicKey; + depositor?: PublicKey; + }) { + const relaunchSigner = this.getRelaunchSignerAddress({ relaunch }); + const depositRecord = this.getDepositRecordAddress({ + relaunch, + depositor, + }); + + const oldTokenVault = getAssociatedTokenAddressSync( + oldMint, + relaunchSigner, + true, + oldTokenProgram, + ); + const depositorTokenAccount = getAssociatedTokenAddressSync( + oldMint, + depositor, + false, + oldTokenProgram, + ); + + return this.relaunchProgram.methods.claimRefund().accounts({ + relaunch, + depositRecord, + oldMint, + oldTokenVault, + relaunchSigner, + depositor, + depositorTokenAccount, + oldTokenProgram, + }); + } + + claimIx({ + relaunch, + newMint, + depositor = this.provider.publicKey, + payer = this.provider.publicKey, + }: { + relaunch: PublicKey; + newMint: PublicKey; + depositor?: PublicKey; + payer?: PublicKey; + }) { + const relaunchSigner = this.getRelaunchSignerAddress({ relaunch }); + const depositRecord = this.getDepositRecordAddress({ + relaunch, + depositor, + }); + + const newTokenVault = getAssociatedTokenAddressSync( + newMint, + relaunchSigner, + true, + ); + const depositorTokenAccount = getAssociatedTokenAddressSync( + newMint, + depositor, + false, + ); + + return this.relaunchProgram.methods + .claim() + .accounts({ + relaunch, + depositRecord, + newMint, + newTokenVault, + relaunchSigner, + depositor, + depositorTokenAccount, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .preInstructions([ + createAssociatedTokenAccountIdempotentInstruction( + payer, + depositorTokenAccount, + depositor, + newMint, + ), + ]); + } + + // Deposits from the provider wallet, reading the old mint and its owner + // program from the stored relaunch. + async deposit({ + relaunch, + amount, + }: { + relaunch: PublicKey; + amount: BN; + }): Promise { + const storedRelaunch = await this.fetchRelaunch(relaunch); + if (storedRelaunch === null) { + throw new Error(`relaunch ${relaunch.toBase58()} does not exist`); + } + + const oldMintAccount = await this.provider.connection.getAccountInfo( + storedRelaunch.oldMint, + ); + if (oldMintAccount === null) { + throw new Error( + `old mint ${storedRelaunch.oldMint.toBase58()} does not exist`, + ); + } + + return this.depositIx({ + relaunch, + oldMint: storedRelaunch.oldMint, + oldTokenProgram: oldMintAccount.owner, + amount, + }).rpc(); + } + + // Tops the provider wallet's WSOL ATA up to maxQuoteIn, wrapping any + // shortfall from SOL in a separate preparatory transaction + private async wrapWsolShortfall(maxQuoteIn: BN): Promise { + const wsolAta = getAssociatedTokenAddressSync( + NATIVE_MINT, + this.provider.publicKey, + ); + let wsolAtaAccount: AccountInfo | null = null; + try { + wsolAtaAccount = await this.provider.connection.getAccountInfo(wsolAta); + } catch { + // anchor-bankrun's connection proxy throws for missing accounts + // instead of returning null. + } + const wsolBalance = + wsolAtaAccount === null + ? 0n + : AccountLayout.decode(wsolAtaAccount.data).amount; + const shortfall = BigInt(maxQuoteIn.toString()) - wsolBalance; + if (shortfall > 0n) { + await this.provider.sendAndConfirm!( + new Transaction().add( + createAssociatedTokenAccountIdempotentInstruction( + this.provider.publicKey, + wsolAta, + this.provider.publicKey, + NATIVE_MINT, + ), + SystemProgram.transfer({ + fromPubkey: this.provider.publicKey, + toPubkey: wsolAta, + lamports: Number(shortfall), + }), + createSyncNativeInstruction(wsolAta), + ), + ); + } + } + + // Buys baseOut old tokens off the source pool as the provider wallet and + // credits them as a deposit, dispatching on the relaunch's stored source + // venue and deriving the venue's account set like executeSell. When + // maxQuoteIn is not given, it is computed live from the pool reserves: the + // constant-product input for the exact output plus slippageBps. Pump's + // swap fees are not modeled, so slippageBps must also cover them; + // Raydium's flat 25 bps fee is modeled exactly, so slippageBps only covers + // price movement. For WSOL-quoted pools, any shortfall in the depositor's + // WSOL ATA is wrapped from SOL in a separate preparatory transaction. + async depositViaBuy({ + relaunch, + baseOut, + maxQuoteIn, + slippageBps = 100, + }: { + relaunch: PublicKey; + baseOut: BN; + maxQuoteIn?: BN; + slippageBps?: number; + }): Promise { + const storedRelaunch = await this.fetchRelaunch(relaunch); + if (storedRelaunch === null) { + throw new Error(`relaunch ${relaunch.toBase58()} does not exist`); + } + + if (storedRelaunch.sourceVenue.raydiumAmmV4 !== undefined) { + const pool = await fetchRaydiumPool( + this.provider.connection, + storedRelaunch.sourcePool, + ); + + if (maxQuoteIn === undefined) { + const tokenIsCoin = pool.coinMint.equals(storedRelaunch.oldMint); + const [tokenReserve, quoteReserve] = await Promise.all( + [ + tokenIsCoin ? pool.coinVault : pool.pcVault, + tokenIsCoin ? pool.pcVault : pool.coinVault, + ].map((address) => this.fetchTokenBalance(address)), + ); + const baseOutBig = BigInt(baseOut.toString()); + if (baseOutBig >= tokenReserve) { + throw new Error( + `baseOut ${baseOutBig} exceeds the pool's token reserve ${tokenReserve}`, + ); + } + // The exact-out input is ceil-rounded, with the 25 bps fee + // ceil-rounded on top of it (the fee stays in the pool). + const inBeforeFee = ceilDiv( + quoteReserve * baseOutBig, + tokenReserve - baseOutBig, + ); + const grossIn = ceilDiv(inBeforeFee * 10_000n, 9_975n); + const cap = (grossIn * (10_000n + BigInt(slippageBps))) / 10_000n; + maxQuoteIn = new BN(cap.toString()); + } + + // Raydium sources are WSOL-quoted by construction. + await this.wrapWsolShortfall(maxQuoteIn); + + return this.depositViaBuyRaydiumIx({ + relaunch, + oldMint: storedRelaunch.oldMint, + sourceQuoteMint: storedRelaunch.sourceQuoteMint, + sourcePool: storedRelaunch.sourcePool, + ammCoinVault: pool.coinVault, + ammPcVault: pool.pcVault, + baseOut, + maxQuoteIn, + }).rpc(); + } + + const oldMintAccount = await this.provider.connection.getAccountInfo( + storedRelaunch.oldMint, + ); + if (oldMintAccount === null) { + throw new Error( + `old mint ${storedRelaunch.oldMint.toBase58()} does not exist`, + ); + } + + const pool = await fetchPumpPool( + this.provider.connection, + storedRelaunch.sourcePool, + ); + const { protocolFeeRecipient, buybackFeeRecipient } = + await getPumpFeeRecipients(this.provider.connection); + + if (maxQuoteIn === undefined) { + const [baseReserve, quoteReserve] = await Promise.all( + [pool.poolBaseTokenAccount, pool.poolQuoteTokenAccount].map((address) => + this.fetchTokenBalance(address), + ), + ); + const baseOutBig = BigInt(baseOut.toString()); + if (baseOutBig >= baseReserve) { + throw new Error( + `baseOut ${baseOutBig} exceeds the pool's base reserve ${baseReserve}`, + ); + } + const grossIn = (quoteReserve * baseOutBig) / (baseReserve - baseOutBig); + const cap = (grossIn * (10_000n + BigInt(slippageBps))) / 10_000n; + maxQuoteIn = new BN(cap.toString()); + } + + if (storedRelaunch.sourceQuoteMint.equals(NATIVE_MINT)) { + await this.wrapWsolShortfall(maxQuoteIn); + } + + return this.depositViaBuyIx({ + relaunch, + oldMint: storedRelaunch.oldMint, + oldTokenProgram: oldMintAccount.owner, + sourceQuoteMint: storedRelaunch.sourceQuoteMint, + sourcePool: storedRelaunch.sourcePool, + poolBaseTokenAccount: pool.poolBaseTokenAccount, + poolQuoteTokenAccount: pool.poolQuoteTokenAccount, + coinCreator: pool.coinCreator, + protocolFeeRecipient, + buybackFeeRecipient, + baseOut, + maxQuoteIn, + }).rpc(); + } + + // Sells the whole old-token vault as the admin (the provider wallet), + // dispatching on the relaunch's stored source venue and deriving the + // venue's account set from the stored relaunch and its pool. When + // minQuoteOut is not given, it is computed live from the pool reserves: + // the constant-product output of the sell minus slippageBps. Pump's swap + // fees are not modeled, so slippageBps must also cover them; Raydium's + // flat 25 bps fee is exact, so slippageBps only covers price movement. + async executeSell({ + relaunch, + minQuoteOut, + slippageBps = 100, + }: { + relaunch: PublicKey; + minQuoteOut?: BN; + slippageBps?: number; + }): Promise { + const storedRelaunch = await this.fetchRelaunch(relaunch); + if (storedRelaunch === null) { + throw new Error(`relaunch ${relaunch.toBase58()} does not exist`); + } + + if (storedRelaunch.sourceVenue.raydiumAmmV4 !== undefined) { + const pool = await fetchRaydiumPool( + this.provider.connection, + storedRelaunch.sourcePool, + ); + + if (minQuoteOut === undefined) { + const tokenIsCoin = pool.coinMint.equals(storedRelaunch.oldMint); + const [baseIn, tokenReserve, quoteReserve] = await Promise.all( + [ + storedRelaunch.oldTokenVault, + tokenIsCoin ? pool.coinVault : pool.pcVault, + tokenIsCoin ? pool.pcVault : pool.coinVault, + ].map((address) => this.fetchTokenBalance(address)), + ); + // The 25 bps fee is ceil-rounded off the input and stays in the pool. + const netIn = baseIn - (baseIn * 25n + 9_999n) / 10_000n; + const grossOut = (quoteReserve * netIn) / (tokenReserve + netIn); + const floor = (grossOut * (10_000n - BigInt(slippageBps))) / 10_000n; + minQuoteOut = new BN(floor.toString()); + } + + return this.executeSellRaydiumIx({ + relaunch, + oldMint: storedRelaunch.oldMint, + sourceQuoteMint: storedRelaunch.sourceQuoteMint, + sourcePool: storedRelaunch.sourcePool, + ammCoinVault: pool.coinVault, + ammPcVault: pool.pcVault, + minQuoteOut, + }).rpc(); + } + + const oldMintAccount = await this.provider.connection.getAccountInfo( + storedRelaunch.oldMint, + ); + if (oldMintAccount === null) { + throw new Error( + `old mint ${storedRelaunch.oldMint.toBase58()} does not exist`, + ); + } + + const pool = await fetchPumpPool( + this.provider.connection, + storedRelaunch.sourcePool, + ); + const { protocolFeeRecipient, buybackFeeRecipient } = + await getPumpFeeRecipients(this.provider.connection); + + if (minQuoteOut === undefined) { + const [baseIn, baseReserve, quoteReserve] = await Promise.all( + [ + storedRelaunch.oldTokenVault, + pool.poolBaseTokenAccount, + pool.poolQuoteTokenAccount, + ].map((address) => this.fetchTokenBalance(address)), + ); + const grossOut = (quoteReserve * baseIn) / (baseReserve + baseIn); + const floor = (grossOut * (10_000n - BigInt(slippageBps))) / 10_000n; + minQuoteOut = new BN(floor.toString()); + } + + return this.executeSellIx({ + relaunch, + oldMint: storedRelaunch.oldMint, + oldTokenProgram: oldMintAccount.owner, + sourceQuoteMint: storedRelaunch.sourceQuoteMint, + sourcePool: storedRelaunch.sourcePool, + poolBaseTokenAccount: pool.poolBaseTokenAccount, + poolQuoteTokenAccount: pool.poolQuoteTokenAccount, + coinCreator: pool.coinCreator, + protocolFeeRecipient, + buybackFeeRecipient, + minQuoteOut, + }).rpc(); + } + + private async fetchTokenBalance(address: PublicKey): Promise { + const accountInfo = await this.provider.connection.getAccountInfo(address); + if (accountInfo === null) { + throw new Error(`token account ${address.toBase58()} does not exist`); + } + return AccountLayout.decode(accountInfo.data).amount; + } + + // Claims a refund for the given depositor (the provider wallet by default), + // reading the old mint and its owner program from the stored relaunch. + async claimRefund({ + relaunch, + depositor = this.provider.publicKey, + }: { + relaunch: PublicKey; + depositor?: PublicKey; + }): Promise { + const storedRelaunch = await this.fetchRelaunch(relaunch); + if (storedRelaunch === null) { + throw new Error(`relaunch ${relaunch.toBase58()} does not exist`); + } + + const oldMintAccount = await this.provider.connection.getAccountInfo( + storedRelaunch.oldMint, + ); + if (oldMintAccount === null) { + throw new Error( + `old mint ${storedRelaunch.oldMint.toBase58()} does not exist`, + ); + } + + return this.claimRefundIx({ + relaunch, + oldMint: storedRelaunch.oldMint, + oldTokenProgram: oldMintAccount.owner, + depositor, + }).rpc(); + } + + // Claims the depositor's pro-rata share of the new token (the provider + // wallet by default), reading the new mint from the stored relaunch. + async claim({ + relaunch, + depositor = this.provider.publicKey, + }: { + relaunch: PublicKey; + depositor?: PublicKey; + }): Promise { + const storedRelaunch = await this.fetchRelaunch(relaunch); + if (storedRelaunch === null) { + throw new Error(`relaunch ${relaunch.toBase58()} does not exist`); + } + + return this.claimIx({ + relaunch, + newMint: storedRelaunch.newMint, + depositor, + }).rpc(); + } + + // Builds the create-mint-to-self pre-instructions: a `createAccountWithSeed` + // + `initializeMint2` pair with the payer as mint authority, so + // `initialize_relaunch` can take the authority from a mint the payer + // provably controls. + async createNewMintIxs({ + payer = this.provider.publicKey, + seed = Keypair.generate().publicKey.toBase58().slice(0, 32), + }: { + payer?: PublicKey; + seed?: string; + } = {}) { + const newMint = await PublicKey.createWithSeed( + payer, + seed, + TOKEN_PROGRAM_ID, + ); + const lamports = + await this.provider.connection.getMinimumBalanceForRentExemption( + MINT_SIZE, + ); + + const instructions = [ + SystemProgram.createAccountWithSeed({ + fromPubkey: payer, + basePubkey: payer, + seed, + newAccountPubkey: newMint, + lamports, + space: MINT_SIZE, + programId: TOKEN_PROGRAM_ID, + }), + createInitializeMint2Instruction(newMint, 6, payer, null), + ]; + + return { newMint, instructions }; + } + + // Creates the new mint and initializes the relaunch in a single + // transaction, signed entirely by the provider wallet. + async initializeRelaunch({ + oldMint, + sourcePool, + sourceQuoteMint, + tokenName, + tokenSymbol, + tokenUri, + secondsForDeposits, + gracePeriodSeconds, + thresholdBps, + monthlySpendingLimitAmount, + monthlySpendingLimitMembers, + teamAddress, + admin, + }: { + oldMint: PublicKey; + sourcePool: PublicKey; + sourceQuoteMint: PublicKey; + tokenName: string; + tokenSymbol: string; + tokenUri: string; + secondsForDeposits: number; + gracePeriodSeconds: number; + thresholdBps: number; + monthlySpendingLimitAmount?: BN; + monthlySpendingLimitMembers?: PublicKey[]; + teamAddress: PublicKey; + admin?: PublicKey; + }): Promise<{ + newMint: PublicKey; + relaunch: PublicKey; + txSignature: TransactionSignature; + }> { + const { newMint, instructions } = await this.createNewMintIxs(); + + const oldMintAccount = + await this.provider.connection.getAccountInfo(oldMint); + if (oldMintAccount === null) { + throw new Error(`old mint ${oldMint.toBase58()} does not exist`); + } + + const sourcePoolAccount = + await this.provider.connection.getAccountInfo(sourcePool); + if (sourcePoolAccount === null) { + throw new Error(`source pool ${sourcePool.toBase58()} does not exist`); + } + // Raydium sources need the pool's LP mint alongside for the burned-LP + // check; PumpSwap sources are validated purely from the pool account. + const sourcePoolLpMint = sourcePoolAccount.owner.equals( + RAYDIUM_AMM_PROGRAM_ID, + ) + ? parseRaydiumPool(sourcePoolAccount.data).lpMint + : null; + + const txSignature = await this.initializeRelaunchIx({ + newMint, + oldMint, + oldTokenProgram: oldMintAccount.owner, + sourcePool, + sourcePoolLpMint, + sourceQuoteMint, + tokenName, + tokenSymbol, + tokenUri, + secondsForDeposits, + gracePeriodSeconds, + thresholdBps, + monthlySpendingLimitAmount, + monthlySpendingLimitMembers, + teamAddress, + admin, + }) + .preInstructions(instructions) + .rpc(); + + return { + newMint, + relaunch: this.getRelaunchAddress({ newMint }), + txSignature, + }; + } + + async fetchRelaunch(relaunch: PublicKey): Promise { + return this.relaunchProgram.account.relaunch.fetchNullable(relaunch); + } + + async deserializeRelaunch( + accountInfo: AccountInfo, + ): Promise { + return this.relaunchProgram.coder.accounts.decode( + "relaunch", + accountInfo.data, + ); + } + + async fetchDepositRecord( + depositRecord: PublicKey, + ): Promise { + return this.relaunchProgram.account.depositRecord.fetchNullable( + depositRecord, + ); + } + + async deserializeDepositRecord( + accountInfo: AccountInfo, + ): Promise { + return this.relaunchProgram.coder.accounts.decode( + "depositRecord", + accountInfo.data, + ); + } + + async getRelaunch({ + newMint, + }: { + newMint: PublicKey; + }): Promise { + const relaunch = this.getRelaunchAddress({ newMint }); + return this.fetchRelaunch(relaunch); + } + + async getDepositRecord({ + relaunch, + depositor, + }: { + relaunch: PublicKey; + depositor: PublicKey; + }): Promise { + const depositRecord = this.getDepositRecordAddress({ + relaunch, + depositor, + }); + return this.fetchDepositRecord(depositRecord); + } + + public getRelaunchAddress({ newMint }: { newMint: PublicKey }): PublicKey { + return getRelaunchAddr({ programId: this.programId, newMint })[0]; + } + + public getRelaunchSignerAddress({ + relaunch, + }: { + relaunch: PublicKey; + }): PublicKey { + return getRelaunchSignerAddr({ programId: this.programId, relaunch })[0]; + } + + public getDepositRecordAddress({ + relaunch, + depositor, + }: { + relaunch: PublicKey; + depositor: PublicKey; + }): PublicKey { + return getDepositRecordAddr({ + programId: this.programId, + relaunch, + depositor, + })[0]; + } + + public getEventAuthorityAddress(): PublicKey { + return getEventAuthorityAddr(this.programId)[0]; + } +} diff --git a/sdk/src/relaunch/v0.1/index.ts b/sdk/src/relaunch/v0.1/index.ts new file mode 100644 index 00000000..9dca2b15 --- /dev/null +++ b/sdk/src/relaunch/v0.1/index.ts @@ -0,0 +1,6 @@ +export * from "./types/index.js"; +export * from "./pda.js"; +export * from "./pumpAmm.js"; +export * from "./raydiumAmm.js"; +export * from "./whirlpool.js"; +export * from "./RelaunchClient.js"; diff --git a/sdk/src/relaunch/v0.1/pda.ts b/sdk/src/relaunch/v0.1/pda.ts new file mode 100644 index 00000000..40edb26a --- /dev/null +++ b/sdk/src/relaunch/v0.1/pda.ts @@ -0,0 +1,43 @@ +import { PublicKey } from "@solana/web3.js"; +import { RELAUNCH_V0_1_PROGRAM_ID } from "../../constants.js"; + +export const getRelaunchAddr = ({ + programId = RELAUNCH_V0_1_PROGRAM_ID, + newMint, +}: { + programId?: PublicKey; + newMint: PublicKey; +}) => { + return PublicKey.findProgramAddressSync( + [Buffer.from("relaunch"), newMint.toBuffer()], + programId, + ); +}; + +export const getRelaunchSignerAddr = ({ + programId = RELAUNCH_V0_1_PROGRAM_ID, + relaunch, +}: { + programId?: PublicKey; + relaunch: PublicKey; +}) => { + return PublicKey.findProgramAddressSync( + [Buffer.from("relaunch_signer"), relaunch.toBuffer()], + programId, + ); +}; + +export const getDepositRecordAddr = ({ + programId = RELAUNCH_V0_1_PROGRAM_ID, + relaunch, + depositor, +}: { + programId?: PublicKey; + relaunch: PublicKey; + depositor: PublicKey; +}) => { + return PublicKey.findProgramAddressSync( + [Buffer.from("deposit_record"), relaunch.toBuffer(), depositor.toBuffer()], + programId, + ); +}; diff --git a/sdk/src/relaunch/v0.1/pumpAmm.ts b/sdk/src/relaunch/v0.1/pumpAmm.ts new file mode 100644 index 00000000..fad0d5fa --- /dev/null +++ b/sdk/src/relaunch/v0.1/pumpAmm.ts @@ -0,0 +1,169 @@ +import { Connection, PublicKey } from "@solana/web3.js"; +import { PUMP_AMM_PROGRAM_ID, PUMP_FEES_PROGRAM_ID } from "../../constants.js"; + +export const PUMP_AMM_GLOBAL_CONFIG = PublicKey.findProgramAddressSync( + [Buffer.from("global_config")], + PUMP_AMM_PROGRAM_ID, +)[0]; + +export const PUMP_AMM_EVENT_AUTHORITY = PublicKey.findProgramAddressSync( + [Buffer.from("__event_authority")], + PUMP_AMM_PROGRAM_ID, +)[0]; + +// Per-consumer fee config the pump fee program keeps for pump_amm. +export const PUMP_AMM_FEE_CONFIG = PublicKey.findProgramAddressSync( + [Buffer.from("fee_config"), PUMP_AMM_PROGRAM_ID.toBuffer()], + PUMP_FEES_PROGRAM_ID, +)[0]; + +export const PUMP_AMM_GLOBAL_VOLUME_ACCUMULATOR = + PublicKey.findProgramAddressSync( + [Buffer.from("global_volume_accumulator")], + PUMP_AMM_PROGRAM_ID, + )[0]; + +export function getPumpUserVolumeAccumulatorAddr(user: PublicKey): PublicKey { + return PublicKey.findProgramAddressSync( + [Buffer.from("user_volume_accumulator"), user.toBuffer()], + PUMP_AMM_PROGRAM_ID, + )[0]; +} + +// The current pump_amm requires this PDA as the first remaining account on +// buys and sells (checked by address only — the account need not exist). +export function getPumpPoolV2Addr(baseMint: PublicKey): PublicKey { + return PublicKey.findProgramAddressSync( + [Buffer.from("pool-v2"), baseMint.toBuffer()], + PUMP_AMM_PROGRAM_ID, + )[0]; +} + +export function getPumpCreatorVaultAuthorityAddr( + coinCreator: PublicKey, +): PublicKey { + return PublicKey.findProgramAddressSync( + [Buffer.from("creator_vault"), coinCreator.toBuffer()], + PUMP_AMM_PROGRAM_ID, + )[0]; +} + +export type PumpPoolAccount = { + poolBump: number; + index: number; + creator: PublicKey; + baseMint: PublicKey; + quoteMint: PublicKey; + lpMint: PublicKey; + poolBaseTokenAccount: PublicKey; + poolQuoteTokenAccount: PublicKey; + coinCreator: PublicKey; +}; + +// Pool account layout: 8-byte discriminator, pool_bump u8, index u16 LE, +// then creator / base_mint / quote_mint / lp_mint / pool_base_token_account / +// pool_quote_token_account pubkeys, lp_supply u64 LE, coin_creator pubkey. +export function parsePumpPool(data: Buffer): PumpPoolAccount { + return { + poolBump: data.readUInt8(8), + index: data.readUInt16LE(9), + creator: new PublicKey(data.subarray(11, 43)), + baseMint: new PublicKey(data.subarray(43, 75)), + quoteMint: new PublicKey(data.subarray(75, 107)), + lpMint: new PublicKey(data.subarray(107, 139)), + poolBaseTokenAccount: new PublicKey(data.subarray(139, 171)), + poolQuoteTokenAccount: new PublicKey(data.subarray(171, 203)), + coinCreator: new PublicKey(data.subarray(211, 243)), + }; +} + +/** Fetches and parses `pool` as a pump_amm pool account. */ +export async function fetchPumpPool( + connection: Connection, + pool: PublicKey, +): Promise { + const info = await connection.getAccountInfo(pool); + if (info === null) { + throw new Error(`pump pool ${pool.toBase58()} does not exist`); + } + return parsePumpPool(info.data); +} + +export type PumpGlobalConfigAccount = { + protocolFeeRecipients: PublicKey[]; + buybackFeeRecipients: PublicKey[]; +}; + +function readRecipients(data: Buffer, offset: number): PublicKey[] { + const recipients: PublicKey[] = []; + for (let i = 0; i < 8; i++) { + const recipient = new PublicKey( + data.subarray(offset + i * 32, offset + (i + 1) * 32), + ); + if (!recipient.equals(PublicKey.default)) { + recipients.push(recipient); + } + } + return recipients; +} + +// GlobalConfig layout (unset recipient slots are the default pubkey and are +// filtered out): +// +// offset size field +// 0 8 discriminator +// 8 32 admin +// 40 8 lp_fee_basis_points +// 48 8 protocol_fee_basis_points +// 56 1 disable_flags +// 57 256 protocol_fee_recipients [Pubkey; 8] +// 313 8 coin_creator_fee_basis_points +// 321 32 admin_set_coin_creator_authority +// 353 32 whitelist_pda +// 385 32 reserved_fee_recipient +// 417 1 mayhem_mode_enabled +// 418 224 reserved_fee_recipients [Pubkey; 7] +// 642 1 is_cashback_enabled +// 643 256 buyback_fee_recipients [Pubkey; 8] +// 899 8 buyback_basis_points +// 907 32 boost_authority +// 939 1 boost_enabled +// 940 total +export function parsePumpGlobalConfig(data: Buffer): PumpGlobalConfigAccount { + return { + protocolFeeRecipients: readRecipients(data, 57), + buybackFeeRecipients: readRecipients(data, 643), + }; +} + +/** Fetches and parses pump_amm's global config. */ +export async function fetchPumpGlobalConfig( + connection: Connection, +): Promise { + const info = await connection.getAccountInfo(PUMP_AMM_GLOBAL_CONFIG); + if (info === null) { + throw new Error("pump_amm global config does not exist"); + } + return parsePumpGlobalConfig(info.data); +} + +/** + * The fee-recipient pair pump's buy and sell instructions need, resolved + * from the global config. pump accepts any member of each list; this picks + * the first of both — pass a different member to the ix builders if + * write-lock contention on the recipients' ATAs matters. + */ +export async function getPumpFeeRecipients(connection: Connection): Promise<{ + protocolFeeRecipient: PublicKey; + buybackFeeRecipient: PublicKey; +}> { + const { protocolFeeRecipients, buybackFeeRecipients } = + await fetchPumpGlobalConfig(connection); + if (protocolFeeRecipients.length === 0 || buybackFeeRecipients.length === 0) { + throw new Error("pump_amm global config has no fee recipients"); + } + return { + protocolFeeRecipient: protocolFeeRecipients[0], + buybackFeeRecipient: buybackFeeRecipients[0], + }; +} diff --git a/sdk/src/relaunch/v0.1/raydiumAmm.ts b/sdk/src/relaunch/v0.1/raydiumAmm.ts new file mode 100644 index 00000000..573c302c --- /dev/null +++ b/sdk/src/relaunch/v0.1/raydiumAmm.ts @@ -0,0 +1,64 @@ +import { Connection, PublicKey } from "@solana/web3.js"; + +// Raydium's legacy "Standard" AMM v4, where pre-PumpSwap pump graduations +// live. +export const RAYDIUM_AMM_PROGRAM_ID = new PublicKey( + "675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8", +); + +// The global authority PDA over every AMM v4 vault: ["amm authority"]. +export const RAYDIUM_AMM_AUTHORITY = new PublicKey( + "5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1", +); + +// OpenBook v1. Stored as market_program by every orderbook-era AMM v4 pool. +export const OPENBOOK_PROGRAM_ID = new PublicKey( + "srmqPvymJeFKQ4zGQed1GFppgkRHL9kaELCbyksJtPX", +); + +// AmmInfo is a fixed-size packed struct with no discriminator, so the exact +// length is the shape check. +export const AMM_INFO_LEN = 752; + +export type RaydiumPoolAccount = { + status: bigint; + coinVault: PublicKey; + pcVault: PublicKey; + coinMint: PublicKey; + pcMint: PublicKey; + lpMint: PublicKey; + marketProgram: PublicKey; + lpAmount: bigint; +}; + +// The subset of AmmInfo the client reads, at the same fixed offsets the +// program's RaydiumPool::try_parse uses. +export function parseRaydiumPool(data: Buffer): RaydiumPoolAccount { + if (data.length !== AMM_INFO_LEN) { + throw new Error( + `expected a ${AMM_INFO_LEN}-byte AMM v4 pool account, got ${data.length} bytes`, + ); + } + return { + status: data.readBigUInt64LE(0), + coinVault: new PublicKey(data.subarray(336, 368)), + pcVault: new PublicKey(data.subarray(368, 400)), + coinMint: new PublicKey(data.subarray(400, 432)), + pcMint: new PublicKey(data.subarray(432, 464)), + lpMint: new PublicKey(data.subarray(464, 496)), + marketProgram: new PublicKey(data.subarray(560, 592)), + lpAmount: data.readBigUInt64LE(720), + }; +} + +/** Fetches and parses `pool` as a Raydium AMM v4 pool account. */ +export async function fetchRaydiumPool( + connection: Connection, + pool: PublicKey, +): Promise { + const info = await connection.getAccountInfo(pool); + if (info === null) { + throw new Error(`raydium pool ${pool.toBase58()} does not exist`); + } + return parseRaydiumPool(info.data); +} diff --git a/sdk/src/relaunch/v0.1/types/index.ts b/sdk/src/relaunch/v0.1/types/index.ts new file mode 100644 index 00000000..2198a125 --- /dev/null +++ b/sdk/src/relaunch/v0.1/types/index.ts @@ -0,0 +1,9 @@ +import { IdlAccounts } from "@coral-xyz/anchor"; + +import { Relaunch as RelaunchProgram, IDL as RelaunchIDL } from "./relaunch.js"; + +export { RelaunchProgram, RelaunchIDL }; + +export type RelaunchAccount = IdlAccounts["relaunch"]; +export type DepositRecordAccount = + IdlAccounts["depositRecord"]; diff --git a/sdk/src/relaunch/v0.1/types/relaunch.ts b/sdk/src/relaunch/v0.1/types/relaunch.ts new file mode 100644 index 00000000..2489c53a --- /dev/null +++ b/sdk/src/relaunch/v0.1/types/relaunch.ts @@ -0,0 +1,4547 @@ +export type Relaunch = { + version: "0.1.0"; + name: "relaunch"; + instructions: [ + { + name: "initializeRelaunch"; + accounts: [ + { + name: "relaunch"; + isMut: true; + isSigner: false; + }, + { + name: "newMint"; + isMut: true; + isSigner: false; + }, + { + name: "mintAuthority"; + isMut: false; + isSigner: true; + docs: [ + "Proof that the initializer controls the new mint: must sign, and the", + "handler CPIs `set_authority` to hand minting to `relaunch_signer`.", + ]; + }, + { + name: "relaunchSigner"; + isMut: false; + isSigner: false; + }, + { + name: "oldMint"; + isMut: false; + isSigner: false; + }, + { + name: "sourcePool"; + isMut: false; + isSigner: false; + }, + { + name: "sourceQuoteMint"; + isMut: false; + isSigner: false; + }, + { + name: "sourcePoolLpMint"; + isMut: false; + isSigner: false; + isOptional: true; + docs: ["The source pool's LP mint: required for Raydium sources"]; + }, + { + name: "usdcMint"; + isMut: false; + isSigner: false; + }, + { + name: "oldTokenVault"; + isMut: true; + isSigner: false; + }, + { + name: "newTokenVault"; + isMut: true; + isSigner: false; + }, + { + name: "sourceQuoteVault"; + isMut: true; + isSigner: false; + }, + { + name: "usdcVault"; + isMut: true; + isSigner: false; + docs: [ + "The same account as `source_quote_vault` for USDC-quoted sources, in", + "which case the `init_if_needed` is a no-op revalidation.", + ]; + }, + { + name: "tokenMetadata"; + isMut: true; + isSigner: false; + }, + { + name: "admin"; + isMut: false; + isSigner: false; + docs: [ + "period. Not required to sign, mirroring launchpad's launch_authority.", + ]; + }, + { + name: "payer"; + isMut: true; + isSigner: true; + }, + { + name: "rent"; + isMut: false; + isSigner: false; + }, + { + name: "oldTokenProgram"; + 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: "InitializeRelaunchArgs"; + }; + }, + ]; + }, + { + name: "startDeposits"; + accounts: [ + { + name: "relaunch"; + isMut: true; + isSigner: false; + }, + { + name: "admin"; + isMut: false; + isSigner: true; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, + { + name: "deposit"; + accounts: [ + { + name: "relaunch"; + isMut: true; + isSigner: false; + }, + { + name: "depositRecord"; + isMut: true; + isSigner: false; + }, + { + name: "oldMint"; + isMut: false; + isSigner: false; + }, + { + name: "oldTokenVault"; + isMut: true; + isSigner: false; + }, + { + name: "depositor"; + isMut: false; + isSigner: true; + }, + { + name: "depositorTokenAccount"; + isMut: true; + isSigner: false; + }, + { + name: "payer"; + isMut: true; + isSigner: true; + }, + { + name: "oldTokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "systemProgram"; + isMut: false; + isSigner: false; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: [ + { + name: "args"; + type: { + defined: "DepositArgs"; + }; + }, + ]; + }, + { + name: "depositViaBuy"; + accounts: [ + { + name: "relaunch"; + isMut: true; + isSigner: false; + }, + { + name: "depositRecord"; + isMut: true; + isSigner: false; + }, + { + name: "depositor"; + isMut: false; + isSigner: true; + }, + { + name: "payer"; + isMut: true; + isSigner: true; + }, + { + name: "relaunchSigner"; + isMut: true; + isSigner: false; + docs: ["user account writable."]; + }, + { + name: "oldMint"; + isMut: false; + isSigner: false; + }, + { + name: "sourceQuoteMint"; + isMut: false; + isSigner: false; + }, + { + name: "oldTokenVault"; + isMut: true; + isSigner: false; + }, + { + name: "sourceQuoteVault"; + isMut: true; + isSigner: false; + }, + { + name: "depositorQuoteAccount"; + isMut: true; + isSigner: false; + }, + { + name: "sourcePool"; + isMut: true; + isSigner: false; + }, + { + name: "pumpGlobalConfig"; + isMut: false; + isSigner: false; + }, + { + name: "protocolFeeRecipient"; + isMut: false; + isSigner: false; + }, + { + name: "protocolFeeRecipientTokenAccount"; + isMut: true; + isSigner: false; + }, + { + name: "poolBaseTokenAccount"; + isMut: true; + isSigner: false; + }, + { + name: "poolQuoteTokenAccount"; + isMut: true; + isSigner: false; + }, + { + name: "coinCreatorVaultAta"; + isMut: true; + isSigner: false; + }, + { + name: "coinCreatorVaultAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "globalVolumeAccumulator"; + isMut: false; + isSigner: false; + }, + { + name: "userVolumeAccumulator"; + isMut: true; + isSigner: false; + docs: ["on the first buy"]; + }, + { + name: "pumpFeeConfig"; + isMut: false; + isSigner: false; + }, + { + name: "pumpFeeProgram"; + isMut: false; + isSigner: false; + }, + { + name: "poolV2"; + isMut: false; + isSigner: false; + }, + { + name: "buybackFeeRecipient"; + isMut: false; + isSigner: false; + }, + { + name: "buybackFeeRecipientTokenAccount"; + isMut: true; + isSigner: false; + }, + { + name: "pumpEventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "pumpAmmProgram"; + isMut: false; + isSigner: false; + }, + { + name: "baseTokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "quoteTokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "associatedTokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "systemProgram"; + isMut: false; + isSigner: false; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: [ + { + name: "args"; + type: { + defined: "DepositViaBuyArgs"; + }; + }, + ]; + }, + { + name: "depositViaBuyRaydium"; + accounts: [ + { + name: "relaunch"; + isMut: true; + isSigner: false; + }, + { + name: "depositRecord"; + isMut: true; + isSigner: false; + }, + { + name: "depositor"; + isMut: false; + isSigner: true; + }, + { + name: "payer"; + isMut: true; + isSigner: true; + }, + { + name: "relaunchSigner"; + isMut: false; + isSigner: false; + }, + { + name: "sourceQuoteMint"; + isMut: false; + isSigner: false; + }, + { + name: "oldTokenVault"; + isMut: true; + isSigner: false; + }, + { + name: "sourceQuoteVault"; + isMut: true; + isSigner: false; + }, + { + name: "depositorQuoteAccount"; + isMut: true; + isSigner: false; + }, + { + name: "sourcePool"; + isMut: true; + isSigner: false; + }, + { + name: "ammAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "ammCoinVault"; + isMut: true; + isSigner: false; + }, + { + name: "ammPcVault"; + isMut: true; + isSigner: false; + }, + { + name: "raydiumAmmProgram"; + isMut: false; + 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: "args"; + type: { + defined: "DepositViaBuyRaydiumArgs"; + }; + }, + ]; + }, + { + name: "closeDeposits"; + accounts: [ + { + name: "relaunch"; + isMut: true; + isSigner: false; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, + { + name: "executeSell"; + accounts: [ + { + name: "relaunch"; + isMut: true; + isSigner: false; + }, + { + name: "admin"; + isMut: false; + isSigner: true; + }, + { + name: "relaunchSigner"; + isMut: true; + isSigner: false; + docs: ["user account writable."]; + }, + { + name: "oldMint"; + isMut: false; + isSigner: false; + }, + { + name: "sourceQuoteMint"; + isMut: false; + isSigner: false; + }, + { + name: "oldTokenVault"; + isMut: true; + isSigner: false; + }, + { + name: "sourceQuoteVault"; + isMut: true; + isSigner: false; + }, + { + name: "sourcePool"; + isMut: true; + isSigner: false; + docs: ["rechecks its internal consistency."]; + }, + { + name: "pumpGlobalConfig"; + isMut: false; + isSigner: false; + }, + { + name: "protocolFeeRecipient"; + isMut: false; + isSigner: false; + }, + { + name: "protocolFeeRecipientTokenAccount"; + isMut: true; + isSigner: false; + }, + { + name: "poolBaseTokenAccount"; + isMut: true; + isSigner: false; + }, + { + name: "poolQuoteTokenAccount"; + isMut: true; + isSigner: false; + }, + { + name: "coinCreatorVaultAta"; + isMut: true; + isSigner: false; + }, + { + name: "coinCreatorVaultAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "pumpFeeConfig"; + isMut: false; + isSigner: false; + }, + { + name: "pumpFeeProgram"; + isMut: false; + isSigner: false; + }, + { + name: "poolV2"; + isMut: false; + isSigner: false; + }, + { + name: "buybackFeeRecipient"; + isMut: false; + isSigner: false; + }, + { + name: "buybackFeeRecipientTokenAccount"; + isMut: true; + isSigner: false; + }, + { + name: "pumpEventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "pumpAmmProgram"; + isMut: false; + isSigner: false; + }, + { + name: "baseTokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "quoteTokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "associatedTokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "systemProgram"; + isMut: false; + isSigner: false; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: [ + { + name: "args"; + type: { + defined: "ExecuteSellArgs"; + }; + }, + ]; + }, + { + name: "executeSellRaydium"; + accounts: [ + { + name: "relaunch"; + isMut: true; + isSigner: false; + }, + { + name: "admin"; + isMut: false; + isSigner: true; + }, + { + name: "relaunchSigner"; + isMut: false; + isSigner: false; + }, + { + name: "oldTokenVault"; + isMut: true; + isSigner: false; + }, + { + name: "sourceQuoteVault"; + isMut: true; + isSigner: false; + }, + { + name: "sourcePool"; + isMut: true; + isSigner: false; + docs: ["rechecks its internal consistency."]; + }, + { + name: "ammAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "ammCoinVault"; + isMut: true; + isSigner: false; + }, + { + name: "ammPcVault"; + isMut: true; + isSigner: false; + }, + { + name: "raydiumAmmProgram"; + isMut: false; + isSigner: false; + }, + { + name: "tokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: [ + { + name: "args"; + type: { + defined: "ExecuteSellRaydiumArgs"; + }; + }, + ]; + }, + { + name: "executeUsdcSwap"; + accounts: [ + { + name: "relaunch"; + isMut: true; + isSigner: false; + }, + { + name: "admin"; + isMut: false; + isSigner: true; + }, + { + name: "relaunchSigner"; + isMut: false; + isSigner: false; + }, + { + name: "sourceQuoteVault"; + isMut: true; + isSigner: false; + docs: [ + "The WSOL vault holding the sell proceeds; `Sold` only occurs for", + "WSOL-quoted sources.", + ]; + }, + { + name: "usdcVault"; + isMut: true; + isSigner: false; + }, + { + name: "whirlpool"; + isMut: true; + isSigner: false; + docs: ["its internal consistency."]; + }, + { + name: "wsolMint"; + isMut: false; + isSigner: false; + }, + { + name: "usdcMint"; + isMut: false; + isSigner: false; + }, + { + name: "whirlpoolWsolVault"; + isMut: true; + isSigner: false; + }, + { + name: "whirlpoolUsdcVault"; + isMut: true; + isSigner: false; + }, + { + name: "tickArray0"; + isMut: true; + isSigner: false; + }, + { + name: "tickArray1"; + isMut: true; + isSigner: false; + }, + { + name: "tickArray2"; + isMut: true; + isSigner: false; + }, + { + name: "oracle"; + isMut: true; + isSigner: false; + }, + { + name: "memoProgram"; + isMut: false; + isSigner: false; + }, + { + name: "whirlpoolProgram"; + isMut: false; + isSigner: false; + }, + { + name: "tokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: [ + { + name: "args"; + type: { + defined: "ExecuteUsdcSwapArgs"; + }; + }, + ]; + }, + { + name: "completeRelaunch"; + accounts: [ + { + name: "relaunch"; + isMut: true; + isSigner: false; + }, + { + name: "payer"; + isMut: true; + isSigner: true; + }, + { + name: "relaunchSigner"; + isMut: false; + isSigner: false; + docs: ["handoffs."]; + }, + { + name: "newMint"; + isMut: true; + isSigner: false; + docs: [ + "The DAO's base mint; its mint authority moves to the Squads vault.", + ]; + }, + { + name: "usdcMint"; + isMut: false; + isSigner: false; + docs: ["The DAO's quote mint."]; + }, + { + name: "newTokenVault"; + isMut: true; + isSigner: false; + }, + { + name: "usdcVault"; + isMut: true; + isSigner: false; + }, + { + name: "tokenMetadata"; + isMut: true; + isSigner: false; + docs: ["Squads vault."]; + }, + { + name: "dao"; + isMut: true; + isSigner: false; + docs: ["hardcoded `nonce: 0` CPI param."]; + }, + { + name: "futarchyAmmBaseVault"; + isMut: true; + isSigner: false; + }, + { + name: "futarchyAmmQuoteVault"; + isMut: true; + isSigner: false; + }, + { + name: "ammPosition"; + isMut: true; + isSigner: false; + docs: ["position."]; + }, + { + name: "squadsMultisig"; + isMut: true; + isSigner: false; + }, + { + name: "squadsMultisigVault"; + isMut: false; + isSigner: false; + docs: ["authorities."]; + }, + { + name: "spendingLimit"; + isMut: true; + isSigner: false; + }, + { + name: "squadsProgramConfig"; + isMut: false; + isSigner: false; + }, + { + name: "squadsProgramConfigTreasury"; + isMut: true; + isSigner: false; + }, + { + name: "futarchyProgram"; + isMut: false; + isSigner: false; + }, + { + name: "futarchyEventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "squadsProgram"; + isMut: false; + isSigner: false; + }, + { + name: "tokenMetadataProgram"; + isMut: false; + isSigner: false; + }, + { + name: "tokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "associatedTokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "systemProgram"; + isMut: false; + isSigner: false; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, + { + name: "claim"; + accounts: [ + { + name: "relaunch"; + isMut: true; + isSigner: false; + }, + { + name: "depositRecord"; + isMut: true; + isSigner: false; + }, + { + name: "newMint"; + isMut: false; + isSigner: false; + }, + { + name: "newTokenVault"; + isMut: true; + isSigner: false; + }, + { + name: "relaunchSigner"; + isMut: false; + isSigner: false; + }, + { + name: "depositor"; + isMut: false; + isSigner: false; + docs: ["claims for any depositor."]; + }, + { + name: "depositorTokenAccount"; + isMut: true; + isSigner: false; + }, + { + name: "tokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, + { + name: "markFailed"; + accounts: [ + { + name: "relaunch"; + isMut: true; + isSigner: false; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, + { + name: "claimRefund"; + accounts: [ + { + name: "relaunch"; + isMut: true; + isSigner: false; + }, + { + name: "depositRecord"; + isMut: true; + isSigner: false; + }, + { + name: "oldMint"; + isMut: false; + isSigner: false; + }, + { + name: "oldTokenVault"; + isMut: true; + isSigner: false; + }, + { + name: "relaunchSigner"; + isMut: false; + isSigner: false; + }, + { + name: "depositor"; + isMut: false; + isSigner: false; + docs: ["refunds for any depositor."]; + }, + { + name: "depositorTokenAccount"; + isMut: true; + isSigner: false; + }, + { + name: "oldTokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, + ]; + accounts: [ + { + name: "depositRecord"; + type: { + kind: "struct"; + fields: [ + { + name: "relaunch"; + docs: ["The relaunch this record belongs to."]; + type: "publicKey"; + }, + { + name: "depositor"; + docs: ["The depositor."]; + type: "publicKey"; + }, + { + name: "amountDeposited"; + docs: [ + "The amount of old tokens deposited, including tokens bought via", + "`deposit_via_buy`.", + ]; + type: "u64"; + }, + { + name: "claimed"; + docs: [ + "Whether the record has been settled by `claim` / `claim_refund`.", + ]; + type: "bool"; + }, + { + name: "seqNum"; + docs: [ + "The sequence number of this record. Useful for sorting events.", + ]; + type: "u64"; + }, + { + name: "pdaBump"; + docs: ["The PDA bump."]; + type: "u8"; + }, + ]; + }; + }, + { + name: "relaunch"; + type: { + kind: "struct"; + fields: [ + { + name: "admin"; + docs: ["The initializer; executes the sell + swap legs."]; + type: "publicKey"; + }, + { + name: "newMint"; + docs: [ + "The token that will be distributed to depositors and that will control the DAO.", + ]; + type: "publicKey"; + }, + { + name: "oldMint"; + docs: ["The token being relaunched."]; + type: "publicKey"; + }, + { + name: "sourcePool"; + docs: [ + "The canonical PumpSwap pool for the old mint, validated at init.", + ]; + type: "publicKey"; + }, + { + name: "sourceQuoteMint"; + docs: [ + "The source pool's quote mint — WSOL or USDC. WSOL sources swap through", + "the `usdc_swap_pool` constant.", + ]; + type: "publicKey"; + }, + { + name: "relaunchSigner"; + docs: [ + 'The PDA that signs all CPIs and owns the vaults: `["relaunch_signer", relaunch]`.', + ]; + type: "publicKey"; + }, + { + name: "relaunchSignerBump"; + docs: ["The PDA bump for the relaunch signer."]; + type: "u8"; + }, + { + name: "oldTokenVault"; + docs: ["The vault that escrows deposited old tokens."]; + type: "publicKey"; + }, + { + name: "newTokenVault"; + docs: [ + "The vault that holds the minted new tokens until claim / liquidity provision.", + ]; + type: "publicKey"; + }, + { + name: "sourceQuoteVault"; + docs: ["The vault that receives raw sell proceeds (WSOL or USDC)."]; + type: "publicKey"; + }, + { + name: "usdcVault"; + docs: [ + "The vault that receives the swap-leg output; == `source_quote_vault`", + "for USDC sources.", + ]; + type: "publicKey"; + }, + { + name: "thresholdBps"; + docs: [ + "The minimum participation, denominated in bps of old-token total supply.", + ]; + type: "u16"; + }, + { + name: "oldSupplySnapshot"; + docs: [ + "The old mint supply captured at init (threshold denominator).", + ]; + type: "u64"; + }, + { + name: "secondsForDeposits"; + docs: ["The number of seconds that deposits will be open for."]; + type: "u32"; + }, + { + name: "gracePeriodSeconds"; + docs: ["The admin's window to sell after deposits close."]; + type: "u32"; + }, + { + name: "monthlySpendingLimitAmount"; + docs: [ + "The monthly spending limit the DAO allocates to the team. Zero, with", + "no members, means the DAO launches without a spending limit.", + ]; + type: "u64"; + }, + { + name: "monthlySpendingLimitMembers"; + docs: [ + "The wallets that have access to the monthly spending limit.", + ]; + type: { + vec: "publicKey"; + }; + }, + { + name: "teamAddress"; + docs: ["The initial address used to sponsor team proposals."]; + type: "publicKey"; + }, + { + name: "state"; + docs: ["The state of the relaunch."]; + type: { + defined: "RelaunchState"; + }; + }, + { + name: "totalDeposited"; + docs: ["The amount of old tokens deposited across all depositors."]; + type: "u64"; + }, + { + name: "quoteRecovered"; + docs: ["The raw sell proceeds, in the source quote asset."]; + type: "u64"; + }, + { + name: "usdcRecovered"; + docs: [ + "The post-swap USDC (== `quote_recovered` for USDC sources).", + ]; + type: "u64"; + }, + { + name: "unixTimestampStarted"; + docs: ["The unix timestamp when deposits were opened."]; + type: { + option: "i64"; + }; + }, + { + name: "unixTimestampClosed"; + docs: ["The unix timestamp when deposits were closed."]; + type: { + option: "i64"; + }; + }, + { + name: "unixTimestampCompleted"; + docs: ["The unix timestamp when the relaunch was completed."]; + type: { + option: "i64"; + }; + }, + { + name: "dao"; + docs: ["The DAO, if the relaunch is complete."]; + type: { + option: "publicKey"; + }; + }, + { + name: "daoVault"; + docs: [ + "The DAO's Squads multisig vault, if the relaunch is complete.", + ]; + type: { + option: "publicKey"; + }; + }, + { + name: "seqNum"; + docs: [ + "The sequence number of this relaunch used for sorting events.", + ]; + type: "u64"; + }, + { + name: "pdaBump"; + docs: ["The PDA bump."]; + type: "u8"; + }, + { + name: "sourceVenue"; + docs: [ + "The venue of the source pool, set from the pool's owner at init.", + ]; + type: { + defined: "SourceVenue"; + }; + }, + ]; + }; + }, + ]; + types: [ + { + name: "CommonFields"; + type: { + kind: "struct"; + fields: [ + { + name: "slot"; + type: "u64"; + }, + { + name: "unixTimestamp"; + type: "i64"; + }, + { + name: "relaunchSeqNum"; + type: "u64"; + }, + ]; + }; + }, + { + name: "DepositViaBuyRaydiumArgs"; + type: { + kind: "struct"; + fields: [ + { + name: "baseOut"; + docs: [ + "The exact amount of old tokens to buy off the source pool", + "(swap_base_out_v2 is exact-output).", + ]; + type: "u64"; + }, + { + name: "maxQuoteIn"; + docs: [ + "The depositor's live slippage cap on the quote spent, inclusive of", + "the AMM's 25 bps fee.", + ]; + type: "u64"; + }, + ]; + }; + }, + { + name: "DepositViaBuyArgs"; + type: { + kind: "struct"; + fields: [ + { + name: "baseOut"; + docs: [ + "The exact amount of old tokens to buy off the source pool (pump's buy", + "is exact-output).", + ]; + type: "u64"; + }, + { + name: "maxQuoteIn"; + docs: [ + "The depositor's live slippage cap on the quote spent, inclusive of", + "pump's fees.", + ]; + type: "u64"; + }, + ]; + }; + }, + { + name: "DepositArgs"; + type: { + kind: "struct"; + fields: [ + { + name: "amount"; + type: "u64"; + }, + ]; + }; + }, + { + name: "ExecuteSellRaydiumArgs"; + type: { + kind: "struct"; + fields: [ + { + name: "minQuoteOut"; + docs: [ + "The admin's live, client-computed slippage floor on the sell proceeds.", + ]; + type: "u64"; + }, + ]; + }; + }, + { + name: "ExecuteSellArgs"; + type: { + kind: "struct"; + fields: [ + { + name: "minQuoteOut"; + docs: [ + "The admin's live, client-computed slippage floor on the sell proceeds.", + ]; + type: "u64"; + }, + ]; + }; + }, + { + name: "ExecuteUsdcSwapArgs"; + type: { + kind: "struct"; + fields: [ + { + name: "minUsdcOut"; + docs: [ + "The admin's live, client-computed slippage floor on the swap output.", + ]; + type: "u64"; + }, + ]; + }; + }, + { + name: "InitializeRelaunchArgs"; + type: { + kind: "struct"; + fields: [ + { + name: "tokenName"; + type: "string"; + }, + { + name: "tokenSymbol"; + type: "string"; + }, + { + name: "tokenUri"; + type: "string"; + }, + { + name: "secondsForDeposits"; + type: "u32"; + }, + { + name: "gracePeriodSeconds"; + type: "u32"; + }, + { + name: "thresholdBps"; + type: "u16"; + }, + { + name: "monthlySpendingLimitAmount"; + type: "u64"; + }, + { + name: "monthlySpendingLimitMembers"; + type: { + vec: "publicKey"; + }; + }, + { + name: "teamAddress"; + type: "publicKey"; + }, + ]; + }; + }, + { + name: "RelaunchState"; + type: { + kind: "enum"; + variants: [ + { + name: "Initialized"; + }, + { + name: "Live"; + }, + { + name: "SellPending"; + }, + { + name: "Sold"; + }, + { + name: "Swapped"; + }, + { + name: "Complete"; + }, + { + name: "Failed"; + }, + ]; + }; + }, + { + name: "SourceVenue"; + docs: [ + "The venue the source pool lives on, deciding how the pool was validated", + "at init and which sell/buy instructions apply.", + ]; + type: { + kind: "enum"; + variants: [ + { + name: "PumpSwap"; + }, + { + name: "RaydiumAmmV4"; + }, + ]; + }; + }, + ]; + events: [ + { + name: "RelaunchInitializedEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "relaunch"; + type: "publicKey"; + index: false; + }, + { + name: "admin"; + type: "publicKey"; + index: false; + }, + { + name: "newMint"; + type: "publicKey"; + index: false; + }, + { + name: "oldMint"; + type: "publicKey"; + index: false; + }, + { + name: "sourcePool"; + type: "publicKey"; + index: false; + }, + { + name: "sourceQuoteMint"; + type: "publicKey"; + index: false; + }, + { + name: "relaunchSigner"; + type: "publicKey"; + index: false; + }, + { + name: "relaunchSignerBump"; + type: "u8"; + index: false; + }, + { + name: "oldTokenVault"; + type: "publicKey"; + index: false; + }, + { + name: "newTokenVault"; + type: "publicKey"; + index: false; + }, + { + name: "sourceQuoteVault"; + type: "publicKey"; + index: false; + }, + { + name: "usdcVault"; + type: "publicKey"; + index: false; + }, + { + name: "thresholdBps"; + type: "u16"; + index: false; + }, + { + name: "oldSupplySnapshot"; + type: "u64"; + index: false; + }, + { + name: "secondsForDeposits"; + type: "u32"; + index: false; + }, + { + name: "gracePeriodSeconds"; + type: "u32"; + index: false; + }, + { + name: "monthlySpendingLimitAmount"; + type: "u64"; + index: false; + }, + { + name: "monthlySpendingLimitMembers"; + type: { + vec: "publicKey"; + }; + index: false; + }, + { + name: "teamAddress"; + type: "publicKey"; + index: false; + }, + { + name: "pdaBump"; + type: "u8"; + index: false; + }, + { + name: "sourceVenue"; + type: { + defined: "SourceVenue"; + }; + index: false; + }, + ]; + }, + { + name: "DepositsStartedEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "relaunch"; + type: "publicKey"; + index: false; + }, + { + name: "admin"; + type: "publicKey"; + index: false; + }, + ]; + }, + { + name: "TokensDepositedEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "relaunch"; + type: "publicKey"; + index: false; + }, + { + name: "depositor"; + type: "publicKey"; + index: false; + }, + { + name: "depositRecord"; + type: "publicKey"; + index: false; + }, + { + name: "amount"; + type: "u64"; + index: false; + }, + { + name: "totalDeposited"; + type: "u64"; + index: false; + }, + { + name: "totalDepositedByDepositor"; + type: "u64"; + index: false; + }, + { + name: "depositRecordSeqNum"; + type: "u64"; + index: false; + }, + ]; + }, + { + name: "DepositsClosedEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "relaunch"; + type: "publicKey"; + index: false; + }, + { + name: "newState"; + type: { + defined: "RelaunchState"; + }; + index: false; + }, + ]; + }, + { + name: "RelaunchMarkedFailedEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "relaunch"; + type: "publicKey"; + index: false; + }, + ]; + }, + { + name: "SellExecutedEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "relaunch"; + type: "publicKey"; + index: false; + }, + { + name: "baseSold"; + type: "u64"; + index: false; + }, + { + name: "quoteRecovered"; + type: "u64"; + index: false; + }, + { + name: "newState"; + type: { + defined: "RelaunchState"; + }; + index: false; + }, + ]; + }, + { + name: "UsdcSwapExecutedEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "relaunch"; + type: "publicKey"; + index: false; + }, + { + name: "wsolSold"; + type: "u64"; + index: false; + }, + { + name: "usdcRecovered"; + type: "u64"; + index: false; + }, + ]; + }, + { + name: "RefundClaimedEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "relaunch"; + type: "publicKey"; + index: false; + }, + { + name: "depositor"; + type: "publicKey"; + index: false; + }, + { + name: "depositRecord"; + type: "publicKey"; + index: false; + }, + { + name: "amountRefunded"; + type: "u64"; + index: false; + }, + { + name: "depositRecordSeqNum"; + type: "u64"; + index: false; + }, + ]; + }, + { + name: "RelaunchCompletedEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "relaunch"; + type: "publicKey"; + index: false; + }, + { + name: "dao"; + type: "publicKey"; + index: false; + }, + { + name: "daoVault"; + type: "publicKey"; + index: false; + }, + { + name: "usdcRecovered"; + type: "u64"; + index: false; + }, + { + name: "twapInitialObservation"; + type: "u128"; + index: false; + }, + { + name: "usdcToLp"; + type: "u64"; + index: false; + }, + { + name: "usdcToTreasury"; + type: "u64"; + index: false; + }, + ]; + }, + { + name: "TokensClaimedEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "relaunch"; + type: "publicKey"; + index: false; + }, + { + name: "depositor"; + type: "publicKey"; + index: false; + }, + { + name: "depositRecord"; + type: "publicKey"; + index: false; + }, + { + name: "amountClaimed"; + type: "u64"; + index: false; + }, + { + name: "depositRecordSeqNum"; + type: "u64"; + index: false; + }, + ]; + }, + { + name: "TokensDepositedViaBuyEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "relaunch"; + type: "publicKey"; + index: false; + }, + { + name: "depositor"; + type: "publicKey"; + index: false; + }, + { + name: "depositRecord"; + type: "publicKey"; + index: false; + }, + { + name: "amount"; + type: "u64"; + index: false; + }, + { + name: "quoteSpent"; + type: "u64"; + index: false; + }, + { + name: "totalDeposited"; + type: "u64"; + index: false; + }, + { + name: "totalDepositedByDepositor"; + type: "u64"; + index: false; + }, + { + name: "depositRecordSeqNum"; + type: "u64"; + index: false; + }, + ]; + }, + ]; + errors: [ + { + code: 6000; + name: "SupplyNonZero"; + msg: "New mint supply must be zero"; + }, + { + code: 6001; + name: "FreezeAuthoritySet"; + msg: "New mint must not have a freeze authority"; + }, + { + code: 6002; + name: "SourcePoolNotCanonical"; + msg: "Source pool is not the canonical PumpSwap pool for the old mint"; + }, + { + code: 6003; + name: "SourcePoolQuoteMintMismatch"; + msg: "Source quote mint does not match the source pool's quote mint"; + }, + { + code: 6004; + name: "InvalidQuoteMint"; + msg: "Source quote mint must be WSOL or USDC"; + }, + { + code: 6005; + name: "ForbiddenOldMintExtension"; + msg: "Old mint carries a Token-2022 extension outside the metadata allowlist"; + }, + { + code: 6006; + name: "InvalidThresholdBps"; + msg: "Threshold must be between 1 and 10000 bps"; + }, + { + code: 6007; + name: "InvalidSecondsForDeposits"; + msg: "Deposit period must be at most 1 year"; + }, + { + code: 6008; + name: "InvalidMonthlySpendingLimit"; + msg: "Monthly spending limit amount and members must both be set or both be empty"; + }, + { + code: 6009; + name: "InvalidMonthlySpendingLimitMembers"; + msg: "There can be at most 10 monthly spending limit members, without duplicates"; + }, + { + code: 6010; + name: "RelaunchNotInitialized"; + msg: "Relaunch must be in the Initialized state"; + }, + { + code: 6011; + name: "RelaunchNotLive"; + msg: "Relaunch must be in the Live state"; + }, + { + code: 6012; + name: "DepositWindowClosed"; + msg: "Deposit window has closed"; + }, + { + code: 6013; + name: "InvalidAmount"; + msg: "Amount must be greater than zero"; + }, + { + code: 6014; + name: "InsufficientFunds"; + msg: "Insufficient balance"; + }, + { + code: 6015; + name: "DepositWindowStillOpen"; + msg: "Deposit window is still open"; + }, + { + code: 6016; + name: "RelaunchNotSellPending"; + msg: "Relaunch must be in the SellPending state"; + }, + { + code: 6017; + name: "GracePeriodStillActive"; + msg: "Grace period has not elapsed"; + }, + { + code: 6018; + name: "RelaunchNotFailed"; + msg: "Relaunch must be in the Failed state"; + }, + { + code: 6019; + name: "AlreadyClaimed"; + msg: "Deposit record has already been claimed"; + }, + { + code: 6020; + name: "GracePeriodElapsed"; + msg: "Grace period has elapsed"; + }, + { + code: 6021; + name: "RelaunchNotSold"; + msg: "Relaunch must be in the Sold state"; + }, + { + code: 6022; + name: "SlippageExceeded"; + msg: "Swap output is below the minimum output amount"; + }, + { + code: 6023; + name: "RelaunchNotSwapped"; + msg: "Relaunch must be in the Swapped state"; + }, + { + code: 6024; + name: "RelaunchNotComplete"; + msg: "Relaunch must be in the Complete state"; + }, + { + code: 6025; + name: "CastingOverflow"; + msg: "Casting overflow. If you're seeing this, please report this"; + }, + { + code: 6026; + name: "SourcePoolLpMintMismatch"; + msg: "Source pool LP mint must be supplied for Raydium sources only and match the pool's stored LP mint"; + }, + { + code: 6027; + name: "SourcePoolLpNotBurned"; + msg: "Source pool's burned LP is below the required floor"; + }, + { + code: 6028; + name: "SourcePoolSwapsDisabled"; + msg: "Source pool's status does not permit swaps"; + }, + { + code: 6029; + name: "SourcePoolWrongEra"; + msg: "Source pool was not created in the orderbook era"; + }, + { + code: 6030; + name: "WrongSourceVenue"; + msg: "Instruction does not match the relaunch's source venue"; + }, + ]; +}; + +export const IDL: Relaunch = { + version: "0.1.0", + name: "relaunch", + instructions: [ + { + name: "initializeRelaunch", + accounts: [ + { + name: "relaunch", + isMut: true, + isSigner: false, + }, + { + name: "newMint", + isMut: true, + isSigner: false, + }, + { + name: "mintAuthority", + isMut: false, + isSigner: true, + docs: [ + "Proof that the initializer controls the new mint: must sign, and the", + "handler CPIs `set_authority` to hand minting to `relaunch_signer`.", + ], + }, + { + name: "relaunchSigner", + isMut: false, + isSigner: false, + }, + { + name: "oldMint", + isMut: false, + isSigner: false, + }, + { + name: "sourcePool", + isMut: false, + isSigner: false, + }, + { + name: "sourceQuoteMint", + isMut: false, + isSigner: false, + }, + { + name: "sourcePoolLpMint", + isMut: false, + isSigner: false, + isOptional: true, + docs: ["The source pool's LP mint: required for Raydium sources"], + }, + { + name: "usdcMint", + isMut: false, + isSigner: false, + }, + { + name: "oldTokenVault", + isMut: true, + isSigner: false, + }, + { + name: "newTokenVault", + isMut: true, + isSigner: false, + }, + { + name: "sourceQuoteVault", + isMut: true, + isSigner: false, + }, + { + name: "usdcVault", + isMut: true, + isSigner: false, + docs: [ + "The same account as `source_quote_vault` for USDC-quoted sources, in", + "which case the `init_if_needed` is a no-op revalidation.", + ], + }, + { + name: "tokenMetadata", + isMut: true, + isSigner: false, + }, + { + name: "admin", + isMut: false, + isSigner: false, + docs: [ + "period. Not required to sign, mirroring launchpad's launch_authority.", + ], + }, + { + name: "payer", + isMut: true, + isSigner: true, + }, + { + name: "rent", + isMut: false, + isSigner: false, + }, + { + name: "oldTokenProgram", + 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: "InitializeRelaunchArgs", + }, + }, + ], + }, + { + name: "startDeposits", + accounts: [ + { + name: "relaunch", + isMut: true, + isSigner: false, + }, + { + name: "admin", + isMut: false, + isSigner: true, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, + { + name: "deposit", + accounts: [ + { + name: "relaunch", + isMut: true, + isSigner: false, + }, + { + name: "depositRecord", + isMut: true, + isSigner: false, + }, + { + name: "oldMint", + isMut: false, + isSigner: false, + }, + { + name: "oldTokenVault", + isMut: true, + isSigner: false, + }, + { + name: "depositor", + isMut: false, + isSigner: true, + }, + { + name: "depositorTokenAccount", + isMut: true, + isSigner: false, + }, + { + name: "payer", + isMut: true, + isSigner: true, + }, + { + name: "oldTokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: "args", + type: { + defined: "DepositArgs", + }, + }, + ], + }, + { + name: "depositViaBuy", + accounts: [ + { + name: "relaunch", + isMut: true, + isSigner: false, + }, + { + name: "depositRecord", + isMut: true, + isSigner: false, + }, + { + name: "depositor", + isMut: false, + isSigner: true, + }, + { + name: "payer", + isMut: true, + isSigner: true, + }, + { + name: "relaunchSigner", + isMut: true, + isSigner: false, + docs: ["user account writable."], + }, + { + name: "oldMint", + isMut: false, + isSigner: false, + }, + { + name: "sourceQuoteMint", + isMut: false, + isSigner: false, + }, + { + name: "oldTokenVault", + isMut: true, + isSigner: false, + }, + { + name: "sourceQuoteVault", + isMut: true, + isSigner: false, + }, + { + name: "depositorQuoteAccount", + isMut: true, + isSigner: false, + }, + { + name: "sourcePool", + isMut: true, + isSigner: false, + }, + { + name: "pumpGlobalConfig", + isMut: false, + isSigner: false, + }, + { + name: "protocolFeeRecipient", + isMut: false, + isSigner: false, + }, + { + name: "protocolFeeRecipientTokenAccount", + isMut: true, + isSigner: false, + }, + { + name: "poolBaseTokenAccount", + isMut: true, + isSigner: false, + }, + { + name: "poolQuoteTokenAccount", + isMut: true, + isSigner: false, + }, + { + name: "coinCreatorVaultAta", + isMut: true, + isSigner: false, + }, + { + name: "coinCreatorVaultAuthority", + isMut: false, + isSigner: false, + }, + { + name: "globalVolumeAccumulator", + isMut: false, + isSigner: false, + }, + { + name: "userVolumeAccumulator", + isMut: true, + isSigner: false, + docs: ["on the first buy"], + }, + { + name: "pumpFeeConfig", + isMut: false, + isSigner: false, + }, + { + name: "pumpFeeProgram", + isMut: false, + isSigner: false, + }, + { + name: "poolV2", + isMut: false, + isSigner: false, + }, + { + name: "buybackFeeRecipient", + isMut: false, + isSigner: false, + }, + { + name: "buybackFeeRecipientTokenAccount", + isMut: true, + isSigner: false, + }, + { + name: "pumpEventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "pumpAmmProgram", + isMut: false, + isSigner: false, + }, + { + name: "baseTokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "quoteTokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "associatedTokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: "args", + type: { + defined: "DepositViaBuyArgs", + }, + }, + ], + }, + { + name: "depositViaBuyRaydium", + accounts: [ + { + name: "relaunch", + isMut: true, + isSigner: false, + }, + { + name: "depositRecord", + isMut: true, + isSigner: false, + }, + { + name: "depositor", + isMut: false, + isSigner: true, + }, + { + name: "payer", + isMut: true, + isSigner: true, + }, + { + name: "relaunchSigner", + isMut: false, + isSigner: false, + }, + { + name: "sourceQuoteMint", + isMut: false, + isSigner: false, + }, + { + name: "oldTokenVault", + isMut: true, + isSigner: false, + }, + { + name: "sourceQuoteVault", + isMut: true, + isSigner: false, + }, + { + name: "depositorQuoteAccount", + isMut: true, + isSigner: false, + }, + { + name: "sourcePool", + isMut: true, + isSigner: false, + }, + { + name: "ammAuthority", + isMut: false, + isSigner: false, + }, + { + name: "ammCoinVault", + isMut: true, + isSigner: false, + }, + { + name: "ammPcVault", + isMut: true, + isSigner: false, + }, + { + name: "raydiumAmmProgram", + isMut: false, + 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: "args", + type: { + defined: "DepositViaBuyRaydiumArgs", + }, + }, + ], + }, + { + name: "closeDeposits", + accounts: [ + { + name: "relaunch", + isMut: true, + isSigner: false, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, + { + name: "executeSell", + accounts: [ + { + name: "relaunch", + isMut: true, + isSigner: false, + }, + { + name: "admin", + isMut: false, + isSigner: true, + }, + { + name: "relaunchSigner", + isMut: true, + isSigner: false, + docs: ["user account writable."], + }, + { + name: "oldMint", + isMut: false, + isSigner: false, + }, + { + name: "sourceQuoteMint", + isMut: false, + isSigner: false, + }, + { + name: "oldTokenVault", + isMut: true, + isSigner: false, + }, + { + name: "sourceQuoteVault", + isMut: true, + isSigner: false, + }, + { + name: "sourcePool", + isMut: true, + isSigner: false, + docs: ["rechecks its internal consistency."], + }, + { + name: "pumpGlobalConfig", + isMut: false, + isSigner: false, + }, + { + name: "protocolFeeRecipient", + isMut: false, + isSigner: false, + }, + { + name: "protocolFeeRecipientTokenAccount", + isMut: true, + isSigner: false, + }, + { + name: "poolBaseTokenAccount", + isMut: true, + isSigner: false, + }, + { + name: "poolQuoteTokenAccount", + isMut: true, + isSigner: false, + }, + { + name: "coinCreatorVaultAta", + isMut: true, + isSigner: false, + }, + { + name: "coinCreatorVaultAuthority", + isMut: false, + isSigner: false, + }, + { + name: "pumpFeeConfig", + isMut: false, + isSigner: false, + }, + { + name: "pumpFeeProgram", + isMut: false, + isSigner: false, + }, + { + name: "poolV2", + isMut: false, + isSigner: false, + }, + { + name: "buybackFeeRecipient", + isMut: false, + isSigner: false, + }, + { + name: "buybackFeeRecipientTokenAccount", + isMut: true, + isSigner: false, + }, + { + name: "pumpEventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "pumpAmmProgram", + isMut: false, + isSigner: false, + }, + { + name: "baseTokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "quoteTokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "associatedTokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: "args", + type: { + defined: "ExecuteSellArgs", + }, + }, + ], + }, + { + name: "executeSellRaydium", + accounts: [ + { + name: "relaunch", + isMut: true, + isSigner: false, + }, + { + name: "admin", + isMut: false, + isSigner: true, + }, + { + name: "relaunchSigner", + isMut: false, + isSigner: false, + }, + { + name: "oldTokenVault", + isMut: true, + isSigner: false, + }, + { + name: "sourceQuoteVault", + isMut: true, + isSigner: false, + }, + { + name: "sourcePool", + isMut: true, + isSigner: false, + docs: ["rechecks its internal consistency."], + }, + { + name: "ammAuthority", + isMut: false, + isSigner: false, + }, + { + name: "ammCoinVault", + isMut: true, + isSigner: false, + }, + { + name: "ammPcVault", + isMut: true, + isSigner: false, + }, + { + name: "raydiumAmmProgram", + isMut: false, + isSigner: false, + }, + { + name: "tokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: "args", + type: { + defined: "ExecuteSellRaydiumArgs", + }, + }, + ], + }, + { + name: "executeUsdcSwap", + accounts: [ + { + name: "relaunch", + isMut: true, + isSigner: false, + }, + { + name: "admin", + isMut: false, + isSigner: true, + }, + { + name: "relaunchSigner", + isMut: false, + isSigner: false, + }, + { + name: "sourceQuoteVault", + isMut: true, + isSigner: false, + docs: [ + "The WSOL vault holding the sell proceeds; `Sold` only occurs for", + "WSOL-quoted sources.", + ], + }, + { + name: "usdcVault", + isMut: true, + isSigner: false, + }, + { + name: "whirlpool", + isMut: true, + isSigner: false, + docs: ["its internal consistency."], + }, + { + name: "wsolMint", + isMut: false, + isSigner: false, + }, + { + name: "usdcMint", + isMut: false, + isSigner: false, + }, + { + name: "whirlpoolWsolVault", + isMut: true, + isSigner: false, + }, + { + name: "whirlpoolUsdcVault", + isMut: true, + isSigner: false, + }, + { + name: "tickArray0", + isMut: true, + isSigner: false, + }, + { + name: "tickArray1", + isMut: true, + isSigner: false, + }, + { + name: "tickArray2", + isMut: true, + isSigner: false, + }, + { + name: "oracle", + isMut: true, + isSigner: false, + }, + { + name: "memoProgram", + isMut: false, + isSigner: false, + }, + { + name: "whirlpoolProgram", + isMut: false, + isSigner: false, + }, + { + name: "tokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: "args", + type: { + defined: "ExecuteUsdcSwapArgs", + }, + }, + ], + }, + { + name: "completeRelaunch", + accounts: [ + { + name: "relaunch", + isMut: true, + isSigner: false, + }, + { + name: "payer", + isMut: true, + isSigner: true, + }, + { + name: "relaunchSigner", + isMut: false, + isSigner: false, + docs: ["handoffs."], + }, + { + name: "newMint", + isMut: true, + isSigner: false, + docs: [ + "The DAO's base mint; its mint authority moves to the Squads vault.", + ], + }, + { + name: "usdcMint", + isMut: false, + isSigner: false, + docs: ["The DAO's quote mint."], + }, + { + name: "newTokenVault", + isMut: true, + isSigner: false, + }, + { + name: "usdcVault", + isMut: true, + isSigner: false, + }, + { + name: "tokenMetadata", + isMut: true, + isSigner: false, + docs: ["Squads vault."], + }, + { + name: "dao", + isMut: true, + isSigner: false, + docs: ["hardcoded `nonce: 0` CPI param."], + }, + { + name: "futarchyAmmBaseVault", + isMut: true, + isSigner: false, + }, + { + name: "futarchyAmmQuoteVault", + isMut: true, + isSigner: false, + }, + { + name: "ammPosition", + isMut: true, + isSigner: false, + docs: ["position."], + }, + { + name: "squadsMultisig", + isMut: true, + isSigner: false, + }, + { + name: "squadsMultisigVault", + isMut: false, + isSigner: false, + docs: ["authorities."], + }, + { + name: "spendingLimit", + isMut: true, + isSigner: false, + }, + { + name: "squadsProgramConfig", + isMut: false, + isSigner: false, + }, + { + name: "squadsProgramConfigTreasury", + isMut: true, + isSigner: false, + }, + { + name: "futarchyProgram", + isMut: false, + isSigner: false, + }, + { + name: "futarchyEventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "squadsProgram", + isMut: false, + isSigner: false, + }, + { + name: "tokenMetadataProgram", + isMut: false, + isSigner: false, + }, + { + name: "tokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "associatedTokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, + { + name: "claim", + accounts: [ + { + name: "relaunch", + isMut: true, + isSigner: false, + }, + { + name: "depositRecord", + isMut: true, + isSigner: false, + }, + { + name: "newMint", + isMut: false, + isSigner: false, + }, + { + name: "newTokenVault", + isMut: true, + isSigner: false, + }, + { + name: "relaunchSigner", + isMut: false, + isSigner: false, + }, + { + name: "depositor", + isMut: false, + isSigner: false, + docs: ["claims for any depositor."], + }, + { + name: "depositorTokenAccount", + isMut: true, + isSigner: false, + }, + { + name: "tokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, + { + name: "markFailed", + accounts: [ + { + name: "relaunch", + isMut: true, + isSigner: false, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, + { + name: "claimRefund", + accounts: [ + { + name: "relaunch", + isMut: true, + isSigner: false, + }, + { + name: "depositRecord", + isMut: true, + isSigner: false, + }, + { + name: "oldMint", + isMut: false, + isSigner: false, + }, + { + name: "oldTokenVault", + isMut: true, + isSigner: false, + }, + { + name: "relaunchSigner", + isMut: false, + isSigner: false, + }, + { + name: "depositor", + isMut: false, + isSigner: false, + docs: ["refunds for any depositor."], + }, + { + name: "depositorTokenAccount", + isMut: true, + isSigner: false, + }, + { + name: "oldTokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, + ], + accounts: [ + { + name: "depositRecord", + type: { + kind: "struct", + fields: [ + { + name: "relaunch", + docs: ["The relaunch this record belongs to."], + type: "publicKey", + }, + { + name: "depositor", + docs: ["The depositor."], + type: "publicKey", + }, + { + name: "amountDeposited", + docs: [ + "The amount of old tokens deposited, including tokens bought via", + "`deposit_via_buy`.", + ], + type: "u64", + }, + { + name: "claimed", + docs: [ + "Whether the record has been settled by `claim` / `claim_refund`.", + ], + type: "bool", + }, + { + name: "seqNum", + docs: [ + "The sequence number of this record. Useful for sorting events.", + ], + type: "u64", + }, + { + name: "pdaBump", + docs: ["The PDA bump."], + type: "u8", + }, + ], + }, + }, + { + name: "relaunch", + type: { + kind: "struct", + fields: [ + { + name: "admin", + docs: ["The initializer; executes the sell + swap legs."], + type: "publicKey", + }, + { + name: "newMint", + docs: [ + "The token that will be distributed to depositors and that will control the DAO.", + ], + type: "publicKey", + }, + { + name: "oldMint", + docs: ["The token being relaunched."], + type: "publicKey", + }, + { + name: "sourcePool", + docs: [ + "The canonical PumpSwap pool for the old mint, validated at init.", + ], + type: "publicKey", + }, + { + name: "sourceQuoteMint", + docs: [ + "The source pool's quote mint — WSOL or USDC. WSOL sources swap through", + "the `usdc_swap_pool` constant.", + ], + type: "publicKey", + }, + { + name: "relaunchSigner", + docs: [ + 'The PDA that signs all CPIs and owns the vaults: `["relaunch_signer", relaunch]`.', + ], + type: "publicKey", + }, + { + name: "relaunchSignerBump", + docs: ["The PDA bump for the relaunch signer."], + type: "u8", + }, + { + name: "oldTokenVault", + docs: ["The vault that escrows deposited old tokens."], + type: "publicKey", + }, + { + name: "newTokenVault", + docs: [ + "The vault that holds the minted new tokens until claim / liquidity provision.", + ], + type: "publicKey", + }, + { + name: "sourceQuoteVault", + docs: ["The vault that receives raw sell proceeds (WSOL or USDC)."], + type: "publicKey", + }, + { + name: "usdcVault", + docs: [ + "The vault that receives the swap-leg output; == `source_quote_vault`", + "for USDC sources.", + ], + type: "publicKey", + }, + { + name: "thresholdBps", + docs: [ + "The minimum participation, denominated in bps of old-token total supply.", + ], + type: "u16", + }, + { + name: "oldSupplySnapshot", + docs: [ + "The old mint supply captured at init (threshold denominator).", + ], + type: "u64", + }, + { + name: "secondsForDeposits", + docs: ["The number of seconds that deposits will be open for."], + type: "u32", + }, + { + name: "gracePeriodSeconds", + docs: ["The admin's window to sell after deposits close."], + type: "u32", + }, + { + name: "monthlySpendingLimitAmount", + docs: [ + "The monthly spending limit the DAO allocates to the team. Zero, with", + "no members, means the DAO launches without a spending limit.", + ], + type: "u64", + }, + { + name: "monthlySpendingLimitMembers", + docs: [ + "The wallets that have access to the monthly spending limit.", + ], + type: { + vec: "publicKey", + }, + }, + { + name: "teamAddress", + docs: ["The initial address used to sponsor team proposals."], + type: "publicKey", + }, + { + name: "state", + docs: ["The state of the relaunch."], + type: { + defined: "RelaunchState", + }, + }, + { + name: "totalDeposited", + docs: ["The amount of old tokens deposited across all depositors."], + type: "u64", + }, + { + name: "quoteRecovered", + docs: ["The raw sell proceeds, in the source quote asset."], + type: "u64", + }, + { + name: "usdcRecovered", + docs: [ + "The post-swap USDC (== `quote_recovered` for USDC sources).", + ], + type: "u64", + }, + { + name: "unixTimestampStarted", + docs: ["The unix timestamp when deposits were opened."], + type: { + option: "i64", + }, + }, + { + name: "unixTimestampClosed", + docs: ["The unix timestamp when deposits were closed."], + type: { + option: "i64", + }, + }, + { + name: "unixTimestampCompleted", + docs: ["The unix timestamp when the relaunch was completed."], + type: { + option: "i64", + }, + }, + { + name: "dao", + docs: ["The DAO, if the relaunch is complete."], + type: { + option: "publicKey", + }, + }, + { + name: "daoVault", + docs: [ + "The DAO's Squads multisig vault, if the relaunch is complete.", + ], + type: { + option: "publicKey", + }, + }, + { + name: "seqNum", + docs: [ + "The sequence number of this relaunch used for sorting events.", + ], + type: "u64", + }, + { + name: "pdaBump", + docs: ["The PDA bump."], + type: "u8", + }, + { + name: "sourceVenue", + docs: [ + "The venue of the source pool, set from the pool's owner at init.", + ], + type: { + defined: "SourceVenue", + }, + }, + ], + }, + }, + ], + types: [ + { + name: "CommonFields", + type: { + kind: "struct", + fields: [ + { + name: "slot", + type: "u64", + }, + { + name: "unixTimestamp", + type: "i64", + }, + { + name: "relaunchSeqNum", + type: "u64", + }, + ], + }, + }, + { + name: "DepositViaBuyRaydiumArgs", + type: { + kind: "struct", + fields: [ + { + name: "baseOut", + docs: [ + "The exact amount of old tokens to buy off the source pool", + "(swap_base_out_v2 is exact-output).", + ], + type: "u64", + }, + { + name: "maxQuoteIn", + docs: [ + "The depositor's live slippage cap on the quote spent, inclusive of", + "the AMM's 25 bps fee.", + ], + type: "u64", + }, + ], + }, + }, + { + name: "DepositViaBuyArgs", + type: { + kind: "struct", + fields: [ + { + name: "baseOut", + docs: [ + "The exact amount of old tokens to buy off the source pool (pump's buy", + "is exact-output).", + ], + type: "u64", + }, + { + name: "maxQuoteIn", + docs: [ + "The depositor's live slippage cap on the quote spent, inclusive of", + "pump's fees.", + ], + type: "u64", + }, + ], + }, + }, + { + name: "DepositArgs", + type: { + kind: "struct", + fields: [ + { + name: "amount", + type: "u64", + }, + ], + }, + }, + { + name: "ExecuteSellRaydiumArgs", + type: { + kind: "struct", + fields: [ + { + name: "minQuoteOut", + docs: [ + "The admin's live, client-computed slippage floor on the sell proceeds.", + ], + type: "u64", + }, + ], + }, + }, + { + name: "ExecuteSellArgs", + type: { + kind: "struct", + fields: [ + { + name: "minQuoteOut", + docs: [ + "The admin's live, client-computed slippage floor on the sell proceeds.", + ], + type: "u64", + }, + ], + }, + }, + { + name: "ExecuteUsdcSwapArgs", + type: { + kind: "struct", + fields: [ + { + name: "minUsdcOut", + docs: [ + "The admin's live, client-computed slippage floor on the swap output.", + ], + type: "u64", + }, + ], + }, + }, + { + name: "InitializeRelaunchArgs", + type: { + kind: "struct", + fields: [ + { + name: "tokenName", + type: "string", + }, + { + name: "tokenSymbol", + type: "string", + }, + { + name: "tokenUri", + type: "string", + }, + { + name: "secondsForDeposits", + type: "u32", + }, + { + name: "gracePeriodSeconds", + type: "u32", + }, + { + name: "thresholdBps", + type: "u16", + }, + { + name: "monthlySpendingLimitAmount", + type: "u64", + }, + { + name: "monthlySpendingLimitMembers", + type: { + vec: "publicKey", + }, + }, + { + name: "teamAddress", + type: "publicKey", + }, + ], + }, + }, + { + name: "RelaunchState", + type: { + kind: "enum", + variants: [ + { + name: "Initialized", + }, + { + name: "Live", + }, + { + name: "SellPending", + }, + { + name: "Sold", + }, + { + name: "Swapped", + }, + { + name: "Complete", + }, + { + name: "Failed", + }, + ], + }, + }, + { + name: "SourceVenue", + docs: [ + "The venue the source pool lives on, deciding how the pool was validated", + "at init and which sell/buy instructions apply.", + ], + type: { + kind: "enum", + variants: [ + { + name: "PumpSwap", + }, + { + name: "RaydiumAmmV4", + }, + ], + }, + }, + ], + events: [ + { + name: "RelaunchInitializedEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "relaunch", + type: "publicKey", + index: false, + }, + { + name: "admin", + type: "publicKey", + index: false, + }, + { + name: "newMint", + type: "publicKey", + index: false, + }, + { + name: "oldMint", + type: "publicKey", + index: false, + }, + { + name: "sourcePool", + type: "publicKey", + index: false, + }, + { + name: "sourceQuoteMint", + type: "publicKey", + index: false, + }, + { + name: "relaunchSigner", + type: "publicKey", + index: false, + }, + { + name: "relaunchSignerBump", + type: "u8", + index: false, + }, + { + name: "oldTokenVault", + type: "publicKey", + index: false, + }, + { + name: "newTokenVault", + type: "publicKey", + index: false, + }, + { + name: "sourceQuoteVault", + type: "publicKey", + index: false, + }, + { + name: "usdcVault", + type: "publicKey", + index: false, + }, + { + name: "thresholdBps", + type: "u16", + index: false, + }, + { + name: "oldSupplySnapshot", + type: "u64", + index: false, + }, + { + name: "secondsForDeposits", + type: "u32", + index: false, + }, + { + name: "gracePeriodSeconds", + type: "u32", + index: false, + }, + { + name: "monthlySpendingLimitAmount", + type: "u64", + index: false, + }, + { + name: "monthlySpendingLimitMembers", + type: { + vec: "publicKey", + }, + index: false, + }, + { + name: "teamAddress", + type: "publicKey", + index: false, + }, + { + name: "pdaBump", + type: "u8", + index: false, + }, + { + name: "sourceVenue", + type: { + defined: "SourceVenue", + }, + index: false, + }, + ], + }, + { + name: "DepositsStartedEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "relaunch", + type: "publicKey", + index: false, + }, + { + name: "admin", + type: "publicKey", + index: false, + }, + ], + }, + { + name: "TokensDepositedEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "relaunch", + type: "publicKey", + index: false, + }, + { + name: "depositor", + type: "publicKey", + index: false, + }, + { + name: "depositRecord", + type: "publicKey", + index: false, + }, + { + name: "amount", + type: "u64", + index: false, + }, + { + name: "totalDeposited", + type: "u64", + index: false, + }, + { + name: "totalDepositedByDepositor", + type: "u64", + index: false, + }, + { + name: "depositRecordSeqNum", + type: "u64", + index: false, + }, + ], + }, + { + name: "DepositsClosedEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "relaunch", + type: "publicKey", + index: false, + }, + { + name: "newState", + type: { + defined: "RelaunchState", + }, + index: false, + }, + ], + }, + { + name: "RelaunchMarkedFailedEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "relaunch", + type: "publicKey", + index: false, + }, + ], + }, + { + name: "SellExecutedEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "relaunch", + type: "publicKey", + index: false, + }, + { + name: "baseSold", + type: "u64", + index: false, + }, + { + name: "quoteRecovered", + type: "u64", + index: false, + }, + { + name: "newState", + type: { + defined: "RelaunchState", + }, + index: false, + }, + ], + }, + { + name: "UsdcSwapExecutedEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "relaunch", + type: "publicKey", + index: false, + }, + { + name: "wsolSold", + type: "u64", + index: false, + }, + { + name: "usdcRecovered", + type: "u64", + index: false, + }, + ], + }, + { + name: "RefundClaimedEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "relaunch", + type: "publicKey", + index: false, + }, + { + name: "depositor", + type: "publicKey", + index: false, + }, + { + name: "depositRecord", + type: "publicKey", + index: false, + }, + { + name: "amountRefunded", + type: "u64", + index: false, + }, + { + name: "depositRecordSeqNum", + type: "u64", + index: false, + }, + ], + }, + { + name: "RelaunchCompletedEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "relaunch", + type: "publicKey", + index: false, + }, + { + name: "dao", + type: "publicKey", + index: false, + }, + { + name: "daoVault", + type: "publicKey", + index: false, + }, + { + name: "usdcRecovered", + type: "u64", + index: false, + }, + { + name: "twapInitialObservation", + type: "u128", + index: false, + }, + { + name: "usdcToLp", + type: "u64", + index: false, + }, + { + name: "usdcToTreasury", + type: "u64", + index: false, + }, + ], + }, + { + name: "TokensClaimedEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "relaunch", + type: "publicKey", + index: false, + }, + { + name: "depositor", + type: "publicKey", + index: false, + }, + { + name: "depositRecord", + type: "publicKey", + index: false, + }, + { + name: "amountClaimed", + type: "u64", + index: false, + }, + { + name: "depositRecordSeqNum", + type: "u64", + index: false, + }, + ], + }, + { + name: "TokensDepositedViaBuyEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "relaunch", + type: "publicKey", + index: false, + }, + { + name: "depositor", + type: "publicKey", + index: false, + }, + { + name: "depositRecord", + type: "publicKey", + index: false, + }, + { + name: "amount", + type: "u64", + index: false, + }, + { + name: "quoteSpent", + type: "u64", + index: false, + }, + { + name: "totalDeposited", + type: "u64", + index: false, + }, + { + name: "totalDepositedByDepositor", + type: "u64", + index: false, + }, + { + name: "depositRecordSeqNum", + type: "u64", + index: false, + }, + ], + }, + ], + errors: [ + { + code: 6000, + name: "SupplyNonZero", + msg: "New mint supply must be zero", + }, + { + code: 6001, + name: "FreezeAuthoritySet", + msg: "New mint must not have a freeze authority", + }, + { + code: 6002, + name: "SourcePoolNotCanonical", + msg: "Source pool is not the canonical PumpSwap pool for the old mint", + }, + { + code: 6003, + name: "SourcePoolQuoteMintMismatch", + msg: "Source quote mint does not match the source pool's quote mint", + }, + { + code: 6004, + name: "InvalidQuoteMint", + msg: "Source quote mint must be WSOL or USDC", + }, + { + code: 6005, + name: "ForbiddenOldMintExtension", + msg: "Old mint carries a Token-2022 extension outside the metadata allowlist", + }, + { + code: 6006, + name: "InvalidThresholdBps", + msg: "Threshold must be between 1 and 10000 bps", + }, + { + code: 6007, + name: "InvalidSecondsForDeposits", + msg: "Deposit period must be at most 1 year", + }, + { + code: 6008, + name: "InvalidMonthlySpendingLimit", + msg: "Monthly spending limit amount and members must both be set or both be empty", + }, + { + code: 6009, + name: "InvalidMonthlySpendingLimitMembers", + msg: "There can be at most 10 monthly spending limit members, without duplicates", + }, + { + code: 6010, + name: "RelaunchNotInitialized", + msg: "Relaunch must be in the Initialized state", + }, + { + code: 6011, + name: "RelaunchNotLive", + msg: "Relaunch must be in the Live state", + }, + { + code: 6012, + name: "DepositWindowClosed", + msg: "Deposit window has closed", + }, + { + code: 6013, + name: "InvalidAmount", + msg: "Amount must be greater than zero", + }, + { + code: 6014, + name: "InsufficientFunds", + msg: "Insufficient balance", + }, + { + code: 6015, + name: "DepositWindowStillOpen", + msg: "Deposit window is still open", + }, + { + code: 6016, + name: "RelaunchNotSellPending", + msg: "Relaunch must be in the SellPending state", + }, + { + code: 6017, + name: "GracePeriodStillActive", + msg: "Grace period has not elapsed", + }, + { + code: 6018, + name: "RelaunchNotFailed", + msg: "Relaunch must be in the Failed state", + }, + { + code: 6019, + name: "AlreadyClaimed", + msg: "Deposit record has already been claimed", + }, + { + code: 6020, + name: "GracePeriodElapsed", + msg: "Grace period has elapsed", + }, + { + code: 6021, + name: "RelaunchNotSold", + msg: "Relaunch must be in the Sold state", + }, + { + code: 6022, + name: "SlippageExceeded", + msg: "Swap output is below the minimum output amount", + }, + { + code: 6023, + name: "RelaunchNotSwapped", + msg: "Relaunch must be in the Swapped state", + }, + { + code: 6024, + name: "RelaunchNotComplete", + msg: "Relaunch must be in the Complete state", + }, + { + code: 6025, + name: "CastingOverflow", + msg: "Casting overflow. If you're seeing this, please report this", + }, + { + code: 6026, + name: "SourcePoolLpMintMismatch", + msg: "Source pool LP mint must be supplied for Raydium sources only and match the pool's stored LP mint", + }, + { + code: 6027, + name: "SourcePoolLpNotBurned", + msg: "Source pool's burned LP is below the required floor", + }, + { + code: 6028, + name: "SourcePoolSwapsDisabled", + msg: "Source pool's status does not permit swaps", + }, + { + code: 6029, + name: "SourcePoolWrongEra", + msg: "Source pool was not created in the orderbook era", + }, + { + code: 6030, + name: "WrongSourceVenue", + msg: "Instruction does not match the relaunch's source venue", + }, + ], +}; diff --git a/sdk/src/relaunch/v0.1/whirlpool.ts b/sdk/src/relaunch/v0.1/whirlpool.ts new file mode 100644 index 00000000..5cd6dfe7 --- /dev/null +++ b/sdk/src/relaunch/v0.1/whirlpool.ts @@ -0,0 +1,106 @@ +import { Connection, PublicKey } from "@solana/web3.js"; +import { WHIRLPOOL_PROGRAM_ID } from "../../constants.js"; + +// Orca Whirlpool SOL/USDC 0.04% — the swap venue pinned by the relaunch +// program's `usdc_swap_pool` constant. +export const USDC_SWAP_POOL = new PublicKey( + "Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE", +); + +// SPL Memo, required by whirlpool's v2 instructions. +export const MEMO_PROGRAM_ID = new PublicKey( + "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr", +); + +const TICKS_PER_ARRAY = 88; + +export function getWhirlpoolOracleAddr(whirlpool: PublicKey): PublicKey { + return PublicKey.findProgramAddressSync( + [Buffer.from("oracle"), whirlpool.toBuffer()], + WHIRLPOOL_PROGRAM_ID, + )[0]; +} + +export function getWhirlpoolTickArrayAddr( + whirlpool: PublicKey, + startTickIndex: number, +): PublicKey { + return PublicKey.findProgramAddressSync( + [ + Buffer.from("tick_array"), + whirlpool.toBuffer(), + Buffer.from(startTickIndex.toString()), + ], + WHIRLPOOL_PROGRAM_ID, + )[0]; +} + +// The three tick arrays a swap walks, starting from the array holding the +// current tick and continuing in the swap's direction. +export function getWhirlpoolSwapTickArrayAddrs( + whirlpool: PublicKey, + tickCurrentIndex: number, + tickSpacing: number, + aToB: boolean, +): [PublicKey, PublicKey, PublicKey] { + const span = tickSpacing * TICKS_PER_ARRAY; + const currentStart = Math.floor(tickCurrentIndex / span) * span; + const direction = aToB ? -1 : 1; + return [0, 1, 2].map((k) => + getWhirlpoolTickArrayAddr(whirlpool, currentStart + k * direction * span), + ) as [PublicKey, PublicKey, PublicKey]; +} + +export type WhirlpoolAccount = { + tickSpacing: number; + sqrtPrice: bigint; + tickCurrentIndex: number; + tokenMintA: PublicKey; + tokenVaultA: PublicKey; + tokenMintB: PublicKey; + tokenVaultB: PublicKey; +}; + +// The prefix of whirlpool's `Whirlpool` account that the swap helpers read: +// +// offset size field +// 0 8 discriminator +// 8 32 whirlpools_config +// 40 1 whirlpool_bump +// 41 2 tick_spacing +// 43 2 fee_tier_index_seed +// 45 2 fee_rate +// 47 2 protocol_fee_rate +// 49 16 liquidity +// 65 16 sqrt_price +// 81 4 tick_current_index (i32) +// 85 8 protocol_fee_owed_a +// 93 8 protocol_fee_owed_b +// 101 32 token_mint_a +// 133 32 token_vault_a +// 165 16 fee_growth_global_a +// 181 32 token_mint_b +// 213 32 token_vault_b +export function parseWhirlpool(data: Buffer): WhirlpoolAccount { + return { + tickSpacing: data.readUInt16LE(41), + sqrtPrice: data.readBigUInt64LE(65) + (data.readBigUInt64LE(73) << 64n), + tickCurrentIndex: data.readInt32LE(81), + tokenMintA: new PublicKey(data.subarray(101, 133)), + tokenVaultA: new PublicKey(data.subarray(133, 165)), + tokenMintB: new PublicKey(data.subarray(181, 213)), + tokenVaultB: new PublicKey(data.subarray(213, 245)), + }; +} + +/** Fetches and parses a whirlpool (the pinned USDC swap pool by default). */ +export async function fetchWhirlpool( + connection: Connection, + whirlpool: PublicKey = USDC_SWAP_POOL, +): Promise { + const info = await connection.getAccountInfo(whirlpool); + if (info === null) { + throw new Error(`whirlpool ${whirlpool.toBase58()} does not exist`); + } + return parseWhirlpool(info.data); +} diff --git a/sdk/sync-types.sh b/sdk/sync-types.sh index d8eb5a75..60f2e40f 100755 --- a/sdk/sync-types.sh +++ b/sdk/sync-types.sh @@ -14,3 +14,4 @@ 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/ cp "$TYPES_DIR/price_based_performance_package.ts" ./src/price_based_performance_package/v0.6/types/ +cp "$TYPES_DIR/relaunch.ts" ./src/relaunch/v0.1/types/ diff --git a/tests/fixtures/pump-fee-config b/tests/fixtures/pump-fee-config new file mode 100644 index 00000000..4f3bed9d Binary files /dev/null and b/tests/fixtures/pump-fee-config differ diff --git a/tests/fixtures/pump-global-config b/tests/fixtures/pump-global-config new file mode 100644 index 00000000..05b6fade Binary files /dev/null and b/tests/fixtures/pump-global-config differ diff --git a/tests/fixtures/pump-global-volume-accumulator b/tests/fixtures/pump-global-volume-accumulator new file mode 100644 index 00000000..69f29718 Binary files /dev/null and b/tests/fixtures/pump-global-volume-accumulator differ diff --git a/tests/fixtures/pump_amm.so b/tests/fixtures/pump_amm.so new file mode 100644 index 00000000..f87cb45a Binary files /dev/null and b/tests/fixtures/pump_amm.so differ diff --git a/tests/fixtures/pump_fees.so b/tests/fixtures/pump_fees.so new file mode 100644 index 00000000..aa4ea3b8 Binary files /dev/null and b/tests/fixtures/pump_fees.so differ diff --git a/tests/fixtures/raydium_amm.so b/tests/fixtures/raydium_amm.so new file mode 100644 index 00000000..76c0bac8 Binary files /dev/null and b/tests/fixtures/raydium_amm.so differ diff --git a/tests/fixtures/relaunch-global-alt b/tests/fixtures/relaunch-global-alt new file mode 100644 index 00000000..cb90e638 Binary files /dev/null and b/tests/fixtures/relaunch-global-alt differ diff --git a/tests/fixtures/whirlpool-config b/tests/fixtures/whirlpool-config new file mode 100644 index 00000000..97f4f21a Binary files /dev/null and b/tests/fixtures/whirlpool-config differ diff --git a/tests/fixtures/whirlpool-fee-tier b/tests/fixtures/whirlpool-fee-tier new file mode 100644 index 00000000..7a48bc28 Binary files /dev/null and b/tests/fixtures/whirlpool-fee-tier differ diff --git a/tests/fixtures/whirlpool.json b/tests/fixtures/whirlpool.json new file mode 100644 index 00000000..42f2c10b --- /dev/null +++ b/tests/fixtures/whirlpool.json @@ -0,0 +1,5036 @@ +{ + "version": "0.4.0", + "name": "whirlpool", + "instructions": [ + { + "name": "initializeConfig", + "docs": [ + "Initializes a WhirlpoolsConfig account that hosts info & authorities", + "required to govern a set of Whirlpools.", + "", + "### Parameters", + "- `fee_authority` - Authority authorized to initialize fee-tiers and set customs fees.", + "- `collect_protocol_fees_authority` - Authority authorized to collect protocol fees.", + "- `reward_emissions_super_authority` - Authority authorized to set reward authorities in pools." + ], + "accounts": [ + { + "name": "config", + "isMut": true, + "isSigner": true + }, + { + "name": "funder", + "isMut": true, + "isSigner": true + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "feeAuthority", + "type": "publicKey" + }, + { + "name": "collectProtocolFeesAuthority", + "type": "publicKey" + }, + { + "name": "rewardEmissionsSuperAuthority", + "type": "publicKey" + }, + { + "name": "defaultProtocolFeeRate", + "type": "u16" + } + ] + }, + { + "name": "initializePool", + "docs": [ + "Initializes a Whirlpool account.", + "Fee rate is set to the default values on the config and supplied fee_tier.", + "", + "### Parameters", + "- `bumps` - The bump value when deriving the PDA of the Whirlpool address.", + "- `tick_spacing` - The desired tick spacing for this pool.", + "- `initial_sqrt_price` - The desired initial sqrt-price for this pool", + "", + "#### Special Errors", + "`InvalidTokenMintOrder` - The order of mints have to be ordered by", + "`SqrtPriceOutOfBounds` - provided initial_sqrt_price is not between 2^-64 to 2^64", + "" + ], + "accounts": [ + { + "name": "whirlpoolsConfig", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenMintA", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenMintB", + "isMut": false, + "isSigner": false + }, + { + "name": "funder", + "isMut": true, + "isSigner": true + }, + { + "name": "whirlpool", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenVaultA", + "isMut": true, + "isSigner": true + }, + { + "name": "tokenVaultB", + "isMut": true, + "isSigner": true + }, + { + "name": "feeTier", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "bumps", + "type": { + "defined": "WhirlpoolBumps" + } + }, + { + "name": "tickSpacing", + "type": "u16" + }, + { + "name": "initialSqrtPrice", + "type": "u128" + } + ] + }, + { + "name": "initializeTickArray", + "docs": [ + "Initializes a tick_array account to represent a tick-range in a Whirlpool.", + "", + "### Parameters", + "- `start_tick_index` - The starting tick index for this tick-array.", + "Has to be a multiple of TickArray size & the tick spacing of this pool.", + "", + "#### Special Errors", + "- `InvalidStartTick` - if the provided start tick is out of bounds or is not a multiple of", + "TICK_ARRAY_SIZE * tick spacing." + ], + "accounts": [ + { + "name": "whirlpool", + "isMut": false, + "isSigner": false + }, + { + "name": "funder", + "isMut": true, + "isSigner": true + }, + { + "name": "tickArray", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "startTickIndex", + "type": "i32" + } + ] + }, + { + "name": "initializeFeeTier", + "docs": [ + "Initializes a fee_tier account usable by Whirlpools in a WhirlpoolConfig space.", + "", + "### Authority", + "- \"fee_authority\" - Set authority in the WhirlpoolConfig", + "", + "### Parameters", + "- `tick_spacing` - The tick-spacing that this fee-tier suggests the default_fee_rate for.", + "- `default_fee_rate` - The default fee rate that a pool will use if the pool uses this", + "fee tier during initialization.", + "", + "#### Special Errors", + "- `InvalidTickSpacing` - If the provided tick_spacing is 0.", + "- `FeeRateMaxExceeded` - If the provided default_fee_rate exceeds MAX_FEE_RATE." + ], + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "feeTier", + "isMut": true, + "isSigner": false + }, + { + "name": "funder", + "isMut": true, + "isSigner": true + }, + { + "name": "feeAuthority", + "isMut": false, + "isSigner": true + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "tickSpacing", + "type": "u16" + }, + { + "name": "defaultFeeRate", + "type": "u16" + } + ] + }, + { + "name": "initializeReward", + "docs": [ + "Initialize reward for a Whirlpool. A pool can only support up to a set number of rewards.", + "", + "### Authority", + "- \"reward_authority\" - assigned authority by the reward_super_authority for the specified", + "reward-index in this Whirlpool", + "", + "### Parameters", + "- `reward_index` - The reward index that we'd like to initialize. (0 <= index <= NUM_REWARDS)", + "", + "#### Special Errors", + "- `InvalidRewardIndex` - If the provided reward index doesn't match the lowest uninitialized", + "index in this pool, or exceeds NUM_REWARDS, or", + "all reward slots for this pool has been initialized." + ], + "accounts": [ + { + "name": "rewardAuthority", + "isMut": false, + "isSigner": true + }, + { + "name": "funder", + "isMut": true, + "isSigner": true + }, + { + "name": "whirlpool", + "isMut": true, + "isSigner": false + }, + { + "name": "rewardMint", + "isMut": false, + "isSigner": false + }, + { + "name": "rewardVault", + "isMut": true, + "isSigner": true + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "rewardIndex", + "type": "u8" + } + ] + }, + { + "name": "setRewardEmissions", + "docs": [ + "Set the reward emissions for a reward in a Whirlpool.", + "", + "### Authority", + "- \"reward_authority\" - assigned authority by the reward_super_authority for the specified", + "reward-index in this Whirlpool", + "", + "### Parameters", + "- `reward_index` - The reward index (0 <= index <= NUM_REWARDS) that we'd like to modify.", + "- `emissions_per_second_x64` - The amount of rewards emitted in this pool.", + "", + "#### Special Errors", + "- `RewardVaultAmountInsufficient` - The amount of rewards in the reward vault cannot emit", + "more than a day of desired emissions.", + "- `InvalidTimestamp` - Provided timestamp is not in order with the previous timestamp.", + "- `InvalidRewardIndex` - If the provided reward index doesn't match the lowest uninitialized", + "index in this pool, or exceeds NUM_REWARDS, or", + "all reward slots for this pool has been initialized." + ], + "accounts": [ + { + "name": "whirlpool", + "isMut": true, + "isSigner": false + }, + { + "name": "rewardAuthority", + "isMut": false, + "isSigner": true + }, + { + "name": "rewardVault", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "rewardIndex", + "type": "u8" + }, + { + "name": "emissionsPerSecondX64", + "type": "u128" + } + ] + }, + { + "name": "openPosition", + "docs": [ + "Open a position in a Whirlpool. A unique token will be minted to represent the position", + "in the users wallet. The position will start off with 0 liquidity.", + "", + "### Parameters", + "- `tick_lower_index` - The tick specifying the lower end of the position range.", + "- `tick_upper_index` - The tick specifying the upper end of the position range.", + "", + "#### Special Errors", + "- `InvalidTickIndex` - If a provided tick is out of bounds, out of order or not a multiple of", + "the tick-spacing in this pool." + ], + "accounts": [ + { + "name": "funder", + "isMut": true, + "isSigner": true + }, + { + "name": "owner", + "isMut": false, + "isSigner": false + }, + { + "name": "position", + "isMut": true, + "isSigner": false + }, + { + "name": "positionMint", + "isMut": true, + "isSigner": true + }, + { + "name": "positionTokenAccount", + "isMut": true, + "isSigner": false + }, + { + "name": "whirlpool", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + }, + { + "name": "associatedTokenProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "bumps", + "type": { + "defined": "OpenPositionBumps" + } + }, + { + "name": "tickLowerIndex", + "type": "i32" + }, + { + "name": "tickUpperIndex", + "type": "i32" + } + ] + }, + { + "name": "openPositionWithMetadata", + "docs": [ + "Open a position in a Whirlpool. A unique token will be minted to represent the position", + "in the users wallet. Additional Metaplex metadata is appended to identify the token.", + "The position will start off with 0 liquidity.", + "", + "### Parameters", + "- `tick_lower_index` - The tick specifying the lower end of the position range.", + "- `tick_upper_index` - The tick specifying the upper end of the position range.", + "", + "#### Special Errors", + "- `InvalidTickIndex` - If a provided tick is out of bounds, out of order or not a multiple of", + "the tick-spacing in this pool." + ], + "accounts": [ + { + "name": "funder", + "isMut": true, + "isSigner": true + }, + { + "name": "owner", + "isMut": false, + "isSigner": false + }, + { + "name": "position", + "isMut": true, + "isSigner": false + }, + { + "name": "positionMint", + "isMut": true, + "isSigner": true + }, + { + "name": "positionMetadataAccount", + "isMut": true, + "isSigner": false, + "docs": [ + "https://github.com/metaplex-foundation/mpl-token-metadata/blob/master/programs/token-metadata/program/src/utils/metadata.rs#L78" + ] + }, + { + "name": "positionTokenAccount", + "isMut": true, + "isSigner": false + }, + { + "name": "whirlpool", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + }, + { + "name": "associatedTokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "metadataProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "metadataUpdateAuth", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "bumps", + "type": { + "defined": "OpenPositionWithMetadataBumps" + } + }, + { + "name": "tickLowerIndex", + "type": "i32" + }, + { + "name": "tickUpperIndex", + "type": "i32" + } + ] + }, + { + "name": "increaseLiquidity", + "docs": [ + "Add liquidity to a position in the Whirlpool. This call also updates the position's accrued fees and rewards.", + "", + "### Authority", + "- `position_authority` - authority that owns the token corresponding to this desired position.", + "", + "### Parameters", + "- `liquidity_amount` - The total amount of Liquidity the user is willing to deposit.", + "- `token_max_a` - The maximum amount of tokenA the user is willing to deposit.", + "- `token_max_b` - The maximum amount of tokenB the user is willing to deposit.", + "", + "#### Special Errors", + "- `LiquidityZero` - Provided liquidity amount is zero.", + "- `LiquidityTooHigh` - Provided liquidity exceeds u128::max.", + "- `TokenMaxExceeded` - The required token to perform this operation exceeds the user defined amount." + ], + "accounts": [ + { + "name": "whirlpool", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "positionAuthority", + "isMut": false, + "isSigner": true + }, + { + "name": "position", + "isMut": true, + "isSigner": false + }, + { + "name": "positionTokenAccount", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenOwnerAccountA", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenOwnerAccountB", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenVaultA", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenVaultB", + "isMut": true, + "isSigner": false + }, + { + "name": "tickArrayLower", + "isMut": true, + "isSigner": false + }, + { + "name": "tickArrayUpper", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "liquidityAmount", + "type": "u128" + }, + { + "name": "tokenMaxA", + "type": "u64" + }, + { + "name": "tokenMaxB", + "type": "u64" + } + ] + }, + { + "name": "decreaseLiquidity", + "docs": [ + "Withdraw liquidity from a position in the Whirlpool. This call also updates the position's accrued fees and rewards.", + "", + "### Authority", + "- `position_authority` - authority that owns the token corresponding to this desired position.", + "", + "### Parameters", + "- `liquidity_amount` - The total amount of Liquidity the user desires to withdraw.", + "- `token_min_a` - The minimum amount of tokenA the user is willing to withdraw.", + "- `token_min_b` - The minimum amount of tokenB the user is willing to withdraw.", + "", + "#### Special Errors", + "- `LiquidityZero` - Provided liquidity amount is zero.", + "- `LiquidityTooHigh` - Provided liquidity exceeds u128::max.", + "- `TokenMinSubceeded` - The required token to perform this operation subceeds the user defined amount." + ], + "accounts": [ + { + "name": "whirlpool", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "positionAuthority", + "isMut": false, + "isSigner": true + }, + { + "name": "position", + "isMut": true, + "isSigner": false + }, + { + "name": "positionTokenAccount", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenOwnerAccountA", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenOwnerAccountB", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenVaultA", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenVaultB", + "isMut": true, + "isSigner": false + }, + { + "name": "tickArrayLower", + "isMut": true, + "isSigner": false + }, + { + "name": "tickArrayUpper", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "liquidityAmount", + "type": "u128" + }, + { + "name": "tokenMinA", + "type": "u64" + }, + { + "name": "tokenMinB", + "type": "u64" + } + ] + }, + { + "name": "updateFeesAndRewards", + "docs": [ + "Update the accrued fees and rewards for a position.", + "", + "#### Special Errors", + "- `TickNotFound` - Provided tick array account does not contain the tick for this position.", + "- `LiquidityZero` - Position has zero liquidity and therefore already has the most updated fees and reward values." + ], + "accounts": [ + { + "name": "whirlpool", + "isMut": true, + "isSigner": false + }, + { + "name": "position", + "isMut": true, + "isSigner": false + }, + { + "name": "tickArrayLower", + "isMut": false, + "isSigner": false + }, + { + "name": "tickArrayUpper", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "collectFees", + "docs": [ + "Collect fees accrued for this position.", + "", + "### Authority", + "- `position_authority` - authority that owns the token corresponding to this desired position." + ], + "accounts": [ + { + "name": "whirlpool", + "isMut": false, + "isSigner": false + }, + { + "name": "positionAuthority", + "isMut": false, + "isSigner": true + }, + { + "name": "position", + "isMut": true, + "isSigner": false + }, + { + "name": "positionTokenAccount", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenOwnerAccountA", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenVaultA", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenOwnerAccountB", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenVaultB", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "collectReward", + "docs": [ + "Collect rewards accrued for this position.", + "", + "### Authority", + "- `position_authority` - authority that owns the token corresponding to this desired position." + ], + "accounts": [ + { + "name": "whirlpool", + "isMut": false, + "isSigner": false + }, + { + "name": "positionAuthority", + "isMut": false, + "isSigner": true + }, + { + "name": "position", + "isMut": true, + "isSigner": false + }, + { + "name": "positionTokenAccount", + "isMut": false, + "isSigner": false + }, + { + "name": "rewardOwnerAccount", + "isMut": true, + "isSigner": false + }, + { + "name": "rewardVault", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "rewardIndex", + "type": "u8" + } + ] + }, + { + "name": "collectProtocolFees", + "docs": [ + "Collect the protocol fees accrued in this Whirlpool", + "", + "### Authority", + "- `collect_protocol_fees_authority` - assigned authority in the WhirlpoolConfig that can collect protocol fees" + ], + "accounts": [ + { + "name": "whirlpoolsConfig", + "isMut": false, + "isSigner": false + }, + { + "name": "whirlpool", + "isMut": true, + "isSigner": false + }, + { + "name": "collectProtocolFeesAuthority", + "isMut": false, + "isSigner": true + }, + { + "name": "tokenVaultA", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenVaultB", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenDestinationA", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenDestinationB", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "swap", + "docs": [ + "Perform a swap in this Whirlpool", + "", + "### Authority", + "- \"token_authority\" - The authority to withdraw tokens from the input token account.", + "", + "### Parameters", + "- `amount` - The amount of input or output token to swap from (depending on amount_specified_is_input).", + "- `other_amount_threshold` - The maximum/minimum of input/output token to swap into (depending on amount_specified_is_input).", + "- `sqrt_price_limit` - The maximum/minimum price the swap will swap to.", + "- `amount_specified_is_input` - Specifies the token the parameter `amount`represents. If true, the amount represents the input token of the swap.", + "- `a_to_b` - The direction of the swap. True if swapping from A to B. False if swapping from B to A.", + "", + "#### Special Errors", + "- `ZeroTradableAmount` - User provided parameter `amount` is 0.", + "- `InvalidSqrtPriceLimitDirection` - User provided parameter `sqrt_price_limit` does not match the direction of the trade.", + "- `SqrtPriceOutOfBounds` - User provided parameter `sqrt_price_limit` is over Whirlppool's max/min bounds for sqrt-price.", + "- `InvalidTickArraySequence` - User provided tick-arrays are not in sequential order required to proceed in this trade direction.", + "- `TickArraySequenceInvalidIndex` - The swap loop attempted to access an invalid array index during the query of the next initialized tick.", + "- `TickArrayIndexOutofBounds` - The swap loop attempted to access an invalid array index during tick crossing.", + "- `LiquidityOverflow` - Liquidity value overflowed 128bits during tick crossing.", + "- `InvalidTickSpacing` - The swap pool was initialized with tick-spacing of 0." + ], + "accounts": [ + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": true + }, + { + "name": "whirlpool", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenOwnerAccountA", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenVaultA", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenOwnerAccountB", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenVaultB", + "isMut": true, + "isSigner": false + }, + { + "name": "tickArray0", + "isMut": true, + "isSigner": false + }, + { + "name": "tickArray1", + "isMut": true, + "isSigner": false + }, + { + "name": "tickArray2", + "isMut": true, + "isSigner": false + }, + { + "name": "oracle", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "amount", + "type": "u64" + }, + { + "name": "otherAmountThreshold", + "type": "u64" + }, + { + "name": "sqrtPriceLimit", + "type": "u128" + }, + { + "name": "amountSpecifiedIsInput", + "type": "bool" + }, + { + "name": "aToB", + "type": "bool" + } + ] + }, + { + "name": "closePosition", + "docs": [ + "Close a position in a Whirlpool. Burns the position token in the owner's wallet.", + "", + "### Authority", + "- \"position_authority\" - The authority that owns the position token.", + "", + "#### Special Errors", + "- `ClosePositionNotEmpty` - The provided position account is not empty." + ], + "accounts": [ + { + "name": "positionAuthority", + "isMut": false, + "isSigner": true + }, + { + "name": "receiver", + "isMut": true, + "isSigner": false + }, + { + "name": "position", + "isMut": true, + "isSigner": false + }, + { + "name": "positionMint", + "isMut": true, + "isSigner": false + }, + { + "name": "positionTokenAccount", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "setDefaultFeeRate", + "docs": [ + "Set the default_fee_rate for a FeeTier", + "Only the current fee authority has permission to invoke this instruction.", + "", + "### Authority", + "- \"fee_authority\" - Set authority in the WhirlpoolConfig", + "", + "### Parameters", + "- `default_fee_rate` - The default fee rate that a pool will use if the pool uses this", + "fee tier during initialization.", + "", + "#### Special Errors", + "- `FeeRateMaxExceeded` - If the provided default_fee_rate exceeds MAX_FEE_RATE." + ], + "accounts": [ + { + "name": "whirlpoolsConfig", + "isMut": false, + "isSigner": false + }, + { + "name": "feeTier", + "isMut": true, + "isSigner": false + }, + { + "name": "feeAuthority", + "isMut": false, + "isSigner": true + } + ], + "args": [ + { + "name": "defaultFeeRate", + "type": "u16" + } + ] + }, + { + "name": "setDefaultProtocolFeeRate", + "docs": [ + "Sets the default protocol fee rate for a WhirlpoolConfig", + "Protocol fee rate is represented as a basis point.", + "Only the current fee authority has permission to invoke this instruction.", + "", + "### Authority", + "- \"fee_authority\" - Set authority that can modify pool fees in the WhirlpoolConfig", + "", + "### Parameters", + "- `default_protocol_fee_rate` - Rate that is referenced during the initialization of a Whirlpool using this config.", + "", + "#### Special Errors", + "- `ProtocolFeeRateMaxExceeded` - If the provided default_protocol_fee_rate exceeds MAX_PROTOCOL_FEE_RATE." + ], + "accounts": [ + { + "name": "whirlpoolsConfig", + "isMut": true, + "isSigner": false + }, + { + "name": "feeAuthority", + "isMut": false, + "isSigner": true + } + ], + "args": [ + { + "name": "defaultProtocolFeeRate", + "type": "u16" + } + ] + }, + { + "name": "setFeeRate", + "docs": [ + "Sets the fee rate for a Whirlpool.", + "Fee rate is represented as hundredths of a basis point.", + "Only the current fee authority has permission to invoke this instruction.", + "", + "### Authority", + "- \"fee_authority\" - Set authority that can modify pool fees in the WhirlpoolConfig", + "", + "### Parameters", + "- `fee_rate` - The rate that the pool will use to calculate fees going onwards.", + "", + "#### Special Errors", + "- `FeeRateMaxExceeded` - If the provided fee_rate exceeds MAX_FEE_RATE." + ], + "accounts": [ + { + "name": "whirlpoolsConfig", + "isMut": false, + "isSigner": false + }, + { + "name": "whirlpool", + "isMut": true, + "isSigner": false + }, + { + "name": "feeAuthority", + "isMut": false, + "isSigner": true + } + ], + "args": [ + { + "name": "feeRate", + "type": "u16" + } + ] + }, + { + "name": "setProtocolFeeRate", + "docs": [ + "Sets the protocol fee rate for a Whirlpool.", + "Protocol fee rate is represented as a basis point.", + "Only the current fee authority has permission to invoke this instruction.", + "", + "### Authority", + "- \"fee_authority\" - Set authority that can modify pool fees in the WhirlpoolConfig", + "", + "### Parameters", + "- `protocol_fee_rate` - The rate that the pool will use to calculate protocol fees going onwards.", + "", + "#### Special Errors", + "- `ProtocolFeeRateMaxExceeded` - If the provided default_protocol_fee_rate exceeds MAX_PROTOCOL_FEE_RATE." + ], + "accounts": [ + { + "name": "whirlpoolsConfig", + "isMut": false, + "isSigner": false + }, + { + "name": "whirlpool", + "isMut": true, + "isSigner": false + }, + { + "name": "feeAuthority", + "isMut": false, + "isSigner": true + } + ], + "args": [ + { + "name": "protocolFeeRate", + "type": "u16" + } + ] + }, + { + "name": "setFeeAuthority", + "docs": [ + "Sets the fee authority for a WhirlpoolConfig.", + "The fee authority can set the fee & protocol fee rate for individual pools or", + "set the default fee rate for newly minted pools.", + "Only the current fee authority has permission to invoke this instruction.", + "", + "### Authority", + "- \"fee_authority\" - Set authority that can modify pool fees in the WhirlpoolConfig" + ], + "accounts": [ + { + "name": "whirlpoolsConfig", + "isMut": true, + "isSigner": false + }, + { + "name": "feeAuthority", + "isMut": false, + "isSigner": true + }, + { + "name": "newFeeAuthority", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "setCollectProtocolFeesAuthority", + "docs": [ + "Sets the fee authority to collect protocol fees for a WhirlpoolConfig.", + "Only the current collect protocol fee authority has permission to invoke this instruction.", + "", + "### Authority", + "- \"fee_authority\" - Set authority that can collect protocol fees in the WhirlpoolConfig" + ], + "accounts": [ + { + "name": "whirlpoolsConfig", + "isMut": true, + "isSigner": false + }, + { + "name": "collectProtocolFeesAuthority", + "isMut": false, + "isSigner": true + }, + { + "name": "newCollectProtocolFeesAuthority", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "setRewardAuthority", + "docs": [ + "Set the whirlpool reward authority at the provided `reward_index`.", + "Only the current reward authority for this reward index has permission to invoke this instruction.", + "", + "### Authority", + "- \"reward_authority\" - Set authority that can control reward emission for this particular reward.", + "", + "#### Special Errors", + "- `InvalidRewardIndex` - If the provided reward index doesn't match the lowest uninitialized", + "index in this pool, or exceeds NUM_REWARDS, or", + "all reward slots for this pool has been initialized." + ], + "accounts": [ + { + "name": "whirlpool", + "isMut": true, + "isSigner": false + }, + { + "name": "rewardAuthority", + "isMut": false, + "isSigner": true + }, + { + "name": "newRewardAuthority", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "rewardIndex", + "type": "u8" + } + ] + }, + { + "name": "setRewardAuthorityBySuperAuthority", + "docs": [ + "Set the whirlpool reward authority at the provided `reward_index`.", + "Only the current reward super authority has permission to invoke this instruction.", + "", + "### Authority", + "- \"reward_authority\" - Set authority that can control reward emission for this particular reward.", + "", + "#### Special Errors", + "- `InvalidRewardIndex` - If the provided reward index doesn't match the lowest uninitialized", + "index in this pool, or exceeds NUM_REWARDS, or", + "all reward slots for this pool has been initialized." + ], + "accounts": [ + { + "name": "whirlpoolsConfig", + "isMut": false, + "isSigner": false + }, + { + "name": "whirlpool", + "isMut": true, + "isSigner": false + }, + { + "name": "rewardEmissionsSuperAuthority", + "isMut": false, + "isSigner": true + }, + { + "name": "newRewardAuthority", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "rewardIndex", + "type": "u8" + } + ] + }, + { + "name": "setRewardEmissionsSuperAuthority", + "docs": [ + "Set the whirlpool reward super authority for a WhirlpoolConfig", + "Only the current reward super authority has permission to invoke this instruction.", + "This instruction will not change the authority on any `WhirlpoolRewardInfo` whirlpool rewards.", + "", + "### Authority", + "- \"reward_emissions_super_authority\" - Set authority that can control reward authorities for all pools in this config space." + ], + "accounts": [ + { + "name": "whirlpoolsConfig", + "isMut": true, + "isSigner": false + }, + { + "name": "rewardEmissionsSuperAuthority", + "isMut": false, + "isSigner": true + }, + { + "name": "newRewardEmissionsSuperAuthority", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "twoHopSwap", + "docs": [ + "Perform a two-hop swap in this Whirlpool", + "", + "### Authority", + "- \"token_authority\" - The authority to withdraw tokens from the input token account.", + "", + "### Parameters", + "- `amount` - The amount of input or output token to swap from (depending on amount_specified_is_input).", + "- `other_amount_threshold` - The maximum/minimum of input/output token to swap into (depending on amount_specified_is_input).", + "- `amount_specified_is_input` - Specifies the token the parameter `amount`represents. If true, the amount represents the input token of the swap.", + "- `a_to_b_one` - The direction of the swap of hop one. True if swapping from A to B. False if swapping from B to A.", + "- `a_to_b_two` - The direction of the swap of hop two. True if swapping from A to B. False if swapping from B to A.", + "- `sqrt_price_limit_one` - The maximum/minimum price the swap will swap to in the first hop.", + "- `sqrt_price_limit_two` - The maximum/minimum price the swap will swap to in the second hop.", + "", + "#### Special Errors", + "- `ZeroTradableAmount` - User provided parameter `amount` is 0.", + "- `InvalidSqrtPriceLimitDirection` - User provided parameter `sqrt_price_limit` does not match the direction of the trade.", + "- `SqrtPriceOutOfBounds` - User provided parameter `sqrt_price_limit` is over Whirlppool's max/min bounds for sqrt-price.", + "- `InvalidTickArraySequence` - User provided tick-arrays are not in sequential order required to proceed in this trade direction.", + "- `TickArraySequenceInvalidIndex` - The swap loop attempted to access an invalid array index during the query of the next initialized tick.", + "- `TickArrayIndexOutofBounds` - The swap loop attempted to access an invalid array index during tick crossing.", + "- `LiquidityOverflow` - Liquidity value overflowed 128bits during tick crossing.", + "- `InvalidTickSpacing` - The swap pool was initialized with tick-spacing of 0.", + "- `InvalidIntermediaryMint` - Error if the intermediary mint between hop one and two do not equal.", + "- `DuplicateTwoHopPool` - Error if whirlpool one & two are the same pool." + ], + "accounts": [ + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": true + }, + { + "name": "whirlpoolOne", + "isMut": true, + "isSigner": false + }, + { + "name": "whirlpoolTwo", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenOwnerAccountOneA", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenVaultOneA", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenOwnerAccountOneB", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenVaultOneB", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenOwnerAccountTwoA", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenVaultTwoA", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenOwnerAccountTwoB", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenVaultTwoB", + "isMut": true, + "isSigner": false + }, + { + "name": "tickArrayOne0", + "isMut": true, + "isSigner": false + }, + { + "name": "tickArrayOne1", + "isMut": true, + "isSigner": false + }, + { + "name": "tickArrayOne2", + "isMut": true, + "isSigner": false + }, + { + "name": "tickArrayTwo0", + "isMut": true, + "isSigner": false + }, + { + "name": "tickArrayTwo1", + "isMut": true, + "isSigner": false + }, + { + "name": "tickArrayTwo2", + "isMut": true, + "isSigner": false + }, + { + "name": "oracleOne", + "isMut": false, + "isSigner": false + }, + { + "name": "oracleTwo", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "amount", + "type": "u64" + }, + { + "name": "otherAmountThreshold", + "type": "u64" + }, + { + "name": "amountSpecifiedIsInput", + "type": "bool" + }, + { + "name": "aToBOne", + "type": "bool" + }, + { + "name": "aToBTwo", + "type": "bool" + }, + { + "name": "sqrtPriceLimitOne", + "type": "u128" + }, + { + "name": "sqrtPriceLimitTwo", + "type": "u128" + } + ] + }, + { + "name": "initializePositionBundle", + "docs": [ + "Initializes a PositionBundle account that bundles several positions.", + "A unique token will be minted to represent the position bundle in the users wallet." + ], + "accounts": [ + { + "name": "positionBundle", + "isMut": true, + "isSigner": false + }, + { + "name": "positionBundleMint", + "isMut": true, + "isSigner": true + }, + { + "name": "positionBundleTokenAccount", + "isMut": true, + "isSigner": false + }, + { + "name": "positionBundleOwner", + "isMut": false, + "isSigner": false + }, + { + "name": "funder", + "isMut": true, + "isSigner": true + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + }, + { + "name": "associatedTokenProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "initializePositionBundleWithMetadata", + "docs": [ + "Initializes a PositionBundle account that bundles several positions.", + "A unique token will be minted to represent the position bundle in the users wallet.", + "Additional Metaplex metadata is appended to identify the token." + ], + "accounts": [ + { + "name": "positionBundle", + "isMut": true, + "isSigner": false + }, + { + "name": "positionBundleMint", + "isMut": true, + "isSigner": true + }, + { + "name": "positionBundleMetadata", + "isMut": true, + "isSigner": false, + "docs": [ + "https://github.com/metaplex-foundation/metaplex-program-library/blob/773a574c4b34e5b9f248a81306ec24db064e255f/token-metadata/program/src/utils/metadata.rs#L100" + ] + }, + { + "name": "positionBundleTokenAccount", + "isMut": true, + "isSigner": false + }, + { + "name": "positionBundleOwner", + "isMut": false, + "isSigner": false + }, + { + "name": "funder", + "isMut": true, + "isSigner": true + }, + { + "name": "metadataUpdateAuth", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + }, + { + "name": "associatedTokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "metadataProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "deletePositionBundle", + "docs": [ + "Delete a PositionBundle account. Burns the position bundle token in the owner's wallet.", + "", + "### Authority", + "- `position_bundle_owner` - The owner that owns the position bundle token.", + "", + "### Special Errors", + "- `PositionBundleNotDeletable` - The provided position bundle has open positions." + ], + "accounts": [ + { + "name": "positionBundle", + "isMut": true, + "isSigner": false + }, + { + "name": "positionBundleMint", + "isMut": true, + "isSigner": false + }, + { + "name": "positionBundleTokenAccount", + "isMut": true, + "isSigner": false + }, + { + "name": "positionBundleOwner", + "isMut": false, + "isSigner": true + }, + { + "name": "receiver", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "openBundledPosition", + "docs": [ + "Open a bundled position in a Whirlpool. No new tokens are issued", + "because the owner of the position bundle becomes the owner of the position.", + "The position will start off with 0 liquidity.", + "", + "### Authority", + "- `position_bundle_authority` - authority that owns the token corresponding to this desired position bundle.", + "", + "### Parameters", + "- `bundle_index` - The bundle index that we'd like to open.", + "- `tick_lower_index` - The tick specifying the lower end of the position range.", + "- `tick_upper_index` - The tick specifying the upper end of the position range.", + "", + "#### Special Errors", + "- `InvalidBundleIndex` - If the provided bundle index is out of bounds.", + "- `InvalidTickIndex` - If a provided tick is out of bounds, out of order or not a multiple of", + "the tick-spacing in this pool." + ], + "accounts": [ + { + "name": "bundledPosition", + "isMut": true, + "isSigner": false + }, + { + "name": "positionBundle", + "isMut": true, + "isSigner": false + }, + { + "name": "positionBundleTokenAccount", + "isMut": false, + "isSigner": false + }, + { + "name": "positionBundleAuthority", + "isMut": false, + "isSigner": true + }, + { + "name": "whirlpool", + "isMut": false, + "isSigner": false + }, + { + "name": "funder", + "isMut": true, + "isSigner": true + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "bundleIndex", + "type": "u16" + }, + { + "name": "tickLowerIndex", + "type": "i32" + }, + { + "name": "tickUpperIndex", + "type": "i32" + } + ] + }, + { + "name": "closeBundledPosition", + "docs": [ + "Close a bundled position in a Whirlpool.", + "", + "### Authority", + "- `position_bundle_authority` - authority that owns the token corresponding to this desired position bundle.", + "", + "### Parameters", + "- `bundle_index` - The bundle index that we'd like to close.", + "", + "#### Special Errors", + "- `InvalidBundleIndex` - If the provided bundle index is out of bounds.", + "- `ClosePositionNotEmpty` - The provided position account is not empty." + ], + "accounts": [ + { + "name": "bundledPosition", + "isMut": true, + "isSigner": false + }, + { + "name": "positionBundle", + "isMut": true, + "isSigner": false + }, + { + "name": "positionBundleTokenAccount", + "isMut": false, + "isSigner": false + }, + { + "name": "positionBundleAuthority", + "isMut": false, + "isSigner": true + }, + { + "name": "receiver", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "bundleIndex", + "type": "u16" + } + ] + }, + { + "name": "openPositionWithTokenExtensions", + "docs": [ + "Open a position in a Whirlpool. A unique token will be minted to represent the position", + "in the users wallet. Additional TokenMetadata extension is initialized to identify the token.", + "Mint and TokenAccount are based on Token-2022.", + "The position will start off with 0 liquidity.", + "", + "### Parameters", + "- `tick_lower_index` - The tick specifying the lower end of the position range.", + "- `tick_upper_index` - The tick specifying the upper end of the position range.", + "- `with_token_metadata_extension` - If true, the token metadata extension will be initialized.", + "", + "#### Special Errors", + "- `InvalidTickIndex` - If a provided tick is out of bounds, out of order or not a multiple of", + "the tick-spacing in this pool." + ], + "accounts": [ + { + "name": "funder", + "isMut": true, + "isSigner": true + }, + { + "name": "owner", + "isMut": false, + "isSigner": false + }, + { + "name": "position", + "isMut": true, + "isSigner": false + }, + { + "name": "positionMint", + "isMut": true, + "isSigner": true + }, + { + "name": "positionTokenAccount", + "isMut": true, + "isSigner": false + }, + { + "name": "whirlpool", + "isMut": false, + "isSigner": false + }, + { + "name": "token2022Program", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "associatedTokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "metadataUpdateAuth", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "tickLowerIndex", + "type": "i32" + }, + { + "name": "tickUpperIndex", + "type": "i32" + }, + { + "name": "withTokenMetadataExtension", + "type": "bool" + } + ] + }, + { + "name": "closePositionWithTokenExtensions", + "docs": [ + "Close a position in a Whirlpool. Burns the position token in the owner's wallet.", + "Mint and TokenAccount are based on Token-2022. And Mint accout will be also closed.", + "", + "### Authority", + "- \"position_authority\" - The authority that owns the position token.", + "", + "#### Special Errors", + "- `ClosePositionNotEmpty` - The provided position account is not empty." + ], + "accounts": [ + { + "name": "positionAuthority", + "isMut": false, + "isSigner": true + }, + { + "name": "receiver", + "isMut": true, + "isSigner": false + }, + { + "name": "position", + "isMut": true, + "isSigner": false + }, + { + "name": "positionMint", + "isMut": true, + "isSigner": false + }, + { + "name": "positionTokenAccount", + "isMut": true, + "isSigner": false + }, + { + "name": "token2022Program", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "lockPosition", + "docs": [ + "Lock the position to prevent any liquidity changes.", + "", + "### Authority", + "- `position_authority` - The authority that owns the position token.", + "", + "#### Special Errors", + "- `PositionAlreadyLocked` - The provided position is already locked.", + "- `PositionNotLockable` - The provided position is not lockable (e.g. An empty position)." + ], + "accounts": [ + { + "name": "funder", + "isMut": true, + "isSigner": true + }, + { + "name": "positionAuthority", + "isMut": false, + "isSigner": true + }, + { + "name": "position", + "isMut": false, + "isSigner": false + }, + { + "name": "positionMint", + "isMut": false, + "isSigner": false + }, + { + "name": "positionTokenAccount", + "isMut": true, + "isSigner": false + }, + { + "name": "lockConfig", + "isMut": true, + "isSigner": false + }, + { + "name": "whirlpool", + "isMut": false, + "isSigner": false + }, + { + "name": "token2022Program", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "lockType", + "type": { + "defined": "LockType" + } + } + ] + }, + { + "name": "resetPositionRange", + "docs": [ + "Reset the position range to a new range.", + "", + "### Authority", + "- `position_authority` - The authority that owns the position token.", + "", + "### Parameters", + "- `new_tick_lower_index` - The new tick specifying the lower end of the position range.", + "- `new_tick_upper_index` - The new tick specifying the upper end of the position range.", + "", + "#### Special Errors", + "- `InvalidTickIndex` - If a provided tick is out of bounds, out of order or not a multiple of", + "the tick-spacing in this pool.", + "- `ClosePositionNotEmpty` - The provided position account is not empty.", + "- `SameTickRangeNotAllowed` - The provided tick range is the same as the current tick range." + ], + "accounts": [ + { + "name": "funder", + "isMut": true, + "isSigner": true + }, + { + "name": "positionAuthority", + "isMut": false, + "isSigner": true + }, + { + "name": "whirlpool", + "isMut": false, + "isSigner": false + }, + { + "name": "position", + "isMut": true, + "isSigner": false + }, + { + "name": "positionTokenAccount", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "newTickLowerIndex", + "type": "i32" + }, + { + "name": "newTickUpperIndex", + "type": "i32" + } + ] + }, + { + "name": "transferLockedPosition", + "docs": [ + "Transfer a locked position to to a different token account.", + "", + "### Authority", + "- `position_authority` - The authority that owns the position token." + ], + "accounts": [ + { + "name": "positionAuthority", + "isMut": false, + "isSigner": true + }, + { + "name": "receiver", + "isMut": true, + "isSigner": false + }, + { + "name": "position", + "isMut": false, + "isSigner": false + }, + { + "name": "positionMint", + "isMut": false, + "isSigner": false + }, + { + "name": "positionTokenAccount", + "isMut": true, + "isSigner": false + }, + { + "name": "destinationTokenAccount", + "isMut": true, + "isSigner": false + }, + { + "name": "lockConfig", + "isMut": true, + "isSigner": false + }, + { + "name": "token2022Program", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "initializeAdaptiveFeeTier", + "docs": [ + "Initializes an adaptive_fee_tier account usable by Whirlpools in a WhirlpoolConfig space.", + "", + "### Authority", + "- \"fee_authority\" - Set authority in the WhirlpoolConfig", + "", + "### Parameters", + "- `fee_tier_index` - The index of the fee-tier that this adaptive fee tier will be initialized.", + "- `tick_spacing` - The tick-spacing that this fee-tier suggests the default_fee_rate for.", + "- `initialize_pool_authority` - The authority that can initialize pools with this adaptive fee-tier.", + "- `delegated_fee_authority` - The authority that can set the base fee rate for pools using this adaptive fee-tier.", + "- `default_fee_rate` - The default fee rate that a pool will use if the pool uses this", + "fee tier during initialization.", + "- `filter_period` - Period determine high frequency trading time window. (seconds)", + "- `decay_period` - Period determine when the adaptive fee start decrease. (seconds)", + "- `reduction_factor` - Adaptive fee rate decrement rate.", + "- `adaptive_fee_control_factor` - Adaptive fee control factor.", + "- `max_volatility_accumulator` - Max volatility accumulator.", + "- `tick_group_size` - Tick group size to define tick group index.", + "- `major_swap_threshold_ticks` - Major swap threshold ticks to define major swap.", + "", + "#### Special Errors", + "- `InvalidTickSpacing` - If the provided tick_spacing is 0.", + "- `InvalidFeeTierIndex` - If the provided fee_tier_index is same to tick_spacing.", + "- `FeeRateMaxExceeded` - If the provided default_fee_rate exceeds MAX_FEE_RATE.", + "- `InvalidAdaptiveFeeConstants` - If the provided adaptive fee constants are invalid." + ], + "accounts": [ + { + "name": "whirlpoolsConfig", + "isMut": false, + "isSigner": false + }, + { + "name": "adaptiveFeeTier", + "isMut": true, + "isSigner": false + }, + { + "name": "funder", + "isMut": true, + "isSigner": true + }, + { + "name": "feeAuthority", + "isMut": false, + "isSigner": true + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "feeTierIndex", + "type": "u16" + }, + { + "name": "tickSpacing", + "type": "u16" + }, + { + "name": "initializePoolAuthority", + "type": "publicKey" + }, + { + "name": "delegatedFeeAuthority", + "type": "publicKey" + }, + { + "name": "defaultBaseFeeRate", + "type": "u16" + }, + { + "name": "filterPeriod", + "type": "u16" + }, + { + "name": "decayPeriod", + "type": "u16" + }, + { + "name": "reductionFactor", + "type": "u16" + }, + { + "name": "adaptiveFeeControlFactor", + "type": "u32" + }, + { + "name": "maxVolatilityAccumulator", + "type": "u32" + }, + { + "name": "tickGroupSize", + "type": "u16" + }, + { + "name": "majorSwapThresholdTicks", + "type": "u16" + } + ] + }, + { + "name": "setDefaultBaseFeeRate", + "docs": [ + "Set the default_base_fee_rate for an AdaptiveFeeTier", + "Only the current fee authority in WhirlpoolsConfig has permission to invoke this instruction.", + "", + "### Authority", + "- \"fee_authority\" - Set authority in the WhirlpoolConfig", + "", + "### Parameters", + "- `default_base_fee_rate` - The default base fee rate that a pool will use if the pool uses this", + "adaptive fee-tier during initialization.", + "", + "#### Special Errors", + "- `FeeRateMaxExceeded` - If the provided default_fee_rate exceeds MAX_FEE_RATE." + ], + "accounts": [ + { + "name": "whirlpoolsConfig", + "isMut": false, + "isSigner": false + }, + { + "name": "adaptiveFeeTier", + "isMut": true, + "isSigner": false + }, + { + "name": "feeAuthority", + "isMut": false, + "isSigner": true + } + ], + "args": [ + { + "name": "defaultBaseFeeRate", + "type": "u16" + } + ] + }, + { + "name": "setDelegatedFeeAuthority", + "docs": [ + "Sets the delegated fee authority for an AdaptiveFeeTier.", + "The delegated fee authority can set the fee rate for individual pools initialized with the adaptive fee-tier.", + "Only the current fee authority in WhirlpoolsConfig has permission to invoke this instruction.", + "", + "### Authority", + "- \"fee_authority\" - Set authority in the WhirlpoolConfig" + ], + "accounts": [ + { + "name": "whirlpoolsConfig", + "isMut": false, + "isSigner": false + }, + { + "name": "adaptiveFeeTier", + "isMut": true, + "isSigner": false + }, + { + "name": "feeAuthority", + "isMut": false, + "isSigner": true + }, + { + "name": "newDelegatedFeeAuthority", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "setInitializePoolAuthority", + "docs": [ + "Sets the initialize pool authority for an AdaptiveFeeTier.", + "Only the initialize pool authority can initialize pools with the adaptive fee-tier.", + "Only the current fee authority in WhirlpoolsConfig has permission to invoke this instruction.", + "", + "### Authority", + "- \"fee_authority\" - Set authority in the WhirlpoolConfig" + ], + "accounts": [ + { + "name": "whirlpoolsConfig", + "isMut": false, + "isSigner": false + }, + { + "name": "adaptiveFeeTier", + "isMut": true, + "isSigner": false + }, + { + "name": "feeAuthority", + "isMut": false, + "isSigner": true + }, + { + "name": "newInitializePoolAuthority", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "setPresetAdaptiveFeeConstants", + "docs": [ + "Sets the adaptive fee constants for an AdaptiveFeeTier.", + "Only the current fee authority in WhirlpoolsConfig has permission to invoke this instruction.", + "", + "### Authority", + "- \"fee_authority\" - Set authority in the WhirlpoolConfig", + "", + "### Parameters", + "- `filter_period` - Period determine high frequency trading time window. (seconds)", + "- `decay_period` - Period determine when the adaptive fee start decrease. (seconds)", + "- `reduction_factor` - Adaptive fee rate decrement rate.", + "- `adaptive_fee_control_factor` - Adaptive fee control factor.", + "- `max_volatility_accumulator` - Max volatility accumulator.", + "- `tick_group_size` - Tick group size to define tick group index.", + "- `major_swap_threshold_ticks` - Major swap threshold ticks to define major swap." + ], + "accounts": [ + { + "name": "whirlpoolsConfig", + "isMut": false, + "isSigner": false + }, + { + "name": "adaptiveFeeTier", + "isMut": true, + "isSigner": false + }, + { + "name": "feeAuthority", + "isMut": false, + "isSigner": true + } + ], + "args": [ + { + "name": "filterPeriod", + "type": "u16" + }, + { + "name": "decayPeriod", + "type": "u16" + }, + { + "name": "reductionFactor", + "type": "u16" + }, + { + "name": "adaptiveFeeControlFactor", + "type": "u32" + }, + { + "name": "maxVolatilityAccumulator", + "type": "u32" + }, + { + "name": "tickGroupSize", + "type": "u16" + }, + { + "name": "majorSwapThresholdTicks", + "type": "u16" + } + ] + }, + { + "name": "initializePoolWithAdaptiveFee", + "docs": [ + "Initializes a Whirlpool account and Oracle account with adaptive fee.", + "", + "### Parameters", + "- `initial_sqrt_price` - The desired initial sqrt-price for this pool", + "- `trade_enable_timestamp` - The timestamp when trading is enabled for this pool (within 72 hours)", + "", + "#### Special Errors", + "`InvalidTokenMintOrder` - The order of mints have to be ordered by", + "`SqrtPriceOutOfBounds` - provided initial_sqrt_price is not between 2^-64 to 2^64", + "`InvalidTradeEnableTimestamp` - provided trade_enable_timestamp is not within 72 hours or the adaptive fee-tier is permission-less", + "`UnsupportedTokenMint` - The provided token mint is not supported by the program (e.g. it has risky token extensions)", + "" + ], + "accounts": [ + { + "name": "whirlpoolsConfig", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenMintA", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenMintB", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenBadgeA", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenBadgeB", + "isMut": false, + "isSigner": false + }, + { + "name": "funder", + "isMut": true, + "isSigner": true + }, + { + "name": "initializePoolAuthority", + "isMut": false, + "isSigner": true + }, + { + "name": "whirlpool", + "isMut": true, + "isSigner": false + }, + { + "name": "oracle", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenVaultA", + "isMut": true, + "isSigner": true + }, + { + "name": "tokenVaultB", + "isMut": true, + "isSigner": true + }, + { + "name": "adaptiveFeeTier", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenProgramA", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenProgramB", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "initialSqrtPrice", + "type": "u128" + }, + { + "name": "tradeEnableTimestamp", + "type": { + "option": "u64" + } + } + ] + }, + { + "name": "setFeeRateByDelegatedFeeAuthority", + "docs": [ + "Sets the fee rate for a Whirlpool by the delegated fee authority in AdaptiveFeeTier.", + "Fee rate is represented as hundredths of a basis point.", + "", + "### Authority", + "- \"delegated_fee_authority\" - Set authority that can modify pool fees in the AdaptiveFeeTier", + "", + "### Parameters", + "- `fee_rate` - The rate that the pool will use to calculate fees going onwards.", + "", + "#### Special Errors", + "- `FeeRateMaxExceeded` - If the provided fee_rate exceeds MAX_FEE_RATE." + ], + "accounts": [ + { + "name": "whirlpool", + "isMut": true, + "isSigner": false + }, + { + "name": "adaptiveFeeTier", + "isMut": false, + "isSigner": false + }, + { + "name": "delegatedFeeAuthority", + "isMut": false, + "isSigner": true + } + ], + "args": [ + { + "name": "feeRate", + "type": "u16" + } + ] + }, + { + "name": "collectFeesV2", + "docs": [ + "Collect fees accrued for this position.", + "", + "### Authority", + "- `position_authority` - authority that owns the token corresponding to this desired position." + ], + "accounts": [ + { + "name": "whirlpool", + "isMut": false, + "isSigner": false + }, + { + "name": "positionAuthority", + "isMut": false, + "isSigner": true + }, + { + "name": "position", + "isMut": true, + "isSigner": false + }, + { + "name": "positionTokenAccount", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenMintA", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenMintB", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenOwnerAccountA", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenVaultA", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenOwnerAccountB", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenVaultB", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenProgramA", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenProgramB", + "isMut": false, + "isSigner": false + }, + { + "name": "memoProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "remainingAccountsInfo", + "type": { + "option": { + "defined": "RemainingAccountsInfo" + } + } + } + ] + }, + { + "name": "collectProtocolFeesV2", + "docs": [ + "Collect the protocol fees accrued in this Whirlpool", + "", + "### Authority", + "- `collect_protocol_fees_authority` - assigned authority in the WhirlpoolConfig that can collect protocol fees" + ], + "accounts": [ + { + "name": "whirlpoolsConfig", + "isMut": false, + "isSigner": false + }, + { + "name": "whirlpool", + "isMut": true, + "isSigner": false + }, + { + "name": "collectProtocolFeesAuthority", + "isMut": false, + "isSigner": true + }, + { + "name": "tokenMintA", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenMintB", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenVaultA", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenVaultB", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenDestinationA", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenDestinationB", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenProgramA", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenProgramB", + "isMut": false, + "isSigner": false + }, + { + "name": "memoProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "remainingAccountsInfo", + "type": { + "option": { + "defined": "RemainingAccountsInfo" + } + } + } + ] + }, + { + "name": "collectRewardV2", + "docs": [ + "Collect rewards accrued for this position.", + "", + "### Authority", + "- `position_authority` - authority that owns the token corresponding to this desired position." + ], + "accounts": [ + { + "name": "whirlpool", + "isMut": false, + "isSigner": false + }, + { + "name": "positionAuthority", + "isMut": false, + "isSigner": true + }, + { + "name": "position", + "isMut": true, + "isSigner": false + }, + { + "name": "positionTokenAccount", + "isMut": false, + "isSigner": false + }, + { + "name": "rewardOwnerAccount", + "isMut": true, + "isSigner": false + }, + { + "name": "rewardMint", + "isMut": false, + "isSigner": false + }, + { + "name": "rewardVault", + "isMut": true, + "isSigner": false + }, + { + "name": "rewardTokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "memoProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "rewardIndex", + "type": "u8" + }, + { + "name": "remainingAccountsInfo", + "type": { + "option": { + "defined": "RemainingAccountsInfo" + } + } + } + ] + }, + { + "name": "decreaseLiquidityV2", + "docs": [ + "Withdraw liquidity from a position in the Whirlpool. This call also updates the position's accrued fees and rewards.", + "", + "### Authority", + "- `position_authority` - authority that owns the token corresponding to this desired position.", + "", + "### Parameters", + "- `liquidity_amount` - The total amount of Liquidity the user desires to withdraw.", + "- `token_min_a` - The minimum amount of tokenA the user is willing to withdraw.", + "- `token_min_b` - The minimum amount of tokenB the user is willing to withdraw.", + "", + "#### Special Errors", + "- `LiquidityZero` - Provided liquidity amount is zero.", + "- `LiquidityTooHigh` - Provided liquidity exceeds u128::max.", + "- `TokenMinSubceeded` - The required token to perform this operation subceeds the user defined amount." + ], + "accounts": [ + { + "name": "whirlpool", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenProgramA", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenProgramB", + "isMut": false, + "isSigner": false + }, + { + "name": "memoProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "positionAuthority", + "isMut": false, + "isSigner": true + }, + { + "name": "position", + "isMut": true, + "isSigner": false + }, + { + "name": "positionTokenAccount", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenMintA", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenMintB", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenOwnerAccountA", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenOwnerAccountB", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenVaultA", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenVaultB", + "isMut": true, + "isSigner": false + }, + { + "name": "tickArrayLower", + "isMut": true, + "isSigner": false + }, + { + "name": "tickArrayUpper", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "liquidityAmount", + "type": "u128" + }, + { + "name": "tokenMinA", + "type": "u64" + }, + { + "name": "tokenMinB", + "type": "u64" + }, + { + "name": "remainingAccountsInfo", + "type": { + "option": { + "defined": "RemainingAccountsInfo" + } + } + } + ] + }, + { + "name": "increaseLiquidityV2", + "docs": [ + "Add liquidity to a position in the Whirlpool. This call also updates the position's accrued fees and rewards.", + "", + "### Authority", + "- `position_authority` - authority that owns the token corresponding to this desired position.", + "", + "### Parameters", + "- `liquidity_amount` - The total amount of Liquidity the user is willing to deposit.", + "- `token_max_a` - The maximum amount of tokenA the user is willing to deposit.", + "- `token_max_b` - The maximum amount of tokenB the user is willing to deposit.", + "", + "#### Special Errors", + "- `LiquidityZero` - Provided liquidity amount is zero.", + "- `LiquidityTooHigh` - Provided liquidity exceeds u128::max.", + "- `TokenMaxExceeded` - The required token to perform this operation exceeds the user defined amount." + ], + "accounts": [ + { + "name": "whirlpool", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenProgramA", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenProgramB", + "isMut": false, + "isSigner": false + }, + { + "name": "memoProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "positionAuthority", + "isMut": false, + "isSigner": true + }, + { + "name": "position", + "isMut": true, + "isSigner": false + }, + { + "name": "positionTokenAccount", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenMintA", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenMintB", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenOwnerAccountA", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenOwnerAccountB", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenVaultA", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenVaultB", + "isMut": true, + "isSigner": false + }, + { + "name": "tickArrayLower", + "isMut": true, + "isSigner": false + }, + { + "name": "tickArrayUpper", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "liquidityAmount", + "type": "u128" + }, + { + "name": "tokenMaxA", + "type": "u64" + }, + { + "name": "tokenMaxB", + "type": "u64" + }, + { + "name": "remainingAccountsInfo", + "type": { + "option": { + "defined": "RemainingAccountsInfo" + } + } + } + ] + }, + { + "name": "initializePoolV2", + "docs": [ + "Initializes a Whirlpool account.", + "Fee rate is set to the default values on the config and supplied fee_tier.", + "", + "### Parameters", + "- `bumps` - The bump value when deriving the PDA of the Whirlpool address.", + "- `tick_spacing` - The desired tick spacing for this pool.", + "- `initial_sqrt_price` - The desired initial sqrt-price for this pool", + "", + "#### Special Errors", + "`InvalidTokenMintOrder` - The order of mints have to be ordered by", + "`SqrtPriceOutOfBounds` - provided initial_sqrt_price is not between 2^-64 to 2^64", + "" + ], + "accounts": [ + { + "name": "whirlpoolsConfig", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenMintA", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenMintB", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenBadgeA", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenBadgeB", + "isMut": false, + "isSigner": false + }, + { + "name": "funder", + "isMut": true, + "isSigner": true + }, + { + "name": "whirlpool", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenVaultA", + "isMut": true, + "isSigner": true + }, + { + "name": "tokenVaultB", + "isMut": true, + "isSigner": true + }, + { + "name": "feeTier", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenProgramA", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenProgramB", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "tickSpacing", + "type": "u16" + }, + { + "name": "initialSqrtPrice", + "type": "u128" + } + ] + }, + { + "name": "initializeRewardV2", + "docs": [ + "Initialize reward for a Whirlpool. A pool can only support up to a set number of rewards.", + "", + "### Authority", + "- \"reward_authority\" - assigned authority by the reward_super_authority for the specified", + "reward-index in this Whirlpool", + "", + "### Parameters", + "- `reward_index` - The reward index that we'd like to initialize. (0 <= index <= NUM_REWARDS)", + "", + "#### Special Errors", + "- `InvalidRewardIndex` - If the provided reward index doesn't match the lowest uninitialized", + "index in this pool, or exceeds NUM_REWARDS, or", + "all reward slots for this pool has been initialized." + ], + "accounts": [ + { + "name": "rewardAuthority", + "isMut": false, + "isSigner": true + }, + { + "name": "funder", + "isMut": true, + "isSigner": true + }, + { + "name": "whirlpool", + "isMut": true, + "isSigner": false + }, + { + "name": "rewardMint", + "isMut": false, + "isSigner": false + }, + { + "name": "rewardTokenBadge", + "isMut": false, + "isSigner": false + }, + { + "name": "rewardVault", + "isMut": true, + "isSigner": true + }, + { + "name": "rewardTokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "rewardIndex", + "type": "u8" + } + ] + }, + { + "name": "setRewardEmissionsV2", + "docs": [ + "Set the reward emissions for a reward in a Whirlpool.", + "", + "### Authority", + "- \"reward_authority\" - assigned authority by the reward_super_authority for the specified", + "reward-index in this Whirlpool", + "", + "### Parameters", + "- `reward_index` - The reward index (0 <= index <= NUM_REWARDS) that we'd like to modify.", + "- `emissions_per_second_x64` - The amount of rewards emitted in this pool.", + "", + "#### Special Errors", + "- `RewardVaultAmountInsufficient` - The amount of rewards in the reward vault cannot emit", + "more than a day of desired emissions.", + "- `InvalidTimestamp` - Provided timestamp is not in order with the previous timestamp.", + "- `InvalidRewardIndex` - If the provided reward index doesn't match the lowest uninitialized", + "index in this pool, or exceeds NUM_REWARDS, or", + "all reward slots for this pool has been initialized." + ], + "accounts": [ + { + "name": "whirlpool", + "isMut": true, + "isSigner": false + }, + { + "name": "rewardAuthority", + "isMut": false, + "isSigner": true + }, + { + "name": "rewardVault", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "rewardIndex", + "type": "u8" + }, + { + "name": "emissionsPerSecondX64", + "type": "u128" + } + ] + }, + { + "name": "swapV2", + "docs": [ + "Perform a swap in this Whirlpool", + "", + "### Authority", + "- \"token_authority\" - The authority to withdraw tokens from the input token account.", + "", + "### Parameters", + "- `amount` - The amount of input or output token to swap from (depending on amount_specified_is_input).", + "- `other_amount_threshold` - The maximum/minimum of input/output token to swap into (depending on amount_specified_is_input).", + "- `sqrt_price_limit` - The maximum/minimum price the swap will swap to.", + "- `amount_specified_is_input` - Specifies the token the parameter `amount`represents. If true, the amount represents the input token of the swap.", + "- `a_to_b` - The direction of the swap. True if swapping from A to B. False if swapping from B to A.", + "", + "#### Special Errors", + "- `ZeroTradableAmount` - User provided parameter `amount` is 0.", + "- `InvalidSqrtPriceLimitDirection` - User provided parameter `sqrt_price_limit` does not match the direction of the trade.", + "- `SqrtPriceOutOfBounds` - User provided parameter `sqrt_price_limit` is over Whirlppool's max/min bounds for sqrt-price.", + "- `InvalidTickArraySequence` - User provided tick-arrays are not in sequential order required to proceed in this trade direction.", + "- `TickArraySequenceInvalidIndex` - The swap loop attempted to access an invalid array index during the query of the next initialized tick.", + "- `TickArrayIndexOutofBounds` - The swap loop attempted to access an invalid array index during tick crossing.", + "- `LiquidityOverflow` - Liquidity value overflowed 128bits during tick crossing.", + "- `InvalidTickSpacing` - The swap pool was initialized with tick-spacing of 0." + ], + "accounts": [ + { + "name": "tokenProgramA", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenProgramB", + "isMut": false, + "isSigner": false + }, + { + "name": "memoProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": true + }, + { + "name": "whirlpool", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenMintA", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenMintB", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenOwnerAccountA", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenVaultA", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenOwnerAccountB", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenVaultB", + "isMut": true, + "isSigner": false + }, + { + "name": "tickArray0", + "isMut": true, + "isSigner": false + }, + { + "name": "tickArray1", + "isMut": true, + "isSigner": false + }, + { + "name": "tickArray2", + "isMut": true, + "isSigner": false + }, + { + "name": "oracle", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "amount", + "type": "u64" + }, + { + "name": "otherAmountThreshold", + "type": "u64" + }, + { + "name": "sqrtPriceLimit", + "type": "u128" + }, + { + "name": "amountSpecifiedIsInput", + "type": "bool" + }, + { + "name": "aToB", + "type": "bool" + }, + { + "name": "remainingAccountsInfo", + "type": { + "option": { + "defined": "RemainingAccountsInfo" + } + } + } + ] + }, + { + "name": "twoHopSwapV2", + "docs": [ + "Perform a two-hop swap in this Whirlpool", + "", + "### Authority", + "- \"token_authority\" - The authority to withdraw tokens from the input token account.", + "", + "### Parameters", + "- `amount` - The amount of input or output token to swap from (depending on amount_specified_is_input).", + "- `other_amount_threshold` - The maximum/minimum of input/output token to swap into (depending on amount_specified_is_input).", + "- `amount_specified_is_input` - Specifies the token the parameter `amount`represents. If true, the amount represents the input token of the swap.", + "- `a_to_b_one` - The direction of the swap of hop one. True if swapping from A to B. False if swapping from B to A.", + "- `a_to_b_two` - The direction of the swap of hop two. True if swapping from A to B. False if swapping from B to A.", + "- `sqrt_price_limit_one` - The maximum/minimum price the swap will swap to in the first hop.", + "- `sqrt_price_limit_two` - The maximum/minimum price the swap will swap to in the second hop.", + "", + "#### Special Errors", + "- `ZeroTradableAmount` - User provided parameter `amount` is 0.", + "- `InvalidSqrtPriceLimitDirection` - User provided parameter `sqrt_price_limit` does not match the direction of the trade.", + "- `SqrtPriceOutOfBounds` - User provided parameter `sqrt_price_limit` is over Whirlppool's max/min bounds for sqrt-price.", + "- `InvalidTickArraySequence` - User provided tick-arrays are not in sequential order required to proceed in this trade direction.", + "- `TickArraySequenceInvalidIndex` - The swap loop attempted to access an invalid array index during the query of the next initialized tick.", + "- `TickArrayIndexOutofBounds` - The swap loop attempted to access an invalid array index during tick crossing.", + "- `LiquidityOverflow` - Liquidity value overflowed 128bits during tick crossing.", + "- `InvalidTickSpacing` - The swap pool was initialized with tick-spacing of 0.", + "- `InvalidIntermediaryMint` - Error if the intermediary mint between hop one and two do not equal.", + "- `DuplicateTwoHopPool` - Error if whirlpool one & two are the same pool." + ], + "accounts": [ + { + "name": "whirlpoolOne", + "isMut": true, + "isSigner": false + }, + { + "name": "whirlpoolTwo", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenMintInput", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenMintIntermediate", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenMintOutput", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenProgramInput", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenProgramIntermediate", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenProgramOutput", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenOwnerAccountInput", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenVaultOneInput", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenVaultOneIntermediate", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenVaultTwoIntermediate", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenVaultTwoOutput", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenOwnerAccountOutput", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": true + }, + { + "name": "tickArrayOne0", + "isMut": true, + "isSigner": false + }, + { + "name": "tickArrayOne1", + "isMut": true, + "isSigner": false + }, + { + "name": "tickArrayOne2", + "isMut": true, + "isSigner": false + }, + { + "name": "tickArrayTwo0", + "isMut": true, + "isSigner": false + }, + { + "name": "tickArrayTwo1", + "isMut": true, + "isSigner": false + }, + { + "name": "tickArrayTwo2", + "isMut": true, + "isSigner": false + }, + { + "name": "oracleOne", + "isMut": true, + "isSigner": false + }, + { + "name": "oracleTwo", + "isMut": true, + "isSigner": false + }, + { + "name": "memoProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "amount", + "type": "u64" + }, + { + "name": "otherAmountThreshold", + "type": "u64" + }, + { + "name": "amountSpecifiedIsInput", + "type": "bool" + }, + { + "name": "aToBOne", + "type": "bool" + }, + { + "name": "aToBTwo", + "type": "bool" + }, + { + "name": "sqrtPriceLimitOne", + "type": "u128" + }, + { + "name": "sqrtPriceLimitTwo", + "type": "u128" + }, + { + "name": "remainingAccountsInfo", + "type": { + "option": { + "defined": "RemainingAccountsInfo" + } + } + } + ] + }, + { + "name": "initializeConfigExtension", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "configExtension", + "isMut": true, + "isSigner": false + }, + { + "name": "funder", + "isMut": true, + "isSigner": true + }, + { + "name": "feeAuthority", + "isMut": false, + "isSigner": true + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "setConfigExtensionAuthority", + "accounts": [ + { + "name": "whirlpoolsConfig", + "isMut": false, + "isSigner": false + }, + { + "name": "whirlpoolsConfigExtension", + "isMut": true, + "isSigner": false + }, + { + "name": "configExtensionAuthority", + "isMut": false, + "isSigner": true + }, + { + "name": "newConfigExtensionAuthority", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "setTokenBadgeAuthority", + "accounts": [ + { + "name": "whirlpoolsConfig", + "isMut": false, + "isSigner": false + }, + { + "name": "whirlpoolsConfigExtension", + "isMut": true, + "isSigner": false + }, + { + "name": "configExtensionAuthority", + "isMut": false, + "isSigner": true + }, + { + "name": "newTokenBadgeAuthority", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "initializeTokenBadge", + "accounts": [ + { + "name": "whirlpoolsConfig", + "isMut": false, + "isSigner": false + }, + { + "name": "whirlpoolsConfigExtension", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenBadgeAuthority", + "isMut": false, + "isSigner": true + }, + { + "name": "tokenMint", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenBadge", + "isMut": true, + "isSigner": false + }, + { + "name": "funder", + "isMut": true, + "isSigner": true + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "deleteTokenBadge", + "accounts": [ + { + "name": "whirlpoolsConfig", + "isMut": false, + "isSigner": false + }, + { + "name": "whirlpoolsConfigExtension", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenBadgeAuthority", + "isMut": false, + "isSigner": true + }, + { + "name": "tokenMint", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenBadge", + "isMut": true, + "isSigner": false + }, + { + "name": "receiver", + "isMut": true, + "isSigner": false + } + ], + "args": [] + } + ], + "accounts": [ + { + "name": "AdaptiveFeeTier", + "type": { + "kind": "struct", + "fields": [ + { + "name": "whirlpoolsConfig", + "type": "publicKey" + }, + { + "name": "feeTierIndex", + "type": "u16" + }, + { + "name": "tickSpacing", + "type": "u16" + }, + { + "name": "initializePoolAuthority", + "type": "publicKey" + }, + { + "name": "delegatedFeeAuthority", + "type": "publicKey" + }, + { + "name": "defaultBaseFeeRate", + "type": "u16" + }, + { + "name": "filterPeriod", + "type": "u16" + }, + { + "name": "decayPeriod", + "type": "u16" + }, + { + "name": "reductionFactor", + "type": "u16" + }, + { + "name": "adaptiveFeeControlFactor", + "type": "u32" + }, + { + "name": "maxVolatilityAccumulator", + "type": "u32" + }, + { + "name": "tickGroupSize", + "type": "u16" + }, + { + "name": "majorSwapThresholdTicks", + "type": "u16" + } + ] + } + }, + { + "name": "WhirlpoolsConfig", + "type": { + "kind": "struct", + "fields": [ + { + "name": "feeAuthority", + "type": "publicKey" + }, + { + "name": "collectProtocolFeesAuthority", + "type": "publicKey" + }, + { + "name": "rewardEmissionsSuperAuthority", + "type": "publicKey" + }, + { + "name": "defaultProtocolFeeRate", + "type": "u16" + } + ] + } + }, + { + "name": "WhirlpoolsConfigExtension", + "type": { + "kind": "struct", + "fields": [ + { + "name": "whirlpoolsConfig", + "type": "publicKey" + }, + { + "name": "configExtensionAuthority", + "type": "publicKey" + }, + { + "name": "tokenBadgeAuthority", + "type": "publicKey" + } + ] + } + }, + { + "name": "FeeTier", + "type": { + "kind": "struct", + "fields": [ + { + "name": "whirlpoolsConfig", + "type": "publicKey" + }, + { + "name": "tickSpacing", + "type": "u16" + }, + { + "name": "defaultFeeRate", + "type": "u16" + } + ] + } + }, + { + "name": "LockConfig", + "type": { + "kind": "struct", + "fields": [ + { + "name": "position", + "type": "publicKey" + }, + { + "name": "positionOwner", + "type": "publicKey" + }, + { + "name": "whirlpool", + "type": "publicKey" + }, + { + "name": "lockedTimestamp", + "type": "u64" + }, + { + "name": "lockType", + "type": { + "defined": "LockTypeLabel" + } + } + ] + } + }, + { + "name": "Oracle", + "type": { + "kind": "struct", + "fields": [ + { + "name": "whirlpool", + "type": "publicKey" + }, + { + "name": "tradeEnableTimestamp", + "type": "u64" + }, + { + "name": "adaptiveFeeConstants", + "type": { + "defined": "AdaptiveFeeConstants" + } + }, + { + "name": "adaptiveFeeVariables", + "type": { + "defined": "AdaptiveFeeVariables" + } + }, + { + "name": "reserved", + "type": { + "array": [ + "u8", + 128 + ] + } + } + ] + } + }, + { + "name": "Position", + "type": { + "kind": "struct", + "fields": [ + { + "name": "whirlpool", + "type": "publicKey" + }, + { + "name": "positionMint", + "type": "publicKey" + }, + { + "name": "liquidity", + "type": "u128" + }, + { + "name": "tickLowerIndex", + "type": "i32" + }, + { + "name": "tickUpperIndex", + "type": "i32" + }, + { + "name": "feeGrowthCheckpointA", + "type": "u128" + }, + { + "name": "feeOwedA", + "type": "u64" + }, + { + "name": "feeGrowthCheckpointB", + "type": "u128" + }, + { + "name": "feeOwedB", + "type": "u64" + }, + { + "name": "rewardInfos", + "type": { + "array": [ + { + "defined": "PositionRewardInfo" + }, + 3 + ] + } + } + ] + } + }, + { + "name": "PositionBundle", + "type": { + "kind": "struct", + "fields": [ + { + "name": "positionBundleMint", + "type": "publicKey" + }, + { + "name": "positionBitmap", + "type": { + "array": [ + "u8", + 32 + ] + } + } + ] + } + }, + { + "name": "TickArray", + "type": { + "kind": "struct", + "fields": [ + { + "name": "startTickIndex", + "type": "i32" + }, + { + "name": "ticks", + "type": { + "array": [ + { + "defined": "Tick" + }, + 88 + ] + } + }, + { + "name": "whirlpool", + "type": "publicKey" + } + ] + } + }, + { + "name": "TokenBadge", + "type": { + "kind": "struct", + "fields": [ + { + "name": "whirlpoolsConfig", + "type": "publicKey" + }, + { + "name": "tokenMint", + "type": "publicKey" + } + ] + } + }, + { + "name": "Whirlpool", + "type": { + "kind": "struct", + "fields": [ + { + "name": "whirlpoolsConfig", + "type": "publicKey" + }, + { + "name": "whirlpoolBump", + "type": { + "array": [ + "u8", + 1 + ] + } + }, + { + "name": "tickSpacing", + "type": "u16" + }, + { + "name": "feeTierIndexSeed", + "type": { + "array": [ + "u8", + 2 + ] + } + }, + { + "name": "feeRate", + "type": "u16" + }, + { + "name": "protocolFeeRate", + "type": "u16" + }, + { + "name": "liquidity", + "type": "u128" + }, + { + "name": "sqrtPrice", + "type": "u128" + }, + { + "name": "tickCurrentIndex", + "type": "i32" + }, + { + "name": "protocolFeeOwedA", + "type": "u64" + }, + { + "name": "protocolFeeOwedB", + "type": "u64" + }, + { + "name": "tokenMintA", + "type": "publicKey" + }, + { + "name": "tokenVaultA", + "type": "publicKey" + }, + { + "name": "feeGrowthGlobalA", + "type": "u128" + }, + { + "name": "tokenMintB", + "type": "publicKey" + }, + { + "name": "tokenVaultB", + "type": "publicKey" + }, + { + "name": "feeGrowthGlobalB", + "type": "u128" + }, + { + "name": "rewardLastUpdatedTimestamp", + "type": "u64" + }, + { + "name": "rewardInfos", + "type": { + "array": [ + { + "defined": "WhirlpoolRewardInfo" + }, + 3 + ] + } + } + ] + } + } + ], + "types": [ + { + "name": "LockType", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Permanent" + } + ] + } + }, + { + "name": "LockTypeLabel", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Permanent" + } + ] + } + }, + { + "name": "AdaptiveFeeConstants", + "type": { + "kind": "struct", + "fields": [ + { + "name": "filterPeriod", + "type": "u16" + }, + { + "name": "decayPeriod", + "type": "u16" + }, + { + "name": "reductionFactor", + "type": "u16" + }, + { + "name": "adaptiveFeeControlFactor", + "type": "u32" + }, + { + "name": "maxVolatilityAccumulator", + "type": "u32" + }, + { + "name": "tickGroupSize", + "type": "u16" + }, + { + "name": "majorSwapThresholdTicks", + "type": "u16" + }, + { + "name": "reserved", + "type": { + "array": [ + "u8", + 16 + ] + } + } + ] + } + }, + { + "name": "AdaptiveFeeVariables", + "type": { + "kind": "struct", + "fields": [ + { + "name": "lastReferenceUpdateTimestamp", + "type": "u64" + }, + { + "name": "lastMajorSwapTimestamp", + "type": "u64" + }, + { + "name": "volatilityReference", + "type": "u32" + }, + { + "name": "tickGroupIndexReference", + "type": "i32" + }, + { + "name": "volatilityAccumulator", + "type": "u32" + }, + { + "name": "reserved", + "type": { + "array": [ + "u8", + 16 + ] + } + } + ] + } + }, + { + "name": "OpenPositionBumps", + "type": { + "kind": "struct", + "fields": [ + { + "name": "positionBump", + "type": "u8" + } + ] + } + }, + { + "name": "OpenPositionWithMetadataBumps", + "type": { + "kind": "struct", + "fields": [ + { + "name": "positionBump", + "type": "u8" + }, + { + "name": "metadataBump", + "type": "u8" + } + ] + } + }, + { + "name": "PositionRewardInfo", + "type": { + "kind": "struct", + "fields": [ + { + "name": "growthInsideCheckpoint", + "type": "u128" + }, + { + "name": "amountOwed", + "type": "u64" + } + ] + } + }, + { + "name": "Tick", + "type": { + "kind": "struct", + "fields": [ + { + "name": "initialized", + "type": "bool" + }, + { + "name": "liquidityNet", + "type": "i128" + }, + { + "name": "liquidityGross", + "type": "u128" + }, + { + "name": "feeGrowthOutsideA", + "type": "u128" + }, + { + "name": "feeGrowthOutsideB", + "type": "u128" + }, + { + "name": "rewardGrowthsOutside", + "type": { + "array": [ + "u128", + 3 + ] + } + } + ] + } + }, + { + "name": "WhirlpoolBumps", + "type": { + "kind": "struct", + "fields": [ + { + "name": "whirlpoolBump", + "type": "u8" + } + ] + } + }, + { + "name": "WhirlpoolRewardInfo", + "docs": [ + "Stores the state relevant for tracking liquidity mining rewards at the `Whirlpool` level.", + "These values are used in conjunction with `PositionRewardInfo`, `Tick.reward_growths_outside`,", + "and `Whirlpool.reward_last_updated_timestamp` to determine how many rewards are earned by open", + "positions." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "mint", + "docs": [ + "Reward token mint." + ], + "type": "publicKey" + }, + { + "name": "vault", + "docs": [ + "Reward vault token account." + ], + "type": "publicKey" + }, + { + "name": "authority", + "docs": [ + "Authority account that has permission to initialize the reward and set emissions." + ], + "type": "publicKey" + }, + { + "name": "emissionsPerSecondX64", + "docs": [ + "Q64.64 number that indicates how many tokens per second are earned per unit of liquidity." + ], + "type": "u128" + }, + { + "name": "growthGlobalX64", + "docs": [ + "Q64.64 number that tracks the total tokens earned per unit of liquidity since the reward", + "emissions were turned on." + ], + "type": "u128" + } + ] + } + }, + { + "name": "AccountsType", + "type": { + "kind": "enum", + "variants": [ + { + "name": "TransferHookA" + }, + { + "name": "TransferHookB" + }, + { + "name": "TransferHookReward" + }, + { + "name": "TransferHookInput" + }, + { + "name": "TransferHookIntermediate" + }, + { + "name": "TransferHookOutput" + }, + { + "name": "SupplementalTickArrays" + }, + { + "name": "SupplementalTickArraysOne" + }, + { + "name": "SupplementalTickArraysTwo" + } + ] + } + }, + { + "name": "RemainingAccountsInfo", + "type": { + "kind": "struct", + "fields": [ + { + "name": "slices", + "type": { + "vec": { + "defined": "RemainingAccountsSlice" + } + } + } + ] + } + }, + { + "name": "RemainingAccountsSlice", + "type": { + "kind": "struct", + "fields": [ + { + "name": "accountsType", + "type": { + "defined": "AccountsType" + } + }, + { + "name": "length", + "type": "u8" + } + ] + } + } + ], + "events": [ + { + "name": "LiquidityDecreased", + "fields": [ + { + "name": "whirlpool", + "type": "publicKey", + "index": false + }, + { + "name": "position", + "type": "publicKey", + "index": false + }, + { + "name": "tickLowerIndex", + "type": "i32", + "index": false + }, + { + "name": "tickUpperIndex", + "type": "i32", + "index": false + }, + { + "name": "liquidity", + "type": "u128", + "index": false + }, + { + "name": "tokenAAmount", + "type": "u64", + "index": false + }, + { + "name": "tokenBAmount", + "type": "u64", + "index": false + }, + { + "name": "tokenATransferFee", + "type": "u64", + "index": false + }, + { + "name": "tokenBTransferFee", + "type": "u64", + "index": false + } + ] + }, + { + "name": "LiquidityIncreased", + "fields": [ + { + "name": "whirlpool", + "type": "publicKey", + "index": false + }, + { + "name": "position", + "type": "publicKey", + "index": false + }, + { + "name": "tickLowerIndex", + "type": "i32", + "index": false + }, + { + "name": "tickUpperIndex", + "type": "i32", + "index": false + }, + { + "name": "liquidity", + "type": "u128", + "index": false + }, + { + "name": "tokenAAmount", + "type": "u64", + "index": false + }, + { + "name": "tokenBAmount", + "type": "u64", + "index": false + }, + { + "name": "tokenATransferFee", + "type": "u64", + "index": false + }, + { + "name": "tokenBTransferFee", + "type": "u64", + "index": false + } + ] + }, + { + "name": "PoolInitialized", + "fields": [ + { + "name": "whirlpool", + "type": "publicKey", + "index": false + }, + { + "name": "whirlpoolsConfig", + "type": "publicKey", + "index": false + }, + { + "name": "tokenMintA", + "type": "publicKey", + "index": false + }, + { + "name": "tokenMintB", + "type": "publicKey", + "index": false + }, + { + "name": "tickSpacing", + "type": "u16", + "index": false + }, + { + "name": "tokenProgramA", + "type": "publicKey", + "index": false + }, + { + "name": "tokenProgramB", + "type": "publicKey", + "index": false + }, + { + "name": "decimalsA", + "type": "u8", + "index": false + }, + { + "name": "decimalsB", + "type": "u8", + "index": false + }, + { + "name": "initialSqrtPrice", + "type": "u128", + "index": false + } + ] + }, + { + "name": "Traded", + "fields": [ + { + "name": "whirlpool", + "type": "publicKey", + "index": false + }, + { + "name": "aToB", + "type": "bool", + "index": false + }, + { + "name": "preSqrtPrice", + "type": "u128", + "index": false + }, + { + "name": "postSqrtPrice", + "type": "u128", + "index": false + }, + { + "name": "inputAmount", + "type": "u64", + "index": false + }, + { + "name": "outputAmount", + "type": "u64", + "index": false + }, + { + "name": "inputTransferFee", + "type": "u64", + "index": false + }, + { + "name": "outputTransferFee", + "type": "u64", + "index": false + }, + { + "name": "lpFee", + "type": "u64", + "index": false + }, + { + "name": "protocolFee", + "type": "u64", + "index": false + } + ] + } + ], + "errors": [ + { + "code": 6000, + "name": "InvalidEnum", + "msg": "Enum value could not be converted" + }, + { + "code": 6001, + "name": "InvalidStartTick", + "msg": "Invalid start tick index provided." + }, + { + "code": 6002, + "name": "TickArrayExistInPool", + "msg": "Tick-array already exists in this whirlpool" + }, + { + "code": 6003, + "name": "TickArrayIndexOutofBounds", + "msg": "Attempt to search for a tick-array failed" + }, + { + "code": 6004, + "name": "InvalidTickSpacing", + "msg": "Tick-spacing is not supported" + }, + { + "code": 6005, + "name": "ClosePositionNotEmpty", + "msg": "Position is not empty It cannot be closed" + }, + { + "code": 6006, + "name": "DivideByZero", + "msg": "Unable to divide by zero" + }, + { + "code": 6007, + "name": "NumberCastError", + "msg": "Unable to cast number into BigInt" + }, + { + "code": 6008, + "name": "NumberDownCastError", + "msg": "Unable to down cast number" + }, + { + "code": 6009, + "name": "TickNotFound", + "msg": "Tick not found within tick array" + }, + { + "code": 6010, + "name": "InvalidTickIndex", + "msg": "Provided tick index is either out of bounds or uninitializable" + }, + { + "code": 6011, + "name": "SqrtPriceOutOfBounds", + "msg": "Provided sqrt price out of bounds" + }, + { + "code": 6012, + "name": "LiquidityZero", + "msg": "Liquidity amount must be greater than zero" + }, + { + "code": 6013, + "name": "LiquidityTooHigh", + "msg": "Liquidity amount must be less than i64::MAX" + }, + { + "code": 6014, + "name": "LiquidityOverflow", + "msg": "Liquidity overflow" + }, + { + "code": 6015, + "name": "LiquidityUnderflow", + "msg": "Liquidity underflow" + }, + { + "code": 6016, + "name": "LiquidityNetError", + "msg": "Tick liquidity net underflowed or overflowed" + }, + { + "code": 6017, + "name": "TokenMaxExceeded", + "msg": "Exceeded token max" + }, + { + "code": 6018, + "name": "TokenMinSubceeded", + "msg": "Did not meet token min" + }, + { + "code": 6019, + "name": "MissingOrInvalidDelegate", + "msg": "Position token account has a missing or invalid delegate" + }, + { + "code": 6020, + "name": "InvalidPositionTokenAmount", + "msg": "Position token amount must be 1" + }, + { + "code": 6021, + "name": "InvalidTimestampConversion", + "msg": "Timestamp should be convertible from i64 to u64" + }, + { + "code": 6022, + "name": "InvalidTimestamp", + "msg": "Timestamp should be greater than the last updated timestamp" + }, + { + "code": 6023, + "name": "InvalidTickArraySequence", + "msg": "Invalid tick array sequence provided for instruction." + }, + { + "code": 6024, + "name": "InvalidTokenMintOrder", + "msg": "Token Mint in wrong order" + }, + { + "code": 6025, + "name": "RewardNotInitialized", + "msg": "Reward not initialized" + }, + { + "code": 6026, + "name": "InvalidRewardIndex", + "msg": "Invalid reward index" + }, + { + "code": 6027, + "name": "RewardVaultAmountInsufficient", + "msg": "Reward vault requires amount to support emissions for at least one day" + }, + { + "code": 6028, + "name": "FeeRateMaxExceeded", + "msg": "Exceeded max fee rate" + }, + { + "code": 6029, + "name": "ProtocolFeeRateMaxExceeded", + "msg": "Exceeded max protocol fee rate" + }, + { + "code": 6030, + "name": "MultiplicationShiftRightOverflow", + "msg": "Multiplication with shift right overflow" + }, + { + "code": 6031, + "name": "MulDivOverflow", + "msg": "Muldiv overflow" + }, + { + "code": 6032, + "name": "MulDivInvalidInput", + "msg": "Invalid div_u256 input" + }, + { + "code": 6033, + "name": "MultiplicationOverflow", + "msg": "Multiplication overflow" + }, + { + "code": 6034, + "name": "InvalidSqrtPriceLimitDirection", + "msg": "Provided SqrtPriceLimit not in the same direction as the swap." + }, + { + "code": 6035, + "name": "ZeroTradableAmount", + "msg": "There are no tradable amount to swap." + }, + { + "code": 6036, + "name": "AmountOutBelowMinimum", + "msg": "Amount out below minimum threshold" + }, + { + "code": 6037, + "name": "AmountInAboveMaximum", + "msg": "Amount in above maximum threshold" + }, + { + "code": 6038, + "name": "TickArraySequenceInvalidIndex", + "msg": "Invalid index for tick array sequence" + }, + { + "code": 6039, + "name": "AmountCalcOverflow", + "msg": "Amount calculated overflows" + }, + { + "code": 6040, + "name": "AmountRemainingOverflow", + "msg": "Amount remaining overflows" + }, + { + "code": 6041, + "name": "InvalidIntermediaryMint", + "msg": "Invalid intermediary mint" + }, + { + "code": 6042, + "name": "DuplicateTwoHopPool", + "msg": "Duplicate two hop pool" + }, + { + "code": 6043, + "name": "InvalidBundleIndex", + "msg": "Bundle index is out of bounds" + }, + { + "code": 6044, + "name": "BundledPositionAlreadyOpened", + "msg": "Position has already been opened" + }, + { + "code": 6045, + "name": "BundledPositionAlreadyClosed", + "msg": "Position has already been closed" + }, + { + "code": 6046, + "name": "PositionBundleNotDeletable", + "msg": "Unable to delete PositionBundle with open positions" + }, + { + "code": 6047, + "name": "UnsupportedTokenMint", + "msg": "Token mint has unsupported attributes" + }, + { + "code": 6048, + "name": "RemainingAccountsInvalidSlice", + "msg": "Invalid remaining accounts" + }, + { + "code": 6049, + "name": "RemainingAccountsInsufficient", + "msg": "Insufficient remaining accounts" + }, + { + "code": 6050, + "name": "NoExtraAccountsForTransferHook", + "msg": "Unable to call transfer hook without extra accounts" + }, + { + "code": 6051, + "name": "IntermediateTokenAmountMismatch", + "msg": "Output and input amount mismatch" + }, + { + "code": 6052, + "name": "TransferFeeCalculationError", + "msg": "Transfer fee calculation failed" + }, + { + "code": 6053, + "name": "RemainingAccountsDuplicatedAccountsType", + "msg": "Same accounts type is provided more than once" + }, + { + "code": 6054, + "name": "FullRangeOnlyPool", + "msg": "This whirlpool only supports full-range positions" + }, + { + "code": 6055, + "name": "TooManySupplementalTickArrays", + "msg": "Too many supplemental tick arrays provided" + }, + { + "code": 6056, + "name": "DifferentWhirlpoolTickArrayAccount", + "msg": "TickArray account for different whirlpool provided" + }, + { + "code": 6057, + "name": "PartialFillError", + "msg": "Trade resulted in partial fill" + }, + { + "code": 6058, + "name": "PositionNotLockable", + "msg": "Position is not lockable" + }, + { + "code": 6059, + "name": "OperationNotAllowedOnLockedPosition", + "msg": "Operation not allowed on locked position" + }, + { + "code": 6060, + "name": "SameTickRangeNotAllowed", + "msg": "Cannot reset position range with same tick range" + }, + { + "code": 6061, + "name": "InvalidAdaptiveFeeConstants", + "msg": "Invalid adaptive fee constants" + }, + { + "code": 6062, + "name": "InvalidFeeTierIndex", + "msg": "Invalid fee tier index" + }, + { + "code": 6063, + "name": "InvalidTradeEnableTimestamp", + "msg": "Invalid trade enable timestamp" + }, + { + "code": 6064, + "name": "TradeIsNotEnabled", + "msg": "Trade is not enabled yet" + } + ] +} diff --git a/tests/fixtures/whirlpool.so b/tests/fixtures/whirlpool.so new file mode 100644 index 00000000..8b3d04bb Binary files /dev/null and b/tests/fixtures/whirlpool.so differ diff --git a/tests/fixtures/wsol-mint b/tests/fixtures/wsol-mint new file mode 100644 index 00000000..422ea925 Binary files /dev/null and b/tests/fixtures/wsol-mint differ diff --git a/tests/futarchy/unit/adminEnqueueMultisigProposalApproval.test.ts b/tests/futarchy/unit/adminEnqueueMultisigProposalApproval.test.ts index 2bcdb77a..3ff8e6ff 100644 --- a/tests/futarchy/unit/adminEnqueueMultisigProposalApproval.test.ts +++ b/tests/futarchy/unit/adminEnqueueMultisigProposalApproval.test.ts @@ -23,12 +23,13 @@ export default function suite() { await this.createTokenAccount(META, this.payer.publicKey); await this.createTokenAccount(USDC, this.payer.publicKey); - await this.mintTo(META, this.payer.publicKey, this.payer, 100 * 10 ** 9); + await this.mintTo(META, this.payer.publicKey, this.payer, 100 * 10 ** 9, 1); await this.mintTo( USDC, this.payer.publicKey, this.payer, 100_000 * 1_000_000, + 1, ); dao = await this.setupBasicDaoWithLiquidity({ diff --git a/tests/futarchy/unit/executeMultisigProposalApproval.test.ts b/tests/futarchy/unit/executeMultisigProposalApproval.test.ts index 233118d1..ee582cc7 100644 --- a/tests/futarchy/unit/executeMultisigProposalApproval.test.ts +++ b/tests/futarchy/unit/executeMultisigProposalApproval.test.ts @@ -23,12 +23,13 @@ export default function suite() { await this.createTokenAccount(META, this.payer.publicKey); await this.createTokenAccount(USDC, this.payer.publicKey); - await this.mintTo(META, this.payer.publicKey, this.payer, 100 * 10 ** 9); + await this.mintTo(META, this.payer.publicKey, this.payer, 100 * 10 ** 9, 1); await this.mintTo( USDC, this.payer.publicKey, this.payer, 100_000 * 1_000_000, + 1, ); dao = await this.setupBasicDaoWithLiquidity({ diff --git a/tests/futarchy/unit/executeSpendingLimitChange.test.ts b/tests/futarchy/unit/executeSpendingLimitChange.test.ts index da68fc85..aa32d7f8 100644 --- a/tests/futarchy/unit/executeSpendingLimitChange.test.ts +++ b/tests/futarchy/unit/executeSpendingLimitChange.test.ts @@ -33,7 +33,7 @@ export default function suite() { await this.createTokenAccount(META, this.payer.publicKey); await this.createTokenAccount(USDC, this.payer.publicKey); - await this.mintTo(META, this.payer.publicKey, this.payer, 100 * 10 ** 9); + await this.mintTo(META, this.payer.publicKey, this.payer, 100 * 10 ** 9, 1); await this.mintTo( USDC, this.payer.publicKey, diff --git a/tests/gatedMint/unit/removeWhitelistedUser.test.ts b/tests/gatedMint/unit/removeWhitelistedUser.test.ts index 713743f1..1dd5e9ba 100644 --- a/tests/gatedMint/unit/removeWhitelistedUser.test.ts +++ b/tests/gatedMint/unit/removeWhitelistedUser.test.ts @@ -1,4 +1,4 @@ -import { Keypair, PublicKey } from "@solana/web3.js"; +import { ComputeBudgetProgram, Keypair, PublicKey } from "@solana/web3.js"; import * as token from "@solana/spl-token"; import { assert } from "chai"; import { mintTo } from "spl-token-bankrun"; @@ -191,8 +191,21 @@ export default function suite() { .rpc(); assert.isNull(await this.banksClient.getAccount(addr)); - // The PDA was freed, so the same user can be whitelisted again. - await whitelistUser(gatedMintClient, mint, admin, user, this.payer); + // The PDA was freed, so the same user can be whitelisted again. The + // compute-unit-limit instruction makes the transaction hash unique so + // the re-add isn't rejected as a duplicate of the first add. + await gatedMintClient + .addWhitelistedUserIx({ + mint, + authority: admin.publicKey, + user, + payer: this.payer.publicKey, + }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_001 }), + ]) + .signers([admin]) + .rpc(); assert.isNotNull(await this.banksClient.getAccount(addr)); }); diff --git a/tests/integration/relaunch.test.ts b/tests/integration/relaunch.test.ts new file mode 100644 index 00000000..a0485a75 --- /dev/null +++ b/tests/integration/relaunch.test.ts @@ -0,0 +1,873 @@ +import { Keypair, PublicKey, Transaction } from "@solana/web3.js"; +import * as token from "@solana/spl-token"; +import { assert } from "chai"; +import { BN } from "bn.js"; +import { BankrunProvider } from "anchor-bankrun"; +import { BanksClient } from "solana-bankrun"; +import { + FutarchyClient, + getDaoAddr, + MAINNET_USDC, + parseWhirlpool, + RelaunchClient, + USDC_SWAP_POOL, +} from "@metadaoproject/programs"; +import { + setupRelaunch, + RelaunchSetup, + DEFAULT_OLD_SUPPLY, +} from "../relaunch/utils.js"; +import { writePumpPool } from "../relaunch/pumpAmm.js"; +import { writeRaydiumPool } from "../relaunch/raydiumAmm.js"; +import { + ensureWhirlpool, + WhirlpoolFixture, + wrapSol, +} from "../relaunch/whirlpool.js"; + +const POOL_BASE_RESERVE = 1_000_000n * 10n ** 6n; // 1M old tokens +// Small enough that the sell proceeds (~5 SOL) swap through the whirlpool +// fixture (1000 SOL / 100k USDC) with well under 1% price impact. +const WSOL_POOL_QUOTE_RESERVE = 5n * 10n ** 9n; // 5 SOL +const USDC_POOL_QUOTE_RESERVE = 100_000n * 10n ** 6n; // 100k USDC + +const TOKENS_TO_DEPOSITORS = 12_500_000n * 10n ** 6n; +const TOKENS_TO_FUTARCHY_LIQUIDITY = 12_500_000n * 10n ** 6n; +const PRICE_SCALE = 10n ** 12n; + +const ONE_WEEK = 60 * 60 * 24 * 7; +const ONE_DAY = 60 * 60 * 24; + +// 10% of the 1B-token default supply = 100M tokens. +const THRESHOLD_BPS = 1000; +const THRESHOLD_AMOUNT = DEFAULT_OLD_SUPPLY / 10n; + +// The happy-path deposits: 60M + 39.99M direct plus 10k bought lands exactly +// on the 100M threshold and splits the 10M depositor bucket into +// 6M / 3.999M / 1k with zero dust. +const ALICE_DEPOSIT = 60_000_000n * 10n ** 6n; +const BOB_DEPOSIT = 39_990_000n * 10n ** 6n; +const BUY_DEPOSIT = 10_000n * 10n ** 6n; + +async function tokenBalance( + banksClient: BanksClient, + address: PublicKey, + tokenProgram: PublicKey = token.TOKEN_PROGRAM_ID, +): Promise { + const raw = await banksClient.getAccount(address); + if (!raw) return 0n; + return token.unpackAccount( + address, + { ...raw, data: Buffer.from(raw.data) } as any, + tokenProgram, + ).amount; +} + +function ceilDiv(a: bigint, b: bigint): bigint { + return (a + b - 1n) / b; +} + +// Raydium AMM v4 money math at its flat 25 bps fee, which stays in the pool. +// The exact input pulled for an exact-output buy: the constant-product input, +// ceil-rounded, with the fee ceil-rounded on top of it. +function raydiumBuyIn( + amountOut: bigint, + quoteReserve: bigint, + tokenReserve: bigint, +): bigint { + const inBeforeFee = ceilDiv( + quoteReserve * amountOut, + tokenReserve - amountOut, + ); + return ceilDiv(inBeforeFee * 10_000n, 9_975n); +} + +// The exact output of an exact-in sell: the fee is ceil-rounded off the +// input, the remainder swaps at constant product. +function raydiumSellOut( + amountIn: bigint, + tokenReserve: bigint, + quoteReserve: bigint, +): bigint { + const net = amountIn - ceilDiv(amountIn * 25n, 10_000n); + return (quoteReserve * net) / (tokenReserve + net); +} + +export default function suite() { + let client: RelaunchClient; + let futarchyClient: FutarchyClient; + let whirlpool: WhirlpoolFixture; + + before(async function () { + // Created here rather than taken from the context so the suite also runs + // standalone, without the relaunch unit suites' before hook. + const provider = new BankrunProvider(this.context); + client = RelaunchClient.createClient({ provider: provider as any }); + futarchyClient = this.futarchy; + whirlpool = await ensureWhirlpool({ + provider: provider as any, + payer: this.payer, + banksClient: this.banksClient, + }); + }); + + const initializeLiveRelaunch = async function ( + this: Mocha.Context, + { + quoteMint, + oldTokenProgram, + }: { quoteMint: PublicKey; oldTokenProgram: PublicKey }, + ) { + const setup = await setupRelaunch({ + banksClient: this.banksClient, + payer: this.payer, + oldTokenProgram, + }); + const pool = await writePumpPool({ + context: this.context, + baseMint: setup.oldMint, + quoteMint, + baseReserve: POOL_BASE_RESERVE, + quoteReserve: quoteMint.equals(token.NATIVE_MINT) + ? WSOL_POOL_QUOTE_RESERVE + : USDC_POOL_QUOTE_RESERVE, + baseTokenProgram: oldTokenProgram, + }); + + const { relaunch, newMint } = await client.initializeRelaunch({ + oldMint: setup.oldMint, + sourcePool: pool.pool, + sourceQuoteMint: quoteMint, + tokenName: "Relaunched", + tokenSymbol: "RLNCH", + tokenUri: "https://example.com/rlnch.json", + secondsForDeposits: ONE_WEEK, + gracePeriodSeconds: ONE_DAY, + thresholdBps: THRESHOLD_BPS, + teamAddress: this.payer.publicKey, + }); + + await client.startDepositsIx({ relaunch }).rpc(); + + return { setup, pool, relaunch, newMint }; + }; + + // Raydium sources are WSOL-quoted classic-SPL by construction, so unlike + // the pump helper there is no matrix to parameterize. + const initializeLiveRaydiumRelaunch = async function (this: Mocha.Context) { + const setup = await setupRelaunch({ + banksClient: this.banksClient, + payer: this.payer, + }); + const pool = writeRaydiumPool({ + context: this.context, + oldMint: setup.oldMint, + tokenReserve: POOL_BASE_RESERVE, + quoteReserve: WSOL_POOL_QUOTE_RESERVE, + }); + + const { relaunch, newMint } = await client.initializeRelaunch({ + oldMint: setup.oldMint, + sourcePool: pool.pool, + sourceQuoteMint: token.NATIVE_MINT, + tokenName: "Relaunched", + tokenSymbol: "RLNCH", + tokenUri: "https://example.com/rlnch.json", + secondsForDeposits: ONE_WEEK, + gracePeriodSeconds: ONE_DAY, + thresholdBps: THRESHOLD_BPS, + teamAddress: this.payer.publicKey, + }); + + await client.startDepositsIx({ relaunch }).rpc(); + + return { setup, pool, relaunch, newMint }; + }; + + // Creates the depositor's ATA and funds it with old tokens from the payer. + const fundDepositor = async function ( + this: Mocha.Context, + { oldMint, oldTokenProgram, payerOldTokenAccount }: RelaunchSetup, + depositor: PublicKey, + amount: bigint, + ): Promise { + const ata = token.getAssociatedTokenAddressSync( + oldMint, + depositor, + false, + oldTokenProgram, + ); + const tx = new Transaction().add( + token.createAssociatedTokenAccountIdempotentInstruction( + this.payer.publicKey, + ata, + depositor, + oldMint, + oldTokenProgram, + ), + token.createTransferCheckedInstruction( + payerOldTokenAccount, + oldMint, + ata, + this.payer.publicKey, + amount, + 6, + [], + oldTokenProgram, + ), + ); + tx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + tx.feePayer = this.payer.publicKey; + tx.sign(this.payer); + await this.banksClient.processTransaction(tx); + return ata; + }; + + const deposit = async function ( + this: Mocha.Context, + relaunch: PublicKey, + { oldMint, oldTokenProgram }: RelaunchSetup, + amount: bigint, + depositor?: Keypair, + ) { + const builder = client.depositIx({ + relaunch, + oldMint, + oldTokenProgram, + amount: new BN(amount.toString()), + depositor: depositor?.publicKey, + }); + if (depositor !== undefined) { + builder.signers([depositor]); + } + await builder.rpc(); + }; + + // Drives a sold relaunch through the venue-invariant tail — the Orca leg + // for WSOL-quoted sources, completion, and the pro-rata claims — asserting + // identities that hold for every source venue. + const runCompletionTail = async function ( + this: Mocha.Context, + { + relaunch, + newMint, + alice, + bob, + isWsol, + }: { + relaunch: PublicKey; + newMint: PublicKey; + alice: Keypair; + bob: Keypair; + isWsol: boolean; + }, + ) { + let stored = await client.fetchRelaunch(relaunch); + const quoteRecovered = BigInt(stored.quoteRecovered.toString()); + + if (isWsol) { + assert.isDefined(stored.state.sold); + assert.equal(stored.usdcRecovered.toString(), "0"); + assert.equal( + ( + await tokenBalance(this.banksClient, stored.sourceQuoteVault) + ).toString(), + quoteRecovered.toString(), + ); + + const whirlpoolState = parseWhirlpool( + Buffer.from((await this.banksClient.getAccount(USDC_SWAP_POOL))!.data), + ); + // Spot price in USDC-raw per WSOL-raw is (sqrtPrice / 2^64)^2. + const spotUsdcOut = + (quoteRecovered * + whirlpoolState.sqrtPrice * + whirlpoolState.sqrtPrice) >> + 128n; + const whirlpoolWsolBefore = await tokenBalance( + this.banksClient, + whirlpool.tokenVaultA, + ); + const whirlpoolUsdcBefore = await tokenBalance( + this.banksClient, + whirlpool.tokenVaultB, + ); + + await client.executeUsdcSwap({ relaunch }); + + stored = await client.fetchRelaunch(relaunch); + assert.equal(stored.quoteRecovered.toString(), quoteRecovered.toString()); + assert.equal( + ( + await tokenBalance(this.banksClient, stored.sourceQuoteVault) + ).toString(), + "0", + ); + + // The full WSOL proceeds went into the whirlpool, every USDC it paid + // out landed in the relaunch's vault, and the output sits at spot + // minus the fee and a little price impact. + const usdcRecovered = BigInt(stored.usdcRecovered.toString()); + assert.equal( + ( + (await tokenBalance(this.banksClient, whirlpool.tokenVaultA)) - + whirlpoolWsolBefore + ).toString(), + quoteRecovered.toString(), + ); + assert.equal( + ( + whirlpoolUsdcBefore - + (await tokenBalance(this.banksClient, whirlpool.tokenVaultB)) + ).toString(), + usdcRecovered.toString(), + ); + assert.isTrue(usdcRecovered > (spotUsdcOut * 97n) / 100n); + assert.isTrue(usdcRecovered <= spotUsdcOut); + } else { + // USDC-quoted sources share one vault for quote and USDC and jump + // straight to Swapped. + assert.ok(stored.sourceQuoteVault.equals(stored.usdcVault)); + assert.equal(stored.usdcRecovered.toString(), quoteRecovered.toString()); + } + + assert.isDefined(stored.state.swapped); + const usdcRecovered = BigInt(stored.usdcRecovered.toString()); + assert.equal( + (await tokenBalance(this.banksClient, stored.usdcVault)).toString(), + usdcRecovered.toString(), + ); + + await client.completeRelaunch({ relaunch }); + + stored = await client.fetchRelaunch(relaunch); + assert.isDefined(stored.state.complete); + + const relaunchSigner = client.getRelaunchSignerAddress({ relaunch }); + const [dao] = getDaoAddr({ nonce: new BN(0), daoCreator: relaunchSigner }); + assert.ok(stored.dao.equals(dao)); + + const storedDao = await futarchyClient.getDao(dao); + assert.ok(storedDao.baseMint.equals(newMint)); + assert.ok(storedDao.quoteMint.equals(MAINNET_USDC)); + + // Price identity: the TWAP opens at the raise valued over the liquidity + // bucket, and the AMM opens at exactly that ratio — the full 12.5M + // liquidity bucket against the whole raise. + const expectedTwap = + (usdcRecovered * PRICE_SCALE) / TOKENS_TO_FUTARCHY_LIQUIDITY; + assert.equal( + storedDao.twapInitialObservation.toString(), + expectedTwap.toString(), + ); + assert.equal( + storedDao.twapMaxObservationChangePerUpdate.toString(), + (expectedTwap / 20n).toString(), + ); + + const spot = storedDao.amm.state.spot.spot; + const baseReserves = BigInt(spot.baseReserves.toString()); + const quoteReserves = BigInt(spot.quoteReserves.toString()); + assert.equal( + baseReserves.toString(), + TOKENS_TO_FUTARCHY_LIQUIDITY.toString(), + ); + assert.equal(quoteReserves.toString(), usdcRecovered.toString()); + assert.equal( + ((quoteReserves * PRICE_SCALE) / baseReserves).toString(), + expectedTwap.toString(), + ); + + // The whole raise seeds the AMM; the Squads vault gets no USDC. + const treasuryBalance = await tokenBalance( + this.banksClient, + token.getAssociatedTokenAddressSync(MAINNET_USDC, stored.daoVault, true), + ); + assert.equal(treasuryBalance.toString(), "0"); + assert.equal( + (await tokenBalance(this.banksClient, stored.usdcVault)).toString(), + "0", + ); + + assert.equal( + (await tokenBalance(this.banksClient, stored.newTokenVault)).toString(), + TOKENS_TO_DEPOSITORS.toString(), + ); + const mint = await this.getMint(newMint); + assert.ok(mint.mintAuthority.equals(stored.daoVault)); + + // Claims: 12.5M × 60M/100M, 12.5M × 39.99M/100M, 12.5M × 10k/100M — + // every share divides exactly, so the vault empties with zero dust. + await client + .claimIx({ relaunch, newMint, depositor: alice.publicKey }) + .rpc(); + await client.claimIx({ relaunch, newMint, depositor: bob.publicKey }).rpc(); + await client.claimIx({ relaunch, newMint }).rpc(); + + const expectedClaims: [PublicKey, bigint][] = [ + [alice.publicKey, 7_500_000n * 10n ** 6n], + [bob.publicKey, 4_998_750n * 10n ** 6n], + [this.payer.publicKey, 1_250n * 10n ** 6n], + ]; + for (const [owner, amount] of expectedClaims) { + assert.equal( + ( + await tokenBalance( + this.banksClient, + token.getAssociatedTokenAddressSync(newMint, owner), + ) + ).toString(), + amount.toString(), + ); + const record = await client.getDepositRecord({ + relaunch, + depositor: owner, + }); + assert.isTrue(record.claimed); + } + assert.equal( + (await tokenBalance(this.banksClient, stored.newTokenVault)).toString(), + "0", + ); + + // Every hop emitted exactly one event: start, three deposits, close, + // sell, (the USDC swap,) complete, three claims. + stored = await client.fetchRelaunch(relaunch); + assert.equal(stored.seqNum.toString(), isWsol ? "11" : "10"); + }; + + const runHappyPath = async function ( + this: Mocha.Context, + { + quoteMint, + oldTokenProgram, + }: { quoteMint: PublicKey; oldTokenProgram: PublicKey }, + ) { + const isWsol = quoteMint.equals(token.NATIVE_MINT); + const { setup, pool, relaunch, newMint } = + await initializeLiveRelaunch.call(this, { quoteMint, oldTokenProgram }); + + let stored = await client.fetchRelaunch(relaunch); + assert.isDefined(stored.state.live); + assert.equal( + stored.oldSupplySnapshot.toString(), + DEFAULT_OLD_SUPPLY.toString(), + ); + assert.equal( + (await tokenBalance(this.banksClient, stored.newTokenVault)).toString(), + (TOKENS_TO_DEPOSITORS + TOKENS_TO_FUTARCHY_LIQUIDITY).toString(), + ); + + // Mixed entry: two direct depositors interleaved with a bought deposit. + const alice = Keypair.generate(); + const bob = Keypair.generate(); + await fundDepositor.call(this, setup, alice.publicKey, ALICE_DEPOSIT); + await fundDepositor.call(this, setup, bob.publicKey, BOB_DEPOSIT); + + await deposit.call(this, relaunch, setup, ALICE_DEPOSIT, alice); + await client.depositViaBuy({ + relaunch, + baseOut: new BN(BUY_DEPOSIT.toString()), + maxQuoteIn: new BN((isWsol ? 10n ** 9n : 2_000n * 10n ** 6n).toString()), + }); + await deposit.call(this, relaunch, setup, BOB_DEPOSIT, bob); + + stored = await client.fetchRelaunch(relaunch); + assert.equal(stored.totalDeposited.toString(), THRESHOLD_AMOUNT.toString()); + assert.equal( + ( + await tokenBalance( + this.banksClient, + stored.oldTokenVault, + oldTokenProgram, + ) + ).toString(), + THRESHOLD_AMOUNT.toString(), + ); + const expectedDeposits: [PublicKey, bigint][] = [ + [alice.publicKey, ALICE_DEPOSIT], + [bob.publicKey, BOB_DEPOSIT], + [this.payer.publicKey, BUY_DEPOSIT], + ]; + for (const [depositor, amount] of expectedDeposits) { + const record = await client.getDepositRecord({ relaunch, depositor }); + assert.equal(record.amountDeposited.toString(), amount.toString()); + } + + // The deposits land exactly on the threshold, so closing moves to + // SellPending. + await this.advanceBySeconds(ONE_WEEK); + await client.closeDepositsIx({ relaunch }).rpc(); + stored = await client.fetchRelaunch(relaunch); + assert.isDefined(stored.state.sellPending); + + const poolBaseBefore = await tokenBalance( + this.banksClient, + pool.poolBaseTokenAccount, + oldTokenProgram, + ); + const poolQuoteBefore = await tokenBalance( + this.banksClient, + pool.poolQuoteTokenAccount, + ); + + // 100 bps of slippage can exactly match pump's fee, leaving no rounding + // margin; give the sell floor explicit headroom. + await client.executeSell({ relaunch, slippageBps: 200 }); + + stored = await client.fetchRelaunch(relaunch); + const quoteRecovered = BigInt(stored.quoteRecovered.toString()); + assert.equal( + ( + await tokenBalance( + this.banksClient, + stored.oldTokenVault, + oldTokenProgram, + ) + ).toString(), + "0", + ); + assert.equal( + ( + await tokenBalance( + this.banksClient, + pool.poolBaseTokenAccount, + oldTokenProgram, + ) + ).toString(), + (poolBaseBefore + THRESHOLD_AMOUNT).toString(), + ); + + // The proceeds are the constant-product output minus pump's fees. + const grossQuoteOut = + (poolQuoteBefore * THRESHOLD_AMOUNT) / + (poolBaseBefore + THRESHOLD_AMOUNT); + assert.isTrue(quoteRecovered > (grossQuoteOut * 97n) / 100n); + assert.isTrue(quoteRecovered <= grossQuoteOut); + + await runCompletionTail.call(this, { + relaunch, + newMint, + alice, + bob, + isWsol, + }); + }; + + describe("happy path", function () { + it("relaunches a classic-SPL token from a WSOL-quoted pool", async function () { + await runHappyPath.call(this, { + quoteMint: token.NATIVE_MINT, + oldTokenProgram: token.TOKEN_PROGRAM_ID, + }); + }); + + it("relaunches a Token-2022 token from a WSOL-quoted pool", async function () { + await runHappyPath.call(this, { + quoteMint: token.NATIVE_MINT, + oldTokenProgram: token.TOKEN_2022_PROGRAM_ID, + }); + }); + + it("relaunches a classic-SPL token from a USDC-quoted pool", async function () { + await runHappyPath.call(this, { + quoteMint: MAINNET_USDC, + oldTokenProgram: token.TOKEN_PROGRAM_ID, + }); + }); + + it("relaunches a Token-2022 token from a USDC-quoted pool", async function () { + await runHappyPath.call(this, { + quoteMint: MAINNET_USDC, + oldTokenProgram: token.TOKEN_2022_PROGRAM_ID, + }); + }); + }); + + // Two direct depositors and a bought deposit that together miss the + // threshold, then exact refunds for all three — buy-credited tokens refund + // as old tokens just like direct ones, whatever the source venue. + const runThresholdMiss = async function ( + this: Mocha.Context, + { setup, relaunch }: { setup: RelaunchSetup; relaunch: PublicKey }, + ) { + const alice = Keypair.generate(); + const bob = Keypair.generate(); + const aliceDeposit = 30_000_000n * 10n ** 6n; + const bobDeposit = 20_000_000n * 10n ** 6n; + const aliceAta = await fundDepositor.call( + this, + setup, + alice.publicKey, + aliceDeposit, + ); + const bobAta = await fundDepositor.call( + this, + setup, + bob.publicKey, + bobDeposit, + ); + + await deposit.call(this, relaunch, setup, aliceDeposit, alice); + await client.depositViaBuy({ + relaunch, + baseOut: new BN(BUY_DEPOSIT.toString()), + maxQuoteIn: new BN((10n ** 9n).toString()), + }); + await deposit.call(this, relaunch, setup, bobDeposit, bob); + + // 50.01M of the 100M threshold, so closing lands in Failed. + await this.advanceBySeconds(ONE_WEEK); + await client.closeDepositsIx({ relaunch }).rpc(); + + const stored = await client.fetchRelaunch(relaunch); + assert.isDefined(stored.state.failed); + const totalDeposited = aliceDeposit + bobDeposit + BUY_DEPOSIT; + assert.equal(stored.totalDeposited.toString(), totalDeposited.toString()); + assert.equal( + (await tokenBalance(this.banksClient, stored.oldTokenVault)).toString(), + totalDeposited.toString(), + ); + + const payerBefore = await tokenBalance( + this.banksClient, + setup.payerOldTokenAccount, + ); + + await client.claimRefund({ relaunch, depositor: alice.publicKey }); + await client.claimRefund({ relaunch, depositor: bob.publicKey }); + await client.claimRefund({ relaunch }); + + assert.equal( + (await tokenBalance(this.banksClient, aliceAta)).toString(), + aliceDeposit.toString(), + ); + assert.equal( + (await tokenBalance(this.banksClient, bobAta)).toString(), + bobDeposit.toString(), + ); + // The buy is not unwound — the bought tokens refund as old tokens on + // top of what the payer held. + assert.equal( + ( + (await tokenBalance(this.banksClient, setup.payerOldTokenAccount)) - + payerBefore + ).toString(), + BUY_DEPOSIT.toString(), + ); + assert.equal( + (await tokenBalance(this.banksClient, stored.oldTokenVault)).toString(), + "0", + ); + }; + + it("misses the threshold and refunds every deposit exactly", async function () { + const { setup, relaunch } = await initializeLiveRelaunch.call(this, { + quoteMint: token.NATIVE_MINT, + oldTokenProgram: token.TOKEN_PROGRAM_ID, + }); + await runThresholdMiss.call(this, { setup, relaunch }); + }); + + it("marks a stalled sell Failed after the grace period and refunds exactly", async function () { + const { setup, relaunch } = await initializeLiveRelaunch.call(this, { + quoteMint: MAINNET_USDC, + oldTokenProgram: token.TOKEN_2022_PROGRAM_ID, + }); + + const alice = Keypair.generate(); + const aliceDeposit = 60_000_000n * 10n ** 6n; + const payerDeposit = 40_000_000n * 10n ** 6n; + const aliceAta = await fundDepositor.call( + this, + setup, + alice.publicKey, + aliceDeposit, + ); + + await deposit.call(this, relaunch, setup, aliceDeposit, alice); + await deposit.call(this, relaunch, setup, payerDeposit); + + // The threshold is met, so closing lands in SellPending — but the admin + // never sells. + await this.advanceBySeconds(ONE_WEEK); + await client.closeDepositsIx({ relaunch }).rpc(); + let stored = await client.fetchRelaunch(relaunch); + assert.isDefined(stored.state.sellPending); + + await this.advanceBySeconds(ONE_DAY + 1); + await client.markFailedIx({ relaunch }).rpc(); + stored = await client.fetchRelaunch(relaunch); + assert.isDefined(stored.state.failed); + + const payerBefore = await tokenBalance( + this.banksClient, + setup.payerOldTokenAccount, + token.TOKEN_2022_PROGRAM_ID, + ); + + await client.claimRefund({ relaunch, depositor: alice.publicKey }); + await client.claimRefund({ relaunch }); + + assert.equal( + ( + await tokenBalance( + this.banksClient, + aliceAta, + token.TOKEN_2022_PROGRAM_ID, + ) + ).toString(), + aliceDeposit.toString(), + ); + assert.equal( + ( + (await tokenBalance( + this.banksClient, + setup.payerOldTokenAccount, + token.TOKEN_2022_PROGRAM_ID, + )) - payerBefore + ).toString(), + payerDeposit.toString(), + ); + assert.equal( + ( + await tokenBalance( + this.banksClient, + stored.oldTokenVault, + token.TOKEN_2022_PROGRAM_ID, + ) + ).toString(), + "0", + ); + }); + + describe("Raydium source", function () { + it("relaunches through an AMM v4 pool with exact money math at each hop", async function () { + const { setup, pool, relaunch, newMint } = + await initializeLiveRaydiumRelaunch.call(this); + + let stored = await client.fetchRelaunch(relaunch); + assert.isDefined(stored.state.live); + assert.isDefined(stored.sourceVenue.raydiumAmmV4); + assert.equal( + stored.oldSupplySnapshot.toString(), + DEFAULT_OLD_SUPPLY.toString(), + ); + assert.equal( + (await tokenBalance(this.banksClient, stored.newTokenVault)).toString(), + (TOKENS_TO_DEPOSITORS + TOKENS_TO_FUTARCHY_LIQUIDITY).toString(), + ); + + // Mixed entry: two direct depositors interleaved with a bought deposit. + const alice = Keypair.generate(); + const bob = Keypair.generate(); + await fundDepositor.call(this, setup, alice.publicKey, ALICE_DEPOSIT); + await fundDepositor.call(this, setup, bob.publicKey, BOB_DEPOSIT); + + await deposit.call(this, relaunch, setup, ALICE_DEPOSIT, alice); + + // Pre-fund the WSOL ATA past max_quote_in so no shortfall wrap runs + // and the buy's spend is a clean delta. + const wsolAta = await wrapSol(client.provider, this.payer, 10n ** 9n); + const wsolBefore = await tokenBalance(this.banksClient, wsolAta); + await client.depositViaBuy({ + relaunch, + baseOut: new BN(BUY_DEPOSIT.toString()), + maxQuoteIn: new BN((10n ** 9n).toString()), + }); + + // Buy identity: the CPI pulled exactly the exact-out input from the + // depositor, and the full input (fee included) landed in the pool. + // Default orientation puts the token on the pc side, WSOL on coin. + const buyIn = raydiumBuyIn( + BUY_DEPOSIT, + WSOL_POOL_QUOTE_RESERVE, + POOL_BASE_RESERVE, + ); + assert.equal( + ( + wsolBefore - (await tokenBalance(this.banksClient, wsolAta)) + ).toString(), + buyIn.toString(), + ); + assert.equal( + (await tokenBalance(this.banksClient, pool.pcVault)).toString(), + (POOL_BASE_RESERVE - BUY_DEPOSIT).toString(), + ); + assert.equal( + (await tokenBalance(this.banksClient, pool.coinVault)).toString(), + (WSOL_POOL_QUOTE_RESERVE + buyIn).toString(), + ); + + await deposit.call(this, relaunch, setup, BOB_DEPOSIT, bob); + + stored = await client.fetchRelaunch(relaunch); + assert.equal( + stored.totalDeposited.toString(), + THRESHOLD_AMOUNT.toString(), + ); + assert.equal( + (await tokenBalance(this.banksClient, stored.oldTokenVault)).toString(), + THRESHOLD_AMOUNT.toString(), + ); + const expectedDeposits: [PublicKey, bigint][] = [ + [alice.publicKey, ALICE_DEPOSIT], + [bob.publicKey, BOB_DEPOSIT], + [this.payer.publicKey, BUY_DEPOSIT], + ]; + for (const [depositor, amount] of expectedDeposits) { + const record = await client.getDepositRecord({ relaunch, depositor }); + assert.equal(record.amountDeposited.toString(), amount.toString()); + } + + // The deposits land exactly on the threshold, so closing moves to + // SellPending. + await this.advanceBySeconds(ONE_WEEK); + await client.closeDepositsIx({ relaunch }).rpc(); + stored = await client.fetchRelaunch(relaunch); + assert.isDefined(stored.state.sellPending); + + // Raydium's flat 25 bps fee is modeled exactly, so the SDK's default + // slippage floor needs no fee headroom. + await client.executeSell({ relaunch }); + + // Sell identity: the whole vault sold off the buy-shifted reserves at + // the exact constant-product output, fee left in the pool. + const sellTokenReserve = POOL_BASE_RESERVE - BUY_DEPOSIT; + const sellQuoteReserve = WSOL_POOL_QUOTE_RESERVE + buyIn; + const predictedOut = raydiumSellOut( + THRESHOLD_AMOUNT, + sellTokenReserve, + sellQuoteReserve, + ); + stored = await client.fetchRelaunch(relaunch); + assert.equal(stored.quoteRecovered.toString(), predictedOut.toString()); + assert.equal( + (await tokenBalance(this.banksClient, stored.oldTokenVault)).toString(), + "0", + ); + assert.equal( + (await tokenBalance(this.banksClient, pool.pcVault)).toString(), + (sellTokenReserve + THRESHOLD_AMOUNT).toString(), + ); + assert.equal( + (await tokenBalance(this.banksClient, pool.coinVault)).toString(), + (sellQuoteReserve - predictedOut).toString(), + ); + + await runCompletionTail.call(this, { + relaunch, + newMint, + alice, + bob, + isWsol: true, + }); + }); + + it("misses the threshold and refunds direct- and buy-credited deposits exactly", async function () { + const { setup, relaunch } = + await initializeLiveRaydiumRelaunch.call(this); + await runThresholdMiss.call(this, { setup, relaunch }); + }); + }); +} diff --git a/tests/main.test.ts b/tests/main.test.ts index 7c4d6307..6031d439 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -9,6 +9,7 @@ import mintGovernor from "./mintGovernor/main.test.js"; import performancePackageV2 from "./performancePackageV2/main.test.js"; import liquidation from "./liquidation/main.test.js"; import gatedMint from "./gatedMint/main.test.js"; +import relaunch from "./relaunch/main.test.js"; import { BanksClient, @@ -40,9 +41,19 @@ import { MintGovernorClient, GatedMintClient, LiquidationClient, + RelaunchClient, LOW_FEE_RAYDIUM_CONFIG, + PUMP_AMM_FEE_CONFIG, + PUMP_AMM_GLOBAL_CONFIG, + PUMP_AMM_PROGRAM_ID, + PUMP_FEES_PROGRAM_ID, + RELAUNCH_V0_1_GLOBAL_ALT, + WHIRLPOOL_PROGRAM_ID, sha256, } from "@metadaoproject/programs"; +import { PUMP_GLOBAL_VOLUME_ACCUMULATOR } from "./relaunch/pumpAmm.js"; +import { RAYDIUM_AMM_PROGRAM_ID } from "./relaunch/raydiumAmm.js"; +import { WHIRLPOOLS_CONFIG, WHIRLPOOL_FEE_TIER } from "./relaunch/whirlpool.js"; import { LaunchpadClient as LaunchpadClientV6 } from "@metadaoproject/programs/launchpad/v0.6"; import { LaunchpadClient as LaunchpadClientV8 } from "@metadaoproject/programs/launchpad/v0.8"; @@ -54,6 +65,7 @@ import { Transaction, ComputeBudgetProgram, TransactionInstruction, + AddressLookupTableProgram, } from "@solana/web3.js"; import { @@ -85,6 +97,7 @@ import fullLaunch_v7 from "./integration/fullLaunch_v7.test.js"; import fullLaunch_v8 from "./integration/launchpad_v8_full_lifecycle.test.js"; import gatedLaunchpadV8 from "./integration/gatedLaunchpadV8.test.js"; import trancheLifecycle_v8 from "./integration/launchpad_v8_tranche_lifecycle.test.js"; +import relaunchLifecycle from "./integration/relaunch.test.js"; import { BN } from "bn.js"; const ONE_BUCK_PRICE = PriceMath.getAmmPrice(1, 6, 6); @@ -93,6 +106,7 @@ const ONE_BUCK_PRICE = PriceMath.getAmmPrice(1, 6, 6); export interface TestContext { context: ProgramTestContext; banksClient: BanksClient; + connection: Connection; conditionalVault: ConditionalVaultClient; futarchy: FutarchyClient; launchpad_v7: LaunchpadClientV7; @@ -103,6 +117,7 @@ export interface TestContext { mintGovernor: MintGovernorClient; gatedMint: GatedMintClient; liquidation: LiquidationClient; + relaunch: RelaunchClient; payer: Keypair; squadsConnection: Connection; createTokenAccount: (mint: PublicKey, owner: PublicKey) => Promise; @@ -115,6 +130,7 @@ export interface TestContext { to: PublicKey, mintAuthority: Keypair, amount: number, + computeUnitPrice?: number, ) => Promise; getTokenBalance: (mint: PublicKey, owner: PublicKey) => Promise; getMint: (mint: PublicKey) => Promise; @@ -200,6 +216,22 @@ before(async function () { name: "cp_amm", programId: DAMM_V2_PROGRAM_ID, }, + { + name: "pump_amm", + programId: PUMP_AMM_PROGRAM_ID, + }, + { + name: "pump_fees", + programId: PUMP_FEES_PROGRAM_ID, + }, + { + name: "whirlpool", + programId: WHIRLPOOL_PROGRAM_ID, + }, + { + name: "raydium_amm", + programId: RAYDIUM_AMM_PROGRAM_ID, + }, ], [ { @@ -249,11 +281,85 @@ before(async function () { lamports: 1_000_000_000, }, }, + { + address: PUMP_AMM_GLOBAL_CONFIG, + info: { + data: fs.readFileSync("./tests/fixtures/pump-global-config"), + executable: false, + owner: PUMP_AMM_PROGRAM_ID, + lamports: 9_215_825, + }, + }, + { + address: PUMP_AMM_FEE_CONFIG, + info: { + data: fs.readFileSync("./tests/fixtures/pump-fee-config"), + executable: false, + owner: PUMP_FEES_PROGRAM_ID, + lamports: 33_103_977, + }, + }, + { + address: PUMP_GLOBAL_VOLUME_ACCUMULATOR, + info: { + data: fs.readFileSync( + "./tests/fixtures/pump-global-volume-accumulator", + ), + executable: false, + owner: PUMP_AMM_PROGRAM_ID, + lamports: 28_668_918, + }, + }, + { + address: token.NATIVE_MINT, + info: { + data: fs.readFileSync("./tests/fixtures/wsol-mint"), + executable: false, + owner: token.TOKEN_PROGRAM_ID, + lamports: 1_642_232_546_455, + }, + }, + { + address: WHIRLPOOLS_CONFIG, + info: { + data: fs.readFileSync("./tests/fixtures/whirlpool-config"), + executable: false, + owner: WHIRLPOOL_PROGRAM_ID, + lamports: 1_642_560, + }, + }, + { + address: WHIRLPOOL_FEE_TIER, + info: { + data: fs.readFileSync("./tests/fixtures/whirlpool-fee-tier"), + executable: false, + owner: WHIRLPOOL_PROGRAM_ID, + lamports: 1_197_120, + }, + }, + { + // Dumped by `yarn relaunch-create-alt dump`, which zeroes + // last_extended_slot so every entry is active at bankrun's low slots. + address: RELAUNCH_V0_1_GLOBAL_ALT, + info: { + data: fs.readFileSync("./tests/fixtures/relaunch-global-alt"), + executable: false, + owner: AddressLookupTableProgram.programId, + lamports: 42_261_120, + }, + }, ], ); this.banksClient = this.context.banksClient; const provider = new BankrunProvider(this.context); anchor.setProvider(provider); + // web3.js implements getAddressLookupTable purely in terms of + // getAccountInfoAndContext, which the bankrun connection proxy provides, so + // grafting the real implementation on lets tests fetch lookup tables the + // same way a script would. + (provider.connection as any).getAddressLookupTable = + Connection.prototype.getAddressLookupTable; + this.connection = provider.connection; this.conditionalVault = ConditionalVaultClient.createClient({ provider: provider as any, @@ -375,16 +481,29 @@ before(async function () { ); }; + // computeUnitPrice, when set, prepends a ComputeBudget instruction so an + // otherwise byte-identical mint transaction gets a unique hash — bankrun + // rejects duplicate hashes within a blockhash window with "This transaction + // has already been processed". this.mintTo = async ( mint: PublicKey, to: PublicKey, mintAuthority: Keypair, amount: number, + computeUnitPrice?: number, ) => { const tokenAccount = token.getAssociatedTokenAddressSync(mint, to, true); const tx = new Transaction(); + if (computeUnitPrice !== undefined) { + tx.add( + ComputeBudgetProgram.setComputeUnitPrice({ + microLamports: computeUnitPrice, + }), + ); + } + tx.add( token.createAssociatedTokenAccountIdempotentInstruction( this.payer.publicKey, @@ -762,6 +881,7 @@ describe("mint_governor", mintGovernor); describe("performance_package_v2", performancePackageV2); describe("liquidation", liquidation); describe("gated_mint", gatedMint); +describe("relaunch", relaunch); describe("project-wide integration tests", function () { it.skip("mint and swap in a single transaction", mintAndSwap); describe("full launch v6", fullLaunch); @@ -769,4 +889,5 @@ describe("project-wide integration tests", function () { describe("full launch v8", fullLaunch_v8); describe("gated_mint + launchpad v8", gatedLaunchpadV8); describe("full launch v8 - tranche lifecycle", trancheLifecycle_v8); + describe("relaunch full lifecycle", relaunchLifecycle); }); diff --git a/tests/performancePackageV2/unit/completeUnlock.test.ts b/tests/performancePackageV2/unit/completeUnlock.test.ts index 45d1b40d..dcf23871 100644 --- a/tests/performancePackageV2/unit/completeUnlock.test.ts +++ b/tests/performancePackageV2/unit/completeUnlock.test.ts @@ -543,6 +543,9 @@ export default function suite() { recipient: recipient.publicKey, signer: authority.publicKey, }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ]) .signers([authority]) .rpc(); @@ -657,6 +660,9 @@ export default function suite() { signer: recipient.publicKey, dao, }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ]) .signers([recipient]) .rpc(); @@ -822,6 +828,9 @@ export default function suite() { recipient: recipient.publicKey, signer: authority.publicKey, }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ]) .signers([authority]) .rpc(); diff --git a/tests/relaunch/main.test.ts b/tests/relaunch/main.test.ts new file mode 100644 index 00000000..ec14b436 --- /dev/null +++ b/tests/relaunch/main.test.ts @@ -0,0 +1,44 @@ +import scaffold from "./unit/scaffold.test.js"; +import venues from "./unit/venues.test.js"; +import initializeRelaunch from "./unit/initializeRelaunch.test.js"; +import startDeposits from "./unit/startDeposits.test.js"; +import deposit from "./unit/deposit.test.js"; +import depositViaBuy from "./unit/depositViaBuy.test.js"; +import depositViaBuyRaydium from "./unit/depositViaBuyRaydium.test.js"; +import closeDeposits from "./unit/closeDeposits.test.js"; +import executeSell from "./unit/executeSell.test.js"; +import executeSellRaydium from "./unit/executeSellRaydium.test.js"; +import executeUsdcSwap from "./unit/executeUsdcSwap.test.js"; +import completeRelaunch from "./unit/completeRelaunch.test.js"; +import claim from "./unit/claim.test.js"; +import markFailed from "./unit/markFailed.test.js"; +import claimRefund from "./unit/claimRefund.test.js"; +import altTransactions from "./unit/altTransactions.test.js"; +import { RelaunchClient } from "@metadaoproject/programs"; +import { BankrunProvider } from "anchor-bankrun"; + +export default function suite() { + before(async function () { + const provider = new BankrunProvider(this.context); + this.relaunch = RelaunchClient.createClient({ + provider: provider as any, + }); + }); + + describe("scaffold", scaffold); + describe("venues", venues); + describe("#initialize_relaunch", initializeRelaunch); + describe("#start_deposits", startDeposits); + describe("#deposit", deposit); + describe("#deposit_via_buy", depositViaBuy); + describe("#deposit_via_buy_raydium", depositViaBuyRaydium); + describe("#close_deposits", closeDeposits); + describe("#execute_sell", executeSell); + describe("#execute_sell_raydium", executeSellRaydium); + describe("#execute_usdc_swap", executeUsdcSwap); + describe("#complete_relaunch", completeRelaunch); + describe("#claim", claim); + describe("#mark_failed", markFailed); + describe("#claim_refund", claimRefund); + describe("ALT transactions", altTransactions); +} diff --git a/tests/relaunch/pumpAmm.ts b/tests/relaunch/pumpAmm.ts new file mode 100644 index 00000000..e55d5a32 --- /dev/null +++ b/tests/relaunch/pumpAmm.ts @@ -0,0 +1,528 @@ +import { + Keypair, + PublicKey, + SystemProgram, + TransactionInstruction, +} from "@solana/web3.js"; +import * as token from "@solana/spl-token"; +import { BanksClient, ProgramTestContext } from "solana-bankrun"; +import { + getPumpCreatorVaultAuthorityAddr, + getPumpPoolV2Addr, + parsePumpGlobalConfig, + PumpGlobalConfigAccount, + PUMP_AMM_EVENT_AUTHORITY, + PUMP_AMM_FEE_CONFIG, + PUMP_AMM_GLOBAL_CONFIG, + PUMP_AMM_PROGRAM_ID, + PUMP_FEES_PROGRAM_ID, + PUMP_PROGRAM_ID, +} from "@metadaoproject/programs"; + +const TOKEN_ACCOUNT_RENT = 2_039_280n; + +export const PUMP_GLOBAL_VOLUME_ACCUMULATOR = PublicKey.findProgramAddressSync( + [Buffer.from("global_volume_accumulator")], + PUMP_AMM_PROGRAM_ID, +)[0]; + +// Stable coin creator used by fabricated pools so creator-vault ATAs are +// deterministic across tests. +export const PUMP_TEST_COIN_CREATOR = Keypair.fromSeed( + new Uint8Array(Buffer.from("relaunch-fixture-coin-creator!!!")), +).publicKey; + +export function getPumpPoolAuthorityAddr(baseMint: PublicKey): PublicKey { + return PublicKey.findProgramAddressSync( + [Buffer.from("pool-authority"), baseMint.toBuffer()], + PUMP_PROGRAM_ID, + )[0]; +} + +export function getPumpPoolAddr({ + index, + creator, + baseMint, + quoteMint, +}: { + index: number; + creator: PublicKey; + baseMint: PublicKey; + quoteMint: PublicKey; +}): [PublicKey, number] { + const indexBuf = Buffer.alloc(2); + indexBuf.writeUInt16LE(index); + return PublicKey.findProgramAddressSync( + [ + Buffer.from("pool"), + indexBuf, + creator.toBuffer(), + baseMint.toBuffer(), + quoteMint.toBuffer(), + ], + PUMP_AMM_PROGRAM_ID, + ); +} + +export function getCanonicalPumpPoolAddr( + baseMint: PublicKey, + quoteMint: PublicKey, +): [PublicKey, number] { + return getPumpPoolAddr({ + index: 0, + creator: getPumpPoolAuthorityAddr(baseMint), + baseMint, + quoteMint, + }); +} + +export function getUserVolumeAccumulatorAddr(user: PublicKey): PublicKey { + return PublicKey.findProgramAddressSync( + [Buffer.from("user_volume_accumulator"), user.toBuffer()], + PUMP_AMM_PROGRAM_ID, + )[0]; +} + +async function fetchGlobalConfig( + banksClient: BanksClient, +): Promise { + const globalConfig = await banksClient.getAccount(PUMP_AMM_GLOBAL_CONFIG); + return parsePumpGlobalConfig(Buffer.from(globalConfig!.data)); +} + +function packTokenAccount({ + mint, + owner, + amount, +}: { + mint: PublicKey; + owner: PublicKey; + amount: bigint; +}): Buffer { + const isNative = mint.equals(token.NATIVE_MINT); + const data = Buffer.alloc(token.ACCOUNT_SIZE); + token.AccountLayout.encode( + { + mint, + owner, + amount, + delegateOption: 0, + delegate: PublicKey.default, + state: token.AccountState.Initialized, + isNativeOption: isNative ? 1 : 0, + isNative: isNative ? TOKEN_ACCOUNT_RENT : 0n, + delegatedAmount: 0n, + closeAuthorityOption: 0, + closeAuthority: PublicKey.default, + }, + data, + ); + return data; +} + +// Writes a token account at `address` via setAccount. WSOL accounts get +// amount-backed lamports so the token program can move lamports alongside +// native transfers. +export function writeTokenAccount( + context: ProgramTestContext, + { + address, + mint, + owner, + amount, + tokenProgram = token.TOKEN_PROGRAM_ID, + }: { + address: PublicKey; + mint: PublicKey; + owner: PublicKey; + amount: bigint; + tokenProgram?: PublicKey; + }, +) { + const isNative = mint.equals(token.NATIVE_MINT); + context.setAccount(address, { + data: packTokenAccount({ mint, owner, amount }), + owner: tokenProgram, + lamports: Number(TOKEN_ACCOUNT_RENT + (isNative ? amount : 0n)), + executable: false, + }); +} + +export type WritePumpPoolParams = { + context: ProgramTestContext; + baseMint: PublicKey; + quoteMint: PublicKey; + baseReserve: bigint; + quoteReserve: bigint; + baseTokenProgram?: PublicKey; + // Overrides below fabricate non-canonical pools for negative tests. + index?: number; + creator?: PublicKey; + owner?: PublicKey; + coinCreator?: PublicKey; +}; + +export type PumpPool = { + pool: PublicKey; + baseMint: PublicKey; + quoteMint: PublicKey; + baseTokenProgram: PublicKey; + poolBaseTokenAccount: PublicKey; + poolQuoteTokenAccount: PublicKey; + coinCreator: PublicKey; +}; + +// Pool account layout +// +// offset size field type +// 0 8 discriminator [241,154,109,4,17,177,109,188] +// 8 1 pool_bump u8 +// 9 2 index u16 LE (0 = canonical) +// 11 32 creator Pubkey (pool-authority PDA when canonical) +// 43 32 base_mint Pubkey +// 75 32 quote_mint Pubkey +// 107 32 lp_mint Pubkey (["pool_lp_mint", pool] PDA) +// 139 32 pool_base_token_account Pubkey (ATA of pool, base token program) +// 171 32 pool_quote_token_account Pubkey (ATA of pool, classic SPL) +// 203 8 lp_supply u64 LE (unread by swaps) +// 211 32 coin_creator Pubkey +// 243 1 is_mayhem_mode bool +// 244 1 is_cashback_coin bool +// 245 16 virtual_quote_reserves i128 LE (0 = legacy behavior) +// 261 40 (reserved) zero on mainnet pools +// 301 total +// +// Fabricates a pump_amm Pool plus funded vaults directly via setAccount. +// Deliberate even with the real program loaded: only the pump bonding-curve +// program can sign for the canonical creator PDA, so a real create_pool can +// never produce the canonical fingerprint in tests. +export async function writePumpPool({ + context, + baseMint, + quoteMint, + baseReserve, + quoteReserve, + baseTokenProgram = token.TOKEN_PROGRAM_ID, + index = 0, + creator, + owner = PUMP_AMM_PROGRAM_ID, + coinCreator = PUMP_TEST_COIN_CREATOR, +}: WritePumpPoolParams): Promise { + creator = creator ?? getPumpPoolAuthorityAddr(baseMint); + const [pool, poolBump] = getPumpPoolAddr({ + index, + creator, + baseMint, + quoteMint, + }); + + const poolBaseTokenAccount = token.getAssociatedTokenAddressSync( + baseMint, + pool, + true, + baseTokenProgram, + ); + const poolQuoteTokenAccount = token.getAssociatedTokenAddressSync( + quoteMint, + pool, + true, + ); + const lpMint = PublicKey.findProgramAddressSync( + [Buffer.from("pool_lp_mint"), pool.toBuffer()], + PUMP_AMM_PROGRAM_ID, + )[0]; + + const data = Buffer.alloc(301); + let offset = 0; + Buffer.from([241, 154, 109, 4, 17, 177, 109, 188]).copy(data, offset); // Pool discriminator + offset += 8; + data.writeUInt8(poolBump, offset); + offset += 1; + data.writeUInt16LE(index, offset); + offset += 2; + for (const key of [ + creator, + baseMint, + quoteMint, + lpMint, + poolBaseTokenAccount, + poolQuoteTokenAccount, + ]) { + key.toBuffer().copy(data, offset); + offset += 32; + } + data.writeBigUInt64LE(1_000_000_000n, offset); // lp_supply, unread by swaps + offset += 8; + coinCreator.toBuffer().copy(data, offset); + offset += 32; + // is_mayhem_mode, is_cashback_coin, virtual_quote_reserves and the 40 + // reserved tail bytes stay zeroed. + + context.setAccount(pool, { + data, + owner, + lamports: 2_985_840, + executable: false, + }); + + writeTokenAccount(context, { + address: poolBaseTokenAccount, + mint: baseMint, + owner: pool, + amount: baseReserve, + tokenProgram: baseTokenProgram, + }); + writeTokenAccount(context, { + address: poolQuoteTokenAccount, + mint: quoteMint, + owner: pool, + amount: quoteReserve, + }); + + // Fee destinations must exist before swaps: the coin creator's quote vault + // ATA and the protocol fee recipient's quote ATA. Skip any that another + // fabricated pool already wrote so accrued balances survive. + const creatorVaultAta = token.getAssociatedTokenAddressSync( + quoteMint, + getPumpCreatorVaultAuthorityAddr(coinCreator), + true, + ); + if (!(await context.banksClient.getAccount(creatorVaultAta))) { + writeTokenAccount(context, { + address: creatorVaultAta, + mint: quoteMint, + owner: getPumpCreatorVaultAuthorityAddr(coinCreator), + amount: 0n, + }); + } + const { protocolFeeRecipients, buybackFeeRecipients } = + await fetchGlobalConfig(context.banksClient); + for (const recipient of [protocolFeeRecipients[0], ...buybackFeeRecipients]) { + const recipientAta = token.getAssociatedTokenAddressSync( + quoteMint, + recipient, + true, + ); + if (!(await context.banksClient.getAccount(recipientAta))) { + writeTokenAccount(context, { + address: recipientAta, + mint: quoteMint, + owner: recipient, + amount: 0n, + }); + } + } + + return { + pool, + baseMint, + quoteMint, + baseTokenProgram, + poolBaseTokenAccount, + poolQuoteTokenAccount, + coinCreator, + }; +} + +function swapAccountMetas( + pool: PumpPool, + user: PublicKey, + protocolFeeRecipient: PublicKey, +) { + const userBaseTokenAccount = token.getAssociatedTokenAddressSync( + pool.baseMint, + user, + true, + pool.baseTokenProgram, + ); + const userQuoteTokenAccount = token.getAssociatedTokenAddressSync( + pool.quoteMint, + user, + true, + ); + const protocolFeeRecipientTokenAccount = token.getAssociatedTokenAddressSync( + pool.quoteMint, + protocolFeeRecipient, + true, + ); + const coinCreatorVaultAuthority = getPumpCreatorVaultAuthorityAddr( + pool.coinCreator, + ); + const coinCreatorVaultAta = token.getAssociatedTokenAddressSync( + pool.quoteMint, + coinCreatorVaultAuthority, + true, + ); + + return [ + { pubkey: pool.pool, isSigner: false, isWritable: true }, + { pubkey: user, isSigner: true, isWritable: true }, + { pubkey: PUMP_AMM_GLOBAL_CONFIG, isSigner: false, isWritable: false }, + { pubkey: pool.baseMint, isSigner: false, isWritable: false }, + { pubkey: pool.quoteMint, isSigner: false, isWritable: false }, + { pubkey: userBaseTokenAccount, isSigner: false, isWritable: true }, + { pubkey: userQuoteTokenAccount, isSigner: false, isWritable: true }, + { pubkey: pool.poolBaseTokenAccount, isSigner: false, isWritable: true }, + { pubkey: pool.poolQuoteTokenAccount, isSigner: false, isWritable: true }, + { pubkey: protocolFeeRecipient, isSigner: false, isWritable: false }, + { + pubkey: protocolFeeRecipientTokenAccount, + isSigner: false, + isWritable: true, + }, + { pubkey: pool.baseTokenProgram, isSigner: false, isWritable: false }, + { pubkey: token.TOKEN_PROGRAM_ID, isSigner: false, isWritable: false }, + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + { + pubkey: token.ASSOCIATED_TOKEN_PROGRAM_ID, + isSigner: false, + isWritable: false, + }, + { pubkey: PUMP_AMM_EVENT_AUTHORITY, isSigner: false, isWritable: false }, + { pubkey: PUMP_AMM_PROGRAM_ID, isSigner: false, isWritable: false }, + { pubkey: coinCreatorVaultAta, isSigner: false, isWritable: true }, + { pubkey: coinCreatorVaultAuthority, isSigner: false, isWritable: false }, + ]; +} + +// sell(base_amount_in, min_quote_amount_out) — 21 accounts plus the same +// remaining-account tail as buy: pool_v2, then a buyback fee recipient with +// its quote ATA (mirrors pump's own SDK; required once buyback fees have +// accrued). +export function pumpSellIx({ + pool, + user, + protocolFeeRecipient, + buybackFeeRecipient, + baseAmountIn, + minQuoteAmountOut, +}: { + pool: PumpPool; + user: PublicKey; + protocolFeeRecipient: PublicKey; + buybackFeeRecipient: PublicKey; + baseAmountIn: bigint; + minQuoteAmountOut: bigint; +}): TransactionInstruction { + const data = Buffer.alloc(8 + 8 + 8); + Buffer.from([51, 230, 133, 164, 1, 127, 131, 173]).copy(data, 0); + data.writeBigUInt64LE(baseAmountIn, 8); + data.writeBigUInt64LE(minQuoteAmountOut, 16); + + const keys = [ + ...swapAccountMetas(pool, user, protocolFeeRecipient), + { pubkey: PUMP_AMM_FEE_CONFIG, isSigner: false, isWritable: false }, + { pubkey: PUMP_FEES_PROGRAM_ID, isSigner: false, isWritable: false }, + { + pubkey: getPumpPoolV2Addr(pool.baseMint), + isSigner: false, + isWritable: false, + }, + { pubkey: buybackFeeRecipient, isSigner: false, isWritable: false }, + { + pubkey: token.getAssociatedTokenAddressSync( + pool.quoteMint, + buybackFeeRecipient, + true, + ), + isSigner: false, + isWritable: true, + }, + ]; + + return new TransactionInstruction({ + programId: PUMP_AMM_PROGRAM_ID, + keys, + data, + }); +} + +// buy(base_amount_out, max_quote_amount_in, track_volume) — sell's accounts +// plus the two volume accumulators, 23 total, then the remaining accounts: +// pool_v2 and (required for buys, unlike sells) one buyback fee recipient +// with its quote ATA. Any member of the global config's buyback list works. +export function pumpBuyIx({ + pool, + user, + protocolFeeRecipient, + buybackFeeRecipient, + baseAmountOut, + maxQuoteAmountIn, + trackVolume = false, +}: { + pool: PumpPool; + user: PublicKey; + protocolFeeRecipient: PublicKey; + buybackFeeRecipient: PublicKey; + baseAmountOut: bigint; + maxQuoteAmountIn: bigint; + trackVolume?: boolean; +}): TransactionInstruction { + const data = Buffer.alloc(8 + 8 + 8 + 1); + Buffer.from([102, 6, 61, 18, 1, 218, 235, 234]).copy(data, 0); + data.writeBigUInt64LE(baseAmountOut, 8); + data.writeBigUInt64LE(maxQuoteAmountIn, 16); + data.writeUInt8(trackVolume ? 1 : 0, 24); + + const keys = [ + ...swapAccountMetas(pool, user, protocolFeeRecipient), + { + pubkey: PUMP_GLOBAL_VOLUME_ACCUMULATOR, + isSigner: false, + isWritable: false, + }, + { + pubkey: getUserVolumeAccumulatorAddr(user), + isSigner: false, + isWritable: true, + }, + { pubkey: PUMP_AMM_FEE_CONFIG, isSigner: false, isWritable: false }, + { pubkey: PUMP_FEES_PROGRAM_ID, isSigner: false, isWritable: false }, + { + pubkey: getPumpPoolV2Addr(pool.baseMint), + isSigner: false, + isWritable: false, + }, + { pubkey: buybackFeeRecipient, isSigner: false, isWritable: false }, + { + pubkey: token.getAssociatedTokenAddressSync( + pool.quoteMint, + buybackFeeRecipient, + true, + ), + isSigner: false, + isWritable: true, + }, + ]; + + return new TransactionInstruction({ + programId: PUMP_AMM_PROGRAM_ID, + keys, + data, + }); +} + +export function pumpInitUserVolumeAccumulatorIx({ + payer, + user, +}: { + payer: PublicKey; + user: PublicKey; +}): TransactionInstruction { + return new TransactionInstruction({ + programId: PUMP_AMM_PROGRAM_ID, + keys: [ + { pubkey: payer, isSigner: true, isWritable: true }, + { pubkey: user, isSigner: false, isWritable: false }, + { + pubkey: getUserVolumeAccumulatorAddr(user), + isSigner: false, + isWritable: true, + }, + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + { pubkey: PUMP_AMM_EVENT_AUTHORITY, isSigner: false, isWritable: false }, + { pubkey: PUMP_AMM_PROGRAM_ID, isSigner: false, isWritable: false }, + ], + data: Buffer.from([94, 6, 202, 115, 255, 96, 232, 183]), + }); +} diff --git a/tests/relaunch/raydiumAmm.ts b/tests/relaunch/raydiumAmm.ts new file mode 100644 index 00000000..413fe5dc --- /dev/null +++ b/tests/relaunch/raydiumAmm.ts @@ -0,0 +1,280 @@ +import { Keypair, PublicKey, TransactionInstruction } from "@solana/web3.js"; +import * as token from "@solana/spl-token"; +import { ProgramTestContext } from "solana-bankrun"; +import { + OPENBOOK_PROGRAM_ID, + RAYDIUM_AMM_AUTHORITY, + RAYDIUM_AMM_PROGRAM_ID, +} from "@metadaoproject/programs"; +import { writeTokenAccount } from "./pumpAmm.js"; + +export { OPENBOOK_PROGRAM_ID, RAYDIUM_AMM_AUTHORITY, RAYDIUM_AMM_PROGRAM_ID }; + +const AMM_INFO_LEN = 752; +const POOL_RENT = 6_124_800n; // rent-exempt minimum for 752 bytes +const MINT_RENT = 1_461_600n; // rent-exempt minimum for 82 bytes + +export type WriteRaydiumPoolParams = { + context: ProgramTestContext; + oldMint: PublicKey; + tokenReserve: bigint; + quoteReserve: bigint; + // Which AMM side holds the old token. Pump migrations put the token on the + // pc side with WSOL as coin; "coin" fabricates the flipped orientation. + tokenSide?: "pc" | "coin"; + // Overrides below fabricate non-canonical pools for negative tests. + owner?: PublicKey; + quoteMint?: PublicKey; + status?: bigint; + marketProgram?: PublicKey; + lpAmount?: bigint; + lpSupply?: bigint; +}; + +export type RaydiumPool = { + pool: PublicKey; + coinMint: PublicKey; + pcMint: PublicKey; + coinVault: PublicKey; + pcVault: PublicKey; + lpMint: PublicKey; +}; + +// AmmInfo layout (752 bytes, packed, no discriminator). The swap path reads +// status, nonce, the vault/mint fields, swap fees, and need_take_pnl; the +// rest are orderbook-era plumbing whose defaults below copy the live MOBY +// pool (AemYRZmJryzAQ9Z4RLfUBLnPRUY5ecooc94EJvemfti4, 2026-08-12). +// +// offset size field value written +// 0 8 status 6 (SwapOnly) unless overridden +// 8 8 nonce 254 (derives the 5Q544… authority) +// 16 8 order_num 7 +// 24 8 depth 3 +// 32 8 coin_decimals per orientation (WSOL 9, token 6) +// 40 8 pc_decimals per orientation +// 48 8 state 1 +// 56 8 reset_flag 0 +// 64 8 min_size 10_000_000 +// 72 8 vol_max_cut_ratio 500 +// 80 8 amount_wave 5_000_000 +// 88 8 coin_lot_size 10_000_000 +// 96 8 pc_lot_size 10_000_000 +// 104 8 min_price_multiplier 1 +// 112 8 max_price_multiplier 1_000_000_000 +// 120 8 sys_decimal_value 1_000_000_000 +// 128 64 fees 5/10000, 25/10000, 12/100, 25/10000 +// 192 144 state_data zero (need_take_pnl, PnL + swap stats) +// 336 32 coin_vault +// 368 32 pc_vault +// 400 32 coin_mint +// 432 32 pc_mint +// 464 32 lp_mint +// 496 32 open_orders zero (unread by the V2 path) +// 528 32 market zero (unread by the V2 path) +// 560 32 market_program OpenBook unless overridden +// 592 32 target_orders zero (unread by the V2 path) +// 624 64 padding1 zero +// 688 32 amm_owner zero (unread by swaps) +// 720 8 lp_amount ~4_045e9 unless overridden +// 728 8 client_order_id 0 +// 736 8 recent_epoch 0 +// 744 8 padding2 0 +// +// Fabricates the pool plus funded vaults and LP mint directly via setAccount. +// Deliberate rather than initialize2: canonicality is fingerprint-based +// (owner + shape), and real pool creation would drag in OpenBook markets the +// V2 swap path never touches. +export function writeRaydiumPool({ + context, + oldMint, + tokenReserve, + quoteReserve, + tokenSide = "pc", + owner = RAYDIUM_AMM_PROGRAM_ID, + quoteMint = token.NATIVE_MINT, + status = 6n, + marketProgram = OPENBOOK_PROGRAM_ID, + lpAmount = 4_045_000_000_000n, + lpSupply = 2_000_000_000n, +}: WriteRaydiumPoolParams): RaydiumPool { + const pool = Keypair.generate().publicKey; + const coinVault = Keypair.generate().publicKey; + const pcVault = Keypair.generate().publicKey; + const lpMint = Keypair.generate().publicKey; + + const tokenAsPc = tokenSide === "pc"; + const coinMint = tokenAsPc ? quoteMint : oldMint; + const pcMint = tokenAsPc ? oldMint : quoteMint; + const coinReserve = tokenAsPc ? quoteReserve : tokenReserve; + const pcReserve = tokenAsPc ? tokenReserve : quoteReserve; + + const data = Buffer.alloc(AMM_INFO_LEN); + const u64Fields: [number, bigint][] = [ + [0, status], + [8, 254n], // nonce + [16, 7n], // order_num + [24, 3n], // depth + [32, tokenAsPc ? 9n : 6n], // coin_decimals + [40, tokenAsPc ? 6n : 9n], // pc_decimals + [48, 1n], // state + [64, 10_000_000n], // min_size + [72, 500n], // vol_max_cut_ratio + [80, 5_000_000n], // amount_wave + [88, 10_000_000n], // coin_lot_size + [96, 10_000_000n], // pc_lot_size + [104, 1n], // min_price_multiplier + [112, 1_000_000_000n], // max_price_multiplier + [120, 1_000_000_000n], // sys_decimal_value + [128, 5n], // min_separate_numerator + [136, 10_000n], // min_separate_denominator + [144, 25n], // trade_fee_numerator + [152, 10_000n], // trade_fee_denominator + [160, 12n], // pnl_numerator + [168, 100n], // pnl_denominator + [176, 25n], // swap_fee_numerator + [184, 10_000n], // swap_fee_denominator + [720, lpAmount], + ]; + for (const [offset, value] of u64Fields) { + data.writeBigUInt64LE(value, offset); + } + const pubkeyFields: [number, PublicKey][] = [ + [336, coinVault], + [368, pcVault], + [400, coinMint], + [432, pcMint], + [464, lpMint], + [560, marketProgram], + ]; + for (const [offset, key] of pubkeyFields) { + key.toBuffer().copy(data, offset); + } + + context.setAccount(pool, { + data, + owner, + lamports: Number(POOL_RENT), + executable: false, + }); + + writeTokenAccount(context, { + address: coinVault, + mint: coinMint, + owner: RAYDIUM_AMM_AUTHORITY, + amount: coinReserve, + }); + writeTokenAccount(context, { + address: pcVault, + mint: pcMint, + owner: RAYDIUM_AMM_AUTHORITY, + amount: pcReserve, + }); + + const lpMintData = Buffer.alloc(token.MINT_SIZE); + token.MintLayout.encode( + { + mintAuthorityOption: 1, + mintAuthority: RAYDIUM_AMM_AUTHORITY, + supply: lpSupply, + decimals: 9, + isInitialized: true, + freezeAuthorityOption: 0, + freezeAuthority: PublicKey.default, + }, + lpMintData, + ); + context.setAccount(lpMint, { + data: lpMintData, + owner: token.TOKEN_PROGRAM_ID, + lamports: Number(MINT_RENT), + executable: false, + }); + + return { pool, coinMint, pcMint, coinVault, pcVault, lpMint }; +} + +function swapAccountMetas( + pool: RaydiumPool, + userSourceTokenAccount: PublicKey, + userDestinationTokenAccount: PublicKey, + userSourceOwner: PublicKey, +) { + return [ + { pubkey: token.TOKEN_PROGRAM_ID, isSigner: false, isWritable: false }, + { pubkey: pool.pool, isSigner: false, isWritable: true }, + { pubkey: RAYDIUM_AMM_AUTHORITY, isSigner: false, isWritable: false }, + { pubkey: pool.coinVault, isSigner: false, isWritable: true }, + { pubkey: pool.pcVault, isSigner: false, isWritable: true }, + { pubkey: userSourceTokenAccount, isSigner: false, isWritable: true }, + { pubkey: userDestinationTokenAccount, isSigner: false, isWritable: true }, + { pubkey: userSourceOwner, isSigner: true, isWritable: false }, + ]; +} + +// swap_base_in_v2(amount_in, minimum_amount_out) — tag 16, exact input. +// Direction is inferred from the source/destination account mints. +export function raydiumSwapBaseInV2Ix({ + pool, + userSourceTokenAccount, + userDestinationTokenAccount, + userSourceOwner, + amountIn, + minimumAmountOut, +}: { + pool: RaydiumPool; + userSourceTokenAccount: PublicKey; + userDestinationTokenAccount: PublicKey; + userSourceOwner: PublicKey; + amountIn: bigint; + minimumAmountOut: bigint; +}): TransactionInstruction { + const data = Buffer.alloc(1 + 8 + 8); + data.writeUInt8(16, 0); + data.writeBigUInt64LE(amountIn, 1); + data.writeBigUInt64LE(minimumAmountOut, 9); + + return new TransactionInstruction({ + programId: RAYDIUM_AMM_PROGRAM_ID, + keys: swapAccountMetas( + pool, + userSourceTokenAccount, + userDestinationTokenAccount, + userSourceOwner, + ), + data, + }); +} + +// swap_base_out_v2(max_amount_in, amount_out) — tag 17, exact output; only +// the needed input is pulled from the source account. +export function raydiumSwapBaseOutV2Ix({ + pool, + userSourceTokenAccount, + userDestinationTokenAccount, + userSourceOwner, + maxAmountIn, + amountOut, +}: { + pool: RaydiumPool; + userSourceTokenAccount: PublicKey; + userDestinationTokenAccount: PublicKey; + userSourceOwner: PublicKey; + maxAmountIn: bigint; + amountOut: bigint; +}): TransactionInstruction { + const data = Buffer.alloc(1 + 8 + 8); + data.writeUInt8(17, 0); + data.writeBigUInt64LE(maxAmountIn, 1); + data.writeBigUInt64LE(amountOut, 9); + + return new TransactionInstruction({ + programId: RAYDIUM_AMM_PROGRAM_ID, + keys: swapAccountMetas( + pool, + userSourceTokenAccount, + userDestinationTokenAccount, + userSourceOwner, + ), + data, + }); +} diff --git a/tests/relaunch/unit/altTransactions.test.ts b/tests/relaunch/unit/altTransactions.test.ts new file mode 100644 index 00000000..757ae9ae --- /dev/null +++ b/tests/relaunch/unit/altTransactions.test.ts @@ -0,0 +1,534 @@ +// Executable documentation for building v0 transactions against the global +// frozen ALT (RELAUNCH_V0_1_GLOBAL_ALT, loaded in bankrun from +// tests/fixtures/relaunch-global-alt). Case selection and rationale: +// vibes/relaunch-alt-example-tests.html. The SDK conveniences still send +// legacy transactions; integrators composing the ix builders themselves are +// the audience for these patterns. +import { + AddressLookupTableAccount, + ComputeBudgetProgram, + Keypair, + PublicKey, + SystemProgram, + Transaction, +} from "@solana/web3.js"; +import * as token from "@solana/spl-token"; +import { assert } from "chai"; +import { BN } from "bn.js"; +import { BanksClient } from "solana-bankrun"; +import { + getPumpFeeRecipients, + RAYDIUM_AMM_AUTHORITY, + RAYDIUM_AMM_PROGRAM_ID, + RELAUNCH_V0_1_GLOBAL_ALT, + RelaunchClient, +} from "@metadaoproject/programs"; +import { buildV0Tx, setupRelaunch, DEFAULT_OLD_SUPPLY } from "../utils.js"; +import { writePumpPool, PumpPool } from "../pumpAmm.js"; +import { writeRaydiumPool, RaydiumPool } from "../raydiumAmm.js"; + +const POOL_BASE_RESERVE = 1_000_000n * 10n ** 6n; // 1M old tokens +const WSOL_POOL_QUOTE_RESERVE = 100n * 10n ** 9n; // 100 SOL + +const ONE_WEEK = 60 * 60 * 24 * 7; +const ONE_DAY = 60 * 60 * 24; + +const BASE_OUT = 10_000n * 10n ** 6n; // 10k old tokens +const MAX_QUOTE_IN = 2n * 10n ** 9n; // 2 SOL cap on the buy + +async function tokenBalance( + banksClient: BanksClient, + address: PublicKey, + tokenProgram: PublicKey = token.TOKEN_PROGRAM_ID, +): Promise { + const raw = await banksClient.getAccount(address); + if (!raw) return 0n; + return token.unpackAccount( + address, + { ...raw, data: Buffer.from(raw.data) } as any, + tokenProgram, + ).amount; +} + +async function lamports( + banksClient: BanksClient, + address: PublicKey, +): Promise { + return BigInt((await banksClient.getAccount(address))!.lamports); +} + +// The constant-product input for an exact-output buy, before fees. +function grossQuoteIn(baseOut: bigint): bigint { + return (WSOL_POOL_QUOTE_RESERVE * baseOut) / (POOL_BASE_RESERVE - baseOut); +} + +export default function suite() { + let client: RelaunchClient; + let globalAlt: AddressLookupTableAccount; + let protocolFeeRecipient: PublicKey; + let buybackFeeRecipient: PublicKey; + + before(async function () { + client = this.relaunch; + ({ protocolFeeRecipient, buybackFeeRecipient } = await getPumpFeeRecipients( + this.connection, + )); + // Fetched the way a script would fetch it: the harness connection runs + // web3.js's real getAddressLookupTable against bankrun state. + globalAlt = ( + await this.connection.getAddressLookupTable(RELAUNCH_V0_1_GLOBAL_ALT) + ).value!; + }); + + const setupLiveRelaunch = async function (this: Mocha.Context): Promise<{ + relaunch: PublicKey; + pool: PumpPool; + oldMint: PublicKey; + oldTokenProgram: PublicKey; + }> { + const setup = await setupRelaunch({ + banksClient: this.banksClient, + payer: this.payer, + }); + const pool = await writePumpPool({ + context: this.context, + baseMint: setup.oldMint, + quoteMint: token.NATIVE_MINT, + baseReserve: POOL_BASE_RESERVE, + quoteReserve: WSOL_POOL_QUOTE_RESERVE, + }); + + const { relaunch } = await client.initializeRelaunch({ + oldMint: setup.oldMint, + sourcePool: pool.pool, + sourceQuoteMint: token.NATIVE_MINT, + tokenName: "Relaunched", + tokenSymbol: "RLNCH", + tokenUri: "https://example.com/rlnch.json", + secondsForDeposits: ONE_WEEK, + gracePeriodSeconds: ONE_DAY, + thresholdBps: 1000, + teamAddress: this.payer.publicKey, + }); + await client.startDepositsIx({ relaunch }).rpc(); + + return { ...setup, pool, relaunch }; + }; + + const fundSol = async function ( + this: Mocha.Context, + to: PublicKey, + amount: bigint, + ) { + const tx = new Transaction().add( + SystemProgram.transfer({ + fromPubkey: this.payer.publicKey, + toPubkey: to, + lamports: Number(amount), + }), + ); + tx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + tx.feePayer = this.payer.publicKey; + tx.sign(this.payer); + await this.banksClient.processTransaction(tx); + }; + + // The full single-transaction buy flow for a depositor holding native SOL: + // wrap instructions, the buy, and an unwrap of the refund. + const wrapBuyUnwrapIxs = async ({ + relaunch, + pool, + depositor, + payer, + }: { + relaunch: PublicKey; + pool: PumpPool; + depositor: PublicKey; + payer: PublicKey; + }) => { + const wsolAta = token.getAssociatedTokenAddressSync( + token.NATIVE_MINT, + depositor, + ); + return [ + ComputeBudgetProgram.setComputeUnitLimit({ units: 350_000 }), + token.createAssociatedTokenAccountIdempotentInstruction( + depositor, + wsolAta, + depositor, + token.NATIVE_MINT, + ), + SystemProgram.transfer({ + fromPubkey: depositor, + toPubkey: wsolAta, + lamports: Number(MAX_QUOTE_IN), + }), + token.createSyncNativeInstruction(wsolAta), + await client + .depositViaBuyIx({ + relaunch, + oldMint: pool.baseMint, + oldTokenProgram: token.TOKEN_PROGRAM_ID, + sourceQuoteMint: pool.quoteMint, + sourcePool: pool.pool, + poolBaseTokenAccount: pool.poolBaseTokenAccount, + poolQuoteTokenAccount: pool.poolQuoteTokenAccount, + coinCreator: pool.coinCreator, + protocolFeeRecipient, + buybackFeeRecipient, + baseOut: new BN(BASE_OUT.toString()), + maxQuoteIn: new BN(MAX_QUOTE_IN.toString()), + depositor, + payer, + }) + .instruction(), + token.createCloseAccountInstruction(wsolAta, depositor, depositor), + ]; + }; + + it("deposit_via_buy: wrap, buy, and unwrap the refund in one atomic transaction", async function () { + const { relaunch, pool } = await setupLiveRelaunch.call(this); + const depositor = Keypair.generate(); + await fundSol.call(this, depositor.publicKey, 5n * 10n ** 9n); + + const ixs = await wrapBuyUnwrapIxs({ + relaunch, + pool, + depositor: depositor.publicKey, + payer: depositor.publicKey, + }); + + // As a legacy transaction this instruction list does not fit — which is + // why, without the ALT, the wrap rides a separate (non-atomic) + // preparatory transaction. + const legacyTx = new Transaction().add(...ixs); + legacyTx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + legacyTx.feePayer = depositor.publicKey; + legacyTx.sign(depositor); + assert.throws(() => legacyTx.serialize(), /too large/i); + + const solBefore = await lamports(this.banksClient, depositor.publicKey); + const tx = await buildV0Tx({ + banksClient: this.banksClient, + payerKey: depositor.publicKey, + instructions: ixs, + signers: [depositor], + tables: [globalAlt], + }); + assert.isAtMost(tx.serialize().length, 1232); + await this.banksClient.processTransaction(tx); + + const record = await client.getDepositRecord({ + relaunch, + depositor: depositor.publicKey, + }); + assert.equal(record.amountDeposited.toString(), BASE_OUT.toString()); + + const { oldTokenVault } = await client.fetchRelaunch(relaunch); + const vaultBalance = await tokenBalance(this.banksClient, oldTokenVault); + assert.equal(vaultBalance.toString(), BASE_OUT.toString()); + + // The refund came back as native SOL: the WSOL ATA is gone, and the + // depositor's lamport outflow is the buy's cost (plus fee and the + // deposit-record/volume-accumulator rents), well under the 2 SOL cap. + const wsolAta = token.getAssociatedTokenAddressSync( + token.NATIVE_MINT, + depositor.publicKey, + ); + assert.isNull(await this.banksClient.getAccount(wsolAta)); + const outflow = + solBefore - (await lamports(this.banksClient, depositor.publicKey)); + assert.isTrue(outflow >= grossQuoteIn(BASE_OUT) && outflow < MAX_QUOTE_IN); + }); + + it("deposit_via_buy: sponsor pays fees and rent, depositor only spends the quote", async function () { + const { relaunch, pool } = await setupLiveRelaunch.call(this); + const sponsor = Keypair.generate(); + const depositor = Keypair.generate(); + await fundSol.call(this, sponsor.publicKey, 10n ** 9n); + await fundSol.call(this, depositor.publicKey, 3n * 10n ** 9n); + + const ixs = await wrapBuyUnwrapIxs({ + relaunch, + pool, + depositor: depositor.publicKey, + payer: sponsor.publicKey, + }); + + // Two distinct signers push the legacy encoding even further past the + // limit: this flow does not exist without the ALT. + const legacyTx = new Transaction().add(...ixs); + legacyTx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + legacyTx.feePayer = sponsor.publicKey; + legacyTx.sign(sponsor, depositor); + assert.throws(() => legacyTx.serialize(), /too large/i); + + const sponsorBefore = await lamports(this.banksClient, sponsor.publicKey); + const depositorBefore = await lamports( + this.banksClient, + depositor.publicKey, + ); + const tx = await buildV0Tx({ + banksClient: this.banksClient, + payerKey: sponsor.publicKey, + instructions: ixs, + signers: [sponsor, depositor], + tables: [globalAlt], + }); + assert.isAtMost(tx.serialize().length, 1232); + await this.banksClient.processTransaction(tx); + + const record = await client.getDepositRecord({ + relaunch, + depositor: depositor.publicKey, + }); + assert.isTrue(record.depositor.equals(depositor.publicKey)); + assert.equal(record.amountDeposited.toString(), BASE_OUT.toString()); + + // The depositor paid exactly the buy's cost: no transaction fee, no + // record rent, and the wrap ATA's rent round-tripped back on close. The + // sponsor covered the rest. + const spent = + depositorBefore - (await lamports(this.banksClient, depositor.publicKey)); + const gross = grossQuoteIn(BASE_OUT); + assert.isTrue(spent >= gross && spent < (gross * 103n) / 100n); + assert.isTrue( + (await lamports(this.banksClient, sponsor.publicKey)) < sponsorBefore, + ); + }); + + const setupLiveRaydiumRelaunch = async function ( + this: Mocha.Context, + ): Promise<{ + relaunch: PublicKey; + pool: RaydiumPool; + oldMint: PublicKey; + }> { + const setup = await setupRelaunch({ + banksClient: this.banksClient, + payer: this.payer, + }); + const pool = writeRaydiumPool({ + context: this.context, + oldMint: setup.oldMint, + tokenReserve: POOL_BASE_RESERVE, + quoteReserve: WSOL_POOL_QUOTE_RESERVE, + }); + + const { relaunch } = await client.initializeRelaunch({ + oldMint: setup.oldMint, + sourcePool: pool.pool, + sourceQuoteMint: token.NATIVE_MINT, + tokenName: "Relaunched", + tokenSymbol: "RLNCH", + tokenUri: "https://example.com/rlnch.json", + secondsForDeposits: ONE_WEEK, + gracePeriodSeconds: ONE_DAY, + thresholdBps: 1000, + teamAddress: this.payer.publicKey, + }); + await client.startDepositsIx({ relaunch }).rpc(); + + return { oldMint: setup.oldMint, pool, relaunch }; + }; + + // The Raydium counterpart of wrapBuyUnwrapIxs: same wrap choreography, no + // pump fee-recipient or volume-accumulator accounts. + const wrapBuyUnwrapRaydiumIxs = async ({ + relaunch, + pool, + oldMint, + depositor, + payer, + }: { + relaunch: PublicKey; + pool: RaydiumPool; + oldMint: PublicKey; + depositor: PublicKey; + payer: PublicKey; + }) => { + const wsolAta = token.getAssociatedTokenAddressSync( + token.NATIVE_MINT, + depositor, + ); + return [ + ComputeBudgetProgram.setComputeUnitLimit({ units: 250_000 }), + token.createAssociatedTokenAccountIdempotentInstruction( + depositor, + wsolAta, + depositor, + token.NATIVE_MINT, + ), + SystemProgram.transfer({ + fromPubkey: depositor, + toPubkey: wsolAta, + lamports: Number(MAX_QUOTE_IN), + }), + token.createSyncNativeInstruction(wsolAta), + await client + .depositViaBuyRaydiumIx({ + relaunch, + oldMint, + sourceQuoteMint: token.NATIVE_MINT, + sourcePool: pool.pool, + ammCoinVault: pool.coinVault, + ammPcVault: pool.pcVault, + baseOut: new BN(BASE_OUT.toString()), + maxQuoteIn: new BN(MAX_QUOTE_IN.toString()), + depositor, + payer, + }) + .instruction(), + token.createCloseAccountInstruction(wsolAta, depositor, depositor), + ]; + }; + + it("deposit_via_buy_raydium: the wrap-buy-unwrap flow fits a single legacy transaction", async function () { + const { relaunch, pool, oldMint } = + await setupLiveRaydiumRelaunch.call(this); + const depositor = Keypair.generate(); + await fundSol.call(this, depositor.publicKey, 5n * 10n ** 9n); + + const ixs = await wrapBuyUnwrapRaydiumIxs({ + relaunch, + pool, + oldMint, + depositor: depositor.publicKey, + payer: depositor.publicKey, + }); + + // No fee-recipient tail and no volume accumulator: unlike the pump + // variant above, the Raydium buy fits the 1232-byte legacy limit without + // the lookup table. + const legacyTx = new Transaction().add(...ixs); + legacyTx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + legacyTx.feePayer = depositor.publicKey; + legacyTx.sign(depositor); + assert.isAtMost(legacyTx.serialize().length, 1232); + await this.banksClient.processTransaction(legacyTx); + + const record = await client.getDepositRecord({ + relaunch, + depositor: depositor.publicKey, + }); + assert.equal(record.amountDeposited.toString(), BASE_OUT.toString()); + + const { oldTokenVault } = await client.fetchRelaunch(relaunch); + const vaultBalance = await tokenBalance(this.banksClient, oldTokenVault); + assert.equal(vaultBalance.toString(), BASE_OUT.toString()); + + // The refund unwrapped: the WSOL ATA closed at the end of the flow. + const wsolAta = token.getAssociatedTokenAddressSync( + token.NATIVE_MINT, + depositor.publicKey, + ); + assert.isNull(await this.banksClient.getAccount(wsolAta)); + }); + + it("deposit_via_buy_raydium: built as a v0 transaction against the extended table", async function () { + const { relaunch, pool, oldMint } = + await setupLiveRaydiumRelaunch.call(this); + const depositor = Keypair.generate(); + await fundSol.call(this, depositor.publicKey, 5n * 10n ** 9n); + + // The extension that landed with the Raydium venue: both AMM v4 statics + // are entries of the frozen table. + const tableKeys = globalAlt.state.addresses.map((k) => k.toBase58()); + assert.include(tableKeys, RAYDIUM_AMM_PROGRAM_ID.toBase58()); + assert.include(tableKeys, RAYDIUM_AMM_AUTHORITY.toBase58()); + + const ixs = await wrapBuyUnwrapRaydiumIxs({ + relaunch, + pool, + oldMint, + depositor: depositor.publicKey, + payer: depositor.publicKey, + }); + const tx = await buildV0Tx({ + banksClient: this.banksClient, + payerKey: depositor.publicKey, + instructions: ixs, + signers: [depositor], + tables: [globalAlt], + }); + assert.isAtMost(tx.serialize().length, 1232); + + // Both statics resolved through the table instead of riding as static + // message keys. + const staticKeys = tx.message.staticAccountKeys.map((k) => k.toBase58()); + assert.notInclude(staticKeys, RAYDIUM_AMM_PROGRAM_ID.toBase58()); + assert.notInclude(staticKeys, RAYDIUM_AMM_AUTHORITY.toBase58()); + + await this.banksClient.processTransaction(tx); + + const record = await client.getDepositRecord({ + relaunch, + depositor: depositor.publicKey, + }); + assert.equal(record.amountDeposited.toString(), BASE_OUT.toString()); + + const { oldTokenVault } = await client.fetchRelaunch(relaunch); + const vaultBalance = await tokenBalance(this.banksClient, oldTokenVault); + assert.equal(vaultBalance.toString(), BASE_OUT.toString()); + }); + + it("execute_sell: built as a v0 transaction with the ALT", async function () { + const { relaunch, pool, oldMint } = await setupLiveRelaunch.call(this); + // 100M tokens meets the 10% threshold, so closing lands in SellPending. + const depositAmount = DEFAULT_OLD_SUPPLY / 10n; + await client + .depositIx({ + relaunch, + oldMint, + oldTokenProgram: token.TOKEN_PROGRAM_ID, + amount: new BN(depositAmount.toString()), + }) + .rpc(); + await this.advanceBySeconds(ONE_WEEK); + await client.closeDepositsIx({ relaunch }).rpc(); + + // 90% of the constant-product output as the sell's slippage floor. + const minQuoteOut = + (((WSOL_POOL_QUOTE_RESERVE * depositAmount) / + (POOL_BASE_RESERVE + depositAmount)) * + 90n) / + 100n; + + const ixs = [ + ComputeBudgetProgram.setComputeUnitLimit({ units: 250_000 }), + await client + .executeSellIx({ + relaunch, + oldMint, + oldTokenProgram: token.TOKEN_PROGRAM_ID, + sourceQuoteMint: pool.quoteMint, + sourcePool: pool.pool, + poolBaseTokenAccount: pool.poolBaseTokenAccount, + poolQuoteTokenAccount: pool.poolQuoteTokenAccount, + coinCreator: pool.coinCreator, + protocolFeeRecipient, + buybackFeeRecipient, + minQuoteOut: new BN(minQuoteOut.toString()), + }) + .instruction(), + ]; + + // Unlike the deposit flows this leg is not size-blocked; the example + // documents the assembly pattern, which applies unchanged to the other + // one-shot legs (execute_usdc_swap, complete_relaunch). + const tx = await buildV0Tx({ + banksClient: this.banksClient, + payerKey: this.payer.publicKey, + instructions: ixs, + signers: [this.payer], + tables: [globalAlt], + }); + await this.banksClient.processTransaction(tx); + + const storedRelaunch = await client.fetchRelaunch(relaunch); + assert.isDefined(storedRelaunch.state.sold); + assert.isTrue( + storedRelaunch.quoteRecovered.gte(new BN(minQuoteOut.toString())), + ); + }); +} diff --git a/tests/relaunch/unit/claim.test.ts b/tests/relaunch/unit/claim.test.ts new file mode 100644 index 00000000..9aa33d47 --- /dev/null +++ b/tests/relaunch/unit/claim.test.ts @@ -0,0 +1,349 @@ +import { + ComputeBudgetProgram, + Keypair, + PublicKey, + Transaction, +} from "@solana/web3.js"; +import * as token from "@solana/spl-token"; +import { assert } from "chai"; +import { BN } from "bn.js"; +import { MAINNET_USDC, RelaunchClient } from "@metadaoproject/programs"; +import { getAccount } from "spl-token-bankrun"; +import { setupRelaunch, DEFAULT_OLD_SUPPLY } from "../utils.js"; +import { writePumpPool } from "../pumpAmm.js"; + +const POOL_BASE_RESERVE = 1_000_000n * 10n ** 6n; // 1M old tokens +const USDC_POOL_QUOTE_RESERVE = 100_000n * 10n ** 6n; // 100k USDC + +const TOKENS_TO_DEPOSITORS = 12_500_000n * 10n ** 6n; + +const ONE_WEEK = 60 * 60 * 24 * 7; +const ONE_DAY = 60 * 60 * 24; + +// 10% of the 1B-token default supply = 100M tokens. +const DEFAULT_THRESHOLD_BPS = 1000; +const DEFAULT_THRESHOLD_AMOUNT = DEFAULT_OLD_SUPPLY / 10n; + +export default function suite() { + let client: RelaunchClient; + let oldMint: PublicKey; + let oldTokenProgram: PublicKey; + let payerOldTokenAccount: PublicKey; + let relaunch: PublicKey; + let newMint: PublicKey; + + before(function () { + client = this.relaunch; + }); + + // Initializes a Live relaunch on a USDC-quoted source pool, so the sell + // leg lands directly in Swapped and completion needs no whirlpool swap. + const setupLiveRelaunch = async function ( + this: Mocha.Context, + thresholdBps: number = DEFAULT_THRESHOLD_BPS, + ) { + const setup = await setupRelaunch({ + banksClient: this.banksClient, + payer: this.payer, + }); + const pool = await writePumpPool({ + context: this.context, + baseMint: setup.oldMint, + quoteMint: MAINNET_USDC, + baseReserve: POOL_BASE_RESERVE, + quoteReserve: USDC_POOL_QUOTE_RESERVE, + baseTokenProgram: setup.oldTokenProgram, + }); + + ({ oldMint, oldTokenProgram, payerOldTokenAccount } = setup); + ({ relaunch, newMint } = await client.initializeRelaunch({ + oldMint: setup.oldMint, + sourcePool: pool.pool, + sourceQuoteMint: MAINNET_USDC, + tokenName: "Relaunched", + tokenSymbol: "RLNCH", + tokenUri: "https://example.com/rlnch.json", + secondsForDeposits: ONE_WEEK, + gracePeriodSeconds: ONE_DAY, + thresholdBps, + teamAddress: this.payer.publicKey, + })); + + await client.startDepositsIx({ relaunch }).rpc(); + }; + + // Creates the depositor's ATA and funds it with old tokens from the payer. + const fundDepositor = async function ( + this: Mocha.Context, + depositor: PublicKey, + amount: bigint, + ): Promise { + const ata = token.getAssociatedTokenAddressSync( + oldMint, + depositor, + false, + oldTokenProgram, + ); + const tx = new Transaction().add( + token.createAssociatedTokenAccountIdempotentInstruction( + this.payer.publicKey, + ata, + depositor, + oldMint, + oldTokenProgram, + ), + token.createTransferCheckedInstruction( + payerOldTokenAccount, + oldMint, + ata, + this.payer.publicKey, + amount, + 6, + [], + oldTokenProgram, + ), + ); + tx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + tx.feePayer = this.payer.publicKey; + tx.sign(this.payer); + await this.banksClient.processTransaction(tx); + return ata; + }; + + const deposit = async function ( + this: Mocha.Context, + amount: bigint, + depositor?: Keypair, + ) { + const builder = client.depositIx({ + relaunch, + oldMint, + oldTokenProgram, + amount: new BN(amount.toString()), + depositor: depositor?.publicKey, + }); + if (depositor !== undefined) { + builder.signers([depositor]); + } + await builder.rpc(); + }; + + const closeDeposits = async function (this: Mocha.Context) { + await this.advanceBySeconds(ONE_WEEK); + await client.closeDepositsIx({ relaunch }).rpc(); + }; + + const sellAndComplete = async function (this: Mocha.Context) { + await closeDeposits.call(this); + await client.executeSell({ relaunch }); + await client.completeRelaunch({ relaunch }); + }; + + const newTokenBalance = async function ( + this: Mocha.Context, + owner: PublicKey, + ): Promise { + const account = await getAccount( + this.banksClient, + token.getAssociatedTokenAddressSync(newMint, owner), + ); + return account.amount; + }; + + it("distributes the depositor bucket pro-rata across depositors", async function () { + await setupLiveRelaunch.call(this); + const alice = Keypair.generate(); + const bob = Keypair.generate(); + await fundDepositor.call(this, alice.publicKey, 25_000_000_000_000n); + await fundDepositor.call(this, bob.publicKey, 75_000_000_000_000n); + + await deposit.call(this, 25_000_000_000_000n, alice); // 25M tokens + await deposit.call(this, 75_000_000_000_000n, bob); // 75M tokens + await deposit.call(this, 100_000_000_000_000n); // 100M tokens + await sellAndComplete.call(this); + + // The depositor never signs — the provider wallet cranks alice's and + // bob's claims. + await client + .claimIx({ relaunch, newMint, depositor: alice.publicKey }) + .rpc(); + await client.claimIx({ relaunch, newMint, depositor: bob.publicKey }).rpc(); + await client.claimIx({ relaunch, newMint }).rpc(); + + // 12.5M × 25/200, 12.5M × 75/200, 12.5M × 100/200. + assert.equal( + (await newTokenBalance.call(this, alice.publicKey)).toString(), + "1562500000000", + ); + assert.equal( + (await newTokenBalance.call(this, bob.publicKey)).toString(), + "4687500000000", + ); + assert.equal( + (await newTokenBalance.call(this, this.payer.publicKey)).toString(), + "6250000000000", + ); + + const record = await client.getDepositRecord({ + relaunch, + depositor: this.payer.publicKey, + }); + assert.isTrue(record.claimed); + assert.equal(record.seqNum.toString(), "1"); + + const storedRelaunch = await client.fetchRelaunch(relaunch); + assert.equal(storedRelaunch.seqNum.toString(), "10"); + + // The shares divide evenly, so the vault empties completely. + const vault = await getAccount( + this.banksClient, + storedRelaunch.newTokenVault, + ); + assert.equal(vault.amount.toString(), "0"); + }); + + it("floors each entitlement and strands the dust in the vault", async function () { + await setupLiveRelaunch.call(this); + const alice = Keypair.generate(); + await fundDepositor.call(this, alice.publicKey, 100_000_000_000_000n); + + const aliceDeposit = 100_000_000_000_000n; // 100M tokens + const payerDeposit = 200_000_000_000_000n; // 200M tokens + const total = aliceDeposit + payerDeposit; + await deposit.call(this, aliceDeposit, alice); + await deposit.call(this, payerDeposit); + await sellAndComplete.call(this); + + await client + .claimIx({ relaunch, newMint, depositor: alice.publicKey }) + .rpc(); + await client.claimIx({ relaunch, newMint }).rpc(); + + const aliceClaimed = await newTokenBalance.call(this, alice.publicKey); + const payerClaimed = await newTokenBalance.call(this, this.payer.publicKey); + assert.equal( + aliceClaimed.toString(), + ((TOKENS_TO_DEPOSITORS * aliceDeposit) / total).toString(), + ); + assert.equal( + payerClaimed.toString(), + ((TOKENS_TO_DEPOSITORS * payerDeposit) / total).toString(), + ); + + // Both thirds floor, so exactly one raw unit of dust stays behind. + assert.isTrue(aliceClaimed + payerClaimed <= TOKENS_TO_DEPOSITORS); + const storedRelaunch = await client.fetchRelaunch(relaunch); + const vault = await getAccount( + this.banksClient, + storedRelaunch.newTokenVault, + ); + assert.equal(vault.amount.toString(), "1"); + assert.equal( + (aliceClaimed + payerClaimed + vault.amount).toString(), + TOKENS_TO_DEPOSITORS.toString(), + ); + }); + + it("claims identically for a depositor who entered via deposit_via_buy", async function () { + // 1 bps of the 1B supply = 100k tokens, reachable with a 50k-token buy + // off the 1M-token pool. + await setupLiveRelaunch.call(this, 1); + const alice = Keypair.generate(); + await fundDepositor.call(this, alice.publicKey, 50_000_000_000n); + + await deposit.call(this, 50_000_000_000n, alice); // 50k tokens + await client.depositViaBuy({ + relaunch, + baseOut: new BN(50_000_000_000), // 50k tokens + }); + await sellAndComplete.call(this); + + await client + .claimIx({ relaunch, newMint, depositor: alice.publicKey }) + .rpc(); + await client.claimIx({ relaunch, newMint }).rpc(); + + // Equal deposits, equal shares: 12.5M × 50k/100k each. + const aliceClaimed = await newTokenBalance.call(this, alice.publicKey); + const payerClaimed = await newTokenBalance.call(this, this.payer.publicKey); + assert.equal(aliceClaimed.toString(), "6250000000000"); + assert.equal(payerClaimed.toString(), aliceClaimed.toString()); + }); + + it("fails to claim the same record twice", async function () { + await setupLiveRelaunch.call(this); + await deposit.call(this, DEFAULT_THRESHOLD_AMOUNT); + await sellAndComplete.call(this); + + await client.claimIx({ relaunch, newMint }).rpc(); + + try { + // The compute-unit-price instruction makes the transaction hash unique + // so the retry isn't rejected as a duplicate of the first claim. + await client + .claimIx({ relaunch, newMint }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ]) + .rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "AlreadyClaimed"); + } + + assert.equal( + (await newTokenBalance.call(this, this.payer.publicKey)).toString(), + TOKENS_TO_DEPOSITORS.toString(), + ); + }); + + it("fails without a deposit record", async function () { + await setupLiveRelaunch.call(this); + await deposit.call(this, DEFAULT_THRESHOLD_AMOUNT); + await sellAndComplete.call(this); + + const rando = Keypair.generate(); + try { + await client + .claimIx({ relaunch, newMint, depositor: rando.publicKey }) + .rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "AccountNotInitialized"); + } + }); + + it("fails before the relaunch completes", async function () { + await setupLiveRelaunch.call(this); + await deposit.call(this, DEFAULT_THRESHOLD_AMOUNT); + await closeDeposits.call(this); + await client.executeSell({ relaunch }); + + const storedRelaunch = await client.fetchRelaunch(relaunch); + assert.isDefined(storedRelaunch.state.swapped); + + try { + await client.claimIx({ relaunch, newMint }).rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "RelaunchNotComplete"); + } + }); + + it("fails for a failed relaunch", async function () { + await setupLiveRelaunch.call(this); + // Half the threshold, so closing lands in Failed. + await deposit.call(this, DEFAULT_THRESHOLD_AMOUNT / 2n); + await closeDeposits.call(this); + + const storedRelaunch = await client.fetchRelaunch(relaunch); + assert.isDefined(storedRelaunch.state.failed); + + try { + await client.claimIx({ relaunch, newMint }).rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "RelaunchNotComplete"); + } + }); +} diff --git a/tests/relaunch/unit/claimRefund.test.ts b/tests/relaunch/unit/claimRefund.test.ts new file mode 100644 index 00000000..5ba0ea53 --- /dev/null +++ b/tests/relaunch/unit/claimRefund.test.ts @@ -0,0 +1,447 @@ +import { + ComputeBudgetProgram, + Keypair, + LAMPORTS_PER_SOL, + PublicKey, + SystemProgram, + Transaction, +} from "@solana/web3.js"; +import * as token from "@solana/spl-token"; +import { assert } from "chai"; +import { BN } from "bn.js"; +import { RelaunchClient } from "@metadaoproject/programs"; +import { getAccount } from "spl-token-bankrun"; +import { setupRelaunch, DEFAULT_OLD_SUPPLY } from "../utils.js"; +import { writePumpPool } from "../pumpAmm.js"; + +const POOL_BASE_RESERVE = 1_000_000n * 10n ** 6n; // 1M old tokens +const WSOL_POOL_QUOTE_RESERVE = 100n * 10n ** 9n; // 100 SOL + +const ONE_WEEK = 60 * 60 * 24 * 7; +const ONE_DAY = 60 * 60 * 24; + +// 10% of the 1B-token default supply = 100M tokens. +const DEFAULT_THRESHOLD_BPS = 1000; +const DEFAULT_THRESHOLD_AMOUNT = DEFAULT_OLD_SUPPLY / 10n; + +export default function suite() { + let client: RelaunchClient; + let oldMint: PublicKey; + let oldTokenProgram: PublicKey; + let payerOldTokenAccount: PublicKey; + let relaunch: PublicKey; + let oldTokenVault: PublicKey; + + before(function () { + client = this.relaunch; + }); + + const setupLiveRelaunch = async function ( + this: Mocha.Context, + tokenProgram: PublicKey = token.TOKEN_PROGRAM_ID, + ) { + const setup = await setupRelaunch({ + banksClient: this.banksClient, + payer: this.payer, + oldTokenProgram: tokenProgram, + }); + const pool = await writePumpPool({ + context: this.context, + baseMint: setup.oldMint, + quoteMint: token.NATIVE_MINT, + baseReserve: POOL_BASE_RESERVE, + quoteReserve: WSOL_POOL_QUOTE_RESERVE, + baseTokenProgram: tokenProgram, + }); + + const { relaunch } = await client.initializeRelaunch({ + oldMint: setup.oldMint, + sourcePool: pool.pool, + sourceQuoteMint: token.NATIVE_MINT, + tokenName: "Relaunched", + tokenSymbol: "RLNCH", + tokenUri: "https://example.com/rlnch.json", + secondsForDeposits: ONE_WEEK, + gracePeriodSeconds: ONE_DAY, + thresholdBps: DEFAULT_THRESHOLD_BPS, + teamAddress: this.payer.publicKey, + }); + + await client.startDepositsIx({ relaunch }).rpc(); + + const relaunchSigner = client.getRelaunchSignerAddress({ relaunch }); + const oldTokenVault = token.getAssociatedTokenAddressSync( + setup.oldMint, + relaunchSigner, + true, + tokenProgram, + ); + + return { ...setup, relaunch, oldTokenVault }; + }; + + // Creates the depositor's ATA and funds it with old tokens from the payer. + const fundDepositor = async function ( + this: Mocha.Context, + depositor: PublicKey, + amount: bigint, + ): Promise { + const ata = token.getAssociatedTokenAddressSync( + oldMint, + depositor, + false, + oldTokenProgram, + ); + const tx = new Transaction().add( + token.createAssociatedTokenAccountIdempotentInstruction( + this.payer.publicKey, + ata, + depositor, + oldMint, + oldTokenProgram, + ), + token.createTransferCheckedInstruction( + payerOldTokenAccount, + oldMint, + ata, + this.payer.publicKey, + amount, + 6, + [], + oldTokenProgram, + ), + ); + tx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + tx.feePayer = this.payer.publicKey; + tx.sign(this.payer); + await this.banksClient.processTransaction(tx); + return ata; + }; + + const deposit = async function ( + this: Mocha.Context, + amount: bigint, + depositor?: Keypair, + ) { + const builder = client.depositIx({ + relaunch, + oldMint, + oldTokenProgram, + amount: new BN(amount.toString()), + depositor: depositor?.publicKey, + }); + if (depositor !== undefined) { + builder.signers([depositor]); + } + await builder.rpc(); + }; + + const closeDeposits = async function (this: Mocha.Context) { + await this.advanceBySeconds(ONE_WEEK); + await client.closeDepositsIx({ relaunch }).rpc(); + }; + + beforeEach(async function () { + ({ + oldMint, + oldTokenProgram, + payerOldTokenAccount, + relaunch, + oldTokenVault, + } = await setupLiveRelaunch.call(this)); + }); + + it("refunds the exact accumulated deposit after a threshold miss", async function () { + await deposit.call(this, 100_000_000n); // 100 tokens + await deposit.call(this, 200_000_000n); // 200 tokens + await closeDeposits.call(this); + + let storedRelaunch = await client.fetchRelaunch(relaunch); + assert.isDefined(storedRelaunch.state.failed); + + await client.claimRefundIx({ relaunch, oldMint, oldTokenProgram }).rpc(); + + const record = await client.getDepositRecord({ + relaunch, + depositor: this.payer.publicKey, + }); + assert.isTrue(record.claimed); + assert.equal(record.amountDeposited.toString(), "300000000"); + assert.equal(record.seqNum.toString(), "2"); + + storedRelaunch = await client.fetchRelaunch(relaunch); + assert.equal(storedRelaunch.seqNum.toString(), "5"); + + const vault = await getAccount(this.banksClient, oldTokenVault); + assert.equal(vault.amount.toString(), "0"); + + const depositorAccount = await getAccount( + this.banksClient, + payerOldTokenAccount, + ); + assert.equal( + depositorAccount.amount.toString(), + DEFAULT_OLD_SUPPLY.toString(), + ); + }); + + it("refunds under Token-2022", async function () { + const setup = await setupLiveRelaunch.call( + this, + token.TOKEN_2022_PROGRAM_ID, + ); + + await client + .depositIx({ + relaunch: setup.relaunch, + oldMint: setup.oldMint, + oldTokenProgram: token.TOKEN_2022_PROGRAM_ID, + amount: new BN(100_000_000), + }) + .rpc(); + await this.advanceBySeconds(ONE_WEEK); + await client.closeDepositsIx({ relaunch: setup.relaunch }).rpc(); + + await client + .claimRefundIx({ + relaunch: setup.relaunch, + oldMint: setup.oldMint, + oldTokenProgram: token.TOKEN_2022_PROGRAM_ID, + }) + .rpc(); + + const record = await client.getDepositRecord({ + relaunch: setup.relaunch, + depositor: this.payer.publicKey, + }); + assert.isTrue(record.claimed); + + const vault = await getAccount( + this.banksClient, + setup.oldTokenVault, + undefined, + token.TOKEN_2022_PROGRAM_ID, + ); + assert.equal(vault.amount.toString(), "0"); + + const depositorAccount = await getAccount( + this.banksClient, + setup.payerOldTokenAccount, + undefined, + token.TOKEN_2022_PROGRAM_ID, + ); + assert.equal( + depositorAccount.amount.toString(), + DEFAULT_OLD_SUPPLY.toString(), + ); + }); + + it("refunds every depositor exactly and empties the vault", async function () { + const alice = Keypair.generate(); + const bob = Keypair.generate(); + const aliceAta = await fundDepositor.call( + this, + alice.publicKey, + 1_000_000_000n, // 1,000 tokens + ); + const bobAta = await fundDepositor.call( + this, + bob.publicKey, + 1_000_000_000n, + ); + + await deposit.call(this, 100_000_000n, alice); + await deposit.call(this, 200_000_000n, bob); + await deposit.call(this, 300_000_000n); + await closeDeposits.call(this); + + await client + .claimRefundIx({ + relaunch, + oldMint, + oldTokenProgram, + depositor: alice.publicKey, + }) + .rpc(); + await client + .claimRefundIx({ + relaunch, + oldMint, + oldTokenProgram, + depositor: bob.publicKey, + }) + .rpc(); + await client.claimRefundIx({ relaunch, oldMint, oldTokenProgram }).rpc(); + + const aliceAccount = await getAccount(this.banksClient, aliceAta); + assert.equal(aliceAccount.amount.toString(), "1000000000"); + + const bobAccount = await getAccount(this.banksClient, bobAta); + assert.equal(bobAccount.amount.toString(), "1000000000"); + + const payerAccount = await getAccount( + this.banksClient, + payerOldTokenAccount, + ); + assert.equal( + payerAccount.amount.toString(), + (DEFAULT_OLD_SUPPLY - 2_000_000_000n).toString(), + ); + + const vault = await getAccount(this.banksClient, oldTokenVault); + assert.equal(vault.amount.toString(), "0"); + }); + + it("fails to refund the same record twice", async function () { + await deposit.call(this, 100_000_000n); + await closeDeposits.call(this); + + await client.claimRefundIx({ relaunch, oldMint, oldTokenProgram }).rpc(); + + try { + // The compute-unit-price instruction makes the transaction hash unique + // so the retry isn't rejected as a duplicate of the first claim. + await client + .claimRefundIx({ relaunch, oldMint, oldTokenProgram }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ]) + .rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "AlreadyClaimed"); + } + + const depositorAccount = await getAccount( + this.banksClient, + payerOldTokenAccount, + ); + assert.equal( + depositorAccount.amount.toString(), + DEFAULT_OLD_SUPPLY.toString(), + ); + }); + + it("fails without a deposit record", async function () { + await deposit.call(this, 100_000_000n); + await closeDeposits.call(this); + + const rando = Keypair.generate(); + await fundDepositor.call(this, rando.publicKey, 0n); + + try { + await client + .claimRefundIx({ + relaunch, + oldMint, + oldTokenProgram, + depositor: rando.publicKey, + }) + .rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "AccountNotInitialized"); + } + }); + + it("fails while the relaunch is Live", async function () { + await deposit.call(this, 100_000_000n); + + try { + await client.claimRefundIx({ relaunch, oldMint, oldTokenProgram }).rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "RelaunchNotFailed"); + } + }); + + it("fails while the relaunch is SellPending", async function () { + await deposit.call(this, DEFAULT_THRESHOLD_AMOUNT); + await closeDeposits.call(this); + + const storedRelaunch = await client.fetchRelaunch(relaunch); + assert.isDefined(storedRelaunch.state.sellPending); + + try { + await client.claimRefundIx({ relaunch, oldMint, oldTokenProgram }).rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "RelaunchNotFailed"); + } + }); + + it("refunds after the grace period lapses and mark_failed cranks", async function () { + await deposit.call(this, DEFAULT_THRESHOLD_AMOUNT); + await closeDeposits.call(this); + await this.advanceBySeconds(ONE_DAY + 1); + await client.markFailedIx({ relaunch }).rpc(); + + await client.claimRefundIx({ relaunch, oldMint, oldTokenProgram }).rpc(); + + const record = await client.getDepositRecord({ + relaunch, + depositor: this.payer.publicKey, + }); + assert.isTrue(record.claimed); + + const vault = await getAccount(this.banksClient, oldTokenVault); + assert.equal(vault.amount.toString(), "0"); + + const depositorAccount = await getAccount( + this.banksClient, + payerOldTokenAccount, + ); + assert.equal( + depositorAccount.amount.toString(), + DEFAULT_OLD_SUPPLY.toString(), + ); + }); + + it("lets any keypair crank a refund for a depositor", async function () { + const alice = Keypair.generate(); + const aliceAta = await fundDepositor.call( + this, + alice.publicKey, + 1_000_000_000n, + ); + await deposit.call(this, 100_000_000n, alice); + await closeDeposits.call(this); + + const cranker = Keypair.generate(); + const fund = new Transaction().add( + SystemProgram.transfer({ + fromPubkey: this.payer.publicKey, + toPubkey: cranker.publicKey, + lamports: LAMPORTS_PER_SOL, + }), + ); + fund.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + fund.feePayer = this.payer.publicKey; + fund.sign(this.payer); + await this.banksClient.processTransaction(fund); + + const tx = new Transaction().add( + await client + .claimRefundIx({ + relaunch, + oldMint, + oldTokenProgram, + depositor: alice.publicKey, + }) + .instruction(), + ); + tx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + tx.feePayer = cranker.publicKey; + tx.sign(cranker); + await this.banksClient.processTransaction(tx); + + const aliceAccount = await getAccount(this.banksClient, aliceAta); + assert.equal(aliceAccount.amount.toString(), "1000000000"); + + const record = await client.getDepositRecord({ + relaunch, + depositor: alice.publicKey, + }); + assert.isTrue(record.claimed); + }); +} diff --git a/tests/relaunch/unit/closeDeposits.test.ts b/tests/relaunch/unit/closeDeposits.test.ts new file mode 100644 index 00000000..1347a080 --- /dev/null +++ b/tests/relaunch/unit/closeDeposits.test.ts @@ -0,0 +1,235 @@ +import { + ComputeBudgetProgram, + Keypair, + LAMPORTS_PER_SOL, + PublicKey, + SystemProgram, + Transaction, +} from "@solana/web3.js"; +import * as token from "@solana/spl-token"; +import { assert } from "chai"; +import { BN } from "bn.js"; +import { RelaunchClient } from "@metadaoproject/programs"; +import { setupRelaunch, DEFAULT_OLD_SUPPLY } from "../utils.js"; +import { writePumpPool } from "../pumpAmm.js"; + +const POOL_BASE_RESERVE = 1_000_000n * 10n ** 6n; // 1M old tokens +const WSOL_POOL_QUOTE_RESERVE = 100n * 10n ** 9n; // 100 SOL + +const ONE_WEEK = 60 * 60 * 24 * 7; +const ONE_DAY = 60 * 60 * 24; + +// 10% of the 1B-token default supply = 100M tokens. +const DEFAULT_THRESHOLD_BPS = 1000; +const DEFAULT_THRESHOLD_AMOUNT = DEFAULT_OLD_SUPPLY / 10n; + +export default function suite() { + let client: RelaunchClient; + let oldMint: PublicKey; + let oldTokenProgram: PublicKey; + let relaunch: PublicKey; + + before(function () { + client = this.relaunch; + }); + + const setupLiveRelaunch = async function ( + this: Mocha.Context, + { + thresholdBps = DEFAULT_THRESHOLD_BPS, + oldSupply, + start = true, + }: { thresholdBps?: number; oldSupply?: bigint; start?: boolean } = {}, + ) { + const setup = await setupRelaunch({ + banksClient: this.banksClient, + payer: this.payer, + oldSupply, + }); + const pool = await writePumpPool({ + context: this.context, + baseMint: setup.oldMint, + quoteMint: token.NATIVE_MINT, + baseReserve: POOL_BASE_RESERVE, + quoteReserve: WSOL_POOL_QUOTE_RESERVE, + }); + + const { relaunch } = await client.initializeRelaunch({ + oldMint: setup.oldMint, + sourcePool: pool.pool, + sourceQuoteMint: token.NATIVE_MINT, + tokenName: "Relaunched", + tokenSymbol: "RLNCH", + tokenUri: "https://example.com/rlnch.json", + secondsForDeposits: ONE_WEEK, + gracePeriodSeconds: ONE_DAY, + thresholdBps, + teamAddress: this.payer.publicKey, + }); + + if (start) { + await client.startDepositsIx({ relaunch }).rpc(); + } + + return { ...setup, relaunch }; + }; + + const deposit = async function (this: Mocha.Context, amount: bigint) { + await client + .depositIx({ + relaunch, + oldMint, + oldTokenProgram, + amount: new BN(amount.toString()), + }) + .rpc(); + }; + + beforeEach(async function () { + ({ oldMint, oldTokenProgram, relaunch } = + await setupLiveRelaunch.call(this)); + }); + + it("closes into SellPending when deposits exactly meet the threshold", async function () { + await deposit.call(this, DEFAULT_THRESHOLD_AMOUNT); + await this.advanceBySeconds(ONE_WEEK); + + const clock = await this.banksClient.getClock(); + await client.closeDepositsIx({ relaunch }).rpc(); + + const storedRelaunch = await client.fetchRelaunch(relaunch); + assert.isDefined(storedRelaunch.state.sellPending); + assert.equal( + storedRelaunch.unixTimestampClosed.toString(), + clock.unixTimestamp.toString(), + ); + assert.equal(storedRelaunch.seqNum.toString(), "3"); + }); + + it("closes into Failed when deposits are one base unit short", async function () { + await deposit.call(this, DEFAULT_THRESHOLD_AMOUNT - 1n); + await this.advanceBySeconds(ONE_WEEK); + + await client.closeDepositsIx({ relaunch }).rpc(); + + const storedRelaunch = await client.fetchRelaunch(relaunch); + assert.isDefined(storedRelaunch.state.failed); + assert.isNotNull(storedRelaunch.unixTimestampClosed); + }); + + it("fails before the deposit window elapses", async function () { + await deposit.call(this, DEFAULT_THRESHOLD_AMOUNT); + await this.advanceBySeconds(ONE_WEEK - 10); + + try { + // The compute-unit-price instruction makes the transaction hash unique + // so the later successful close isn't rejected as a duplicate. + await client + .closeDepositsIx({ relaunch }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ]) + .rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "DepositWindowStillOpen"); + } + + let storedRelaunch = await client.fetchRelaunch(relaunch); + assert.isDefined(storedRelaunch.state.live); + assert.isNull(storedRelaunch.unixTimestampClosed); + + // The window closes exactly at started + seconds_for_deposits. + await this.advanceBySeconds(10); + await client.closeDepositsIx({ relaunch }).rpc(); + + storedRelaunch = await client.fetchRelaunch(relaunch); + assert.isDefined(storedRelaunch.state.sellPending); + }); + + it("fails when deposits are already closed", async function () { + await deposit.call(this, DEFAULT_THRESHOLD_AMOUNT); + await this.advanceBySeconds(ONE_WEEK); + + await client.closeDepositsIx({ relaunch }).rpc(); + + try { + // The compute-unit-price instruction makes the transaction hash unique + // so the retry isn't rejected as a duplicate of the first call. + await client + .closeDepositsIx({ relaunch }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ]) + .rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "RelaunchNotLive"); + } + }); + + it("fails before deposits start", async function () { + const setup = await setupLiveRelaunch.call(this, { start: false }); + await this.advanceBySeconds(ONE_WEEK); + + try { + await client.closeDepositsIx({ relaunch: setup.relaunch }).rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "RelaunchNotLive"); + } + }); + + it("computes the threshold in u128 when the multiplication overflows u64", async function () { + // 10_000 bps × a 10^16 raw supply is 10^20, above u64::MAX (~1.8 × 10^19). + const hugeSupply = 10n ** 16n; + const setup = await setupLiveRelaunch.call(this, { + thresholdBps: 10_000, + oldSupply: hugeSupply, + }); + + await client + .depositIx({ + relaunch: setup.relaunch, + oldMint: setup.oldMint, + oldTokenProgram: setup.oldTokenProgram, + amount: new BN(hugeSupply.toString()), + }) + .rpc(); + await this.advanceBySeconds(ONE_WEEK); + + await client.closeDepositsIx({ relaunch: setup.relaunch }).rpc(); + + const storedRelaunch = await client.fetchRelaunch(setup.relaunch); + assert.isDefined(storedRelaunch.state.sellPending); + }); + + it("lets any keypair crank the close", async function () { + await deposit.call(this, DEFAULT_THRESHOLD_AMOUNT); + await this.advanceBySeconds(ONE_WEEK); + + const cranker = Keypair.generate(); + const fund = new Transaction().add( + SystemProgram.transfer({ + fromPubkey: this.payer.publicKey, + toPubkey: cranker.publicKey, + lamports: LAMPORTS_PER_SOL, + }), + ); + fund.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + fund.feePayer = this.payer.publicKey; + fund.sign(this.payer); + await this.banksClient.processTransaction(fund); + + const tx = new Transaction().add( + await client.closeDepositsIx({ relaunch }).instruction(), + ); + tx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + tx.feePayer = cranker.publicKey; + tx.sign(cranker); + await this.banksClient.processTransaction(tx); + + const storedRelaunch = await client.fetchRelaunch(relaunch); + assert.isDefined(storedRelaunch.state.sellPending); + }); +} diff --git a/tests/relaunch/unit/completeRelaunch.test.ts b/tests/relaunch/unit/completeRelaunch.test.ts new file mode 100644 index 00000000..2fee5a07 --- /dev/null +++ b/tests/relaunch/unit/completeRelaunch.test.ts @@ -0,0 +1,433 @@ +import { + ComputeBudgetProgram, + Keypair, + LAMPORTS_PER_SOL, + PublicKey, + SystemProgram, + Transaction, +} from "@solana/web3.js"; +import * as token from "@solana/spl-token"; +import * as multisig from "@sqds/multisig"; +import { assert } from "chai"; +import BN from "bn.js"; +import { BanksClient } from "solana-bankrun"; +import { + FutarchyClient, + getDaoAddr, + getMetadataAddr, + MAINNET_USDC, + RelaunchClient, +} from "@metadaoproject/programs"; +import { deserializeMetadata } from "@metaplex-foundation/mpl-token-metadata"; +import { + fromWeb3JsPublicKey, + toWeb3JsPublicKey, +} from "@metaplex-foundation/umi-web3js-adapters"; +import { setupRelaunch, DEFAULT_OLD_SUPPLY } from "../utils.js"; +import { writePumpPool } from "../pumpAmm.js"; + +const POOL_BASE_RESERVE = 1_000_000n * 10n ** 6n; // 1M old tokens +const WSOL_POOL_QUOTE_RESERVE = 100n * 10n ** 9n; // 100 SOL +const USDC_POOL_QUOTE_RESERVE = 100_000n * 10n ** 6n; // 100k USDC + +const TOKENS_TO_DEPOSITORS = 12_500_000n * 10n ** 6n; +const TOKENS_TO_FUTARCHY_LIQUIDITY = 12_500_000n * 10n ** 6n; +const PROPOSAL_MIN_STAKE_TOKENS = 1_500_000n * 10n ** 6n; +const PRICE_SCALE = 10n ** 12n; + +const ONE_WEEK = 60 * 60 * 24 * 7; +const ONE_DAY = 60 * 60 * 24; + +const DEFAULT_THRESHOLD_BPS = 1000; +// 10% of the 1B-token default supply = 100M tokens. +const DEPOSIT_AMOUNT = DEFAULT_OLD_SUPPLY / 10n; + +async function tokenBalance( + banksClient: BanksClient, + address: PublicKey, +): Promise { + const raw = await banksClient.getAccount(address); + if (!raw) return 0n; + return token.unpackAccount(address, { + ...raw, + data: Buffer.from(raw.data), + } as any).amount; +} + +async function metadataUpdateAuthority( + banksClient: BanksClient, + mint: PublicKey, +): Promise { + const [tokenMetadata] = getMetadataAddr(mint); + const raw = await banksClient.getAccount(tokenMetadata); + const metadata = deserializeMetadata({ + ...raw, + publicKey: fromWeb3JsPublicKey(tokenMetadata), + owner: fromWeb3JsPublicKey(raw.owner), + lamports: { + basisPoints: BigInt(raw.lamports), + identifier: "SOL", + decimals: 9, + }, + rentEpoch: raw.rentEpoch ? BigInt(raw.rentEpoch) : undefined, + } as any); + return toWeb3JsPublicKey(metadata.updateAuthority); +} + +export default function suite() { + let client: RelaunchClient; + let futarchyClient: FutarchyClient; + + before(function () { + client = this.relaunch; + futarchyClient = this.futarchy; + }); + + const setupSwappedRelaunch = async function ( + this: Mocha.Context, + { + quoteMint = MAINNET_USDC, + depositAmount = DEPOSIT_AMOUNT, + thresholdBps = DEFAULT_THRESHOLD_BPS, + monthlySpendingLimitAmount, + monthlySpendingLimitMembers, + sell = true, + }: { + quoteMint?: PublicKey; + depositAmount?: bigint; + thresholdBps?: number; + monthlySpendingLimitAmount?: BN; + monthlySpendingLimitMembers?: PublicKey[]; + sell?: boolean; + } = {}, + ): Promise<{ relaunch: PublicKey; newMint: PublicKey }> { + const setup = await setupRelaunch({ + banksClient: this.banksClient, + payer: this.payer, + }); + const pool = await writePumpPool({ + context: this.context, + baseMint: setup.oldMint, + quoteMint, + baseReserve: POOL_BASE_RESERVE, + quoteReserve: quoteMint.equals(token.NATIVE_MINT) + ? WSOL_POOL_QUOTE_RESERVE + : USDC_POOL_QUOTE_RESERVE, + baseTokenProgram: setup.oldTokenProgram, + }); + + const { relaunch, newMint } = await client.initializeRelaunch({ + oldMint: setup.oldMint, + sourcePool: pool.pool, + sourceQuoteMint: quoteMint, + tokenName: "Relaunched", + tokenSymbol: "RLNCH", + tokenUri: "https://example.com/rlnch.json", + secondsForDeposits: ONE_WEEK, + gracePeriodSeconds: ONE_DAY, + thresholdBps, + monthlySpendingLimitAmount, + monthlySpendingLimitMembers, + teamAddress: this.payer.publicKey, + }); + + await client.startDepositsIx({ relaunch }).rpc(); + await client + .depositIx({ + relaunch, + oldMint: setup.oldMint, + oldTokenProgram: setup.oldTokenProgram, + amount: new BN(depositAmount.toString()), + }) + .rpc(); + await this.advanceBySeconds(ONE_WEEK); + await client.closeDepositsIx({ relaunch }).rpc(); + if (sell) { + await client.executeSell({ relaunch }); + } + + return { relaunch, newMint }; + }; + + // The happy path runs relaunch → futarchy → squads in one transaction, so + // it doubles as the reentrancy-shape check: no program twice in the stack. + it("completes into a DAO with launchpad-parity params in a single transaction", async function () { + const monthlySpend = new BN(100_000_000); // 100 USDC + const { relaunch, newMint } = await setupSwappedRelaunch.call(this, { + monthlySpendingLimitAmount: monthlySpend, + monthlySpendingLimitMembers: [this.payer.publicKey], + }); + + let storedRelaunch = await client.fetchRelaunch(relaunch); + const usdcRecovered = BigInt(storedRelaunch.usdcRecovered.toString()); + + await client.completeRelaunch({ relaunch }); + + storedRelaunch = await client.fetchRelaunch(relaunch); + assert.isDefined(storedRelaunch.state.complete); + assert.isNotNull(storedRelaunch.unixTimestampCompleted); + assert.equal(storedRelaunch.seqNum.toString(), "5"); + + const relaunchSigner = client.getRelaunchSignerAddress({ relaunch }); + const [dao] = getDaoAddr({ nonce: new BN(0), daoCreator: relaunchSigner }); + const [multisigPda] = multisig.getMultisigPda({ createKey: dao }); + const [multisigVault] = multisig.getVaultPda({ multisigPda, index: 0 }); + assert.ok(storedRelaunch.dao.equals(dao)); + assert.ok(storedRelaunch.daoVault.equals(multisigVault)); + + const storedDao = await futarchyClient.getDao(dao); + assert.ok(storedDao.daoCreator.equals(relaunchSigner)); + assert.equal(storedDao.nonce.toString(), "0"); + assert.ok(storedDao.baseMint.equals(newMint)); + assert.ok(storedDao.quoteMint.equals(MAINNET_USDC)); + assert.ok(storedDao.squadsMultisig.equals(multisigPda)); + assert.ok(storedDao.squadsMultisigVault.equals(multisigVault)); + + const expectedTwap = + (usdcRecovered * PRICE_SCALE) / TOKENS_TO_FUTARCHY_LIQUIDITY; + assert.equal( + storedDao.twapInitialObservation.toString(), + expectedTwap.toString(), + ); + assert.equal( + storedDao.twapMaxObservationChangePerUpdate.toString(), + (expectedTwap / 20n).toString(), + ); + assert.equal(storedDao.twapStartDelaySeconds, 24 * 60 * 60); + assert.equal(storedDao.passThresholdBps, 300); + assert.equal(storedDao.secondsPerProposal, 3 * 24 * 60 * 60); + assert.equal( + storedDao.baseToStake.toString(), + PROPOSAL_MIN_STAKE_TOKENS.toString(), + ); + assert.equal(storedDao.minBaseFutarchicLiquidity.toString(), "1"); + assert.equal(storedDao.minQuoteFutarchicLiquidity.toString(), "1"); + assert.equal(storedDao.teamSponsoredPassThresholdBps, -300); + assert.ok(storedDao.teamAddress.equals(this.payer.publicKey)); + assert.equal( + storedDao.initialSpendingLimit.amountPerMonth.toString(), + monthlySpend.toString(), + ); + assert.deepEqual( + storedDao.initialSpendingLimit.members.map((member) => member.toBase58()), + [this.payer.publicKey.toBase58()], + ); + + const [spendingLimit] = multisig.getSpendingLimitPda({ + multisigPda, + createKey: dao, + }); + assert.isNotNull(await this.banksClient.getAccount(spendingLimit)); + + // The AMM holds the full 12.5M bucket against the whole raise, so its + // open ratio is exactly the TWAP's initial observation. + const spot = storedDao.amm.state.spot.spot; + const baseReserves = BigInt(spot.baseReserves.toString()); + const quoteReserves = BigInt(spot.quoteReserves.toString()); + assert.equal( + baseReserves.toString(), + TOKENS_TO_FUTARCHY_LIQUIDITY.toString(), + ); + assert.equal(quoteReserves.toString(), usdcRecovered.toString()); + assert.equal( + ((quoteReserves * PRICE_SCALE) / baseReserves).toString(), + expectedTwap.toString(), + ); + + const ammBaseVaultBalance = await tokenBalance( + this.banksClient, + token.getAssociatedTokenAddressSync(newMint, dao, true), + ); + assert.equal( + ammBaseVaultBalance.toString(), + TOKENS_TO_FUTARCHY_LIQUIDITY.toString(), + ); + const ammQuoteVaultBalance = await tokenBalance( + this.banksClient, + token.getAssociatedTokenAddressSync(MAINNET_USDC, dao, true), + ); + assert.equal(ammQuoteVaultBalance.toString(), usdcRecovered.toString()); + + const [ammPosition] = PublicKey.findProgramAddressSync( + [Buffer.from("amm_position"), dao.toBuffer(), multisigVault.toBuffer()], + futarchyClient.getProgramId(), + ); + const storedPosition = + await futarchyClient.futarchy.account.ammPosition.fetch(ammPosition); + assert.ok(storedPosition.positionAuthority.equals(multisigVault)); + assert.ok(storedPosition.dao.equals(dao)); + + const treasuryBalance = await tokenBalance( + this.banksClient, + token.getAssociatedTokenAddressSync(MAINNET_USDC, multisigVault, true), + ); + assert.equal(treasuryBalance.toString(), "0"); + + const usdcVaultBalance = await tokenBalance( + this.banksClient, + storedRelaunch.usdcVault, + ); + assert.equal(usdcVaultBalance.toString(), "0"); + + const newTokenVaultBalance = await tokenBalance( + this.banksClient, + storedRelaunch.newTokenVault, + ); + assert.equal( + newTokenVaultBalance.toString(), + TOKENS_TO_DEPOSITORS.toString(), + ); + + const mint = await this.getMint(newMint); + assert.ok(mint.mintAuthority.equals(multisigVault)); + assert.ok( + (await metadataUpdateAuthority(this.banksClient, newMint)).equals( + multisigVault, + ), + ); + }); + + it("completes without a Squads spending limit when none is configured", async function () { + const { relaunch } = await setupSwappedRelaunch.call(this); + + await client.completeRelaunch({ relaunch }); + + const storedRelaunch = await client.fetchRelaunch(relaunch); + assert.isDefined(storedRelaunch.state.complete); + + const storedDao = await futarchyClient.getDao(storedRelaunch.dao); + assert.isNull(storedDao.initialSpendingLimit); + + const [multisigPda] = multisig.getMultisigPda({ + createKey: storedRelaunch.dao, + }); + const [spendingLimit] = multisig.getSpendingLimitPda({ + multisigPda, + createKey: storedRelaunch.dao, + }); + assert.isNull(await this.banksClient.getAccount(spendingLimit)); + }); + + it("lets any keypair crank the completion", async function () { + const { relaunch, newMint } = await setupSwappedRelaunch.call(this); + + const cranker = Keypair.generate(); + const fund = new Transaction().add( + SystemProgram.transfer({ + fromPubkey: this.payer.publicKey, + toPubkey: cranker.publicKey, + lamports: LAMPORTS_PER_SOL, + }), + ); + fund.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + fund.feePayer = this.payer.publicKey; + fund.sign(this.payer); + await this.banksClient.processTransaction(fund); + + const tx = await client + .completeRelaunchIx({ + relaunch, + newMint, + payer: cranker.publicKey, + }) + .transaction(); + tx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + tx.feePayer = cranker.publicKey; + tx.sign(cranker); + await this.banksClient.processTransaction(tx); + + const storedRelaunch = await client.fetchRelaunch(relaunch); + assert.isDefined(storedRelaunch.state.complete); + }); + + it("sends every recovered USDC raw unit to the AMM", async function () { + // 7% of the supply at a 5% threshold, so a different recovered amount + // than the happy path's. + const { relaunch } = await setupSwappedRelaunch.call(this, { + depositAmount: (DEFAULT_OLD_SUPPLY * 7n) / 100n, + thresholdBps: 500, + }); + + let storedRelaunch = await client.fetchRelaunch(relaunch); + const usdcRecovered = BigInt(storedRelaunch.usdcRecovered.toString()); + + await client.completeRelaunch({ relaunch }); + + storedRelaunch = await client.fetchRelaunch(relaunch); + const storedDao = await futarchyClient.getDao(storedRelaunch.dao); + const quoteReserves = BigInt( + storedDao.amm.state.spot.spot.quoteReserves.toString(), + ); + assert.equal(quoteReserves.toString(), usdcRecovered.toString()); + + const treasuryBalance = await tokenBalance( + this.banksClient, + token.getAssociatedTokenAddressSync( + MAINNET_USDC, + storedRelaunch.daoVault, + true, + ), + ); + assert.equal(treasuryBalance.toString(), "0"); + + const usdcVaultBalance = await tokenBalance( + this.banksClient, + storedRelaunch.usdcVault, + ); + assert.equal(usdcVaultBalance.toString(), "0"); + }); + + it("fails before the USDC swap for a WSOL-quoted source", async function () { + const { relaunch } = await setupSwappedRelaunch.call(this, { + quoteMint: token.NATIVE_MINT, + }); + + try { + await client.completeRelaunch({ relaunch }); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "RelaunchNotSwapped"); + } + + const storedRelaunch = await client.fetchRelaunch(relaunch); + assert.isDefined(storedRelaunch.state.sold); + }); + + it("fails for a failed relaunch", async function () { + // Half the threshold, so closing lands in Failed. + const { relaunch } = await setupSwappedRelaunch.call(this, { + depositAmount: DEPOSIT_AMOUNT / 2n, + sell: false, + }); + + const storedRelaunch = await client.fetchRelaunch(relaunch); + assert.isDefined(storedRelaunch.state.failed); + + try { + await client.completeRelaunch({ relaunch }); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "RelaunchNotSwapped"); + } + }); + + it("fails when the relaunch has already been completed", async function () { + const { relaunch, newMint } = await setupSwappedRelaunch.call(this); + + await client.completeRelaunch({ relaunch }); + + try { + // The compute-unit-price instruction makes the transaction hash unique + // so the retry isn't rejected as a duplicate of the first completion. + await client + .completeRelaunchIx({ relaunch, newMint }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ]) + .rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "RelaunchNotSwapped"); + } + }); +} diff --git a/tests/relaunch/unit/deposit.test.ts b/tests/relaunch/unit/deposit.test.ts new file mode 100644 index 00000000..33e91435 --- /dev/null +++ b/tests/relaunch/unit/deposit.test.ts @@ -0,0 +1,402 @@ +import { Keypair, PublicKey, Transaction } from "@solana/web3.js"; +import * as token from "@solana/spl-token"; +import { assert } from "chai"; +import { BN } from "bn.js"; +import { getDepositRecordAddr, RelaunchClient } from "@metadaoproject/programs"; +import { getAccount } from "spl-token-bankrun"; +import { setupRelaunch, DEFAULT_OLD_SUPPLY } from "../utils.js"; +import { writePumpPool } from "../pumpAmm.js"; + +const POOL_BASE_RESERVE = 1_000_000n * 10n ** 6n; // 1M old tokens +const WSOL_POOL_QUOTE_RESERVE = 100n * 10n ** 9n; // 100 SOL + +const ONE_WEEK = 60 * 60 * 24 * 7; +const ONE_DAY = 60 * 60 * 24; + +export default function suite() { + let client: RelaunchClient; + let oldMint: PublicKey; + let oldTokenProgram: PublicKey; + let payerOldTokenAccount: PublicKey; + let relaunch: PublicKey; + let oldTokenVault: PublicKey; + + before(function () { + client = this.relaunch; + }); + + const setupLiveRelaunch = async function ( + this: Mocha.Context, + tokenProgram: PublicKey = token.TOKEN_PROGRAM_ID, + { start = true }: { start?: boolean } = {}, + ) { + const setup = await setupRelaunch({ + banksClient: this.banksClient, + payer: this.payer, + oldTokenProgram: tokenProgram, + }); + const pool = await writePumpPool({ + context: this.context, + baseMint: setup.oldMint, + quoteMint: token.NATIVE_MINT, + baseReserve: POOL_BASE_RESERVE, + quoteReserve: WSOL_POOL_QUOTE_RESERVE, + baseTokenProgram: tokenProgram, + }); + + const { relaunch } = await client.initializeRelaunch({ + oldMint: setup.oldMint, + sourcePool: pool.pool, + sourceQuoteMint: token.NATIVE_MINT, + tokenName: "Relaunched", + tokenSymbol: "RLNCH", + tokenUri: "https://example.com/rlnch.json", + secondsForDeposits: ONE_WEEK, + gracePeriodSeconds: ONE_DAY, + thresholdBps: 1000, + teamAddress: this.payer.publicKey, + }); + + if (start) { + await client.startDepositsIx({ relaunch }).rpc(); + } + + const relaunchSigner = client.getRelaunchSignerAddress({ relaunch }); + const oldTokenVault = token.getAssociatedTokenAddressSync( + setup.oldMint, + relaunchSigner, + true, + tokenProgram, + ); + + return { ...setup, relaunch, oldTokenVault }; + }; + + // Creates the depositor's ATA and funds it with old tokens from the payer. + const fundDepositor = async function ( + this: Mocha.Context, + depositor: PublicKey, + amount: bigint, + ): Promise { + const ata = token.getAssociatedTokenAddressSync( + oldMint, + depositor, + false, + oldTokenProgram, + ); + const tx = new Transaction().add( + token.createAssociatedTokenAccountIdempotentInstruction( + this.payer.publicKey, + ata, + depositor, + oldMint, + oldTokenProgram, + ), + token.createTransferCheckedInstruction( + payerOldTokenAccount, + oldMint, + ata, + this.payer.publicKey, + amount, + 6, + [], + oldTokenProgram, + ), + ); + tx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + tx.feePayer = this.payer.publicKey; + tx.sign(this.payer); + await this.banksClient.processTransaction(tx); + return ata; + }; + + beforeEach(async function () { + ({ + oldMint, + oldTokenProgram, + payerOldTokenAccount, + relaunch, + oldTokenVault, + } = await setupLiveRelaunch.call(this)); + }); + + it("deposits old tokens", async function () { + await client + .depositIx({ + relaunch, + oldMint, + oldTokenProgram, + amount: new BN(100_000_000), // 100 tokens + }) + .rpc(); + + const storedRelaunch = await client.fetchRelaunch(relaunch); + assert.equal(storedRelaunch.totalDeposited.toString(), "100000000"); + assert.equal(storedRelaunch.seqNum.toString(), "2"); + + const record = await client.getDepositRecord({ + relaunch, + depositor: this.payer.publicKey, + }); + assert.isTrue(record.relaunch.equals(relaunch)); + assert.isTrue(record.depositor.equals(this.payer.publicKey)); + assert.equal(record.amountDeposited.toString(), "100000000"); + assert.isFalse(record.claimed); + assert.equal(record.seqNum.toString(), "0"); + const [, recordBump] = getDepositRecordAddr({ + programId: client.getProgramId(), + relaunch, + depositor: this.payer.publicKey, + }); + assert.equal(record.pdaBump, recordBump); + + const vault = await getAccount(this.banksClient, oldTokenVault); + assert.equal(vault.amount.toString(), "100000000"); + + const depositorAccount = await getAccount( + this.banksClient, + payerOldTokenAccount, + ); + assert.equal( + depositorAccount.amount.toString(), + (DEFAULT_OLD_SUPPLY - 100_000_000n).toString(), + ); + }); + + it("deposits old tokens under Token-2022", async function () { + const setup = await setupLiveRelaunch.call( + this, + token.TOKEN_2022_PROGRAM_ID, + ); + + await client + .depositIx({ + relaunch: setup.relaunch, + oldMint: setup.oldMint, + oldTokenProgram: token.TOKEN_2022_PROGRAM_ID, + amount: new BN(100_000_000), + }) + .rpc(); + + const storedRelaunch = await client.fetchRelaunch(setup.relaunch); + assert.equal(storedRelaunch.totalDeposited.toString(), "100000000"); + + const record = await client.getDepositRecord({ + relaunch: setup.relaunch, + depositor: this.payer.publicKey, + }); + assert.equal(record.amountDeposited.toString(), "100000000"); + + const vault = await getAccount( + this.banksClient, + setup.oldTokenVault, + undefined, + token.TOKEN_2022_PROGRAM_ID, + ); + assert.equal(vault.amount.toString(), "100000000"); + }); + + it("accumulates repeat deposits in the same record", async function () { + await client + .depositIx({ + relaunch, + oldMint, + oldTokenProgram, + amount: new BN(100_000_000), + }) + .rpc(); + await client + .depositIx({ + relaunch, + oldMint, + oldTokenProgram, + amount: new BN(200_000_000), + }) + .rpc(); + + const record = await client.getDepositRecord({ + relaunch, + depositor: this.payer.publicKey, + }); + assert.equal(record.amountDeposited.toString(), "300000000"); + assert.equal(record.seqNum.toString(), "1"); + + const storedRelaunch = await client.fetchRelaunch(relaunch); + assert.equal(storedRelaunch.totalDeposited.toString(), "300000000"); + assert.equal(storedRelaunch.seqNum.toString(), "3"); + + const vault = await getAccount(this.banksClient, oldTokenVault); + assert.equal(vault.amount.toString(), "300000000"); + }); + + it("tracks multiple depositors independently", async function () { + const alice = Keypair.generate(); + const bob = Keypair.generate(); + await fundDepositor.call(this, alice.publicKey, 1_000_000_000n); + await fundDepositor.call(this, bob.publicKey, 1_000_000_000n); + + await client + .depositIx({ + relaunch, + oldMint, + oldTokenProgram, + amount: new BN(100_000_000), + depositor: alice.publicKey, + }) + .signers([alice]) + .rpc(); + await client + .depositIx({ + relaunch, + oldMint, + oldTokenProgram, + amount: new BN(200_000_000), + depositor: bob.publicKey, + }) + .signers([bob]) + .rpc(); + + const aliceRecord = await client.getDepositRecord({ + relaunch, + depositor: alice.publicKey, + }); + assert.isTrue(aliceRecord.depositor.equals(alice.publicKey)); + assert.equal(aliceRecord.amountDeposited.toString(), "100000000"); + + const bobRecord = await client.getDepositRecord({ + relaunch, + depositor: bob.publicKey, + }); + assert.isTrue(bobRecord.depositor.equals(bob.publicKey)); + assert.equal(bobRecord.amountDeposited.toString(), "200000000"); + + const storedRelaunch = await client.fetchRelaunch(relaunch); + assert.equal(storedRelaunch.totalDeposited.toString(), "300000000"); + + const vault = await getAccount(this.banksClient, oldTokenVault); + assert.equal(vault.amount.toString(), "300000000"); + }); + + it("fails to deposit zero tokens", async function () { + try { + await client + .depositIx({ relaunch, oldMint, oldTokenProgram, amount: new BN(0) }) + .rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "InvalidAmount"); + } + }); + + it("fails when the depositor balance is insufficient", async function () { + const depositor = Keypair.generate(); + await fundDepositor.call(this, depositor.publicKey, 100_000_000n); + + try { + await client + .depositIx({ + relaunch, + oldMint, + oldTokenProgram, + amount: new BN(100_000_001), + depositor: depositor.publicKey, + }) + .signers([depositor]) + .rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "InsufficientFunds"); + } + + const depositRecord = client.getDepositRecordAddress({ + relaunch, + depositor: depositor.publicKey, + }); + assert.isNull(await this.banksClient.getAccount(depositRecord)); + }); + + it("fails before deposits start", async function () { + const setup = await setupLiveRelaunch.call(this, token.TOKEN_PROGRAM_ID, { + start: false, + }); + + try { + await client + .depositIx({ + relaunch: setup.relaunch, + oldMint: setup.oldMint, + oldTokenProgram: token.TOKEN_PROGRAM_ID, + amount: new BN(100_000_000), + }) + .rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "RelaunchNotLive"); + } + }); + + it("fails after the deposit window closes", async function () { + await this.advanceBySeconds(ONE_WEEK - 10); + await client + .depositIx({ + relaunch, + oldMint, + oldTokenProgram, + amount: new BN(100_000_000), + }) + .rpc(); + + await this.advanceBySeconds(10); + try { + await client + .depositIx({ + relaunch, + oldMint, + oldTokenProgram, + amount: new BN(200_000_000), + }) + .rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "DepositWindowClosed"); + } + + const storedRelaunch = await client.fetchRelaunch(relaunch); + assert.equal(storedRelaunch.totalDeposited.toString(), "100000000"); + }); + + it("fails with the wrong old-token program", async function () { + // The vault ATA re-derived under the wrong token program is an address + // that doesn't exist, so deserialization fails before the has_one check. + try { + await client + .depositIx({ + relaunch, + oldMint, + oldTokenProgram: token.TOKEN_2022_PROGRAM_ID, + amount: new BN(100_000_000), + }) + .rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "AccountNotInitialized"); + } + }); + + it("fails when the destination is not the old token vault", async function () { + try { + await client + .depositIx({ + relaunch, + oldMint, + oldTokenProgram, + amount: new BN(100_000_000), + }) + .accounts({ oldTokenVault: payerOldTokenAccount }) + .rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "ConstraintHasOne"); + } + }); +} diff --git a/tests/relaunch/unit/depositViaBuy.test.ts b/tests/relaunch/unit/depositViaBuy.test.ts new file mode 100644 index 00000000..78c5603c --- /dev/null +++ b/tests/relaunch/unit/depositViaBuy.test.ts @@ -0,0 +1,655 @@ +import { PublicKey } from "@solana/web3.js"; +import * as token from "@solana/spl-token"; +import { assert } from "chai"; +import { BN } from "bn.js"; +import { BanksClient } from "solana-bankrun"; +import { + getDepositRecordAddr, + getPumpFeeRecipients, + MAINNET_USDC, + RelaunchClient, +} from "@metadaoproject/programs"; +import { setupRelaunch } from "../utils.js"; +import { + getUserVolumeAccumulatorAddr, + writePumpPool, + PumpPool, +} from "../pumpAmm.js"; +import { wrapSol } from "../whirlpool.js"; + +const POOL_BASE_RESERVE = 1_000_000n * 10n ** 6n; // 1M old tokens +const WSOL_POOL_QUOTE_RESERVE = 100n * 10n ** 9n; // 100 SOL +const USDC_POOL_QUOTE_RESERVE = 100_000n * 10n ** 6n; // 100k USDC + +const ONE_WEEK = 60 * 60 * 24 * 7; +const ONE_DAY = 60 * 60 * 24; + +const BASE_OUT = 10_000n * 10n ** 6n; // 10k old tokens + +async function tokenBalance( + banksClient: BanksClient, + address: PublicKey, + tokenProgram: PublicKey = token.TOKEN_PROGRAM_ID, +): Promise { + const raw = await banksClient.getAccount(address); + if (!raw) return 0n; + return token.unpackAccount( + address, + { ...raw, data: Buffer.from(raw.data) } as any, + tokenProgram, + ).amount; +} + +// The constant-product input for an exact-output buy, before fees. +function grossQuoteIn(quoteReserve: bigint, baseOut: bigint): bigint { + return (quoteReserve * baseOut) / (POOL_BASE_RESERVE - baseOut); +} + +export default function suite() { + let client: RelaunchClient; + let protocolFeeRecipient: PublicKey; + let buybackFeeRecipient: PublicKey; + + before(async function () { + client = this.relaunch; + // Both recipients come from the loaded global-config fixture, so one + // fetch serves the whole suite. + ({ protocolFeeRecipient, buybackFeeRecipient } = await getPumpFeeRecipients( + this.connection, + )); + }); + + const setupLiveRelaunch = async function ( + this: Mocha.Context, + { + quoteMint = token.NATIVE_MINT, + oldTokenProgram = token.TOKEN_PROGRAM_ID, + start = true, + }: { + quoteMint?: PublicKey; + oldTokenProgram?: PublicKey; + start?: boolean; + } = {}, + ): Promise<{ + relaunch: PublicKey; + pool: PumpPool; + oldMint: PublicKey; + oldTokenProgram: PublicKey; + payerOldTokenAccount: PublicKey; + }> { + const setup = await setupRelaunch({ + banksClient: this.banksClient, + payer: this.payer, + oldTokenProgram, + }); + const pool = await writePumpPool({ + context: this.context, + baseMint: setup.oldMint, + quoteMint, + baseReserve: POOL_BASE_RESERVE, + quoteReserve: quoteMint.equals(token.NATIVE_MINT) + ? WSOL_POOL_QUOTE_RESERVE + : USDC_POOL_QUOTE_RESERVE, + baseTokenProgram: oldTokenProgram, + }); + + const { relaunch } = await client.initializeRelaunch({ + oldMint: setup.oldMint, + sourcePool: pool.pool, + sourceQuoteMint: quoteMint, + tokenName: "Relaunched", + tokenSymbol: "RLNCH", + tokenUri: "https://example.com/rlnch.json", + secondsForDeposits: ONE_WEEK, + gracePeriodSeconds: ONE_DAY, + thresholdBps: 1000, + teamAddress: this.payer.publicKey, + }); + + if (start) { + await client.startDepositsIx({ relaunch }).rpc(); + } + + return { ...setup, pool, relaunch }; + }; + + const depositViaBuyIx = ({ + relaunch, + pool, + oldTokenProgram, + baseOut, + maxQuoteIn, + }: { + relaunch: PublicKey; + pool: PumpPool; + oldTokenProgram: PublicKey; + baseOut: bigint; + maxQuoteIn: bigint; + }) => + client.depositViaBuyIx({ + relaunch, + oldMint: pool.baseMint, + oldTokenProgram, + sourceQuoteMint: pool.quoteMint, + sourcePool: pool.pool, + poolBaseTokenAccount: pool.poolBaseTokenAccount, + poolQuoteTokenAccount: pool.poolQuoteTokenAccount, + coinCreator: pool.coinCreator, + protocolFeeRecipient, + buybackFeeRecipient, + baseOut: new BN(baseOut.toString()), + maxQuoteIn: new BN(maxQuoteIn.toString()), + }); + + it("buys old tokens off a WSOL-quoted pool and credits them as a deposit", async function () { + const { relaunch, pool, oldTokenProgram } = + await setupLiveRelaunch.call(this); + const wsolAta = await wrapSol(client.provider, this.payer, 2n * 10n ** 9n); + const wsolBefore = await tokenBalance(this.banksClient, wsolAta); + const maxQuoteIn = 2n * 10n ** 9n; + + await depositViaBuyIx({ + relaunch, + pool, + oldTokenProgram, + baseOut: BASE_OUT, + maxQuoteIn, + }).rpc(); + + const storedRelaunch = await client.fetchRelaunch(relaunch); + assert.equal(storedRelaunch.totalDeposited.toString(), BASE_OUT.toString()); + assert.equal(storedRelaunch.seqNum.toString(), "2"); + + const record = await client.getDepositRecord({ + relaunch, + depositor: this.payer.publicKey, + }); + assert.isTrue(record.relaunch.equals(relaunch)); + assert.isTrue(record.depositor.equals(this.payer.publicKey)); + assert.equal(record.amountDeposited.toString(), BASE_OUT.toString()); + assert.isFalse(record.claimed); + assert.equal(record.seqNum.toString(), "0"); + const [, recordBump] = getDepositRecordAddr({ + programId: client.getProgramId(), + relaunch, + depositor: this.payer.publicKey, + }); + assert.equal(record.pdaBump, recordBump); + + const vaultBalance = await tokenBalance( + this.banksClient, + storedRelaunch.oldTokenVault, + oldTokenProgram, + ); + assert.equal(vaultBalance.toString(), BASE_OUT.toString()); + + const poolBaseBalance = await tokenBalance( + this.banksClient, + pool.poolBaseTokenAccount, + oldTokenProgram, + ); + assert.equal( + poolBaseBalance.toString(), + (POOL_BASE_RESERVE - BASE_OUT).toString(), + ); + + // The unspent quote was refunded: the cost stays within a few percent of + // the constant-product input, and the vault keeps none of the pull. + const wsolAfter = await tokenBalance(this.banksClient, wsolAta); + const spent = wsolBefore - wsolAfter; + const gross = grossQuoteIn(WSOL_POOL_QUOTE_RESERVE, BASE_OUT); + assert.isTrue(spent >= gross && spent < (gross * 103n) / 100n); + assert.isTrue(spent < maxQuoteIn); + + const quoteVaultBalance = await tokenBalance( + this.banksClient, + storedRelaunch.sourceQuoteVault, + ); + assert.equal(quoteVaultBalance.toString(), "0"); + + // The first buy created the relaunch signer's volume accumulator. + const userVolumeAccumulator = await this.banksClient.getAccount( + getUserVolumeAccumulatorAddr( + client.getRelaunchSignerAddress({ relaunch }), + ), + ); + assert.isNotNull(userVolumeAccumulator); + }); + + it("buys old tokens off a USDC-quoted pool and credits them as a deposit", async function () { + const { relaunch, pool, oldTokenProgram } = await setupLiveRelaunch.call( + this, + { quoteMint: MAINNET_USDC }, + ); + const usdcAta = token.getAssociatedTokenAddressSync( + MAINNET_USDC, + this.payer.publicKey, + ); + const usdcBefore = await tokenBalance(this.banksClient, usdcAta); + const maxQuoteIn = 2_000n * 10n ** 6n; // 2k USDC + + await depositViaBuyIx({ + relaunch, + pool, + oldTokenProgram, + baseOut: BASE_OUT, + maxQuoteIn, + }).rpc(); + + const storedRelaunch = await client.fetchRelaunch(relaunch); + assert.equal(storedRelaunch.totalDeposited.toString(), BASE_OUT.toString()); + assert.equal(storedRelaunch.seqNum.toString(), "2"); + + const record = await client.getDepositRecord({ + relaunch, + depositor: this.payer.publicKey, + }); + assert.isTrue(record.relaunch.equals(relaunch)); + assert.isTrue(record.depositor.equals(this.payer.publicKey)); + assert.equal(record.amountDeposited.toString(), BASE_OUT.toString()); + assert.isFalse(record.claimed); + assert.equal(record.seqNum.toString(), "0"); + const [, recordBump] = getDepositRecordAddr({ + programId: client.getProgramId(), + relaunch, + depositor: this.payer.publicKey, + }); + assert.equal(record.pdaBump, recordBump); + + const vaultBalance = await tokenBalance( + this.banksClient, + storedRelaunch.oldTokenVault, + oldTokenProgram, + ); + assert.equal(vaultBalance.toString(), BASE_OUT.toString()); + + const poolBaseBalance = await tokenBalance( + this.banksClient, + pool.poolBaseTokenAccount, + oldTokenProgram, + ); + assert.equal( + poolBaseBalance.toString(), + (POOL_BASE_RESERVE - BASE_OUT).toString(), + ); + + const usdcAfter = await tokenBalance(this.banksClient, usdcAta); + const spent = usdcBefore - usdcAfter; + const gross = grossQuoteIn(USDC_POOL_QUOTE_RESERVE, BASE_OUT); + assert.isTrue(spent >= gross && spent < (gross * 103n) / 100n); + assert.isTrue(spent < maxQuoteIn); + + const quoteVaultBalance = await tokenBalance( + this.banksClient, + storedRelaunch.sourceQuoteVault, + ); + assert.equal(quoteVaultBalance.toString(), "0"); + + const userVolumeAccumulator = await this.banksClient.getAccount( + getUserVolumeAccumulatorAddr( + client.getRelaunchSignerAddress({ relaunch }), + ), + ); + assert.isNotNull(userVolumeAccumulator); + }); + + it("computes max_quote_in from pool reserves when omitted", async function () { + const { relaunch, oldTokenProgram } = await setupLiveRelaunch.call(this, { + quoteMint: MAINNET_USDC, + }); + + const usdcAta = token.getAssociatedTokenAddressSync( + MAINNET_USDC, + this.payer.publicKey, + ); + const usdcBefore = await tokenBalance(this.banksClient, usdcAta); + + await client.depositViaBuy({ + relaunch, + baseOut: new BN(BASE_OUT.toString()), + }); + + const record = await client.getDepositRecord({ + relaunch, + depositor: this.payer.publicKey, + }); + assert.equal(record.amountDeposited.toString(), BASE_OUT.toString()); + + const { oldTokenVault } = await client.fetchRelaunch(relaunch); + const vaultBalance = await tokenBalance( + this.banksClient, + oldTokenVault, + oldTokenProgram, + ); + assert.equal(vaultBalance.toString(), BASE_OUT.toString()); + + // The computed cap is the constant-product input plus the default + // 100 bps of slippage, which the actual cost stays within. + const usdcAfter = await tokenBalance(this.banksClient, usdcAta); + const spent = usdcBefore - usdcAfter; + const gross = grossQuoteIn(USDC_POOL_QUOTE_RESERVE, BASE_OUT); + const computedCap = (gross * 10_100n) / 10_000n; + assert.isTrue(spent >= gross && spent <= computedCap); + }); + + it("wraps the depositor's SOL shortfall for WSOL-quoted buys", async function () { + const { relaunch, oldTokenProgram } = await setupLiveRelaunch.call(this); + + const wsolAta = token.getAssociatedTokenAddressSync( + token.NATIVE_MINT, + this.payer.publicKey, + ); + // Whatever WSOL is left over from earlier tests, this max_quote_in + // forces a 2-SOL shortfall that the convenience method must wrap. + const wsolBefore = await tokenBalance(this.banksClient, wsolAta); + const maxQuoteIn = wsolBefore + 2n * 10n ** 9n; + + await client.depositViaBuy({ + relaunch, + baseOut: new BN(BASE_OUT.toString()), + maxQuoteIn: new BN(maxQuoteIn.toString()), + }); + + const record = await client.getDepositRecord({ + relaunch, + depositor: this.payer.publicKey, + }); + assert.equal(record.amountDeposited.toString(), BASE_OUT.toString()); + + const { oldTokenVault } = await client.fetchRelaunch(relaunch); + const vaultBalance = await tokenBalance( + this.banksClient, + oldTokenVault, + oldTokenProgram, + ); + assert.equal(vaultBalance.toString(), BASE_OUT.toString()); + + // The wrap topped the ATA up to exactly max_quote_in before the buy + // spent from it, so the remainder is max_quote_in minus the cost. + const wsolAfter = await tokenBalance(this.banksClient, wsolAta); + const spent = maxQuoteIn - wsolAfter; + const gross = grossQuoteIn(WSOL_POOL_QUOTE_RESERVE, BASE_OUT); + assert.isTrue(spent >= gross && spent < (gross * 103n) / 100n); + }); + + it("accumulates buy-deposits and direct deposits in the same record", async function () { + const { relaunch, pool, oldMint, oldTokenProgram } = + await setupLiveRelaunch.call(this); + await wrapSol(client.provider, this.payer, 2n * 10n ** 9n); + + await client + .depositIx({ + relaunch, + oldMint, + oldTokenProgram, + amount: new BN(100_000_000), // 100 tokens + }) + .rpc(); + await depositViaBuyIx({ + relaunch, + pool, + oldTokenProgram, + baseOut: 50n * 10n ** 6n, // 50 tokens + maxQuoteIn: 10n ** 9n, + }).rpc(); + // A second buy exercises the existing-accumulator and existing-record + // branches. + await depositViaBuyIx({ + relaunch, + pool, + oldTokenProgram, + baseOut: 25n * 10n ** 6n, // 25 tokens + maxQuoteIn: 10n ** 9n, + }).rpc(); + + const record = await client.getDepositRecord({ + relaunch, + depositor: this.payer.publicKey, + }); + assert.equal(record.amountDeposited.toString(), "175000000"); + assert.equal(record.seqNum.toString(), "2"); + + const storedRelaunch = await client.fetchRelaunch(relaunch); + assert.equal(storedRelaunch.totalDeposited.toString(), "175000000"); + assert.equal(storedRelaunch.seqNum.toString(), "4"); + + const vaultBalance = await tokenBalance( + this.banksClient, + storedRelaunch.oldTokenVault, + oldTokenProgram, + ); + assert.equal(vaultBalance.toString(), "175000000"); + }); + + it("buys a Token-2022 old token", async function () { + const { relaunch, pool, oldTokenProgram } = await setupLiveRelaunch.call( + this, + { oldTokenProgram: token.TOKEN_2022_PROGRAM_ID }, + ); + await wrapSol(client.provider, this.payer, 2n * 10n ** 9n); + + await depositViaBuyIx({ + relaunch, + pool, + oldTokenProgram, + baseOut: BASE_OUT, + maxQuoteIn: 2n * 10n ** 9n, + }).rpc(); + + const storedRelaunch = await client.fetchRelaunch(relaunch); + assert.equal(storedRelaunch.totalDeposited.toString(), BASE_OUT.toString()); + + const vaultBalance = await tokenBalance( + this.banksClient, + storedRelaunch.oldTokenVault, + oldTokenProgram, + ); + assert.equal(vaultBalance.toString(), BASE_OUT.toString()); + }); + + it("fails when max_quote_in is too tight, crediting nothing", async function () { + const { relaunch, pool, oldTokenProgram } = + await setupLiveRelaunch.call(this); + await wrapSol(client.provider, this.payer, 2n * 10n ** 9n); + + const wsolAta = token.getAssociatedTokenAddressSync( + token.NATIVE_MINT, + this.payer.publicKey, + ); + const wsolBefore = await tokenBalance(this.banksClient, wsolAta); + + const gross = grossQuoteIn(WSOL_POOL_QUOTE_RESERVE, BASE_OUT); + try { + await depositViaBuyIx({ + relaunch, + pool, + oldTokenProgram, + baseOut: BASE_OUT, + maxQuoteIn: (gross * 90n) / 100n, + }).rpc(); + assert.fail("Should have thrown error"); + } catch (e) {} + + const storedRelaunch = await client.fetchRelaunch(relaunch); + assert.equal(storedRelaunch.totalDeposited.toString(), "0"); + + const depositRecord = client.getDepositRecordAddress({ + relaunch, + depositor: this.payer.publicKey, + }); + assert.isNull(await this.banksClient.getAccount(depositRecord)); + + const vaultBalance = await tokenBalance( + this.banksClient, + storedRelaunch.oldTokenVault, + oldTokenProgram, + ); + assert.equal(vaultBalance.toString(), "0"); + + // The whole transaction reverted, including the quote pull. + const wsolAfter = await tokenBalance(this.banksClient, wsolAta); + assert.equal(wsolAfter.toString(), wsolBefore.toString()); + }); + + it("fails to buy zero tokens", async function () { + const { relaunch, pool, oldTokenProgram } = + await setupLiveRelaunch.call(this); + await wrapSol(client.provider, this.payer, 10n ** 9n); + + try { + await depositViaBuyIx({ + relaunch, + pool, + oldTokenProgram, + baseOut: 0n, + maxQuoteIn: 10n ** 9n, + }).rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "InvalidAmount"); + } + }); + + it("fails with a zero max_quote_in", async function () { + const { relaunch, pool, oldTokenProgram } = + await setupLiveRelaunch.call(this); + await wrapSol(client.provider, this.payer, 10n ** 9n); + + try { + await depositViaBuyIx({ + relaunch, + pool, + oldTokenProgram, + baseOut: BASE_OUT, + maxQuoteIn: 0n, + }).rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "InvalidAmount"); + } + }); + + it("fails when the depositor's quote balance is insufficient", async function () { + const { relaunch, pool, oldTokenProgram } = + await setupLiveRelaunch.call(this); + await wrapSol(client.provider, this.payer, 10n ** 9n); + + const wsolBalance = await tokenBalance( + this.banksClient, + token.getAssociatedTokenAddressSync( + token.NATIVE_MINT, + this.payer.publicKey, + ), + ); + try { + await depositViaBuyIx({ + relaunch, + pool, + oldTokenProgram, + baseOut: BASE_OUT, + maxQuoteIn: wsolBalance + 1n, + }).rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "InsufficientFunds"); + } + }); + + it("fails before deposits start", async function () { + const { relaunch, pool, oldTokenProgram } = await setupLiveRelaunch.call( + this, + { start: false }, + ); + await wrapSol(client.provider, this.payer, 10n ** 9n); + + try { + await depositViaBuyIx({ + relaunch, + pool, + oldTokenProgram, + baseOut: BASE_OUT, + maxQuoteIn: 10n ** 9n, + }).rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "RelaunchNotLive"); + } + }); + + it("fails after the deposit window closes", async function () { + const { relaunch, pool, oldTokenProgram } = + await setupLiveRelaunch.call(this); + await wrapSol(client.provider, this.payer, 10n ** 9n); + await this.advanceBySeconds(ONE_WEEK); + + try { + await depositViaBuyIx({ + relaunch, + pool, + oldTokenProgram, + baseOut: BASE_OUT, + maxQuoteIn: 10n ** 9n, + }).rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "DepositWindowClosed"); + } + + const storedRelaunch = await client.fetchRelaunch(relaunch); + assert.equal(storedRelaunch.totalDeposited.toString(), "0"); + }); + + it("refunds buy-credited tokens as old tokens after the relaunch fails", async function () { + const { relaunch, pool, oldTokenProgram, payerOldTokenAccount } = + await setupLiveRelaunch.call(this); + await wrapSol(client.provider, this.payer, 2n * 10n ** 9n); + + await depositViaBuyIx({ + relaunch, + pool, + oldTokenProgram, + baseOut: BASE_OUT, + maxQuoteIn: 2n * 10n ** 9n, + }).rpc(); + + // 10k tokens misses the 10% threshold of the 1B supply. + await this.advanceBySeconds(ONE_WEEK); + await client.closeDepositsIx({ relaunch }).rpc(); + + let storedRelaunch = await client.fetchRelaunch(relaunch); + assert.isDefined(storedRelaunch.state.failed); + + const oldBalanceBefore = await tokenBalance( + this.banksClient, + payerOldTokenAccount, + oldTokenProgram, + ); + + await client.claimRefund({ relaunch }); + + const record = await client.getDepositRecord({ + relaunch, + depositor: this.payer.publicKey, + }); + assert.isTrue(record.claimed); + + // The buy is not unwound: the depositor is refunded the old tokens the + // buy acquired. + const oldBalanceAfter = await tokenBalance( + this.banksClient, + payerOldTokenAccount, + oldTokenProgram, + ); + assert.equal( + (oldBalanceAfter - oldBalanceBefore).toString(), + BASE_OUT.toString(), + ); + + const vaultBalance = await tokenBalance( + this.banksClient, + storedRelaunch.oldTokenVault, + oldTokenProgram, + ); + assert.equal(vaultBalance.toString(), "0"); + }); +} diff --git a/tests/relaunch/unit/depositViaBuyRaydium.test.ts b/tests/relaunch/unit/depositViaBuyRaydium.test.ts new file mode 100644 index 00000000..8adfe408 --- /dev/null +++ b/tests/relaunch/unit/depositViaBuyRaydium.test.ts @@ -0,0 +1,574 @@ +import { Keypair, PublicKey } from "@solana/web3.js"; +import * as token from "@solana/spl-token"; +import { assert } from "chai"; +import { BN } from "bn.js"; +import { BanksClient } from "solana-bankrun"; +import { + getDepositRecordAddr, + getPumpFeeRecipients, + RelaunchClient, +} from "@metadaoproject/programs"; +import { setupRelaunch } from "../utils.js"; +import { writePumpPool } from "../pumpAmm.js"; +import { writeRaydiumPool, RaydiumPool } from "../raydiumAmm.js"; +import { wrapSol } from "../whirlpool.js"; + +const POOL_BASE_RESERVE = 1_000_000n * 10n ** 6n; // 1M old tokens +const WSOL_POOL_QUOTE_RESERVE = 100n * 10n ** 9n; // 100 SOL + +const ONE_WEEK = 60 * 60 * 24 * 7; +const ONE_DAY = 60 * 60 * 24; + +const BASE_OUT = 10_000n * 10n ** 6n; // 10k old tokens + +async function tokenBalance( + banksClient: BanksClient, + address: PublicKey, +): Promise { + const raw = await banksClient.getAccount(address); + if (!raw) return 0n; + return token.unpackAccount(address, { + ...raw, + data: Buffer.from(raw.data), + } as any).amount; +} + +function ceilDiv(a: bigint, b: bigint): bigint { + return (a + b - 1n) / b; +} + +// The exact input the AMM pulls for an exact-output buy: the constant-product +// input, ceil-rounded, with the 25 bps fee ceil-rounded on top of it (the fee +// stays in the pool). +function raydiumExactOutInput( + amountOut: bigint, + inReserve: bigint, + outReserve: bigint, +): bigint { + const inBeforeFee = ceilDiv(inReserve * amountOut, outReserve - amountOut); + return ceilDiv(inBeforeFee * 10_000n, 9_975n); +} + +export default function suite() { + let client: RelaunchClient; + + before(function () { + client = this.relaunch; + }); + + const setupLiveRelaunch = async function ( + this: Mocha.Context, + { start = true }: { start?: boolean } = {}, + ): Promise<{ + relaunch: PublicKey; + pool: RaydiumPool; + oldMint: PublicKey; + oldTokenProgram: PublicKey; + payerOldTokenAccount: PublicKey; + }> { + const setup = await setupRelaunch({ + banksClient: this.banksClient, + payer: this.payer, + }); + const pool = writeRaydiumPool({ + context: this.context, + oldMint: setup.oldMint, + tokenReserve: POOL_BASE_RESERVE, + quoteReserve: WSOL_POOL_QUOTE_RESERVE, + }); + + const { relaunch } = await client.initializeRelaunch({ + oldMint: setup.oldMint, + sourcePool: pool.pool, + sourceQuoteMint: token.NATIVE_MINT, + tokenName: "Relaunched", + tokenSymbol: "RLNCH", + tokenUri: "https://example.com/rlnch.json", + secondsForDeposits: ONE_WEEK, + gracePeriodSeconds: ONE_DAY, + thresholdBps: 1000, + teamAddress: this.payer.publicKey, + }); + + if (start) { + await client.startDepositsIx({ relaunch }).rpc(); + } + + return { ...setup, pool, relaunch }; + }; + + const depositViaBuyRaydiumIx = ({ + relaunch, + pool, + oldMint, + baseOut, + maxQuoteIn, + }: { + relaunch: PublicKey; + pool: RaydiumPool; + oldMint: PublicKey; + baseOut: bigint; + maxQuoteIn: bigint; + }) => + client.depositViaBuyRaydiumIx({ + relaunch, + oldMint, + sourceQuoteMint: token.NATIVE_MINT, + sourcePool: pool.pool, + ammCoinVault: pool.coinVault, + ammPcVault: pool.pcVault, + baseOut: new BN(baseOut.toString()), + maxQuoteIn: new BN(maxQuoteIn.toString()), + }); + + it("buys exact base_out off the Raydium pool, credits it, and refunds exactly the unspent quote", async function () { + const { relaunch, pool, oldMint } = await setupLiveRelaunch.call(this); + const wsolAta = await wrapSol(client.provider, this.payer, 2n * 10n ** 9n); + const wsolBefore = await tokenBalance(this.banksClient, wsolAta); + const maxQuoteIn = 2n * 10n ** 9n; + + await depositViaBuyRaydiumIx({ + relaunch, + pool, + oldMint, + baseOut: BASE_OUT, + maxQuoteIn, + }).rpc(); + + const storedRelaunch = await client.fetchRelaunch(relaunch); + assert.equal(storedRelaunch.totalDeposited.toString(), BASE_OUT.toString()); + assert.equal(storedRelaunch.seqNum.toString(), "2"); + + const record = await client.getDepositRecord({ + relaunch, + depositor: this.payer.publicKey, + }); + assert.isTrue(record.relaunch.equals(relaunch)); + assert.isTrue(record.depositor.equals(this.payer.publicKey)); + assert.equal(record.amountDeposited.toString(), BASE_OUT.toString()); + assert.isFalse(record.claimed); + assert.equal(record.seqNum.toString(), "0"); + const [, recordBump] = getDepositRecordAddr({ + programId: client.getProgramId(), + relaunch, + depositor: this.payer.publicKey, + }); + assert.equal(record.pdaBump, recordBump); + + const vaultBalance = await tokenBalance( + this.banksClient, + storedRelaunch.oldTokenVault, + ); + assert.equal(vaultBalance.toString(), BASE_OUT.toString()); + + // Default orientation puts the token on the pc side, WSOL on coin. + const poolTokenBalance = await tokenBalance(this.banksClient, pool.pcVault); + assert.equal( + poolTokenBalance.toString(), + (POOL_BASE_RESERVE - BASE_OUT).toString(), + ); + + // The exact refund: the buy consumed exactly the exact-out input and + // everything above it returned to the depositor. + const predictedIn = raydiumExactOutInput( + BASE_OUT, + WSOL_POOL_QUOTE_RESERVE, + POOL_BASE_RESERVE, + ); + const wsolAfter = await tokenBalance(this.banksClient, wsolAta); + assert.equal((wsolBefore - wsolAfter).toString(), predictedIn.toString()); + + // The fee stays in the pool: the coin vault gains the full input. + const poolQuoteBalance = await tokenBalance( + this.banksClient, + pool.coinVault, + ); + assert.equal( + poolQuoteBalance.toString(), + (WSOL_POOL_QUOTE_RESERVE + predictedIn).toString(), + ); + + const quoteVaultBalance = await tokenBalance( + this.banksClient, + storedRelaunch.sourceQuoteVault, + ); + assert.equal(quoteVaultBalance.toString(), "0"); + }); + + it("computes max_quote_in from pool reserves when omitted", async function () { + const { relaunch } = await setupLiveRelaunch.call(this); + // Pre-fund past any computable cap so the wrap path stays out of the + // picture and the spend is a clean delta. + const wsolAta = await wrapSol(client.provider, this.payer, 2n * 10n ** 9n); + const wsolBefore = await tokenBalance(this.banksClient, wsolAta); + + await client.depositViaBuy({ + relaunch, + baseOut: new BN(BASE_OUT.toString()), + }); + + const record = await client.getDepositRecord({ + relaunch, + depositor: this.payer.publicKey, + }); + assert.equal(record.amountDeposited.toString(), BASE_OUT.toString()); + + const { oldTokenVault } = await client.fetchRelaunch(relaunch); + const vaultBalance = await tokenBalance(this.banksClient, oldTokenVault); + assert.equal(vaultBalance.toString(), BASE_OUT.toString()); + + // The computed cap is the exact-out input plus the default 100 bps of + // slippage; the CPI pulls exactly the formula input and the cushion + // never leaves the depositor. + const predictedIn = raydiumExactOutInput( + BASE_OUT, + WSOL_POOL_QUOTE_RESERVE, + POOL_BASE_RESERVE, + ); + const wsolAfter = await tokenBalance(this.banksClient, wsolAta); + assert.equal((wsolBefore - wsolAfter).toString(), predictedIn.toString()); + }); + + it("wraps the depositor's SOL shortfall", async function () { + const { relaunch } = await setupLiveRelaunch.call(this); + + const wsolAta = token.getAssociatedTokenAddressSync( + token.NATIVE_MINT, + this.payer.publicKey, + ); + // Whatever WSOL is left over from earlier tests, this max_quote_in + // forces a 2-SOL shortfall that the convenience method must wrap. + const wsolBefore = await tokenBalance(this.banksClient, wsolAta); + const maxQuoteIn = wsolBefore + 2n * 10n ** 9n; + + await client.depositViaBuy({ + relaunch, + baseOut: new BN(BASE_OUT.toString()), + maxQuoteIn: new BN(maxQuoteIn.toString()), + }); + + const record = await client.getDepositRecord({ + relaunch, + depositor: this.payer.publicKey, + }); + assert.equal(record.amountDeposited.toString(), BASE_OUT.toString()); + + const { oldTokenVault } = await client.fetchRelaunch(relaunch); + const vaultBalance = await tokenBalance(this.banksClient, oldTokenVault); + assert.equal(vaultBalance.toString(), BASE_OUT.toString()); + + // The wrap topped the ATA up to exactly max_quote_in before the buy + // spent from it, so the remainder is max_quote_in minus the exact-out + // input. + const predictedIn = raydiumExactOutInput( + BASE_OUT, + WSOL_POOL_QUOTE_RESERVE, + POOL_BASE_RESERVE, + ); + const wsolAfter = await tokenBalance(this.banksClient, wsolAta); + assert.equal((maxQuoteIn - wsolAfter).toString(), predictedIn.toString()); + }); + + it("accumulates buy-deposits and direct deposits in the same record", async function () { + const { relaunch, pool, oldMint, oldTokenProgram } = + await setupLiveRelaunch.call(this); + await wrapSol(client.provider, this.payer, 2n * 10n ** 9n); + + await client + .depositIx({ + relaunch, + oldMint, + oldTokenProgram, + amount: new BN(100_000_000), // 100 tokens + }) + .rpc(); + await depositViaBuyRaydiumIx({ + relaunch, + pool, + oldMint, + baseOut: 50n * 10n ** 6n, // 50 tokens + maxQuoteIn: 10n ** 9n, + }).rpc(); + // A second buy exercises the existing-record branch. + await depositViaBuyRaydiumIx({ + relaunch, + pool, + oldMint, + baseOut: 25n * 10n ** 6n, // 25 tokens + maxQuoteIn: 10n ** 9n, + }).rpc(); + + const record = await client.getDepositRecord({ + relaunch, + depositor: this.payer.publicKey, + }); + assert.equal(record.amountDeposited.toString(), "175000000"); + assert.equal(record.seqNum.toString(), "2"); + + const storedRelaunch = await client.fetchRelaunch(relaunch); + assert.equal(storedRelaunch.totalDeposited.toString(), "175000000"); + assert.equal(storedRelaunch.seqNum.toString(), "4"); + + const vaultBalance = await tokenBalance( + this.banksClient, + storedRelaunch.oldTokenVault, + ); + assert.equal(vaultBalance.toString(), "175000000"); + }); + + it("fails when max_quote_in is too tight", async function () { + const { relaunch, pool, oldMint } = await setupLiveRelaunch.call(this); + const wsolAta = await wrapSol(client.provider, this.payer, 2n * 10n ** 9n); + const wsolBefore = await tokenBalance(this.banksClient, wsolAta); + + const predictedIn = raydiumExactOutInput( + BASE_OUT, + WSOL_POOL_QUOTE_RESERVE, + POOL_BASE_RESERVE, + ); + try { + await depositViaBuyRaydiumIx({ + relaunch, + pool, + oldMint, + baseOut: BASE_OUT, + maxQuoteIn: predictedIn - 1n, + }).rpc(); + assert.fail("Should have thrown error"); + } catch (e) {} + + const storedRelaunch = await client.fetchRelaunch(relaunch); + assert.equal(storedRelaunch.totalDeposited.toString(), "0"); + + const depositRecord = client.getDepositRecordAddress({ + relaunch, + depositor: this.payer.publicKey, + }); + assert.isNull(await this.banksClient.getAccount(depositRecord)); + + const vaultBalance = await tokenBalance( + this.banksClient, + storedRelaunch.oldTokenVault, + ); + assert.equal(vaultBalance.toString(), "0"); + + // The whole transaction reverted, including the quote pull. + const wsolAfter = await tokenBalance(this.banksClient, wsolAta); + assert.equal(wsolAfter.toString(), wsolBefore.toString()); + }); + + it("fails to buy zero tokens", async function () { + const { relaunch, pool, oldMint } = await setupLiveRelaunch.call(this); + await wrapSol(client.provider, this.payer, 10n ** 9n); + + try { + await depositViaBuyRaydiumIx({ + relaunch, + pool, + oldMint, + baseOut: 0n, + maxQuoteIn: 10n ** 9n, + }).rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "InvalidAmount"); + } + }); + + it("fails with a zero max_quote_in", async function () { + const { relaunch, pool, oldMint } = await setupLiveRelaunch.call(this); + await wrapSol(client.provider, this.payer, 10n ** 9n); + + try { + await depositViaBuyRaydiumIx({ + relaunch, + pool, + oldMint, + baseOut: BASE_OUT, + maxQuoteIn: 0n, + }).rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "InvalidAmount"); + } + }); + + it("fails before deposits start", async function () { + const { relaunch, pool, oldMint } = await setupLiveRelaunch.call(this, { + start: false, + }); + await wrapSol(client.provider, this.payer, 10n ** 9n); + + try { + await depositViaBuyRaydiumIx({ + relaunch, + pool, + oldMint, + baseOut: BASE_OUT, + maxQuoteIn: 10n ** 9n, + }).rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "RelaunchNotLive"); + } + }); + + it("fails after the deposit window closes", async function () { + const { relaunch, pool, oldMint } = await setupLiveRelaunch.call(this); + await wrapSol(client.provider, this.payer, 10n ** 9n); + await this.advanceBySeconds(ONE_WEEK); + + try { + await depositViaBuyRaydiumIx({ + relaunch, + pool, + oldMint, + baseOut: BASE_OUT, + maxQuoteIn: 10n ** 9n, + }).rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "DepositWindowClosed"); + } + + const storedRelaunch = await client.fetchRelaunch(relaunch); + assert.equal(storedRelaunch.totalDeposited.toString(), "0"); + }); + + it("refunds buy-credited tokens as old tokens after the relaunch fails", async function () { + const { relaunch, pool, oldMint, payerOldTokenAccount } = + await setupLiveRelaunch.call(this); + await wrapSol(client.provider, this.payer, 2n * 10n ** 9n); + + await depositViaBuyRaydiumIx({ + relaunch, + pool, + oldMint, + baseOut: BASE_OUT, + maxQuoteIn: 2n * 10n ** 9n, + }).rpc(); + + // 10k tokens misses the 10% threshold of the 1B supply. + await this.advanceBySeconds(ONE_WEEK); + await client.closeDepositsIx({ relaunch }).rpc(); + + const storedRelaunch = await client.fetchRelaunch(relaunch); + assert.isDefined(storedRelaunch.state.failed); + + const oldBalanceBefore = await tokenBalance( + this.banksClient, + payerOldTokenAccount, + ); + + await client.claimRefund({ relaunch }); + + const record = await client.getDepositRecord({ + relaunch, + depositor: this.payer.publicKey, + }); + assert.isTrue(record.claimed); + + // The buy is not unwound: the depositor is refunded the old tokens the + // buy acquired. + const oldBalanceAfter = await tokenBalance( + this.banksClient, + payerOldTokenAccount, + ); + assert.equal( + (oldBalanceAfter - oldBalanceBefore).toString(), + BASE_OUT.toString(), + ); + + const vaultBalance = await tokenBalance( + this.banksClient, + storedRelaunch.oldTokenVault, + ); + assert.equal(vaultBalance.toString(), "0"); + }); + + it("fails when the pump-venue deposit_via_buy is called on a Raydium-source relaunch", async function () { + const { relaunch, pool, oldMint, oldTokenProgram } = + await setupLiveRelaunch.call(this); + await wrapSol(client.provider, this.payer, 10n ** 9n); + + const { protocolFeeRecipient, buybackFeeRecipient } = + await getPumpFeeRecipients(this.connection); + + try { + // The pump-specific accounts are unchecked until the CPI, so arbitrary + // stand-ins get the instruction as far as the venue gate. + await client + .depositViaBuyIx({ + relaunch, + oldMint, + oldTokenProgram, + sourceQuoteMint: token.NATIVE_MINT, + sourcePool: pool.pool, + poolBaseTokenAccount: pool.pcVault, + poolQuoteTokenAccount: pool.coinVault, + coinCreator: Keypair.generate().publicKey, + protocolFeeRecipient, + buybackFeeRecipient, + baseOut: new BN(BASE_OUT.toString()), + maxQuoteIn: new BN((10n ** 9n).toString()), + }) + .rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "WrongSourceVenue"); + } + + const storedRelaunch = await client.fetchRelaunch(relaunch); + assert.equal(storedRelaunch.totalDeposited.toString(), "0"); + }); + + it("fails when deposit_via_buy_raydium is called on a PumpSwap-source relaunch", async function () { + const { oldMint } = await setupRelaunch({ + banksClient: this.banksClient, + payer: this.payer, + }); + const pool = await writePumpPool({ + context: this.context, + baseMint: oldMint, + quoteMint: token.NATIVE_MINT, + baseReserve: POOL_BASE_RESERVE, + quoteReserve: WSOL_POOL_QUOTE_RESERVE, + baseTokenProgram: token.TOKEN_PROGRAM_ID, + }); + const { relaunch } = await client.initializeRelaunch({ + oldMint, + sourcePool: pool.pool, + sourceQuoteMint: token.NATIVE_MINT, + tokenName: "Relaunched", + tokenSymbol: "RLNCH", + tokenUri: "https://example.com/rlnch.json", + secondsForDeposits: ONE_WEEK, + gracePeriodSeconds: ONE_DAY, + thresholdBps: 1000, + teamAddress: this.payer.publicKey, + }); + await client.startDepositsIx({ relaunch }).rpc(); + await wrapSol(client.provider, this.payer, 10n ** 9n); + + try { + await client + .depositViaBuyRaydiumIx({ + relaunch, + oldMint, + sourceQuoteMint: token.NATIVE_MINT, + sourcePool: pool.pool, + ammCoinVault: pool.poolQuoteTokenAccount, + ammPcVault: pool.poolBaseTokenAccount, + baseOut: new BN(BASE_OUT.toString()), + maxQuoteIn: new BN((10n ** 9n).toString()), + }) + .rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "WrongSourceVenue"); + } + + const storedRelaunch = await client.fetchRelaunch(relaunch); + assert.equal(storedRelaunch.totalDeposited.toString(), "0"); + }); +} diff --git a/tests/relaunch/unit/executeSell.test.ts b/tests/relaunch/unit/executeSell.test.ts new file mode 100644 index 00000000..e763c75a --- /dev/null +++ b/tests/relaunch/unit/executeSell.test.ts @@ -0,0 +1,338 @@ +import { Keypair, PublicKey } from "@solana/web3.js"; +import * as token from "@solana/spl-token"; +import { assert } from "chai"; +import { BN } from "bn.js"; +import { BanksClient } from "solana-bankrun"; +import { + getPumpFeeRecipients, + MAINNET_USDC, + RelaunchClient, +} from "@metadaoproject/programs"; +import { setupRelaunch, DEFAULT_OLD_SUPPLY } from "../utils.js"; +import { writePumpPool, PumpPool } from "../pumpAmm.js"; + +const POOL_BASE_RESERVE = 1_000_000n * 10n ** 6n; // 1M old tokens +const WSOL_POOL_QUOTE_RESERVE = 100n * 10n ** 9n; // 100 SOL +const USDC_POOL_QUOTE_RESERVE = 100_000n * 10n ** 6n; // 100k USDC + +const ONE_WEEK = 60 * 60 * 24 * 7; +const ONE_DAY = 60 * 60 * 24; + +const DEFAULT_THRESHOLD_BPS = 1000; +// 10% of the 1B-token default supply = 100M tokens. +const DEPOSIT_AMOUNT = DEFAULT_OLD_SUPPLY / 10n; + +async function tokenBalance( + banksClient: BanksClient, + address: PublicKey, + tokenProgram: PublicKey = token.TOKEN_PROGRAM_ID, +): Promise { + const raw = await banksClient.getAccount(address); + if (!raw) return 0n; + return token.unpackAccount( + address, + { ...raw, data: Buffer.from(raw.data) } as any, + tokenProgram, + ).amount; +} + +// The constant-product output of selling the deposited amount, before fees. +function grossQuoteOut(quoteReserve: bigint): bigint { + return (quoteReserve * DEPOSIT_AMOUNT) / (POOL_BASE_RESERVE + DEPOSIT_AMOUNT); +} + +export default function suite() { + let client: RelaunchClient; + + before(function () { + client = this.relaunch; + }); + + const setupSellPendingRelaunch = async function ( + this: Mocha.Context, + { + quoteMint = token.NATIVE_MINT, + oldTokenProgram = token.TOKEN_PROGRAM_ID, + close = true, + }: { + quoteMint?: PublicKey; + oldTokenProgram?: PublicKey; + close?: boolean; + } = {}, + ): Promise<{ + relaunch: PublicKey; + pool: PumpPool; + oldMint: PublicKey; + oldTokenProgram: PublicKey; + }> { + const setup = await setupRelaunch({ + banksClient: this.banksClient, + payer: this.payer, + oldTokenProgram, + }); + const pool = await writePumpPool({ + context: this.context, + baseMint: setup.oldMint, + quoteMint, + baseReserve: POOL_BASE_RESERVE, + quoteReserve: quoteMint.equals(token.NATIVE_MINT) + ? WSOL_POOL_QUOTE_RESERVE + : USDC_POOL_QUOTE_RESERVE, + baseTokenProgram: oldTokenProgram, + }); + + const { relaunch } = await client.initializeRelaunch({ + oldMint: setup.oldMint, + sourcePool: pool.pool, + sourceQuoteMint: quoteMint, + tokenName: "Relaunched", + tokenSymbol: "RLNCH", + tokenUri: "https://example.com/rlnch.json", + secondsForDeposits: ONE_WEEK, + gracePeriodSeconds: ONE_DAY, + thresholdBps: DEFAULT_THRESHOLD_BPS, + teamAddress: this.payer.publicKey, + }); + + await client.startDepositsIx({ relaunch }).rpc(); + await client + .depositIx({ + relaunch, + oldMint: setup.oldMint, + oldTokenProgram, + amount: new BN(DEPOSIT_AMOUNT.toString()), + }) + .rpc(); + await this.advanceBySeconds(ONE_WEEK); + if (close) { + await client.closeDepositsIx({ relaunch }).rpc(); + } + + return { ...setup, pool, relaunch }; + }; + + it("sells the old-token vault into a WSOL-quoted pool and lands in Sold", async function () { + const { relaunch, pool, oldTokenProgram } = + await setupSellPendingRelaunch.call(this); + + await client.executeSell({ relaunch }); + + const storedRelaunch = await client.fetchRelaunch(relaunch); + assert.isDefined(storedRelaunch.state.sold); + + const oldVaultBalance = await tokenBalance( + this.banksClient, + storedRelaunch.oldTokenVault, + oldTokenProgram, + ); + assert.equal(oldVaultBalance.toString(), "0"); + + const poolBaseBalance = await tokenBalance( + this.banksClient, + pool.poolBaseTokenAccount, + oldTokenProgram, + ); + assert.equal( + poolBaseBalance.toString(), + (POOL_BASE_RESERVE + DEPOSIT_AMOUNT).toString(), + ); + + const quoteVaultBalance = await tokenBalance( + this.banksClient, + storedRelaunch.sourceQuoteVault, + ); + assert.equal( + storedRelaunch.quoteRecovered.toString(), + quoteVaultBalance.toString(), + ); + + // The proceeds are the constant-product output minus pump's fees. + const gross = grossQuoteOut(WSOL_POOL_QUOTE_RESERVE); + const quoteRecovered = BigInt(storedRelaunch.quoteRecovered.toString()); + assert.isTrue(quoteRecovered > (gross * 97n) / 100n); + assert.isTrue(quoteRecovered <= gross); + + assert.equal(storedRelaunch.usdcRecovered.toString(), "0"); + assert.equal(storedRelaunch.seqNum.toString(), "4"); + }); + + it("jumps straight to Swapped for a USDC-quoted pool", async function () { + const { relaunch, oldTokenProgram } = await setupSellPendingRelaunch.call( + this, + { quoteMint: MAINNET_USDC }, + ); + + await client.executeSell({ relaunch }); + + const storedRelaunch = await client.fetchRelaunch(relaunch); + assert.isDefined(storedRelaunch.state.swapped); + + const oldVaultBalance = await tokenBalance( + this.banksClient, + storedRelaunch.oldTokenVault, + oldTokenProgram, + ); + assert.equal(oldVaultBalance.toString(), "0"); + + assert.equal( + storedRelaunch.usdcRecovered.toString(), + storedRelaunch.quoteRecovered.toString(), + ); + + const usdcVaultBalance = await tokenBalance( + this.banksClient, + storedRelaunch.usdcVault, + ); + assert.equal( + storedRelaunch.usdcRecovered.toString(), + usdcVaultBalance.toString(), + ); + + const gross = grossQuoteOut(USDC_POOL_QUOTE_RESERVE); + const usdcRecovered = BigInt(storedRelaunch.usdcRecovered.toString()); + assert.isTrue(usdcRecovered > (gross * 97n) / 100n); + assert.isTrue(usdcRecovered <= gross); + }); + + it("sells a Token-2022 old token correctly", async function () { + const { relaunch, oldTokenProgram } = await setupSellPendingRelaunch.call( + this, + { oldTokenProgram: token.TOKEN_2022_PROGRAM_ID }, + ); + + await client.executeSell({ relaunch }); + + const storedRelaunch = await client.fetchRelaunch(relaunch); + assert.isDefined(storedRelaunch.state.sold); + + const oldVaultBalance = await tokenBalance( + this.banksClient, + storedRelaunch.oldTokenVault, + oldTokenProgram, + ); + assert.equal(oldVaultBalance.toString(), "0"); + + const quoteVaultBalance = await tokenBalance( + this.banksClient, + storedRelaunch.sourceQuoteVault, + ); + assert.equal( + storedRelaunch.quoteRecovered.toString(), + quoteVaultBalance.toString(), + ); + }); + + it("fails when min_quote_out is above the achievable proceeds, leaving state unchanged", async function () { + const { relaunch, oldTokenProgram } = + await setupSellPendingRelaunch.call(this); + + try { + // The whole quote reserve is unreachable output for any sell. + await client.executeSell({ + relaunch, + minQuoteOut: new BN(WSOL_POOL_QUOTE_RESERVE.toString()), + }); + assert.fail("Should have thrown error"); + } catch (e) {} + + let storedRelaunch = await client.fetchRelaunch(relaunch); + assert.isDefined(storedRelaunch.state.sellPending); + assert.equal(storedRelaunch.quoteRecovered.toString(), "0"); + + const oldVaultBalance = await tokenBalance( + this.banksClient, + storedRelaunch.oldTokenVault, + oldTokenProgram, + ); + assert.equal(oldVaultBalance.toString(), DEPOSIT_AMOUNT.toString()); + + // The same sell with a live floor succeeds, so only the floor differed. + await client.executeSell({ relaunch }); + storedRelaunch = await client.fetchRelaunch(relaunch); + assert.isDefined(storedRelaunch.state.sold); + }); + + it("fails when a non-admin executes the sell", async function () { + const { relaunch, pool, oldMint, oldTokenProgram } = + await setupSellPendingRelaunch.call(this); + const nonAdmin = Keypair.generate(); + + const { protocolFeeRecipient, buybackFeeRecipient } = + await getPumpFeeRecipients(this.connection); + + try { + await client + .executeSellIx({ + relaunch, + oldMint, + oldTokenProgram, + sourceQuoteMint: token.NATIVE_MINT, + sourcePool: pool.pool, + poolBaseTokenAccount: pool.poolBaseTokenAccount, + poolQuoteTokenAccount: pool.poolQuoteTokenAccount, + coinCreator: pool.coinCreator, + protocolFeeRecipient, + buybackFeeRecipient, + minQuoteOut: new BN(0), + admin: nonAdmin.publicKey, + }) + .signers([nonAdmin]) + .rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "ConstraintHasOne"); + } + + const storedRelaunch = await client.fetchRelaunch(relaunch); + assert.isDefined(storedRelaunch.state.sellPending); + }); + + it("fails before deposits close", async function () { + const { relaunch } = await setupSellPendingRelaunch.call(this, { + close: false, + }); + + try { + await client.executeSell({ relaunch }); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "RelaunchNotSellPending"); + } + }); + + it("fails when the sell has already been executed", async function () { + const { relaunch } = await setupSellPendingRelaunch.call(this); + + await client.executeSell({ relaunch }); + + try { + await client.executeSell({ relaunch, minQuoteOut: new BN(1) }); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "RelaunchNotSellPending"); + } + }); + + it("fails after the grace period elapses", async function () { + const { relaunch, oldTokenProgram } = + await setupSellPendingRelaunch.call(this); + await this.advanceBySeconds(ONE_DAY + 1); + + try { + await client.executeSell({ relaunch }); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "GracePeriodElapsed"); + } + + const storedRelaunch = await client.fetchRelaunch(relaunch); + assert.isDefined(storedRelaunch.state.sellPending); + + const oldVaultBalance = await tokenBalance( + this.banksClient, + storedRelaunch.oldTokenVault, + oldTokenProgram, + ); + assert.equal(oldVaultBalance.toString(), DEPOSIT_AMOUNT.toString()); + }); +} diff --git a/tests/relaunch/unit/executeSellRaydium.test.ts b/tests/relaunch/unit/executeSellRaydium.test.ts new file mode 100644 index 00000000..155682b7 --- /dev/null +++ b/tests/relaunch/unit/executeSellRaydium.test.ts @@ -0,0 +1,385 @@ +import { Keypair, PublicKey } from "@solana/web3.js"; +import * as token from "@solana/spl-token"; +import { assert } from "chai"; +import { BN } from "bn.js"; +import { BanksClient } from "solana-bankrun"; +import { getPumpFeeRecipients, RelaunchClient } from "@metadaoproject/programs"; +import { setupRelaunch, DEFAULT_OLD_SUPPLY } from "../utils.js"; +import { writePumpPool, PumpPool } from "../pumpAmm.js"; +import { writeRaydiumPool, RaydiumPool } from "../raydiumAmm.js"; + +const POOL_BASE_RESERVE = 1_000_000n * 10n ** 6n; // 1M old tokens +const WSOL_POOL_QUOTE_RESERVE = 100n * 10n ** 9n; // 100 SOL + +const ONE_WEEK = 60 * 60 * 24 * 7; +const ONE_DAY = 60 * 60 * 24; + +const DEFAULT_THRESHOLD_BPS = 1000; +// 10% of the 1B-token default supply = 100M tokens. +const DEPOSIT_AMOUNT = DEFAULT_OLD_SUPPLY / 10n; + +async function tokenBalance( + banksClient: BanksClient, + address: PublicKey, +): Promise { + const raw = await banksClient.getAccount(address); + if (!raw) return 0n; + return token.unpackAccount(address, { + ...raw, + data: Buffer.from(raw.data), + } as any).amount; +} + +function ceilDiv(a: bigint, b: bigint): bigint { + return (a + b - 1n) / b; +} + +// The exact output of selling the deposited amount at AMM v4's flat 25 bps +// fee (ceil-rounded off the input, kept in the pool). +function predictedQuoteOut(): bigint { + const net = DEPOSIT_AMOUNT - ceilDiv(DEPOSIT_AMOUNT * 25n, 10_000n); + return (WSOL_POOL_QUOTE_RESERVE * net) / (POOL_BASE_RESERVE + net); +} + +export default function suite() { + let client: RelaunchClient; + + before(function () { + client = this.relaunch; + }); + + const runToSellPending = async function ( + this: Mocha.Context, + { + oldMint, + sourcePool, + close, + }: { oldMint: PublicKey; sourcePool: PublicKey; close: boolean }, + ): Promise { + const { relaunch } = await client.initializeRelaunch({ + oldMint, + sourcePool, + sourceQuoteMint: token.NATIVE_MINT, + tokenName: "Relaunched", + tokenSymbol: "RLNCH", + tokenUri: "https://example.com/rlnch.json", + secondsForDeposits: ONE_WEEK, + gracePeriodSeconds: ONE_DAY, + thresholdBps: DEFAULT_THRESHOLD_BPS, + teamAddress: this.payer.publicKey, + }); + + await client.startDepositsIx({ relaunch }).rpc(); + await client + .depositIx({ + relaunch, + oldMint, + oldTokenProgram: token.TOKEN_PROGRAM_ID, + amount: new BN(DEPOSIT_AMOUNT.toString()), + }) + .rpc(); + await this.advanceBySeconds(ONE_WEEK); + if (close) { + await client.closeDepositsIx({ relaunch }).rpc(); + } + + return relaunch; + }; + + const setupRaydiumSellPending = async function ( + this: Mocha.Context, + { close = true }: { close?: boolean } = {}, + ): Promise<{ relaunch: PublicKey; pool: RaydiumPool; oldMint: PublicKey }> { + const { oldMint } = await setupRelaunch({ + banksClient: this.banksClient, + payer: this.payer, + }); + const pool = writeRaydiumPool({ + context: this.context, + oldMint, + tokenReserve: POOL_BASE_RESERVE, + quoteReserve: WSOL_POOL_QUOTE_RESERVE, + }); + const relaunch = await runToSellPending.call(this, { + oldMint, + sourcePool: pool.pool, + close, + }); + return { relaunch, pool, oldMint }; + }; + + const setupPumpSellPending = async function ( + this: Mocha.Context, + ): Promise<{ relaunch: PublicKey; pool: PumpPool; oldMint: PublicKey }> { + const { oldMint } = await setupRelaunch({ + banksClient: this.banksClient, + payer: this.payer, + }); + const pool = await writePumpPool({ + context: this.context, + baseMint: oldMint, + quoteMint: token.NATIVE_MINT, + baseReserve: POOL_BASE_RESERVE, + quoteReserve: WSOL_POOL_QUOTE_RESERVE, + baseTokenProgram: token.TOKEN_PROGRAM_ID, + }); + const relaunch = await runToSellPending.call(this, { + oldMint, + sourcePool: pool.pool, + close: true, + }); + return { relaunch, pool, oldMint }; + }; + + it("sells the old-token vault into the Raydium pool and lands in Sold with exact constant-product proceeds", async function () { + const { relaunch, pool } = await setupRaydiumSellPending.call(this); + + await client.executeSell({ relaunch }); + + const storedRelaunch = await client.fetchRelaunch(relaunch); + assert.isDefined(storedRelaunch.state.sold); + + const oldVaultBalance = await tokenBalance( + this.banksClient, + storedRelaunch.oldTokenVault, + ); + assert.equal(oldVaultBalance.toString(), "0"); + + // Default orientation puts the token on the pc side; the full input, + // fee included, lands in the pool. + const poolTokenBalance = await tokenBalance(this.banksClient, pool.pcVault); + assert.equal( + poolTokenBalance.toString(), + (POOL_BASE_RESERVE + DEPOSIT_AMOUNT).toString(), + ); + + const predicted = predictedQuoteOut(); + assert.equal( + storedRelaunch.quoteRecovered.toString(), + predicted.toString(), + ); + + const quoteVaultBalance = await tokenBalance( + this.banksClient, + storedRelaunch.sourceQuoteVault, + ); + assert.equal(quoteVaultBalance.toString(), predicted.toString()); + + const poolQuoteBalance = await tokenBalance( + this.banksClient, + pool.coinVault, + ); + assert.equal( + poolQuoteBalance.toString(), + (WSOL_POOL_QUOTE_RESERVE - predicted).toString(), + ); + + assert.equal(storedRelaunch.usdcRecovered.toString(), "0"); + assert.equal(storedRelaunch.seqNum.toString(), "4"); + }); + + it("fails when min_quote_out is above the achievable proceeds, leaving state unchanged", async function () { + const { relaunch } = await setupRaydiumSellPending.call(this); + + try { + // The whole quote reserve is unreachable output for any sell. + await client.executeSell({ + relaunch, + minQuoteOut: new BN(WSOL_POOL_QUOTE_RESERVE.toString()), + }); + assert.fail("Should have thrown error"); + } catch (e) {} + + let storedRelaunch = await client.fetchRelaunch(relaunch); + assert.isDefined(storedRelaunch.state.sellPending); + assert.equal(storedRelaunch.quoteRecovered.toString(), "0"); + + const oldVaultBalance = await tokenBalance( + this.banksClient, + storedRelaunch.oldTokenVault, + ); + assert.equal(oldVaultBalance.toString(), DEPOSIT_AMOUNT.toString()); + + // The same sell with a live floor succeeds, so only the floor differed. + await client.executeSell({ relaunch }); + storedRelaunch = await client.fetchRelaunch(relaunch); + assert.isDefined(storedRelaunch.state.sold); + }); + + it("fails when a non-admin executes the sell", async function () { + const { relaunch, pool, oldMint } = + await setupRaydiumSellPending.call(this); + const nonAdmin = Keypair.generate(); + + try { + await client + .executeSellRaydiumIx({ + relaunch, + oldMint, + sourceQuoteMint: token.NATIVE_MINT, + sourcePool: pool.pool, + ammCoinVault: pool.coinVault, + ammPcVault: pool.pcVault, + minQuoteOut: new BN(0), + admin: nonAdmin.publicKey, + }) + .signers([nonAdmin]) + .rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "ConstraintHasOne"); + } + + const storedRelaunch = await client.fetchRelaunch(relaunch); + assert.isDefined(storedRelaunch.state.sellPending); + }); + + it("fails before deposits close", async function () { + const { relaunch } = await setupRaydiumSellPending.call(this, { + close: false, + }); + + try { + await client.executeSell({ relaunch }); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "RelaunchNotSellPending"); + } + }); + + it("fails when the sell has already been executed", async function () { + const { relaunch } = await setupRaydiumSellPending.call(this); + + await client.executeSell({ relaunch }); + + try { + await client.executeSell({ relaunch, minQuoteOut: new BN(1) }); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "RelaunchNotSellPending"); + } + }); + + it("fails after the grace period elapses", async function () { + const { relaunch } = await setupRaydiumSellPending.call(this); + await this.advanceBySeconds(ONE_DAY + 1); + + try { + await client.executeSell({ relaunch }); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "GracePeriodElapsed"); + } + + const storedRelaunch = await client.fetchRelaunch(relaunch); + assert.isDefined(storedRelaunch.state.sellPending); + + const oldVaultBalance = await tokenBalance( + this.banksClient, + storedRelaunch.oldTokenVault, + ); + assert.equal(oldVaultBalance.toString(), DEPOSIT_AMOUNT.toString()); + }); + + it("fails when the pump-venue execute_sell is called on a Raydium-source relaunch", async function () { + const { relaunch, pool, oldMint } = + await setupRaydiumSellPending.call(this); + + const { protocolFeeRecipient, buybackFeeRecipient } = + await getPumpFeeRecipients(this.connection); + + try { + // The pump-specific accounts are unchecked until the CPI, so arbitrary + // stand-ins get the instruction as far as the venue gate. + await client + .executeSellIx({ + relaunch, + oldMint, + oldTokenProgram: token.TOKEN_PROGRAM_ID, + sourceQuoteMint: token.NATIVE_MINT, + sourcePool: pool.pool, + poolBaseTokenAccount: pool.pcVault, + poolQuoteTokenAccount: pool.coinVault, + coinCreator: Keypair.generate().publicKey, + protocolFeeRecipient, + buybackFeeRecipient, + minQuoteOut: new BN(0), + }) + .rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "WrongSourceVenue"); + } + + const storedRelaunch = await client.fetchRelaunch(relaunch); + assert.isDefined(storedRelaunch.state.sellPending); + }); + + it("fails when execute_sell_raydium is called on a PumpSwap-source relaunch", async function () { + const { relaunch, pool, oldMint } = await setupPumpSellPending.call(this); + + try { + await client + .executeSellRaydiumIx({ + relaunch, + oldMint, + sourceQuoteMint: token.NATIVE_MINT, + sourcePool: pool.pool, + ammCoinVault: pool.poolQuoteTokenAccount, + ammPcVault: pool.poolBaseTokenAccount, + minQuoteOut: new BN(0), + }) + .rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "WrongSourceVenue"); + } + + const storedRelaunch = await client.fetchRelaunch(relaunch); + assert.isDefined(storedRelaunch.state.sellPending); + }); + + it("fails when the passed coin/pc vault accounts do not match the pool's stored vaults", async function () { + const { relaunch, pool, oldMint } = + await setupRaydiumSellPending.call(this); + + // Swapped vaults: the coin-vault pin fails first. + try { + await client + .executeSellRaydiumIx({ + relaunch, + oldMint, + sourceQuoteMint: token.NATIVE_MINT, + sourcePool: pool.pool, + ammCoinVault: pool.pcVault, + ammPcVault: pool.coinVault, + minQuoteOut: new BN(0), + }) + .rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "SourcePoolNotCanonical"); + } + + // A foreign token account in the pc slot. + const storedRelaunch = await client.fetchRelaunch(relaunch); + try { + await client + .executeSellRaydiumIx({ + relaunch, + oldMint, + sourceQuoteMint: token.NATIVE_MINT, + sourcePool: pool.pool, + ammCoinVault: pool.coinVault, + ammPcVault: storedRelaunch.sourceQuoteVault, + minQuoteOut: new BN(0), + }) + .rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "SourcePoolNotCanonical"); + } + + assert.isDefined((await client.fetchRelaunch(relaunch)).state.sellPending); + }); +} diff --git a/tests/relaunch/unit/executeUsdcSwap.test.ts b/tests/relaunch/unit/executeUsdcSwap.test.ts new file mode 100644 index 00000000..b700c2ed --- /dev/null +++ b/tests/relaunch/unit/executeUsdcSwap.test.ts @@ -0,0 +1,288 @@ +import { Keypair, PublicKey } from "@solana/web3.js"; +import * as token from "@solana/spl-token"; +import { assert } from "chai"; +import { BN } from "bn.js"; +import { BankrunProvider } from "anchor-bankrun"; +import { BanksClient } from "solana-bankrun"; +import { + getWhirlpoolSwapTickArrayAddrs, + parseWhirlpool, + RelaunchClient, + USDC_SWAP_POOL, +} from "@metadaoproject/programs"; +import { setupRelaunch, DEFAULT_OLD_SUPPLY } from "../utils.js"; +import { writePumpPool } from "../pumpAmm.js"; +import { ensureWhirlpool, WhirlpoolFixture } from "../whirlpool.js"; + +const POOL_BASE_RESERVE = 1_000_000n * 10n ** 6n; // 1M old tokens +// Small enough that the sell proceeds (~4.9 SOL) swap through the whirlpool +// fixture (1000 SOL / 100k USDC) with well under 1% price impact. +const WSOL_POOL_QUOTE_RESERVE = 5n * 10n ** 9n; // 5 SOL + +const ONE_WEEK = 60 * 60 * 24 * 7; +const ONE_DAY = 60 * 60 * 24; + +const DEFAULT_THRESHOLD_BPS = 1000; +// 10% of the 1B-token default supply = 100M tokens. +const DEPOSIT_AMOUNT = DEFAULT_OLD_SUPPLY / 10n; + +async function tokenBalance( + banksClient: BanksClient, + address: PublicKey, +): Promise { + const raw = await banksClient.getAccount(address); + if (!raw) return 0n; + return token.unpackAccount(address, { + ...raw, + data: Buffer.from(raw.data), + } as any).amount; +} + +export default function suite() { + let client: RelaunchClient; + let whirlpool: WhirlpoolFixture; + + before(async function () { + client = this.relaunch; + whirlpool = await ensureWhirlpool({ + provider: new BankrunProvider(this.context) as any, + payer: this.payer, + banksClient: this.banksClient, + }); + }); + + // The whirlpool account set the low-level ix builder needs, derived from + // the pinned pool's live state. + const swapAccounts = async function (this: Mocha.Context) { + const raw = await this.banksClient.getAccount(USDC_SWAP_POOL); + const pool = parseWhirlpool(Buffer.from(raw!.data)); + return { + whirlpoolWsolVault: pool.tokenVaultA, + whirlpoolUsdcVault: pool.tokenVaultB, + tickArrays: getWhirlpoolSwapTickArrayAddrs( + USDC_SWAP_POOL, + pool.tickCurrentIndex, + pool.tickSpacing, + true, + ), + }; + }; + + const setupSoldRelaunch = async function ( + this: Mocha.Context, + { sell = true }: { sell?: boolean } = {}, + ): Promise<{ relaunch: PublicKey }> { + const setup = await setupRelaunch({ + banksClient: this.banksClient, + payer: this.payer, + }); + const pool = await writePumpPool({ + context: this.context, + baseMint: setup.oldMint, + quoteMint: token.NATIVE_MINT, + baseReserve: POOL_BASE_RESERVE, + quoteReserve: WSOL_POOL_QUOTE_RESERVE, + baseTokenProgram: setup.oldTokenProgram, + }); + + const { relaunch } = await client.initializeRelaunch({ + oldMint: setup.oldMint, + sourcePool: pool.pool, + sourceQuoteMint: token.NATIVE_MINT, + tokenName: "Relaunched", + tokenSymbol: "RLNCH", + tokenUri: "https://example.com/rlnch.json", + secondsForDeposits: ONE_WEEK, + gracePeriodSeconds: ONE_DAY, + thresholdBps: DEFAULT_THRESHOLD_BPS, + teamAddress: this.payer.publicKey, + }); + + await client.startDepositsIx({ relaunch }).rpc(); + await client + .depositIx({ + relaunch, + oldMint: setup.oldMint, + oldTokenProgram: setup.oldTokenProgram, + amount: new BN(DEPOSIT_AMOUNT.toString()), + }) + .rpc(); + await this.advanceBySeconds(ONE_WEEK); + await client.closeDepositsIx({ relaunch }).rpc(); + if (sell) { + // 100 bps exactly matches pump's fee on these reserves, leaving no + // rounding margin; give the setup sell explicit headroom. + await client.executeSell({ relaunch, slippageBps: 200 }); + } + + return { relaunch }; + }; + + it("swaps the whole WSOL vault to USDC and lands in Swapped", async function () { + const { relaunch } = await setupSoldRelaunch.call(this); + + let storedRelaunch = await client.fetchRelaunch(relaunch); + const wsolSold = BigInt(storedRelaunch.quoteRecovered.toString()); + const whirlpoolWsolBefore = await tokenBalance( + this.banksClient, + whirlpool.tokenVaultA, + ); + const whirlpoolUsdcBefore = await tokenBalance( + this.banksClient, + whirlpool.tokenVaultB, + ); + + await client.executeUsdcSwap({ relaunch }); + + storedRelaunch = await client.fetchRelaunch(relaunch); + assert.isDefined(storedRelaunch.state.swapped); + + const wsolVaultBalance = await tokenBalance( + this.banksClient, + storedRelaunch.sourceQuoteVault, + ); + assert.equal(wsolVaultBalance.toString(), "0"); + + const usdcVaultBalance = await tokenBalance( + this.banksClient, + storedRelaunch.usdcVault, + ); + assert.equal( + storedRelaunch.usdcRecovered.toString(), + usdcVaultBalance.toString(), + ); + + // The output is the spot value (100 USDC/SOL, i.e. one tenth in raw + // units) minus the 0.04% fee and a little price impact. + const spotOut = wsolSold / 10n; + const usdcRecovered = BigInt(storedRelaunch.usdcRecovered.toString()); + assert.isTrue(usdcRecovered > (spotOut * 97n) / 100n); + assert.isTrue(usdcRecovered <= spotOut); + + // The full WSOL balance went to the whirlpool, and every USDC the + // whirlpool paid out landed in the relaunch's vault. + const whirlpoolWsolAfter = await tokenBalance( + this.banksClient, + whirlpool.tokenVaultA, + ); + assert.equal( + (whirlpoolWsolAfter - whirlpoolWsolBefore).toString(), + wsolSold.toString(), + ); + const whirlpoolUsdcAfter = await tokenBalance( + this.banksClient, + whirlpool.tokenVaultB, + ); + assert.equal( + (whirlpoolUsdcBefore - whirlpoolUsdcAfter).toString(), + usdcRecovered.toString(), + ); + + assert.equal(storedRelaunch.quoteRecovered.toString(), wsolSold.toString()); + assert.equal(storedRelaunch.seqNum.toString(), "5"); + }); + + it("fails when the whirlpool account is not the pinned pool", async function () { + const { relaunch } = await setupSoldRelaunch.call(this); + const accounts = await swapAccounts.call(this); + + try { + await client + .executeUsdcSwapIx({ + relaunch, + ...accounts, + minUsdcOut: new BN(0), + whirlpool: Keypair.generate().publicKey, + }) + .rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "ConstraintAddress"); + } + + const storedRelaunch = await client.fetchRelaunch(relaunch); + assert.isDefined(storedRelaunch.state.sold); + }); + + it("fails when min_usdc_out is above the achievable output", async function () { + const { relaunch } = await setupSoldRelaunch.call(this); + + try { + // The whole USDC side of the whirlpool is unreachable output. + await client.executeUsdcSwap({ + relaunch, + minUsdcOut: new BN((100_000n * 10n ** 6n).toString()), + }); + assert.fail("Should have thrown error"); + } catch (e) {} + + let storedRelaunch = await client.fetchRelaunch(relaunch); + assert.isDefined(storedRelaunch.state.sold); + assert.equal(storedRelaunch.usdcRecovered.toString(), "0"); + + const wsolVaultBalance = await tokenBalance( + this.banksClient, + storedRelaunch.sourceQuoteVault, + ); + assert.equal( + wsolVaultBalance.toString(), + storedRelaunch.quoteRecovered.toString(), + ); + + // The same swap with a live floor succeeds, so only the floor differed. + await client.executeUsdcSwap({ relaunch }); + storedRelaunch = await client.fetchRelaunch(relaunch); + assert.isDefined(storedRelaunch.state.swapped); + }); + + it("fails when a non-admin executes the swap", async function () { + const { relaunch } = await setupSoldRelaunch.call(this); + const accounts = await swapAccounts.call(this); + const nonAdmin = Keypair.generate(); + + try { + await client + .executeUsdcSwapIx({ + relaunch, + ...accounts, + minUsdcOut: new BN(0), + admin: nonAdmin.publicKey, + }) + .signers([nonAdmin]) + .rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "ConstraintHasOne"); + } + + const storedRelaunch = await client.fetchRelaunch(relaunch); + assert.isDefined(storedRelaunch.state.sold); + }); + + it("fails before the sell has been executed", async function () { + const { relaunch } = await setupSoldRelaunch.call(this, { sell: false }); + + try { + await client.executeUsdcSwap({ relaunch }); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "RelaunchNotSold"); + } + + const storedRelaunch = await client.fetchRelaunch(relaunch); + assert.isDefined(storedRelaunch.state.sellPending); + }); + + it("fails when the swap has already been executed", async function () { + const { relaunch } = await setupSoldRelaunch.call(this); + + await client.executeUsdcSwap({ relaunch }); + + try { + await client.executeUsdcSwap({ relaunch, minUsdcOut: new BN(1) }); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "RelaunchNotSold"); + } + }); +} diff --git a/tests/relaunch/unit/initializeRelaunch.test.ts b/tests/relaunch/unit/initializeRelaunch.test.ts new file mode 100644 index 00000000..7cab2339 --- /dev/null +++ b/tests/relaunch/unit/initializeRelaunch.test.ts @@ -0,0 +1,1097 @@ +import { + Keypair, + PublicKey, + SystemProgram, + Transaction, + TransactionMessage, + VersionedTransaction, +} from "@solana/web3.js"; +import * as token from "@solana/spl-token"; +import { assert } from "chai"; +import { BN } from "bn.js"; +import { + getMetadataAddr, + MAINNET_USDC, + RelaunchClient, +} from "@metadaoproject/programs"; +import { BanksClient } from "solana-bankrun"; +import { createLookupTableForTransaction } from "../../utils.js"; +import { setupRelaunch, createOldMint, DEFAULT_OLD_SUPPLY } from "../utils.js"; +import { + getPumpPoolAuthorityAddr, + writePumpPool, + PumpPool, +} from "../pumpAmm.js"; +import { writeRaydiumPool, WriteRaydiumPoolParams } from "../raydiumAmm.js"; + +type InitializeRelaunchParams = Parameters< + RelaunchClient["initializeRelaunchIx"] +>[0]; + +const POOL_BASE_RESERVE = 1_000_000n * 10n ** 6n; // 1M old tokens +const WSOL_POOL_QUOTE_RESERVE = 100n * 10n ** 9n; // 100 SOL +const USDC_POOL_QUOTE_RESERVE = 100_000n * 10n ** 6n; // 100k USDC + +// TOKENS_TO_DEPOSITORS + TOKENS_TO_FUTARCHY_LIQUIDITY +const TOTAL_MINTED = 25_000_000n * 10n ** 6n; + +const ONE_WEEK = 60 * 60 * 24 * 7; +const ONE_DAY = 60 * 60 * 24; +const MAX_SECONDS_FOR_DEPOSITS = 60 * 60 * 24 * 365; + +async function createRawMint( + banksClient: BanksClient, + payer: Keypair, + { + decimals = 6, + mintAuthority, + freezeAuthority = null, + }: { + decimals?: number; + mintAuthority: PublicKey; + freezeAuthority?: PublicKey | null; + }, +): Promise { + const mintKeypair = Keypair.generate(); + const rent = await banksClient.getRent(); + + const tx = new Transaction().add( + SystemProgram.createAccount({ + fromPubkey: payer.publicKey, + newAccountPubkey: mintKeypair.publicKey, + lamports: Number(rent.minimumBalance(BigInt(token.MINT_SIZE))), + space: token.MINT_SIZE, + programId: token.TOKEN_PROGRAM_ID, + }), + token.createInitializeMint2Instruction( + mintKeypair.publicKey, + decimals, + mintAuthority, + freezeAuthority, + ), + ); + + tx.recentBlockhash = (await banksClient.getLatestBlockhash())[0]; + tx.feePayer = payer.publicKey; + tx.sign(payer, mintKeypair); + await banksClient.processTransaction(tx); + + return mintKeypair.publicKey; +} + +async function createT22MintWithTransferFee( + banksClient: BanksClient, + payer: Keypair, +): Promise { + const mintKeypair = Keypair.generate(); + const mint = mintKeypair.publicKey; + const rent = await banksClient.getRent(); + const mintLen = token.getMintLen([token.ExtensionType.TransferFeeConfig]); + + const tx = new Transaction().add( + SystemProgram.createAccount({ + fromPubkey: payer.publicKey, + newAccountPubkey: mint, + lamports: Number(rent.minimumBalance(BigInt(mintLen))), + space: mintLen, + programId: token.TOKEN_2022_PROGRAM_ID, + }), + token.createInitializeTransferFeeConfigInstruction( + mint, + payer.publicKey, + payer.publicKey, + 100, + 1_000_000n, + token.TOKEN_2022_PROGRAM_ID, + ), + token.createInitializeMint2Instruction( + mint, + 6, + payer.publicKey, + null, + token.TOKEN_2022_PROGRAM_ID, + ), + ); + + tx.recentBlockhash = (await banksClient.getLatestBlockhash())[0]; + tx.feePayer = payer.publicKey; + tx.sign(payer, mintKeypair); + await banksClient.processTransaction(tx); + + return mint; +} + +export default function suite() { + let client: RelaunchClient; + let oldMint: PublicKey; + let oldTokenProgram: PublicKey; + let pool: PumpPool; + + before(function () { + client = this.relaunch; + }); + + beforeEach(async function () { + ({ oldMint, oldTokenProgram } = await setupRelaunch({ + banksClient: this.banksClient, + payer: this.payer, + })); + pool = await writePumpPool({ + context: this.context, + baseMint: oldMint, + quoteMint: token.NATIVE_MINT, + baseReserve: POOL_BASE_RESERVE, + quoteReserve: WSOL_POOL_QUOTE_RESERVE, + }); + }); + + const defaultParams = function (this: Mocha.Context) { + return { + oldMint, + oldTokenProgram, + sourcePool: pool.pool, + sourceQuoteMint: token.NATIVE_MINT, + tokenName: "Relaunched", + tokenSymbol: "RLNCH", + tokenUri: "https://example.com/rlnch.json", + secondsForDeposits: ONE_WEEK, + gracePeriodSeconds: ONE_DAY, + thresholdBps: 1000, + monthlySpendingLimitAmount: new BN(10_000_000_000), // 10k USDC + monthlySpendingLimitMembers: [this.payer.publicKey], + teamAddress: this.payer.publicKey, + }; + }; + + const initializeWithParams = async function ( + this: Mocha.Context, + overrides: Partial = {}, + ): Promise { + const { newMint, instructions } = await client.createNewMintIxs(); + await client + .initializeRelaunchIx({ + newMint, + ...defaultParams.call(this), + ...overrides, + }) + .preInstructions(instructions) + .rpc(); + return newMint; + }; + + const expectInitializeToFail = async function ( + this: Mocha.Context, + overrides: Partial, + expectedError: string, + ) { + try { + await initializeWithParams.call(this, overrides); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, expectedError); + } + }; + + const writeCanonicalRaydiumPool = function ( + this: Mocha.Context, + overrides: Partial = {}, + ) { + return writeRaydiumPool({ + context: this.context, + oldMint, + tokenReserve: POOL_BASE_RESERVE, + quoteReserve: WSOL_POOL_QUOTE_RESERVE, + ...overrides, + }); + }; + + it("initializes a relaunch with valid parameters", async function () { + const params = defaultParams.call(this); + const { newMint, relaunch, txSignature } = + await client.initializeRelaunch(params); + assert.isString(txSignature); + + const relaunchSigner = client.getRelaunchSignerAddress({ relaunch }); + const storedRelaunch = await client.fetchRelaunch(relaunch); + + assert.isTrue(storedRelaunch.admin.equals(this.payer.publicKey)); + assert.isTrue(storedRelaunch.newMint.equals(newMint)); + assert.isTrue(storedRelaunch.oldMint.equals(oldMint)); + assert.isTrue(storedRelaunch.sourcePool.equals(pool.pool)); + assert.isTrue(storedRelaunch.sourceQuoteMint.equals(token.NATIVE_MINT)); + assert.isTrue(storedRelaunch.relaunchSigner.equals(relaunchSigner)); + assert.isTrue( + storedRelaunch.oldTokenVault.equals( + token.getAssociatedTokenAddressSync(oldMint, relaunchSigner, true), + ), + ); + assert.isTrue( + storedRelaunch.newTokenVault.equals( + token.getAssociatedTokenAddressSync(newMint, relaunchSigner, true), + ), + ); + assert.isTrue( + storedRelaunch.sourceQuoteVault.equals( + token.getAssociatedTokenAddressSync( + token.NATIVE_MINT, + relaunchSigner, + true, + ), + ), + ); + assert.isTrue( + storedRelaunch.usdcVault.equals( + token.getAssociatedTokenAddressSync(MAINNET_USDC, relaunchSigner, true), + ), + ); + assert.equal(storedRelaunch.thresholdBps, params.thresholdBps); + assert.equal( + storedRelaunch.oldSupplySnapshot.toString(), + DEFAULT_OLD_SUPPLY.toString(), + ); + assert.equal(storedRelaunch.secondsForDeposits, params.secondsForDeposits); + assert.equal(storedRelaunch.gracePeriodSeconds, params.gracePeriodSeconds); + assert.equal( + storedRelaunch.monthlySpendingLimitAmount.toString(), + params.monthlySpendingLimitAmount.toString(), + ); + assert.equal(storedRelaunch.monthlySpendingLimitMembers.length, 1); + assert.isTrue( + storedRelaunch.monthlySpendingLimitMembers[0].equals( + this.payer.publicKey, + ), + ); + assert.isTrue(storedRelaunch.teamAddress.equals(this.payer.publicKey)); + assert.isDefined(storedRelaunch.state.initialized); + assert.equal(storedRelaunch.totalDeposited.toString(), "0"); + assert.equal(storedRelaunch.quoteRecovered.toString(), "0"); + assert.equal(storedRelaunch.usdcRecovered.toString(), "0"); + assert.isNull(storedRelaunch.unixTimestampStarted); + assert.isNull(storedRelaunch.unixTimestampClosed); + assert.isNull(storedRelaunch.unixTimestampCompleted); + assert.isNull(storedRelaunch.dao); + assert.isNull(storedRelaunch.daoVault); + assert.equal(storedRelaunch.seqNum.toString(), "0"); + assert.isDefined(storedRelaunch.sourceVenue.pumpSwap); + + const [expectedRelaunch, pdaBump] = PublicKey.findProgramAddressSync( + [Buffer.from("relaunch"), newMint.toBuffer()], + client.getProgramId(), + ); + assert.isTrue(relaunch.equals(expectedRelaunch)); + assert.equal(storedRelaunch.pdaBump, pdaBump); + const [, signerBump] = PublicKey.findProgramAddressSync( + [Buffer.from("relaunch_signer"), relaunch.toBuffer()], + client.getProgramId(), + ); + assert.equal(storedRelaunch.relaunchSignerBump, signerBump); + + const rawNewMint = await this.banksClient.getAccount(newMint); + const newMintState = token.unpackMint(newMint, { + ...rawNewMint, + data: Buffer.from(rawNewMint.data), + } as any); + assert.isTrue(newMintState.mintAuthority.equals(relaunchSigner)); + assert.equal(newMintState.supply.toString(), TOTAL_MINTED.toString()); + + const rawVault = await this.banksClient.getAccount( + storedRelaunch.newTokenVault, + ); + const vault = token.unpackAccount(storedRelaunch.newTokenVault, { + ...rawVault, + data: Buffer.from(rawVault.data), + } as any); + assert.equal(vault.amount.toString(), TOTAL_MINTED.toString()); + + const metadata = await this.banksClient.getAccount( + getMetadataAddr(newMint)[0], + ); + assert.isNotNull(metadata); + }); + + it("stores one shared vault for USDC-quoted sources", async function () { + const usdcPool = await writePumpPool({ + context: this.context, + baseMint: oldMint, + quoteMint: MAINNET_USDC, + baseReserve: POOL_BASE_RESERVE, + quoteReserve: USDC_POOL_QUOTE_RESERVE, + }); + + const newMint = await initializeWithParams.call(this, { + sourcePool: usdcPool.pool, + sourceQuoteMint: MAINNET_USDC, + }); + + const relaunch = client.getRelaunchAddress({ newMint }); + const relaunchSigner = client.getRelaunchSignerAddress({ relaunch }); + const storedRelaunch = await client.fetchRelaunch(relaunch); + + assert.isTrue(storedRelaunch.sourceQuoteMint.equals(MAINNET_USDC)); + assert.isTrue( + storedRelaunch.sourceQuoteVault.equals(storedRelaunch.usdcVault), + ); + assert.isTrue( + storedRelaunch.usdcVault.equals( + token.getAssociatedTokenAddressSync(MAINNET_USDC, relaunchSigner, true), + ), + ); + }); + + it("initializes with a mint created in an earlier transaction", async function () { + const { newMint, instructions } = await client.createNewMintIxs(); + + const tx = new Transaction().add(...instructions); + tx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + tx.feePayer = this.payer.publicKey; + tx.sign(this.payer); + await this.banksClient.processTransaction(tx); + + await client + .initializeRelaunchIx({ + newMint, + ...defaultParams.call(this), + }) + .rpc(); + + const storedRelaunch = await client.getRelaunch({ newMint }); + assert.isTrue(storedRelaunch.newMint.equals(newMint)); + }); + + it("fails when the mint authority does not sign", async function () { + const mintAuthority = Keypair.generate(); + const newMint = await createRawMint(this.banksClient, this.payer, { + mintAuthority: mintAuthority.publicKey, + }); + + const ix = await client + .initializeRelaunchIx({ + newMint, + mintAuthority: mintAuthority.publicKey, + ...defaultParams.call(this), + }) + .instruction(); + for (const key of ix.keys) { + if (key.pubkey.equals(mintAuthority.publicKey)) { + key.isSigner = false; + } + } + + const tx = new Transaction().add(ix); + tx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + tx.feePayer = this.payer.publicKey; + tx.sign(this.payer); + + const result = await this.banksClient.tryProcessTransaction(tx); + assert.isNotNull(result.result); + assert.isTrue( + result.meta.logMessages.some((log) => log.includes("AccountNotSigner")), + ); + }); + + it("fails when the wrong key signs as mint authority", async function () { + const newMint = await createRawMint(this.banksClient, this.payer, { + mintAuthority: this.payer.publicKey, + }); + const wrongAuthority = Keypair.generate(); + + try { + await client + .initializeRelaunchIx({ + newMint, + mintAuthority: wrongAuthority.publicKey, + ...defaultParams.call(this), + }) + .signers([wrongAuthority]) + .rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "ConstraintMintMintAuthority"); + } + }); + + it("fails when the mint is pre-pointed at the relaunch signer", async function () { + const { newMint, instructions } = await client.createNewMintIxs(); + const relaunch = client.getRelaunchAddress({ newMint }); + const relaunchSigner = client.getRelaunchSignerAddress({ relaunch }); + + // Recreate launchpad's convention: mint born with authority already set + // to the program PDA. A PDA can't sign, so this mint is uninitializable. + const createAccountIx = instructions[0]; + const tx = new Transaction().add( + createAccountIx, + token.createInitializeMint2Instruction(newMint, 6, relaunchSigner, null), + ); + tx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + tx.feePayer = this.payer.publicKey; + tx.sign(this.payer); + await this.banksClient.processTransaction(tx); + + try { + await client + .initializeRelaunchIx({ + newMint, + ...defaultParams.call(this), + }) + .rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "ConstraintMintMintAuthority"); + } + }); + + it("fails when the new mint has non-zero supply", async function () { + const newMint = await createRawMint(this.banksClient, this.payer, { + mintAuthority: this.payer.publicKey, + }); + + const ata = token.getAssociatedTokenAddressSync( + newMint, + this.payer.publicKey, + ); + const tx = new Transaction().add( + token.createAssociatedTokenAccountIdempotentInstruction( + this.payer.publicKey, + ata, + this.payer.publicKey, + newMint, + ), + token.createMintToInstruction( + newMint, + ata, + this.payer.publicKey, + 100_000_000, + ), + ); + tx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + tx.feePayer = this.payer.publicKey; + tx.sign(this.payer); + await this.banksClient.processTransaction(tx); + + try { + await client + .initializeRelaunchIx({ + newMint, + ...defaultParams.call(this), + }) + .rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "SupplyNonZero"); + } + }); + + it("fails when the new mint has a freeze authority", async function () { + const newMint = await createRawMint(this.banksClient, this.payer, { + mintAuthority: this.payer.publicKey, + freezeAuthority: this.payer.publicKey, + }); + + try { + await client + .initializeRelaunchIx({ + newMint, + ...defaultParams.call(this), + }) + .rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "FreezeAuthoritySet"); + } + }); + + it("fails when the new mint has wrong decimals", async function () { + const newMint = await createRawMint(this.banksClient, this.payer, { + decimals: 9, + mintAuthority: this.payer.publicKey, + }); + + try { + await client + .initializeRelaunchIx({ + newMint, + ...defaultParams.call(this), + }) + .rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "ConstraintMintDecimals"); + } + }); + + it("initializes with a Token-2022 old mint carrying only metadata extensions", async function () { + const oldMint22 = await createOldMint( + this.banksClient, + this.payer, + token.TOKEN_2022_PROGRAM_ID, + ); + const pool22 = await writePumpPool({ + context: this.context, + baseMint: oldMint22, + quoteMint: token.NATIVE_MINT, + baseReserve: POOL_BASE_RESERVE, + quoteReserve: WSOL_POOL_QUOTE_RESERVE, + baseTokenProgram: token.TOKEN_2022_PROGRAM_ID, + }); + + const newMint = await initializeWithParams.call(this, { + oldMint: oldMint22, + oldTokenProgram: token.TOKEN_2022_PROGRAM_ID, + sourcePool: pool22.pool, + }); + + const relaunch = client.getRelaunchAddress({ newMint }); + const relaunchSigner = client.getRelaunchSignerAddress({ relaunch }); + const storedRelaunch = await client.fetchRelaunch(relaunch); + + assert.isTrue(storedRelaunch.oldMint.equals(oldMint22)); + assert.isTrue( + storedRelaunch.oldTokenVault.equals( + token.getAssociatedTokenAddressSync( + oldMint22, + relaunchSigner, + true, + token.TOKEN_2022_PROGRAM_ID, + ), + ), + ); + assert.equal(storedRelaunch.oldSupplySnapshot.toString(), "0"); + }); + + it("fails closed on a Token-2022 old mint with a transfer-fee extension", async function () { + const oldMint22 = await createT22MintWithTransferFee( + this.banksClient, + this.payer, + ); + const pool22 = await writePumpPool({ + context: this.context, + baseMint: oldMint22, + quoteMint: token.NATIVE_MINT, + baseReserve: POOL_BASE_RESERVE, + quoteReserve: WSOL_POOL_QUOTE_RESERVE, + baseTokenProgram: token.TOKEN_2022_PROGRAM_ID, + }); + + await expectInitializeToFail.call( + this, + { + oldMint: oldMint22, + oldTokenProgram: token.TOKEN_2022_PROGRAM_ID, + sourcePool: pool22.pool, + }, + "ForbiddenOldMintExtension", + ); + }); + + it("fails when the source pool is not owned by pump_amm", async function () { + const foreignPool = await writePumpPool({ + context: this.context, + baseMint: oldMint, + quoteMint: token.NATIVE_MINT, + baseReserve: POOL_BASE_RESERVE, + quoteReserve: WSOL_POOL_QUOTE_RESERVE, + owner: token.TOKEN_PROGRAM_ID, + }); + + await expectInitializeToFail.call( + this, + { sourcePool: foreignPool.pool }, + "SourcePoolNotCanonical", + ); + }); + + it("fails when the source pool index is not 0", async function () { + const indexedPool = await writePumpPool({ + context: this.context, + baseMint: oldMint, + quoteMint: token.NATIVE_MINT, + baseReserve: POOL_BASE_RESERVE, + quoteReserve: WSOL_POOL_QUOTE_RESERVE, + index: 1, + creator: getPumpPoolAuthorityAddr(oldMint), + }); + + await expectInitializeToFail.call( + this, + { sourcePool: indexedPool.pool }, + "SourcePoolNotCanonical", + ); + }); + + it("fails when the source pool has a different base mint", async function () { + const otherMint = Keypair.generate().publicKey; + const otherPool = await writePumpPool({ + context: this.context, + baseMint: otherMint, + quoteMint: token.NATIVE_MINT, + baseReserve: POOL_BASE_RESERVE, + quoteReserve: WSOL_POOL_QUOTE_RESERVE, + }); + + await expectInitializeToFail.call( + this, + { sourcePool: otherPool.pool }, + "SourcePoolNotCanonical", + ); + }); + + it("fails when the source pool creator is not the pool-authority PDA", async function () { + const squattedPool = await writePumpPool({ + context: this.context, + baseMint: oldMint, + quoteMint: token.NATIVE_MINT, + baseReserve: POOL_BASE_RESERVE, + quoteReserve: WSOL_POOL_QUOTE_RESERVE, + creator: Keypair.generate().publicKey, + }); + + await expectInitializeToFail.call( + this, + { sourcePool: squattedPool.pool }, + "SourcePoolNotCanonical", + ); + }); + + it("fails when a canonical-looking pool sits at the wrong address", async function () { + // A byte-for-byte copy of the canonical pool at a different address + // passes every field check — only the derived-address check catches it. + const impostor = Keypair.generate().publicKey; + const canonicalPool = await this.banksClient.getAccount(pool.pool); + this.context.setAccount(impostor, { + data: Buffer.from(canonicalPool.data), + owner: canonicalPool.owner, + lamports: Number(canonicalPool.lamports), + executable: false, + }); + + await expectInitializeToFail.call( + this, + { sourcePool: impostor }, + "SourcePoolNotCanonical", + ); + }); + + it("fails when the quote mint is neither WSOL nor USDC", async function () { + const bogusQuoteMint = await createRawMint(this.banksClient, this.payer, { + mintAuthority: this.payer.publicKey, + }); + const bogusPool = await writePumpPool({ + context: this.context, + baseMint: oldMint, + quoteMint: bogusQuoteMint, + baseReserve: POOL_BASE_RESERVE, + quoteReserve: USDC_POOL_QUOTE_RESERVE, + }); + + await expectInitializeToFail.call( + this, + { sourcePool: bogusPool.pool, sourceQuoteMint: bogusQuoteMint }, + "InvalidQuoteMint", + ); + }); + + it("fails when the quote mint does not match the source pool's quote mint", async function () { + await expectInitializeToFail.call( + this, + { sourceQuoteMint: MAINNET_USDC }, + "SourcePoolQuoteMintMismatch", + ); + }); + + it("initializes with a canonical-shaped Raydium pool", async function () { + const raydiumPool = writeCanonicalRaydiumPool.call(this); + const params = defaultParams.call(this); + + const newMint = await initializeWithParams.call(this, { + sourcePool: raydiumPool.pool, + sourcePoolLpMint: raydiumPool.lpMint, + }); + + const relaunch = client.getRelaunchAddress({ newMint }); + const relaunchSigner = client.getRelaunchSignerAddress({ relaunch }); + const storedRelaunch = await client.fetchRelaunch(relaunch); + + assert.isTrue(storedRelaunch.admin.equals(this.payer.publicKey)); + assert.isTrue(storedRelaunch.newMint.equals(newMint)); + assert.isTrue(storedRelaunch.oldMint.equals(oldMint)); + assert.isTrue(storedRelaunch.sourcePool.equals(raydiumPool.pool)); + assert.isTrue(storedRelaunch.sourceQuoteMint.equals(token.NATIVE_MINT)); + assert.isTrue(storedRelaunch.relaunchSigner.equals(relaunchSigner)); + assert.isTrue( + storedRelaunch.oldTokenVault.equals( + token.getAssociatedTokenAddressSync(oldMint, relaunchSigner, true), + ), + ); + assert.isTrue( + storedRelaunch.newTokenVault.equals( + token.getAssociatedTokenAddressSync(newMint, relaunchSigner, true), + ), + ); + assert.isTrue( + storedRelaunch.sourceQuoteVault.equals( + token.getAssociatedTokenAddressSync( + token.NATIVE_MINT, + relaunchSigner, + true, + ), + ), + ); + assert.isTrue( + storedRelaunch.usdcVault.equals( + token.getAssociatedTokenAddressSync(MAINNET_USDC, relaunchSigner, true), + ), + ); + assert.equal(storedRelaunch.thresholdBps, params.thresholdBps); + assert.equal( + storedRelaunch.oldSupplySnapshot.toString(), + DEFAULT_OLD_SUPPLY.toString(), + ); + assert.equal(storedRelaunch.secondsForDeposits, params.secondsForDeposits); + assert.equal(storedRelaunch.gracePeriodSeconds, params.gracePeriodSeconds); + assert.equal( + storedRelaunch.monthlySpendingLimitAmount.toString(), + params.monthlySpendingLimitAmount.toString(), + ); + assert.equal(storedRelaunch.monthlySpendingLimitMembers.length, 1); + assert.isTrue( + storedRelaunch.monthlySpendingLimitMembers[0].equals( + this.payer.publicKey, + ), + ); + assert.isTrue(storedRelaunch.teamAddress.equals(this.payer.publicKey)); + assert.isDefined(storedRelaunch.state.initialized); + assert.equal(storedRelaunch.totalDeposited.toString(), "0"); + assert.equal(storedRelaunch.quoteRecovered.toString(), "0"); + assert.equal(storedRelaunch.usdcRecovered.toString(), "0"); + assert.isNull(storedRelaunch.unixTimestampStarted); + assert.isNull(storedRelaunch.unixTimestampClosed); + assert.isNull(storedRelaunch.unixTimestampCompleted); + assert.isNull(storedRelaunch.dao); + assert.isNull(storedRelaunch.daoVault); + assert.equal(storedRelaunch.seqNum.toString(), "0"); + assert.isDefined(storedRelaunch.sourceVenue.raydiumAmmV4); + + const [expectedRelaunch, pdaBump] = PublicKey.findProgramAddressSync( + [Buffer.from("relaunch"), newMint.toBuffer()], + client.getProgramId(), + ); + assert.isTrue(relaunch.equals(expectedRelaunch)); + assert.equal(storedRelaunch.pdaBump, pdaBump); + const [, signerBump] = PublicKey.findProgramAddressSync( + [Buffer.from("relaunch_signer"), relaunch.toBuffer()], + client.getProgramId(), + ); + assert.equal(storedRelaunch.relaunchSignerBump, signerBump); + + const rawNewMint = await this.banksClient.getAccount(newMint); + const newMintState = token.unpackMint(newMint, { + ...rawNewMint, + data: Buffer.from(rawNewMint.data), + } as any); + assert.isTrue(newMintState.mintAuthority.equals(relaunchSigner)); + assert.equal(newMintState.supply.toString(), TOTAL_MINTED.toString()); + + const rawVault = await this.banksClient.getAccount( + storedRelaunch.newTokenVault, + ); + const vault = token.unpackAccount(storedRelaunch.newTokenVault, { + ...rawVault, + data: Buffer.from(rawVault.data), + } as any); + assert.equal(vault.amount.toString(), TOTAL_MINTED.toString()); + + const metadata = await this.banksClient.getAccount( + getMetadataAddr(newMint)[0], + ); + assert.isNotNull(metadata); + }); + + it("initializes with a flipped-orientation Raydium pool", async function () { + const raydiumPool = writeCanonicalRaydiumPool.call(this, { + tokenSide: "coin", + }); + + const newMint = await initializeWithParams.call(this, { + sourcePool: raydiumPool.pool, + sourcePoolLpMint: raydiumPool.lpMint, + }); + + const storedRelaunch = await client.getRelaunch({ newMint }); + assert.isDefined(storedRelaunch.sourceVenue.raydiumAmmV4); + }); + + it("initializes when the burned LP floor is met under a large unburned supply", async function () { + // burned = 8,086 − 4,043 = 4,043 LP: organic unburned liquidity on top + // must not trip the floor. + const raydiumPool = writeCanonicalRaydiumPool.call(this, { + lpAmount: 8_086n * 10n ** 9n, + lpSupply: 4_043n * 10n ** 9n, + }); + + const newMint = await initializeWithParams.call(this, { + sourcePool: raydiumPool.pool, + sourcePoolLpMint: raydiumPool.lpMint, + }); + + const storedRelaunch = await client.getRelaunch({ newMint }); + assert.isDefined(storedRelaunch.sourceVenue.raydiumAmmV4); + }); + + it("fails when the source pool is owned by neither pump_amm nor the Raydium AMM", async function () { + const foreignPool = writeCanonicalRaydiumPool.call(this, { + owner: token.TOKEN_PROGRAM_ID, + }); + + await expectInitializeToFail.call( + this, + { sourcePool: foreignPool.pool, sourcePoolLpMint: foreignPool.lpMint }, + "SourcePoolNotCanonical", + ); + }); + + it("fails when the Raydium pool data is not 752 bytes", async function () { + const raydiumPool = writeCanonicalRaydiumPool.call(this); + const raw = await this.banksClient.getAccount(raydiumPool.pool); + this.context.setAccount(raydiumPool.pool, { + data: Buffer.from(raw.data.subarray(0, 751)), + owner: raw.owner, + lamports: Number(raw.lamports), + executable: false, + }); + + await expectInitializeToFail.call( + this, + { sourcePool: raydiumPool.pool, sourcePoolLpMint: raydiumPool.lpMint }, + "SourcePoolNotCanonical", + ); + }); + + it("fails when the Raydium pool pair does not include the old mint", async function () { + const otherPool = writeCanonicalRaydiumPool.call(this, { + oldMint: Keypair.generate().publicKey, + }); + + await expectInitializeToFail.call( + this, + { sourcePool: otherPool.pool, sourcePoolLpMint: otherPool.lpMint }, + "SourcePoolNotCanonical", + ); + }); + + it("fails when the quote side of the Raydium pool is not WSOL", async function () { + const usdcPool = writeCanonicalRaydiumPool.call(this, { + quoteMint: MAINNET_USDC, + }); + + await expectInitializeToFail.call( + this, + { + sourcePool: usdcPool.pool, + sourcePoolLpMint: usdcPool.lpMint, + sourceQuoteMint: MAINNET_USDC, + }, + "InvalidQuoteMint", + ); + }); + + it("fails when the Raydium pool status disables swaps", async function () { + const disabledPool = writeCanonicalRaydiumPool.call(this, { status: 2n }); + + await expectInitializeToFail.call( + this, + { sourcePool: disabledPool.pool, sourcePoolLpMint: disabledPool.lpMint }, + "SourcePoolSwapsDisabled", + ); + }); + + it("fails when the Raydium pool's market program is the system program", async function () { + // Pools created after Raydium removed the orderbook path store the + // system program as market_program — the wrong-era fingerprint. + const modernPool = writeCanonicalRaydiumPool.call(this, { + marketProgram: SystemProgram.programId, + }); + + await expectInitializeToFail.call( + this, + { sourcePool: modernPool.pool, sourcePoolLpMint: modernPool.lpMint }, + "SourcePoolWrongEra", + ); + }); + + it("fails when the Raydium pool's burned LP is below the floor", async function () { + // burned = 4,045 − 200 = 3,845 LP, under the 4,000 floor. + const shallowBurnPool = writeCanonicalRaydiumPool.call(this, { + lpAmount: 4_045n * 10n ** 9n, + lpSupply: 200n * 10n ** 9n, + }); + + await expectInitializeToFail.call( + this, + { + sourcePool: shallowBurnPool.pool, + sourcePoolLpMint: shallowBurnPool.lpMint, + }, + "SourcePoolLpNotBurned", + ); + }); + + it("fails when the supplied LP mint is not the pool's LP mint", async function () { + const raydiumPool = writeCanonicalRaydiumPool.call(this); + + await expectInitializeToFail.call( + this, + { sourcePool: raydiumPool.pool, sourcePoolLpMint: oldMint }, + "SourcePoolLpMintMismatch", + ); + }); + + it("fails when the LP mint is omitted for a Raydium source", async function () { + const raydiumPool = writeCanonicalRaydiumPool.call(this); + + await expectInitializeToFail.call( + this, + { sourcePool: raydiumPool.pool }, + "SourcePoolLpMintMismatch", + ); + }); + + it("fails when an LP mint is supplied for a PumpSwap source", async function () { + await expectInitializeToFail.call( + this, + { sourcePoolLpMint: oldMint }, + "SourcePoolLpMintMismatch", + ); + }); + + it("validates the threshold bounds", async function () { + await expectInitializeToFail.call( + this, + { thresholdBps: 0 }, + "InvalidThresholdBps", + ); + await expectInitializeToFail.call( + this, + { thresholdBps: 10_001 }, + "InvalidThresholdBps", + ); + + const newMint = await initializeWithParams.call(this, { + thresholdBps: 10_000, + }); + const storedRelaunch = await client.getRelaunch({ newMint }); + assert.equal(storedRelaunch.thresholdBps, 10_000); + }); + + it("fails when the deposit period exceeds the cap", async function () { + await expectInitializeToFail.call( + this, + { secondsForDeposits: MAX_SECONDS_FOR_DEPOSITS + 1 }, + "InvalidSecondsForDeposits", + ); + }); + + it("initializes without a spending limit", async function () { + const newMint = await initializeWithParams.call(this, { + monthlySpendingLimitAmount: new BN(0), + monthlySpendingLimitMembers: [], + }); + + const storedRelaunch = await client.getRelaunch({ newMint }); + assert.equal(storedRelaunch.monthlySpendingLimitAmount.toString(), "0"); + assert.equal(storedRelaunch.monthlySpendingLimitMembers.length, 0); + }); + + it("validates the spending limit config", async function () { + // A half-set config fails in either direction: an amount nobody can + // spend, or members with nothing to spend. + await expectInitializeToFail.call( + this, + { monthlySpendingLimitAmount: new BN(0) }, + "InvalidMonthlySpendingLimit", + ); + await expectInitializeToFail.call( + this, + { monthlySpendingLimitMembers: [] }, + "InvalidMonthlySpendingLimit", + ); + await expectInitializeToFail.call( + this, + { + monthlySpendingLimitMembers: [ + this.payer.publicKey, + this.payer.publicKey, + ], + }, + "InvalidMonthlySpendingLimitMembers", + ); + // 11 member pubkeys push the transaction past the packet size limit, so + // this one goes through a v0 transaction with a lookup table. + const { newMint, instructions } = await client.createNewMintIxs(); + const initTx = await client + .initializeRelaunchIx({ + newMint, + ...defaultParams.call(this), + monthlySpendingLimitMembers: Array.from( + { length: 11 }, + () => Keypair.generate().publicKey, + ), + }) + .preInstructions(instructions) + .transaction(); + + const lookupTable = await createLookupTableForTransaction(initTx, this); + const message = new TransactionMessage({ + payerKey: this.payer.publicKey, + recentBlockhash: (await this.banksClient.getLatestBlockhash())[0], + instructions: initTx.instructions, + }).compileToV0Message([lookupTable]); + const tx = new VersionedTransaction(message); + tx.sign([this.payer]); + + const result = await this.banksClient.tryProcessTransaction(tx); + assert.isNotNull(result.result); + assert.isTrue( + result.meta.logMessages.some((log) => + log.includes("InvalidMonthlySpendingLimitMembers"), + ), + ); + }); + + it("fails to initialize the same new mint twice", async function () { + const { newMint, instructions } = await client.createNewMintIxs(); + await client + .initializeRelaunchIx({ + newMint, + ...defaultParams.call(this), + }) + .preInstructions(instructions) + .rpc(); + + try { + await client + .initializeRelaunchIx({ + newMint, + ...defaultParams.call(this), + }) + .rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + const details = [e.message, ...(e.logs ?? [])].join(" "); + assert.include(details, "already in use"); + } + }); + + it("allows a rival relaunch for the same old mint under a different new mint", async function () { + const firstNewMint = await initializeWithParams.call(this); + const secondNewMint = await initializeWithParams.call(this); + + const first = await client.getRelaunch({ newMint: firstNewMint }); + const second = await client.getRelaunch({ newMint: secondNewMint }); + + assert.isTrue(first.oldMint.equals(oldMint)); + assert.isTrue(second.oldMint.equals(oldMint)); + assert.isTrue(first.sourcePool.equals(second.sourcePool)); + assert.isFalse(first.relaunchSigner.equals(second.relaunchSigner)); + assert.isFalse(first.oldTokenVault.equals(second.oldTokenVault)); + }); +} diff --git a/tests/relaunch/unit/markFailed.test.ts b/tests/relaunch/unit/markFailed.test.ts new file mode 100644 index 00000000..1940a488 --- /dev/null +++ b/tests/relaunch/unit/markFailed.test.ts @@ -0,0 +1,185 @@ +import { + ComputeBudgetProgram, + Keypair, + LAMPORTS_PER_SOL, + PublicKey, + SystemProgram, + Transaction, +} from "@solana/web3.js"; +import * as token from "@solana/spl-token"; +import { assert } from "chai"; +import { BN } from "bn.js"; +import { RelaunchClient } from "@metadaoproject/programs"; +import { setupRelaunch, DEFAULT_OLD_SUPPLY } from "../utils.js"; +import { writePumpPool } from "../pumpAmm.js"; + +const POOL_BASE_RESERVE = 1_000_000n * 10n ** 6n; // 1M old tokens +const WSOL_POOL_QUOTE_RESERVE = 100n * 10n ** 9n; // 100 SOL + +const ONE_WEEK = 60 * 60 * 24 * 7; +const ONE_DAY = 60 * 60 * 24; + +// 10% of the 1B-token default supply = 100M tokens. +const DEFAULT_THRESHOLD_BPS = 1000; +const DEFAULT_THRESHOLD_AMOUNT = DEFAULT_OLD_SUPPLY / 10n; + +export default function suite() { + let client: RelaunchClient; + let oldMint: PublicKey; + let oldTokenProgram: PublicKey; + let relaunch: PublicKey; + + before(function () { + client = this.relaunch; + }); + + beforeEach(async function () { + const setup = await setupRelaunch({ + banksClient: this.banksClient, + payer: this.payer, + }); + oldMint = setup.oldMint; + oldTokenProgram = setup.oldTokenProgram; + + const pool = await writePumpPool({ + context: this.context, + baseMint: oldMint, + quoteMint: token.NATIVE_MINT, + baseReserve: POOL_BASE_RESERVE, + quoteReserve: WSOL_POOL_QUOTE_RESERVE, + }); + + ({ relaunch } = await client.initializeRelaunch({ + oldMint, + sourcePool: pool.pool, + sourceQuoteMint: token.NATIVE_MINT, + tokenName: "Relaunched", + tokenSymbol: "RLNCH", + tokenUri: "https://example.com/rlnch.json", + secondsForDeposits: ONE_WEEK, + gracePeriodSeconds: ONE_DAY, + thresholdBps: DEFAULT_THRESHOLD_BPS, + teamAddress: this.payer.publicKey, + })); + + await client.startDepositsIx({ relaunch }).rpc(); + }); + + const closeIntoSellPending = async function (this: Mocha.Context) { + await client + .depositIx({ + relaunch, + oldMint, + oldTokenProgram, + amount: new BN(DEFAULT_THRESHOLD_AMOUNT.toString()), + }) + .rpc(); + await this.advanceBySeconds(ONE_WEEK); + await client.closeDepositsIx({ relaunch }).rpc(); + }; + + it("marks the relaunch failed once the grace period has elapsed", async function () { + await closeIntoSellPending.call(this); + await this.advanceBySeconds(ONE_DAY + 1); + + await client.markFailedIx({ relaunch }).rpc(); + + const storedRelaunch = await client.fetchRelaunch(relaunch); + assert.isDefined(storedRelaunch.state.failed); + assert.equal(storedRelaunch.seqNum.toString(), "4"); + }); + + it("fails until the grace period fully elapses", async function () { + await closeIntoSellPending.call(this); + + // The last second of the grace period still belongs to the admin's sell + // window, so exactly closed + grace must fail. + await this.advanceBySeconds(ONE_DAY); + + try { + // The compute-unit-price instruction makes the transaction hash unique + // so the later successful call isn't rejected as a duplicate. + await client + .markFailedIx({ relaunch }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ]) + .rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "GracePeriodStillActive"); + } + + let storedRelaunch = await client.fetchRelaunch(relaunch); + assert.isDefined(storedRelaunch.state.sellPending); + + await this.advanceBySeconds(1); + await client.markFailedIx({ relaunch }).rpc(); + + storedRelaunch = await client.fetchRelaunch(relaunch); + assert.isDefined(storedRelaunch.state.failed); + }); + + it("fails when the relaunch is Live", async function () { + await this.advanceBySeconds(ONE_WEEK + ONE_DAY + 1); + + try { + await client.markFailedIx({ relaunch }).rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "RelaunchNotSellPending"); + } + + const storedRelaunch = await client.fetchRelaunch(relaunch); + assert.isDefined(storedRelaunch.state.live); + }); + + it("fails when the relaunch is already Failed", async function () { + await closeIntoSellPending.call(this); + await this.advanceBySeconds(ONE_DAY + 1); + await client.markFailedIx({ relaunch }).rpc(); + + try { + // The compute-unit-price instruction makes the transaction hash unique + // so the retry isn't rejected as a duplicate of the first call. + await client + .markFailedIx({ relaunch }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ]) + .rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "RelaunchNotSellPending"); + } + }); + + it("lets any keypair crank mark_failed", async function () { + await closeIntoSellPending.call(this); + await this.advanceBySeconds(ONE_DAY + 1); + + const cranker = Keypair.generate(); + const fund = new Transaction().add( + SystemProgram.transfer({ + fromPubkey: this.payer.publicKey, + toPubkey: cranker.publicKey, + lamports: LAMPORTS_PER_SOL, + }), + ); + fund.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + fund.feePayer = this.payer.publicKey; + fund.sign(this.payer); + await this.banksClient.processTransaction(fund); + + const tx = new Transaction().add( + await client.markFailedIx({ relaunch }).instruction(), + ); + tx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + tx.feePayer = cranker.publicKey; + tx.sign(cranker); + await this.banksClient.processTransaction(tx); + + const storedRelaunch = await client.fetchRelaunch(relaunch); + assert.isDefined(storedRelaunch.state.failed); + }); +} diff --git a/tests/relaunch/unit/scaffold.test.ts b/tests/relaunch/unit/scaffold.test.ts new file mode 100644 index 00000000..c342f4ad --- /dev/null +++ b/tests/relaunch/unit/scaffold.test.ts @@ -0,0 +1,63 @@ +import { PublicKey } from "@solana/web3.js"; +import * as token from "@solana/spl-token"; +import { assert } from "chai"; +import { RELAUNCH_V0_1_PROGRAM_ID } from "@metadaoproject/programs"; +import { setupRelaunch, DEFAULT_OLD_SUPPLY } from "../utils.js"; + +export default function suite() { + it("relaunch program is deployed", async function () { + const program = await this.banksClient.getAccount(RELAUNCH_V0_1_PROGRAM_ID); + + assert.isNotNull(program); + assert.isTrue(program!.executable); + }); + + const assertOldMintSetup = async function ( + this: Mocha.Context, + oldTokenProgram: PublicKey, + ) { + const { oldMint, payerOldTokenAccount } = await setupRelaunch({ + banksClient: this.banksClient, + payer: this.payer, + oldTokenProgram, + }); + + const rawMint = await this.banksClient.getAccount(oldMint); + assert.isTrue(rawMint!.owner.equals(oldTokenProgram)); + + const mint = token.unpackMint( + oldMint, + { ...rawMint!, data: Buffer.from(rawMint!.data) }, + oldTokenProgram, + ); + assert.equal(mint.decimals, 6); + assert.equal(mint.supply.toString(), DEFAULT_OLD_SUPPLY.toString()); + + const extensions = token.getExtensionTypes(mint.tlvData); + if (oldTokenProgram.equals(token.TOKEN_2022_PROGRAM_ID)) { + assert.sameMembers(extensions, [ + token.ExtensionType.MetadataPointer, + token.ExtensionType.TokenMetadata, + ]); + } else { + assert.isEmpty(extensions); + } + + const rawTokenAccount = + await this.banksClient.getAccount(payerOldTokenAccount); + const tokenAccount = token.unpackAccount( + payerOldTokenAccount, + { ...rawTokenAccount!, data: Buffer.from(rawTokenAccount!.data) }, + oldTokenProgram, + ); + assert.equal(tokenAccount.amount.toString(), DEFAULT_OLD_SUPPLY.toString()); + }; + + it("setupRelaunch creates a classic SPL old mint with supply", async function () { + await assertOldMintSetup.call(this, token.TOKEN_PROGRAM_ID); + }); + + it("setupRelaunch creates a Token-2022 old mint with metadata extensions and supply", async function () { + await assertOldMintSetup.call(this, token.TOKEN_2022_PROGRAM_ID); + }); +} diff --git a/tests/relaunch/unit/startDeposits.test.ts b/tests/relaunch/unit/startDeposits.test.ts new file mode 100644 index 00000000..0efafddb --- /dev/null +++ b/tests/relaunch/unit/startDeposits.test.ts @@ -0,0 +1,101 @@ +import { ComputeBudgetProgram, Keypair, PublicKey } from "@solana/web3.js"; +import * as token from "@solana/spl-token"; +import { assert } from "chai"; +import { RelaunchClient } from "@metadaoproject/programs"; +import { setupRelaunch } from "../utils.js"; +import { writePumpPool } from "../pumpAmm.js"; + +const POOL_BASE_RESERVE = 1_000_000n * 10n ** 6n; // 1M old tokens +const WSOL_POOL_QUOTE_RESERVE = 100n * 10n ** 9n; // 100 SOL + +const ONE_WEEK = 60 * 60 * 24 * 7; +const ONE_DAY = 60 * 60 * 24; + +export default function suite() { + let client: RelaunchClient; + let relaunch: PublicKey; + + before(function () { + client = this.relaunch; + }); + + beforeEach(async function () { + const { oldMint } = await setupRelaunch({ + banksClient: this.banksClient, + payer: this.payer, + }); + const pool = await writePumpPool({ + context: this.context, + baseMint: oldMint, + quoteMint: token.NATIVE_MINT, + baseReserve: POOL_BASE_RESERVE, + quoteReserve: WSOL_POOL_QUOTE_RESERVE, + }); + + ({ relaunch } = await client.initializeRelaunch({ + oldMint, + sourcePool: pool.pool, + sourceQuoteMint: token.NATIVE_MINT, + tokenName: "Relaunched", + tokenSymbol: "RLNCH", + tokenUri: "https://example.com/rlnch.json", + secondsForDeposits: ONE_WEEK, + gracePeriodSeconds: ONE_DAY, + thresholdBps: 1000, + teamAddress: this.payer.publicKey, + })); + }); + + it("starts deposits", async function () { + let storedRelaunch = await client.fetchRelaunch(relaunch); + assert.isDefined(storedRelaunch.state.initialized); + assert.isNull(storedRelaunch.unixTimestampStarted); + + const clock = await this.banksClient.getClock(); + + await client.startDepositsIx({ relaunch }).rpc(); + + storedRelaunch = await client.fetchRelaunch(relaunch); + assert.isDefined(storedRelaunch.state.live); + assert.equal( + storedRelaunch.unixTimestampStarted.toString(), + clock.unixTimestamp.toString(), + ); + assert.equal(storedRelaunch.seqNum.toString(), "1"); + }); + + it("fails when a non-admin starts deposits", async function () { + const nonAdmin = Keypair.generate(); + + try { + await client + .startDepositsIx({ relaunch, admin: nonAdmin.publicKey }) + .signers([nonAdmin]) + .rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "ConstraintHasOne"); + } + + const storedRelaunch = await client.fetchRelaunch(relaunch); + assert.isDefined(storedRelaunch.state.initialized); + }); + + it("fails when deposits have already been started", async function () { + await client.startDepositsIx({ relaunch }).rpc(); + + try { + // The compute-unit-price instruction makes the transaction hash unique + // so the retry isn't rejected as a duplicate of the first call. + await client + .startDepositsIx({ relaunch }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ]) + .rpc(); + assert.fail("Should have thrown error"); + } catch (e) { + assert.include(e.message, "RelaunchNotInitialized"); + } + }); +} diff --git a/tests/relaunch/unit/venues.test.ts b/tests/relaunch/unit/venues.test.ts new file mode 100644 index 00000000..6448de9b --- /dev/null +++ b/tests/relaunch/unit/venues.test.ts @@ -0,0 +1,561 @@ +import { PublicKey, Transaction } from "@solana/web3.js"; +import * as token from "@solana/spl-token"; +import { assert } from "chai"; +import { BankrunProvider } from "anchor-bankrun"; +import { BanksClient } from "solana-bankrun"; +import { setupRelaunch } from "../utils.js"; +import { + getCanonicalPumpPoolAddr, + getUserVolumeAccumulatorAddr, + pumpBuyIx, + pumpInitUserVolumeAccumulatorIx, + pumpSellIx, + writePumpPool, + PumpPool, +} from "../pumpAmm.js"; +import { + raydiumSwapBaseInV2Ix, + raydiumSwapBaseOutV2Ix, + writeRaydiumPool, +} from "../raydiumAmm.js"; +import { + FIXTURE_USDC_SWAP_POOL, + setupWhirlpool, + whirlpoolSwapV2Ix, + wrapSol, +} from "../whirlpool.js"; +import { + getPumpCreatorVaultAuthorityAddr, + getPumpFeeRecipients, + MAINNET_USDC, +} from "@metadaoproject/programs"; + +const POOL_BASE_RESERVE = 1_000_000n * 10n ** 6n; // 1M old tokens +const WSOL_POOL_QUOTE_RESERVE = 100n * 10n ** 9n; // 100 SOL +const USDC_POOL_QUOTE_RESERVE = 100_000n * 10n ** 6n; // 100k USDC +const SELL_AMOUNT = 10_000n * 10n ** 6n; // 10k old tokens + +async function tokenBalance( + banksClient: BanksClient, + address: PublicKey, + tokenProgram: PublicKey = token.TOKEN_PROGRAM_ID, +): Promise { + const raw = await banksClient.getAccount(address); + if (!raw) return 0n; + return token.unpackAccount( + address, + { ...raw, data: Buffer.from(raw.data) } as any, + tokenProgram, + ).amount; +} + +function ceilDiv(a: bigint, b: bigint): bigint { + return (a + b - 1n) / b; +} + +// AMM v4 swap math at the 25 bps flat fee. The fee is +// ceil-rounded off the input and stays in the pool. +function raydiumExactInOutput( + amountIn: bigint, + inReserve: bigint, + outReserve: bigint, +): bigint { + const net = amountIn - ceilDiv(amountIn * 25n, 10_000n); + return (outReserve * net) / (inReserve + net); +} + +function raydiumExactOutInput( + amountOut: bigint, + inReserve: bigint, + outReserve: bigint, +): bigint { + const inBeforeFee = ceilDiv(inReserve * amountOut, outReserve - amountOut); + return ceilDiv(inBeforeFee * 10_000n, 9_975n); +} + +export default function suite() { + before(function () { + this.bankrunProvider = new BankrunProvider(this.context); + }); + + const sellIntoPumpPool = async function ( + this: Mocha.Context, + pool: PumpPool, + quoteTokenAccount: PublicKey, + ) { + const { protocolFeeRecipient, buybackFeeRecipient } = + await getPumpFeeRecipients(this.connection); + const feeAtas = [ + protocolFeeRecipient, + buybackFeeRecipient, + getPumpCreatorVaultAuthorityAddr(pool.coinCreator), + ].map((owner) => + token.getAssociatedTokenAddressSync(pool.quoteMint, owner, true), + ); + const payerOldTokenAccount = token.getAssociatedTokenAddressSync( + pool.baseMint, + this.payer.publicKey, + true, + pool.baseTokenProgram, + ); + + const baseBefore = await tokenBalance( + this.banksClient, + payerOldTokenAccount, + pool.baseTokenProgram, + ); + const quoteBefore = await tokenBalance(this.banksClient, quoteTokenAccount); + const poolQuoteBefore = await tokenBalance( + this.banksClient, + pool.poolQuoteTokenAccount, + ); + const feesBefore = await Promise.all( + feeAtas.map((ata) => tokenBalance(this.banksClient, ata)), + ); + + const tx = new Transaction().add( + pumpSellIx({ + pool, + user: this.payer.publicKey, + protocolFeeRecipient, + buybackFeeRecipient, + baseAmountIn: SELL_AMOUNT, + minQuoteAmountOut: 0n, + }), + ); + tx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + tx.feePayer = this.payer.publicKey; + tx.sign(this.payer); + await this.banksClient.processTransaction(tx); + + const baseAfter = await tokenBalance( + this.banksClient, + payerOldTokenAccount, + pool.baseTokenProgram, + ); + const quoteAfter = await tokenBalance(this.banksClient, quoteTokenAccount); + const poolBaseAfter = await tokenBalance( + this.banksClient, + pool.poolBaseTokenAccount, + pool.baseTokenProgram, + ); + + assert.equal((baseBefore - baseAfter).toString(), SELL_AMOUNT.toString()); + assert.equal( + poolBaseAfter.toString(), + (POOL_BASE_RESERVE + SELL_AMOUNT).toString(), + ); + + // ~0.99 quote units gross on these reserves; fees shave a little more. + const quoteReceived = quoteAfter - quoteBefore; + assert.isTrue(quoteReceived > 900_000_000n && quoteReceived < 990_099_010n); + + // Everything the pool paid out landed with the seller or the fee ATAs. + const poolQuoteAfter = await tokenBalance( + this.banksClient, + pool.poolQuoteTokenAccount, + ); + const feesAfter = await Promise.all( + feeAtas.map((ata) => tokenBalance(this.banksClient, ata)), + ); + const feesPaid = feesAfter.reduce( + (sum, after, i) => sum + (after - feesBefore[i]), + 0n, + ); + assert.equal( + (poolQuoteBefore - poolQuoteAfter).toString(), + (quoteReceived + feesPaid).toString(), + ); + + return quoteReceived; + }; + + it("pump_amm sell and buy execute against a fabricated WSOL-quoted pool", async function () { + const { oldMint, oldTokenProgram } = await setupRelaunch({ + banksClient: this.banksClient, + payer: this.payer, + }); + + const pool = await writePumpPool({ + context: this.context, + baseMint: oldMint, + quoteMint: token.NATIVE_MINT, + baseReserve: POOL_BASE_RESERVE, + quoteReserve: WSOL_POOL_QUOTE_RESERVE, + baseTokenProgram: oldTokenProgram, + }); + assert.isTrue( + pool.pool.equals(getCanonicalPumpPoolAddr(oldMint, token.NATIVE_MINT)[0]), + ); + + const wsolAta = await wrapSol(this.bankrunProvider, this.payer, 0n); + await sellIntoPumpPool.call(this, pool, wsolAta); + + // Exact-output buy of the same 10k tokens, capped by max_quote_in. + const { protocolFeeRecipient, buybackFeeRecipient } = + await getPumpFeeRecipients(this.connection); + await wrapSol(this.bankrunProvider, this.payer, 2n * 10n ** 9n); + + const payerOldTokenAccount = token.getAssociatedTokenAddressSync( + oldMint, + this.payer.publicKey, + true, + oldTokenProgram, + ); + const baseBefore = await tokenBalance( + this.banksClient, + payerOldTokenAccount, + oldTokenProgram, + ); + const wsolBefore = await tokenBalance(this.banksClient, wsolAta); + + const buyTx = new Transaction(); + if ( + !(await this.banksClient.getAccount( + getUserVolumeAccumulatorAddr(this.payer.publicKey), + )) + ) { + buyTx.add( + pumpInitUserVolumeAccumulatorIx({ + payer: this.payer.publicKey, + user: this.payer.publicKey, + }), + ); + } + buyTx.add( + pumpBuyIx({ + pool, + user: this.payer.publicKey, + protocolFeeRecipient, + buybackFeeRecipient, + baseAmountOut: SELL_AMOUNT, + maxQuoteAmountIn: 2n * 10n ** 9n, + }), + ); + buyTx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + buyTx.feePayer = this.payer.publicKey; + buyTx.sign(this.payer); + await this.banksClient.processTransaction(buyTx); + + const baseAfter = await tokenBalance( + this.banksClient, + payerOldTokenAccount, + oldTokenProgram, + ); + const wsolAfter = await tokenBalance(this.banksClient, wsolAta); + + assert.equal((baseAfter - baseBefore).toString(), SELL_AMOUNT.toString()); + const wsolSpent = wsolBefore - wsolAfter; + assert.isTrue(wsolSpent > 900_000_000n && wsolSpent <= 2n * 10n ** 9n); + }); + + it("pump_amm sell executes against a fabricated USDC-quoted pool", async function () { + const { oldMint, oldTokenProgram } = await setupRelaunch({ + banksClient: this.banksClient, + payer: this.payer, + }); + + const pool = await writePumpPool({ + context: this.context, + baseMint: oldMint, + quoteMint: MAINNET_USDC, + baseReserve: POOL_BASE_RESERVE, + quoteReserve: USDC_POOL_QUOTE_RESERVE, + baseTokenProgram: oldTokenProgram, + }); + + const usdcAta = token.getAssociatedTokenAddressSync( + MAINNET_USDC, + this.payer.publicKey, + ); + await sellIntoPumpPool.call(this, pool, usdcAta); + }); + + it("pump_amm sell executes against a Token-2022-base pool", async function () { + const { oldMint, oldTokenProgram } = await setupRelaunch({ + banksClient: this.banksClient, + payer: this.payer, + oldTokenProgram: token.TOKEN_2022_PROGRAM_ID, + }); + + const pool = await writePumpPool({ + context: this.context, + baseMint: oldMint, + quoteMint: token.NATIVE_MINT, + baseReserve: POOL_BASE_RESERVE, + quoteReserve: WSOL_POOL_QUOTE_RESERVE, + baseTokenProgram: oldTokenProgram, + }); + + const wsolAta = await wrapSol(this.bankrunProvider, this.payer, 0n); + await sellIntoPumpPool.call(this, pool, wsolAta); + }); + + it("whirlpool fixture pool sits at the pinned address and swaps WSOL to USDC", async function () { + const fixture = await setupWhirlpool({ + provider: this.bankrunProvider, + payer: this.payer, + }); + + // The relaunch program's usdc_swap_pool constant pins this exact + // address — the fixture pool recreates the mainnet pool's PDA because + // it derives from the dumped mainnet config. + assert.equal( + fixture.whirlpool.toBase58(), + "Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE", + ); + assert.isTrue(fixture.whirlpool.equals(FIXTURE_USDC_SWAP_POOL)); + + const wsolAta = await wrapSol(this.bankrunProvider, this.payer, 10n ** 9n); + const usdcAta = token.getAssociatedTokenAddressSync( + MAINNET_USDC, + this.payer.publicKey, + ); + + const wsolBefore = await tokenBalance(this.banksClient, wsolAta); + const usdcBefore = await tokenBalance(this.banksClient, usdcAta); + + const swapIx = await whirlpoolSwapV2Ix(fixture, { + tokenAuthority: this.payer.publicKey, + tokenOwnerAccountA: wsolAta, + tokenOwnerAccountB: usdcAta, + amountIn: 10n ** 9n, // 1 SOL + minAmountOut: 95n * 10n ** 6n, + aToB: true, + }); + const tx = new Transaction().add(swapIx); + tx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + tx.feePayer = this.payer.publicKey; + tx.sign(this.payer); + await this.banksClient.processTransaction(tx); + + const wsolAfter = await tokenBalance(this.banksClient, wsolAta); + const usdcAfter = await tokenBalance(this.banksClient, usdcAta); + + assert.equal((wsolBefore - wsolAfter).toString(), (10n ** 9n).toString()); + // ~100 USDC per SOL at the default sqrt price, minus the 0.04% fee and + // a little price impact. + const usdcReceived = usdcAfter - usdcBefore; + assert.isTrue( + usdcReceived > 95n * 10n ** 6n && usdcReceived < 100n * 10n ** 6n, + ); + }); + + it("raydium_amm exact-in sell moves exactly the constant-product output at 25 bps", async function () { + const { oldMint, payerOldTokenAccount } = await setupRelaunch({ + banksClient: this.banksClient, + payer: this.payer, + }); + + const pool = writeRaydiumPool({ + context: this.context, + oldMint, + tokenReserve: POOL_BASE_RESERVE, + quoteReserve: WSOL_POOL_QUOTE_RESERVE, + }); + const wsolAta = await wrapSol(this.bankrunProvider, this.payer, 0n); + + // Token is pc, WSOL is coin: selling tokens swaps pc → coin. + const predictedOut = raydiumExactInOutput( + SELL_AMOUNT, + POOL_BASE_RESERVE, + WSOL_POOL_QUOTE_RESERVE, + ); + + const tokenBefore = await tokenBalance( + this.banksClient, + payerOldTokenAccount, + ); + const wsolBefore = await tokenBalance(this.banksClient, wsolAta); + + const tx = new Transaction().add( + raydiumSwapBaseInV2Ix({ + pool, + userSourceTokenAccount: payerOldTokenAccount, + userDestinationTokenAccount: wsolAta, + userSourceOwner: this.payer.publicKey, + amountIn: SELL_AMOUNT, + // The floor check is inclusive, so the exact prediction passes — + // the AMM itself asserts our formula. + minimumAmountOut: predictedOut, + }), + ); + tx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + tx.feePayer = this.payer.publicKey; + tx.sign(this.payer); + await this.banksClient.processTransaction(tx); + + const tokenAfter = await tokenBalance( + this.banksClient, + payerOldTokenAccount, + ); + const wsolAfter = await tokenBalance(this.banksClient, wsolAta); + + assert.equal((tokenBefore - tokenAfter).toString(), SELL_AMOUNT.toString()); + assert.equal((wsolAfter - wsolBefore).toString(), predictedOut.toString()); + + // The fee stays in the pool: the pc vault gains the full input, the coin + // vault pays out exactly the prediction. No fee-recipient transfers. + assert.equal( + (await tokenBalance(this.banksClient, pool.pcVault)).toString(), + (POOL_BASE_RESERVE + SELL_AMOUNT).toString(), + ); + assert.equal( + (await tokenBalance(this.banksClient, pool.coinVault)).toString(), + (WSOL_POOL_QUOTE_RESERVE - predictedOut).toString(), + ); + }); + + it("raydium_amm exact-out buy pulls exactly the computed input and leaves the rest untouched", async function () { + const { oldMint, payerOldTokenAccount } = await setupRelaunch({ + banksClient: this.banksClient, + payer: this.payer, + }); + + const pool = writeRaydiumPool({ + context: this.context, + oldMint, + tokenReserve: POOL_BASE_RESERVE, + quoteReserve: WSOL_POOL_QUOTE_RESERVE, + }); + const maxAmountIn = 2n * 10n ** 9n; + const wsolAta = await wrapSol( + this.bankrunProvider, + this.payer, + maxAmountIn, + ); + + // Buying tokens (pc) with WSOL (coin): coin is the input reserve. + const predictedIn = raydiumExactOutInput( + SELL_AMOUNT, + WSOL_POOL_QUOTE_RESERVE, + POOL_BASE_RESERVE, + ); + + const tokenBefore = await tokenBalance( + this.banksClient, + payerOldTokenAccount, + ); + const wsolBefore = await tokenBalance(this.banksClient, wsolAta); + + const tx = new Transaction().add( + raydiumSwapBaseOutV2Ix({ + pool, + userSourceTokenAccount: wsolAta, + userDestinationTokenAccount: payerOldTokenAccount, + userSourceOwner: this.payer.publicKey, + maxAmountIn, + amountOut: SELL_AMOUNT, + }), + ); + tx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + tx.feePayer = this.payer.publicKey; + tx.sign(this.payer); + await this.banksClient.processTransaction(tx); + + const tokenAfter = await tokenBalance( + this.banksClient, + payerOldTokenAccount, + ); + const wsolAfter = await tokenBalance(this.banksClient, wsolAta); + + assert.equal((tokenAfter - tokenBefore).toString(), SELL_AMOUNT.toString()); + // Only the computed input is pulled; the rest of the allowance stays. + assert.equal((wsolBefore - wsolAfter).toString(), predictedIn.toString()); + assert.isTrue(predictedIn < maxAmountIn); + assert.equal( + (await tokenBalance(this.banksClient, pool.coinVault)).toString(), + (WSOL_POOL_QUOTE_RESERVE + predictedIn).toString(), + ); + }); + + it("raydium_amm flipped-orientation pool swaps correctly in both directions", async function () { + const { oldMint, payerOldTokenAccount } = await setupRelaunch({ + banksClient: this.banksClient, + payer: this.payer, + }); + + const pool = writeRaydiumPool({ + context: this.context, + oldMint, + tokenReserve: POOL_BASE_RESERVE, + quoteReserve: WSOL_POOL_QUOTE_RESERVE, + tokenSide: "coin", + }); + assert.isTrue(pool.coinMint.equals(oldMint)); + + const wsolAta = await wrapSol(this.bankrunProvider, this.payer, 0n); + + // Direction is inferred from the mints, so the same sell is coin → pc + // on this pool. + const predictedOut = raydiumExactInOutput( + SELL_AMOUNT, + POOL_BASE_RESERVE, + WSOL_POOL_QUOTE_RESERVE, + ); + + const wsolBefore = await tokenBalance(this.banksClient, wsolAta); + + const sellTx = new Transaction().add( + raydiumSwapBaseInV2Ix({ + pool, + userSourceTokenAccount: payerOldTokenAccount, + userDestinationTokenAccount: wsolAta, + userSourceOwner: this.payer.publicKey, + amountIn: SELL_AMOUNT, + minimumAmountOut: predictedOut, + }), + ); + sellTx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + sellTx.feePayer = this.payer.publicKey; + sellTx.sign(this.payer); + await this.banksClient.processTransaction(sellTx); + + const wsolAfterSell = await tokenBalance(this.banksClient, wsolAta); + assert.equal( + (wsolAfterSell - wsolBefore).toString(), + predictedOut.toString(), + ); + + // Buy the same tokens back (pc → coin) off the post-sell reserves. + const tokenReserveAfterSell = POOL_BASE_RESERVE + SELL_AMOUNT; + const quoteReserveAfterSell = WSOL_POOL_QUOTE_RESERVE - predictedOut; + const predictedIn = raydiumExactOutInput( + SELL_AMOUNT, + quoteReserveAfterSell, + tokenReserveAfterSell, + ); + + const tokenBefore = await tokenBalance( + this.banksClient, + payerOldTokenAccount, + ); + + const buyTx = new Transaction().add( + raydiumSwapBaseOutV2Ix({ + pool, + userSourceTokenAccount: wsolAta, + userDestinationTokenAccount: payerOldTokenAccount, + userSourceOwner: this.payer.publicKey, + maxAmountIn: wsolAfterSell, + amountOut: SELL_AMOUNT, + }), + ); + buyTx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + buyTx.feePayer = this.payer.publicKey; + buyTx.sign(this.payer); + await this.banksClient.processTransaction(buyTx); + + const tokenAfter = await tokenBalance( + this.banksClient, + payerOldTokenAccount, + ); + const wsolAfterBuy = await tokenBalance(this.banksClient, wsolAta); + + assert.equal((tokenAfter - tokenBefore).toString(), SELL_AMOUNT.toString()); + assert.equal( + (wsolAfterSell - wsolAfterBuy).toString(), + predictedIn.toString(), + ); + }); +} diff --git a/tests/relaunch/utils.ts b/tests/relaunch/utils.ts new file mode 100644 index 00000000..ed4edaaa --- /dev/null +++ b/tests/relaunch/utils.ts @@ -0,0 +1,177 @@ +import { + AddressLookupTableAccount, + Keypair, + PublicKey, + Signer, + SystemProgram, + Transaction, + TransactionInstruction, + TransactionMessage, + VersionedTransaction, +} from "@solana/web3.js"; +import * as token from "@solana/spl-token"; +import { BanksClient } from "solana-bankrun"; + +// 1B tokens at 6 decimals — the supply of a pump token. +export const DEFAULT_OLD_SUPPLY = 1_000_000_000n * 10n ** 6n; + +// Compiles instructions into a signed v0 transaction, resolving every +// account it can through the given lookup tables. +export async function buildV0Tx({ + banksClient, + payerKey, + instructions, + signers, + tables, +}: { + banksClient: BanksClient; + payerKey: PublicKey; + instructions: TransactionInstruction[]; + signers: Keypair[]; + tables: AddressLookupTableAccount[]; +}): Promise { + const [blockhash] = (await banksClient.getLatestBlockhash())!; + const message = new TransactionMessage({ + payerKey, + recentBlockhash: blockhash, + instructions, + }).compileToV0Message(tables); + const tx = new VersionedTransaction(message); + tx.sign(signers); + return tx; +} + +// Classic SPL mints are plain; Token-2022 mints get the pump-style shape: +// metadata pointer + mint-embedded token metadata, the only extensions the +// program's allowlist accepts. +export async function createOldMint( + banksClient: BanksClient, + payer: Signer, + tokenProgram: PublicKey = token.TOKEN_PROGRAM_ID, + decimals: number = 6, +): Promise { + const mintKeypair = Keypair.generate(); + const mint = mintKeypair.publicKey; + const rent = await banksClient.getRent(); + + const tx = new Transaction(); + if (tokenProgram.equals(token.TOKEN_2022_PROGRAM_ID)) { + const mintLen = token.getMintLen([token.ExtensionType.MetadataPointer]); + tx.add( + SystemProgram.createAccount({ + fromPubkey: payer.publicKey, + newAccountPubkey: mint, + // Overfund so the token metadata TLV realloc stays rent-exempt. + lamports: Number(rent.minimumBalance(BigInt(mintLen + 500))), + space: mintLen, + programId: tokenProgram, + }), + token.createInitializeMetadataPointerInstruction( + mint, + payer.publicKey, + mint, + tokenProgram, + ), + token.createInitializeMint2Instruction( + mint, + decimals, + payer.publicKey, + null, + tokenProgram, + ), + token.createInitializeInstruction({ + programId: tokenProgram, + mint, + metadata: mint, + name: "Old Token", + symbol: "OLD", + uri: "https://example.com/old.json", + mintAuthority: payer.publicKey, + updateAuthority: payer.publicKey, + }), + ); + } else { + tx.add( + SystemProgram.createAccount({ + fromPubkey: payer.publicKey, + newAccountPubkey: mint, + lamports: Number(rent.minimumBalance(BigInt(token.MINT_SIZE))), + space: token.MINT_SIZE, + programId: tokenProgram, + }), + token.createInitializeMint2Instruction( + mint, + decimals, + payer.publicKey, + null, + tokenProgram, + ), + ); + } + + tx.recentBlockhash = (await banksClient.getLatestBlockhash())[0]; + tx.feePayer = payer.publicKey; + tx.sign(payer, mintKeypair); + + await banksClient.processTransaction(tx); + + return mint; +} + +export type SetupRelaunchParams = { + banksClient: BanksClient; + payer: Keypair; + oldTokenProgram?: PublicKey; + oldSupply?: bigint; +}; + +export type RelaunchSetup = { + oldMint: PublicKey; + oldTokenProgram: PublicKey; + payerOldTokenAccount: PublicKey; +}; + +// Creates a pump-style old mint under the given token program, with the +// initial supply minted to the payer (who funds depositors in tests). The +// payer keeps mint authority so tests can fund depositors directly. +export async function setupRelaunch({ + banksClient, + payer, + oldTokenProgram = token.TOKEN_PROGRAM_ID, + oldSupply = DEFAULT_OLD_SUPPLY, +}: SetupRelaunchParams): Promise { + const oldMint = await createOldMint(banksClient, payer, oldTokenProgram); + + const payerOldTokenAccount = token.getAssociatedTokenAddressSync( + oldMint, + payer.publicKey, + false, + oldTokenProgram, + ); + + const tx = new Transaction().add( + token.createAssociatedTokenAccountIdempotentInstruction( + payer.publicKey, + payerOldTokenAccount, + payer.publicKey, + oldMint, + oldTokenProgram, + ), + token.createMintToInstruction( + oldMint, + payerOldTokenAccount, + payer.publicKey, + oldSupply, + [], + oldTokenProgram, + ), + ); + + tx.recentBlockhash = (await banksClient.getLatestBlockhash())[0]; + tx.feePayer = payer.publicKey; + tx.sign(payer); + + await banksClient.processTransaction(tx); + + return { oldMint, oldTokenProgram, payerOldTokenAccount }; +} diff --git a/tests/relaunch/whirlpool.ts b/tests/relaunch/whirlpool.ts new file mode 100644 index 00000000..52105478 --- /dev/null +++ b/tests/relaunch/whirlpool.ts @@ -0,0 +1,422 @@ +import * as anchor from "@coral-xyz/anchor"; +import { + ComputeBudgetProgram, + Keypair, + PublicKey, + Signer, + SystemProgram, + SYSVAR_RENT_PUBKEY, + Transaction, + TransactionInstruction, +} from "@solana/web3.js"; +import * as token from "@solana/spl-token"; +import { MEMO_PROGRAM_ID } from "@solana/spl-memo"; +import { BanksClient } from "solana-bankrun"; +import { BN } from "bn.js"; +import * as fs from "fs"; +import { MAINNET_USDC, WHIRLPOOL_PROGRAM_ID } from "@metadaoproject/programs"; + +// The mainnet WhirlpoolsConfig the pinned SOL/USDC pool lives under, loaded +// into bankrun as a dumped fixture (initialize_config is admin-gated on the +// current program, and reusing the real config makes the fixture pool's PDA +// land at the exact mainnet USDC_SWAP_POOL address). +export const WHIRLPOOLS_CONFIG = new PublicKey( + "2LecshUwdy9xi7meFgHtFJQNSKk4KdTrcpvaB56dP2NQ", +); + +// Mirrors the pinned mainnet SOL/USDC 0.04% pool's parameters. +export const TICK_SPACING = 4; + +const TICKS_PER_ARRAY = 88; +const TICK_ARRAY_SPAN = TICK_SPACING * TICKS_PER_ARRAY; +// Whirlpool's global tick bounds; both are divisible by TICK_SPACING, so a +// full-range position can sit exactly on them. +const MIN_TICK = -443636; +const MAX_TICK = 443636; + +export const MIN_SQRT_PRICE = 4_295_048_016n; +export const MAX_SQRT_PRICE = 79226673515401279992447579055n; + +function bigintSqrt(n: bigint): bigint { + let x = n; + let y = (x + 1n) / 2n; + while (y < x) { + x = y; + y = (x + n / x) / 2n; + } + return x; +} + +// sqrt(0.1) << 64: raw price 0.1 USDC-per-lamport-ish units — i.e. 100 USDC +// per SOL at 9/6 decimals. +export const DEFAULT_SQRT_PRICE = bigintSqrt((1n << 128n) / 10n); + +export function getWhirlpoolAddr({ + config = WHIRLPOOLS_CONFIG, + tokenMintA = token.NATIVE_MINT, + tokenMintB = MAINNET_USDC, + tickSpacing = TICK_SPACING, +}: { + config?: PublicKey; + tokenMintA?: PublicKey; + tokenMintB?: PublicKey; + tickSpacing?: number; +} = {}): PublicKey { + const spacingBuf = Buffer.alloc(2); + spacingBuf.writeUInt16LE(tickSpacing); + return PublicKey.findProgramAddressSync( + [ + Buffer.from("whirlpool"), + config.toBuffer(), + tokenMintA.toBuffer(), + tokenMintB.toBuffer(), + spacingBuf, + ], + WHIRLPOOL_PROGRAM_ID, + )[0]; +} + +// Identical to the relaunch program's `usdc_swap_pool` constant: the pool +// PDA under the dumped mainnet config is the mainnet pool address. +export const FIXTURE_USDC_SWAP_POOL = getWhirlpoolAddr(); + +export function getFeeTierAddr(config: PublicKey): PublicKey { + const spacingBuf = Buffer.alloc(2); + spacingBuf.writeUInt16LE(TICK_SPACING); + return PublicKey.findProgramAddressSync( + [Buffer.from("fee_tier"), config.toBuffer(), spacingBuf], + WHIRLPOOL_PROGRAM_ID, + )[0]; +} + +// The mainnet fee tier for tick spacing 4 (0.04%), also a dumped fixture. +export const WHIRLPOOL_FEE_TIER = getFeeTierAddr(WHIRLPOOLS_CONFIG); + +export function getOracleAddr(whirlpool: PublicKey): PublicKey { + return PublicKey.findProgramAddressSync( + [Buffer.from("oracle"), whirlpool.toBuffer()], + WHIRLPOOL_PROGRAM_ID, + )[0]; +} + +export function getTickArrayAddr( + whirlpool: PublicKey, + startTickIndex: number, +): PublicKey { + return PublicKey.findProgramAddressSync( + [ + Buffer.from("tick_array"), + whirlpool.toBuffer(), + Buffer.from(startTickIndex.toString()), + ], + WHIRLPOOL_PROGRAM_ID, + )[0]; +} + +export function getPositionAddr(positionMint: PublicKey): [PublicKey, number] { + return PublicKey.findProgramAddressSync( + [Buffer.from("position"), positionMint.toBuffer()], + WHIRLPOOL_PROGRAM_ID, + ); +} + +export function startTickIndex(tick: number): number { + return Math.floor(tick / TICK_ARRAY_SPAN) * TICK_ARRAY_SPAN; +} + +function sqrtPriceToTick(sqrtPrice: bigint): number { + const sqrtPriceFloat = Number(sqrtPrice) / 2 ** 64; + return Math.floor((2 * Math.log(sqrtPriceFloat)) / Math.log(1.0001)); +} + +export function whirlpoolProgram( + provider: anchor.AnchorProvider, +): anchor.Program { + const idl = JSON.parse( + fs.readFileSync("./tests/fixtures/whirlpool.json", "utf-8"), + ); + return new anchor.Program(idl, WHIRLPOOL_PROGRAM_ID, provider); +} + +export async function wrapSol( + provider: anchor.AnchorProvider, + payer: Signer, + lamports: bigint, +): Promise { + const wsolAta = token.getAssociatedTokenAddressSync( + token.NATIVE_MINT, + payer.publicKey, + ); + const tx = new Transaction().add( + token.createAssociatedTokenAccountIdempotentInstruction( + payer.publicKey, + wsolAta, + payer.publicKey, + token.NATIVE_MINT, + ), + SystemProgram.transfer({ + fromPubkey: payer.publicKey, + toPubkey: wsolAta, + lamports: Number(lamports), + }), + token.createSyncNativeInstruction(wsolAta), + ); + await provider.sendAndConfirm!(tx, [payer]); + return wsolAta; +} + +export type WhirlpoolFixture = { + program: anchor.Program; + config: PublicKey; + whirlpool: PublicKey; + oracle: PublicKey; + tokenVaultA: PublicKey; + tokenVaultB: PublicKey; + tickArrayStarts: number[]; +}; + +// Returns the fixture for an already-initialized whirlpool, or builds it via +// setupWhirlpool. The pool PDA is fixed, so whichever suite runs first +// creates it and everyone after reuses it. +export async function ensureWhirlpool({ + provider, + payer, + banksClient, +}: { + provider: anchor.AnchorProvider; + payer: Signer; + banksClient: BanksClient; +}): Promise { + const program = whirlpoolProgram(provider); + const whirlpool = getWhirlpoolAddr(); + + const existing = await banksClient.getAccount(whirlpool); + if (!existing) { + return setupWhirlpool({ provider, payer }); + } + + const pool = await program.account.whirlpool.fetch(whirlpool); + return { + program, + config: WHIRLPOOLS_CONFIG, + whirlpool, + oracle: getOracleAddr(whirlpool), + tokenVaultA: pool.tokenVaultA as PublicKey, + tokenVaultB: pool.tokenVaultB as PublicKey, + tickArrayStarts: [], + }; +} + +// Builds a real WSOL/USDC whirlpool through the program's own instructions +// under the dumped mainnet config: pool + tick arrays + a full-range +// position. The payer funds both sides (WSOL is wrapped here; USDC must +// already be in the payer's ATA). +export async function setupWhirlpool({ + provider, + payer, + sqrtPrice = DEFAULT_SQRT_PRICE, + solAmount = 1_000n * 10n ** 9n, + usdcAmount = 100_000n * 10n ** 6n, +}: { + provider: anchor.AnchorProvider; + payer: Signer; + sqrtPrice?: bigint; + solAmount?: bigint; + usdcAmount?: bigint; +}): Promise { + const program = whirlpoolProgram(provider); + const config = WHIRLPOOLS_CONFIG; + const feeTier = WHIRLPOOL_FEE_TIER; + const whirlpool = getWhirlpoolAddr({ config }); + const oracle = getOracleAddr(whirlpool); + const tokenVaultA = Keypair.generate(); + const tokenVaultB = Keypair.generate(); + + await program.methods + .initializePool( + { whirlpoolBump: 0 }, // ignored by the program; PDA is re-derived + TICK_SPACING, + new BN(sqrtPrice.toString()), + ) + .accounts({ + whirlpoolsConfig: config, + tokenMintA: token.NATIVE_MINT, + tokenMintB: MAINNET_USDC, + funder: payer.publicKey, + whirlpool, + tokenVaultA: tokenVaultA.publicKey, + tokenVaultB: tokenVaultB.publicKey, + feeTier, + tokenProgram: token.TOKEN_PROGRAM_ID, + systemProgram: SystemProgram.programId, + rent: SYSVAR_RENT_PUBKEY, + }) + .signers([tokenVaultA, tokenVaultB]) + .rpc(); + + // Seed tick arrays around the current price (three on each side keeps + // moderate swaps in both directions inside initialized arrays) plus the + // two arrays holding the full-range position's boundary ticks. + const currentStart = startTickIndex(sqrtPriceToTick(sqrtPrice)); + const tickArrayStarts = [ + ...[-3, -2, -1, 0, 1, 2, 3].map((k) => currentStart + k * TICK_ARRAY_SPAN), + startTickIndex(MIN_TICK), + startTickIndex(MAX_TICK), + ].filter((start, i, all) => all.indexOf(start) === i); + + const tickArrayIxs: TransactionInstruction[] = []; + for (const start of tickArrayStarts) { + tickArrayIxs.push( + await program.methods + .initializeTickArray(start) + .accounts({ + whirlpool, + funder: payer.publicKey, + tickArray: getTickArrayAddr(whirlpool, start), + systemProgram: SystemProgram.programId, + }) + .instruction(), + ); + } + const tickArrayTx = new Transaction().add( + ComputeBudgetProgram.setComputeUnitLimit({ units: 1_400_000 }), + ...tickArrayIxs, + ); + await provider.sendAndConfirm!(tickArrayTx, [payer]); + + // Full-range position funded by the payer. + const positionMint = Keypair.generate(); + const [position, positionBump] = getPositionAddr(positionMint.publicKey); + const positionTokenAccount = token.getAssociatedTokenAddressSync( + positionMint.publicKey, + payer.publicKey, + ); + + await program.methods + .openPosition({ positionBump }, MIN_TICK, MAX_TICK) + .accounts({ + funder: payer.publicKey, + owner: payer.publicKey, + position, + positionMint: positionMint.publicKey, + positionTokenAccount, + whirlpool, + tokenProgram: token.TOKEN_PROGRAM_ID, + systemProgram: SystemProgram.programId, + rent: SYSVAR_RENT_PUBKEY, + associatedTokenProgram: token.ASSOCIATED_TOKEN_PROGRAM_ID, + }) + .signers([positionMint]) + .rpc(); + + // For a full-range position, amountA ≈ L / sqrtP and amountB ≈ L * sqrtP + // (float sqrtP). The program computes exact requirements from on-chain + // state; the 1% headroom on the maxes absorbs rounding. + const sqrtPriceFloat = Number(sqrtPrice) / 2 ** 64; + const liquidity = BigInt( + Math.floor( + Math.min( + Number(solAmount) * sqrtPriceFloat, + Number(usdcAmount) / sqrtPriceFloat, + ), + ), + ); + const tokenMaxA = (solAmount * 101n) / 100n; + const tokenMaxB = (usdcAmount * 101n) / 100n; + + const wsolAta = await wrapSol(provider, payer, tokenMaxA); + const usdcAta = token.getAssociatedTokenAddressSync( + MAINNET_USDC, + payer.publicKey, + ); + + await program.methods + .increaseLiquidity( + new BN(liquidity.toString()), + new BN(tokenMaxA.toString()), + new BN(tokenMaxB.toString()), + ) + .accounts({ + whirlpool, + tokenProgram: token.TOKEN_PROGRAM_ID, + positionAuthority: payer.publicKey, + position, + positionTokenAccount, + tokenOwnerAccountA: wsolAta, + tokenOwnerAccountB: usdcAta, + tokenVaultA: tokenVaultA.publicKey, + tokenVaultB: tokenVaultB.publicKey, + tickArrayLower: getTickArrayAddr(whirlpool, startTickIndex(MIN_TICK)), + tickArrayUpper: getTickArrayAddr(whirlpool, startTickIndex(MAX_TICK)), + }) + .rpc(); + + return { + program, + config, + whirlpool, + oracle, + tokenVaultA: tokenVaultA.publicKey, + tokenVaultB: tokenVaultB.publicKey, + tickArrayStarts, + }; +} + +// Builds a swap_v2 instruction with the three tick arrays derived from the +// pool's live tick, walking in the swap's direction. +export async function whirlpoolSwapV2Ix( + fixture: WhirlpoolFixture, + { + tokenAuthority, + tokenOwnerAccountA, + tokenOwnerAccountB, + amountIn, + minAmountOut, + aToB, + }: { + tokenAuthority: PublicKey; + tokenOwnerAccountA: PublicKey; + tokenOwnerAccountB: PublicKey; + amountIn: bigint; + minAmountOut: bigint; + aToB: boolean; + }, +): Promise { + const pool = await fixture.program.account.whirlpool.fetch(fixture.whirlpool); + const currentStart = startTickIndex(pool.tickCurrentIndex as number); + const direction = aToB ? -1 : 1; + const tickArrays = [0, 1, 2].map((k) => + getTickArrayAddr( + fixture.whirlpool, + currentStart + k * direction * TICK_ARRAY_SPAN, + ), + ); + + return fixture.program.methods + .swapV2( + new BN(amountIn.toString()), + new BN(minAmountOut.toString()), + new BN((aToB ? MIN_SQRT_PRICE : MAX_SQRT_PRICE).toString()), + true, // amount specified is input + aToB, + null, // no supplemental tick arrays / transfer hook accounts + ) + .accounts({ + tokenProgramA: token.TOKEN_PROGRAM_ID, + tokenProgramB: token.TOKEN_PROGRAM_ID, + memoProgram: MEMO_PROGRAM_ID, + tokenAuthority, + whirlpool: fixture.whirlpool, + tokenMintA: token.NATIVE_MINT, + tokenMintB: MAINNET_USDC, + tokenOwnerAccountA, + tokenVaultA: fixture.tokenVaultA, + tokenOwnerAccountB, + tokenVaultB: fixture.tokenVaultB, + tickArray0: tickArrays[0], + tickArray1: tickArrays[1], + tickArray2: tickArrays[2], + oracle: fixture.oracle, + }) + .instruction(); +} diff --git a/tsconfig.json b/tsconfig.json index f7ad19bd..85b5e1bd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "strict": false, "types": [ "mocha", "chai"