diff --git a/Anchor.toml b/Anchor.toml index 3016da94..1faca827 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -9,6 +9,7 @@ skip-lint = false bid_wall = "WALL8ucBuUyL46QYxwYJjidaFYhdvxUFrgvBxPshERx" conditional_vault = "VLTX1ishMBbcX3rdBWGssxawAo1Q2X2qxYFYqiGodVg" futarchy = "FUTARELBfJfQ8RDGhg1wdhddq1odMAJUePHFuBYfUxKq" +gated_mint = "GaTEjZy6eMdHg2BcL8dk3iE78jkJ9sPtyw1q2tMNi8PA" launchpad = "MooNyh4CBUYEKyXVnjGYQ8mEiJDpGvJMdvrZx1iGeHV" launchpad_v7 = "moontUzsdepotRGe5xsfip7vLPTJnVuafqdUWexVnPM" launchpad_v8 = "moonDJUoHteKkGATejA5bdJVwJ6V6Dg74gyqyJTx73n" diff --git a/Cargo.lock b/Cargo.lock index 0bb4c021..83881e26 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -947,6 +947,17 @@ dependencies = [ "squads-multisig-program", ] +[[package]] +name = "gated_mint" +version = "0.1.0" +dependencies = [ + "anchor-lang", + "anchor-spl", + "solana-program", + "solana-security-txt", + "spl-token", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -1195,6 +1206,7 @@ dependencies = [ "bid_wall", "damm_v2_cpi", "futarchy", + "gated_mint", "mint_governor", "performance_package_v2", "solana-program", diff --git a/programs/gated_mint/Cargo.toml b/programs/gated_mint/Cargo.toml new file mode 100644 index 00000000..285b3149 --- /dev/null +++ b/programs/gated_mint/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "gated_mint" +version = "0.1.0" +description = "Created with Anchor" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "gated_mint" + +[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 = "0.29.0" +solana-program = "=1.17.14" +spl-token = "=4.0.0" +solana-security-txt = "1.1.1" diff --git a/programs/gated_mint/src/constants.rs b/programs/gated_mint/src/constants.rs new file mode 100644 index 00000000..49b9edd0 --- /dev/null +++ b/programs/gated_mint/src/constants.rs @@ -0,0 +1,20 @@ +use anchor_lang::prelude::Pubkey; +use anchor_lang::solana_program::pubkey; + +pub const WHITELISTED_PROGRAMS: &[Pubkey] = &[ + pubkey!("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"), + pubkey!("FUTARELBfJfQ8RDGhg1wdhddq1odMAJUePHFuBYfUxKq"), + pubkey!("moonDJUoHteKkGATejA5bdJVwJ6V6Dg74gyqyJTx73n"), + pubkey!("VLTX1ishMBbcX3rdBWGssxawAo1Q2X2qxYFYqiGodVg"), + pubkey!("WALL8ucBuUyL46QYxwYJjidaFYhdvxUFrgvBxPshERx"), + pubkey!("gvnr27cVeyW3AVf3acL7VCJ5WjGAphytnsgcK1feHyH"), + pubkey!("cpamdpZCGKUy5JxQXB4dcpGPiikHawvSWAd6mEn1sGG"), +]; + +pub const TOKEN_ACCOUNT_LEN: usize = 165; +pub const TOKEN_ACCOUNT_MINT_OFFSET: usize = 0; +pub const TOKEN_ACCOUNT_STATE_OFFSET: usize = 108; + +pub const TOKEN_STATE_UNINITIALIZED: u8 = 0; +pub const TOKEN_STATE_INITIALIZED: u8 = 1; +pub const TOKEN_STATE_FROZEN: u8 = 2; diff --git a/programs/gated_mint/src/error.rs b/programs/gated_mint/src/error.rs new file mode 100644 index 00000000..8b9a464d --- /dev/null +++ b/programs/gated_mint/src/error.rs @@ -0,0 +1,21 @@ +use anchor_lang::prelude::*; + +#[error_code] +pub enum GatedMintError { + #[msg("Unauthorized: signer is not the gated mint admin")] + UnauthorizedAdmin, + #[msg("Unauthorized: signer is not the current freeze authority of the mint")] + UnauthorizedFreezeAuthority, + #[msg("Mint mismatch: account does not match the expected gated mint")] + MintMismatch, + #[msg("Gating is already disabled for this mint")] + GatingDisabled, + #[msg("Gating must be disabled to call this instruction")] + GatingNotDisabled, + #[msg("Target program is not on the gated_mint whitelist")] + TargetProgramNotWhitelisted, + #[msg("Target program may not be the gated_mint program itself")] + SelfInvocation, + #[msg("Invalid token account: account is not a valid SPL Token account of the gated mint")] + InvalidTokenAccount, +} diff --git a/programs/gated_mint/src/events.rs b/programs/gated_mint/src/events.rs new file mode 100644 index 00000000..f67f1c1b --- /dev/null +++ b/programs/gated_mint/src/events.rs @@ -0,0 +1,63 @@ +use anchor_lang::prelude::*; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct CommonFields { + pub slot: u64, + pub unix_timestamp: i64, + pub gated_mint_config_seq_num: u64, +} + +impl CommonFields { + pub fn new(clock: &Clock, seq_num: u64) -> Self { + Self { + slot: clock.slot, + unix_timestamp: clock.unix_timestamp, + gated_mint_config_seq_num: seq_num, + } + } +} + +#[event] +pub struct GatedMintInitializedEvent { + pub common: CommonFields, + pub gated_mint_config: Pubkey, + pub mint: Pubkey, + pub admin: Pubkey, + pub previous_freeze_authority: Pubkey, + pub pda_bump: u8, +} + +#[event] +pub struct WhitelistedUserAddedEvent { + pub common: CommonFields, + pub gated_mint_config: Pubkey, + pub whitelisted_user: Pubkey, + pub mint: Pubkey, + pub user: Pubkey, +} + +#[event] +pub struct GatedInvokeEvent { + pub common: CommonFields, + pub gated_mint_config: Pubkey, + pub mint: Pubkey, + pub caller: Pubkey, + pub target_program: Pubkey, + pub thawed_count: u32, + pub frozen_count: u32, +} + +#[event] +pub struct GatingDisabledEvent { + pub common: CommonFields, + pub gated_mint_config: Pubkey, + pub mint: Pubkey, +} + +#[event] +pub struct AccountThawedEvent { + pub common: CommonFields, + pub gated_mint_config: Pubkey, + pub mint: Pubkey, + pub token_account: Pubkey, +} diff --git a/programs/gated_mint/src/instructions/add_whitelisted_user.rs b/programs/gated_mint/src/instructions/add_whitelisted_user.rs new file mode 100644 index 00000000..8318e3fa --- /dev/null +++ b/programs/gated_mint/src/instructions/add_whitelisted_user.rs @@ -0,0 +1,68 @@ +use anchor_lang::prelude::*; +use anchor_spl::token::Mint; + +use crate::{ + CommonFields, GatedMintConfig, GatedMintError, WhitelistedUser, WhitelistedUserAddedEvent, + WHITELISTED_USER_SEED, +}; + +#[event_cpi] +#[derive(Accounts)] +pub struct AddWhitelistedUser<'info> { + #[account( + mut, + has_one = mint @ GatedMintError::MintMismatch, + constraint = !gated_mint_config.gating_disabled @ GatedMintError::GatingDisabled, + )] + pub gated_mint_config: Account<'info, GatedMintConfig>, + + #[account(address = gated_mint_config.admin @ GatedMintError::UnauthorizedAdmin)] + pub admin: Signer<'info>, + + pub mint: Account<'info, Mint>, + + /// CHECK: any pubkey may be whitelisted; not signed. + pub user: UncheckedAccount<'info>, + + #[account( + init, + payer = payer, + space = 8 + WhitelistedUser::INIT_SPACE, + seeds = [WHITELISTED_USER_SEED, mint.key().as_ref(), user.key().as_ref()], + bump, + )] + pub whitelisted_user: Account<'info, WhitelistedUser>, + + #[account(mut)] + pub payer: Signer<'info>, + + pub system_program: Program<'info, System>, +} + +impl AddWhitelistedUser<'_> { + pub fn validate(&self) -> Result<()> { + Ok(()) + } + + pub fn handle(ctx: Context) -> Result<()> { + let cfg = &mut ctx.accounts.gated_mint_config; + cfg.seq_num += 1; + + ctx.accounts.whitelisted_user.set_inner(WhitelistedUser { + mint: ctx.accounts.mint.key(), + user: ctx.accounts.user.key(), + bump: ctx.bumps.whitelisted_user, + }); + + let clock = Clock::get()?; + emit_cpi!(WhitelistedUserAddedEvent { + common: CommonFields::new(&clock, cfg.seq_num), + gated_mint_config: cfg.key(), + whitelisted_user: ctx.accounts.whitelisted_user.key(), + mint: ctx.accounts.mint.key(), + user: ctx.accounts.user.key(), + }); + + Ok(()) + } +} diff --git a/programs/gated_mint/src/instructions/disable_gating.rs b/programs/gated_mint/src/instructions/disable_gating.rs new file mode 100644 index 00000000..3d6c685e --- /dev/null +++ b/programs/gated_mint/src/instructions/disable_gating.rs @@ -0,0 +1,37 @@ +use anchor_lang::prelude::*; + +use crate::{CommonFields, GatedMintConfig, GatedMintError, GatingDisabledEvent}; + +#[event_cpi] +#[derive(Accounts)] +pub struct DisableGating<'info> { + #[account( + mut, + constraint = !gated_mint_config.gating_disabled @ GatedMintError::GatingDisabled, + )] + pub gated_mint_config: Account<'info, GatedMintConfig>, + + #[account(address = gated_mint_config.admin @ GatedMintError::UnauthorizedAdmin)] + pub admin: Signer<'info>, +} + +impl DisableGating<'_> { + pub fn validate(&self) -> Result<()> { + Ok(()) + } + + pub fn handle(ctx: Context) -> Result<()> { + let cfg = &mut ctx.accounts.gated_mint_config; + cfg.gating_disabled = true; + cfg.seq_num += 1; + + let clock = Clock::get()?; + emit_cpi!(GatingDisabledEvent { + common: CommonFields::new(&clock, cfg.seq_num), + gated_mint_config: cfg.key(), + mint: cfg.mint, + }); + + Ok(()) + } +} diff --git a/programs/gated_mint/src/instructions/gated_invoke.rs b/programs/gated_mint/src/instructions/gated_invoke.rs new file mode 100644 index 00000000..beed27c0 --- /dev/null +++ b/programs/gated_mint/src/instructions/gated_invoke.rs @@ -0,0 +1,207 @@ +use anchor_lang::prelude::*; +use anchor_lang::solana_program::instruction::{AccountMeta, Instruction}; +use anchor_lang::solana_program::program::{invoke, invoke_signed}; +use anchor_spl::token::spl_token; +use anchor_spl::token::{Mint, Token}; + +use crate::{ + CommonFields, GatedInvokeEvent, GatedMintConfig, GatedMintError, WhitelistedUser, + GATED_MINT_CONFIG_SEED, TOKEN_ACCOUNT_LEN, TOKEN_ACCOUNT_MINT_OFFSET, + TOKEN_ACCOUNT_STATE_OFFSET, TOKEN_STATE_FROZEN, TOKEN_STATE_INITIALIZED, WHITELISTED_PROGRAMS, + WHITELISTED_USER_SEED, +}; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct GatedInvokeArgs { + pub instruction_data: Vec, +} + +#[event_cpi] +#[derive(Accounts)] +pub struct GatedInvoke<'info> { + pub caller: Signer<'info>, + + #[account( + mut, + has_one = mint @ GatedMintError::MintMismatch, + constraint = !gated_mint_config.gating_disabled @ GatedMintError::GatingDisabled, + )] + pub gated_mint_config: Account<'info, GatedMintConfig>, + + #[account( + seeds = [WHITELISTED_USER_SEED, mint.key().as_ref(), caller.key().as_ref()], + bump = whitelisted_user.bump, + has_one = mint @ GatedMintError::MintMismatch, + )] + pub whitelisted_user: Account<'info, WhitelistedUser>, + + pub mint: Account<'info, Mint>, + + /// CHECK: validated against WHITELISTED_PROGRAMS. + pub target_program: UncheckedAccount<'info>, + + pub token_program: Program<'info, Token>, +} + +impl<'info, 'c: 'info> GatedInvoke<'info> { + pub fn validate(&self, _args: &GatedInvokeArgs) -> Result<()> { + let target = self.target_program.key(); + require!( + WHITELISTED_PROGRAMS.contains(&target), + GatedMintError::TargetProgramNotWhitelisted + ); + require_keys_neq!(target, crate::ID, GatedMintError::SelfInvocation); + Ok(()) + } + + pub fn handle(ctx: Context<'_, '_, 'c, 'info, Self>, args: GatedInvokeArgs) -> Result<()> { + let mint_key = ctx.accounts.mint.key(); + let cfg_key = ctx.accounts.gated_mint_config.key(); + let cfg_bump = ctx.accounts.gated_mint_config.bump; + let signer_seeds: &[&[&[u8]]] = + &[&[GATED_MINT_CONFIG_SEED, mint_key.as_ref(), &[cfg_bump]]]; + + let mut thawed_count: u32 = 0; + for acc in ctx.remaining_accounts.iter() { + if !is_gated_mint_token_account(acc, &mint_key)? { + continue; + } + if read_token_state(acc)? != TOKEN_STATE_FROZEN { + continue; + } + cpi_thaw( + &ctx.accounts.token_program.to_account_info(), + acc, + &ctx.accounts.mint.to_account_info(), + &ctx.accounts.gated_mint_config.to_account_info(), + signer_seeds, + )?; + thawed_count = thawed_count.saturating_add(1); + } + + let account_metas: Vec = ctx + .remaining_accounts + .iter() + .map(|a| AccountMeta { + pubkey: a.key(), + is_signer: a.is_signer, + is_writable: a.is_writable, + }) + .collect(); + let mut account_infos: Vec = ctx.remaining_accounts.to_vec(); + account_infos.push(ctx.accounts.target_program.to_account_info()); + + let ix = Instruction { + program_id: ctx.accounts.target_program.key(), + accounts: account_metas, + data: args.instruction_data, + }; + invoke(&ix, &account_infos)?; + + let mut frozen_count: u32 = 0; + for acc in ctx.remaining_accounts.iter() { + if !is_gated_mint_token_account(acc, &mint_key)? { + continue; + } + if read_token_state(acc)? != TOKEN_STATE_INITIALIZED { + continue; + } + cpi_freeze( + &ctx.accounts.token_program.to_account_info(), + acc, + &ctx.accounts.mint.to_account_info(), + &ctx.accounts.gated_mint_config.to_account_info(), + signer_seeds, + )?; + frozen_count = frozen_count.saturating_add(1); + } + + let cfg = &mut ctx.accounts.gated_mint_config; + cfg.seq_num += 1; + let clock = Clock::get()?; + emit_cpi!(GatedInvokeEvent { + common: CommonFields::new(&clock, cfg.seq_num), + gated_mint_config: cfg_key, + mint: mint_key, + caller: ctx.accounts.caller.key(), + target_program: ctx.accounts.target_program.key(), + thawed_count, + frozen_count, + }); + + Ok(()) + } +} + +fn is_gated_mint_token_account(acc: &AccountInfo, expected_mint: &Pubkey) -> Result { + if acc.owner != &spl_token::ID { + return Ok(false); + } + let data = acc.try_borrow_data()?; + if data.len() != TOKEN_ACCOUNT_LEN { + return Ok(false); + } + let mint_bytes: &[u8; 32] = data[TOKEN_ACCOUNT_MINT_OFFSET..TOKEN_ACCOUNT_MINT_OFFSET + 32] + .try_into() + .unwrap(); + Ok(Pubkey::from(*mint_bytes) == *expected_mint) +} + +fn read_token_state(acc: &AccountInfo) -> Result { + let data = acc.try_borrow_data()?; + Ok(data[TOKEN_ACCOUNT_STATE_OFFSET]) +} + +fn cpi_thaw<'info>( + token_program: &AccountInfo<'info>, + account: &AccountInfo<'info>, + mint: &AccountInfo<'info>, + authority: &AccountInfo<'info>, + signer_seeds: &[&[&[u8]]], +) -> Result<()> { + let ix = spl_token::instruction::thaw_account( + &spl_token::ID, + account.key, + mint.key, + authority.key, + &[], + )?; + invoke_signed( + &ix, + &[ + account.clone(), + mint.clone(), + authority.clone(), + token_program.clone(), + ], + signer_seeds, + )?; + Ok(()) +} + +fn cpi_freeze<'info>( + token_program: &AccountInfo<'info>, + account: &AccountInfo<'info>, + mint: &AccountInfo<'info>, + authority: &AccountInfo<'info>, + signer_seeds: &[&[&[u8]]], +) -> Result<()> { + let ix = spl_token::instruction::freeze_account( + &spl_token::ID, + account.key, + mint.key, + authority.key, + &[], + )?; + invoke_signed( + &ix, + &[ + account.clone(), + mint.clone(), + authority.clone(), + token_program.clone(), + ], + signer_seeds, + )?; + Ok(()) +} diff --git a/programs/gated_mint/src/instructions/initialize_gated_mint.rs b/programs/gated_mint/src/instructions/initialize_gated_mint.rs new file mode 100644 index 00000000..b0fb7369 --- /dev/null +++ b/programs/gated_mint/src/instructions/initialize_gated_mint.rs @@ -0,0 +1,84 @@ +use anchor_lang::prelude::*; +use anchor_lang::solana_program::program_option::COption; +use anchor_spl::token::spl_token::instruction::AuthorityType; +use anchor_spl::token::{self, Mint, SetAuthority, Token}; + +use crate::{ + CommonFields, GatedMintConfig, GatedMintError, GatedMintInitializedEvent, + GATED_MINT_CONFIG_SEED, +}; + +#[event_cpi] +#[derive(Accounts)] +pub struct InitializeGatedMint<'info> { + #[account( + mut, + constraint = mint.freeze_authority == COption::Some(current_freeze_authority.key()) + @ GatedMintError::UnauthorizedFreezeAuthority, + )] + pub mint: Account<'info, Mint>, + + #[account( + init, + payer = payer, + space = 8 + GatedMintConfig::INIT_SPACE, + seeds = [GATED_MINT_CONFIG_SEED, mint.key().as_ref()], + bump, + )] + pub gated_mint_config: Account<'info, GatedMintConfig>, + + pub current_freeze_authority: Signer<'info>, + + /// CHECK: stored on GatedMintConfig as the per-mint admin. + pub admin: UncheckedAccount<'info>, + + #[account(mut)] + pub payer: Signer<'info>, + + pub token_program: Program<'info, Token>, + pub system_program: Program<'info, System>, +} + +impl InitializeGatedMint<'_> { + pub fn validate(&self) -> Result<()> { + Ok(()) + } + + pub fn handle(ctx: Context) -> Result<()> { + let previous_authority = ctx.accounts.current_freeze_authority.key(); + let config_key = ctx.accounts.gated_mint_config.key(); + + token::set_authority( + CpiContext::new( + ctx.accounts.token_program.to_account_info(), + SetAuthority { + current_authority: ctx.accounts.current_freeze_authority.to_account_info(), + account_or_mint: ctx.accounts.mint.to_account_info(), + }, + ), + AuthorityType::FreezeAccount, + Some(config_key), + )?; + + ctx.accounts.gated_mint_config.set_inner(GatedMintConfig { + mint: ctx.accounts.mint.key(), + admin: ctx.accounts.admin.key(), + gating_disabled: false, + seq_num: 0, + bump: ctx.bumps.gated_mint_config, + }); + + let clock = Clock::get()?; + let cfg = &ctx.accounts.gated_mint_config; + emit_cpi!(GatedMintInitializedEvent { + common: CommonFields::new(&clock, cfg.seq_num), + gated_mint_config: config_key, + mint: cfg.mint, + admin: cfg.admin, + previous_freeze_authority: previous_authority, + pda_bump: cfg.bump, + }); + + Ok(()) + } +} diff --git a/programs/gated_mint/src/instructions/mod.rs b/programs/gated_mint/src/instructions/mod.rs new file mode 100644 index 00000000..118c3ae5 --- /dev/null +++ b/programs/gated_mint/src/instructions/mod.rs @@ -0,0 +1,11 @@ +pub mod add_whitelisted_user; +pub mod disable_gating; +pub mod gated_invoke; +pub mod initialize_gated_mint; +pub mod thaw_account; + +pub use add_whitelisted_user::*; +pub use disable_gating::*; +pub use gated_invoke::*; +pub use initialize_gated_mint::*; +pub use thaw_account::*; diff --git a/programs/gated_mint/src/instructions/thaw_account.rs b/programs/gated_mint/src/instructions/thaw_account.rs new file mode 100644 index 00000000..0410e7ef --- /dev/null +++ b/programs/gated_mint/src/instructions/thaw_account.rs @@ -0,0 +1,72 @@ +use anchor_lang::prelude::*; +use anchor_lang::solana_program::program::invoke_signed; +use anchor_spl::token::spl_token; +use anchor_spl::token::{Mint, Token, TokenAccount}; + +use crate::{ + AccountThawedEvent, CommonFields, GatedMintConfig, GatedMintError, GATED_MINT_CONFIG_SEED, +}; + +#[event_cpi] +#[derive(Accounts)] +pub struct ThawAccount<'info> { + #[account( + mut, + has_one = mint @ GatedMintError::MintMismatch, + constraint = gated_mint_config.gating_disabled @ GatedMintError::GatingNotDisabled, + )] + pub gated_mint_config: Account<'info, GatedMintConfig>, + + pub mint: Account<'info, Mint>, + + #[account( + mut, + constraint = token_account.mint == mint.key() @ GatedMintError::MintMismatch, + )] + pub token_account: Account<'info, TokenAccount>, + + pub token_program: Program<'info, Token>, +} + +impl ThawAccount<'_> { + pub fn validate(&self) -> Result<()> { + Ok(()) + } + + pub fn handle(ctx: Context) -> Result<()> { + let config = &mut ctx.accounts.gated_mint_config; + let mint_key = ctx.accounts.mint.key(); + let config_key = config.key(); + let signer_seeds: &[&[&[u8]]] = + &[&[GATED_MINT_CONFIG_SEED, mint_key.as_ref(), &[config.bump]]]; + + let ix = spl_token::instruction::thaw_account( + &spl_token::ID, + &ctx.accounts.token_account.key(), + &mint_key, + &config_key, + &[], + )?; + invoke_signed( + &ix, + &[ + ctx.accounts.token_account.to_account_info(), + ctx.accounts.mint.to_account_info(), + config.to_account_info(), + ctx.accounts.token_program.to_account_info(), + ], + signer_seeds, + )?; + + config.seq_num += 1; + let clock = Clock::get()?; + emit_cpi!(AccountThawedEvent { + common: CommonFields::new(&clock, config.seq_num), + gated_mint_config: config_key, + mint: mint_key, + token_account: ctx.accounts.token_account.key(), + }); + + Ok(()) + } +} diff --git a/programs/gated_mint/src/lib.rs b/programs/gated_mint/src/lib.rs new file mode 100644 index 00000000..19f06f08 --- /dev/null +++ b/programs/gated_mint/src/lib.rs @@ -0,0 +1,62 @@ +use anchor_lang::prelude::*; + +pub mod constants; +pub mod error; +pub mod events; +pub mod instructions; +pub mod state; + +pub use constants::*; +pub use error::*; +pub use events::*; +pub use instructions::*; +pub use state::*; + +#[cfg(not(feature = "no-entrypoint"))] +use solana_security_txt::security_txt; + +#[cfg(not(feature = "no-entrypoint"))] +security_txt! { + name: "gated_mint", + 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!("GaTEjZy6eMdHg2BcL8dk3iE78jkJ9sPtyw1q2tMNi8PA"); + +#[program] +pub mod gated_mint { + use super::*; + + #[access_control(ctx.accounts.validate())] + pub fn initialize_gated_mint(ctx: Context) -> Result<()> { + InitializeGatedMint::handle(ctx) + } + + #[access_control(ctx.accounts.validate())] + pub fn add_whitelisted_user(ctx: Context) -> Result<()> { + AddWhitelistedUser::handle(ctx) + } + + #[access_control(ctx.accounts.validate(&args))] + pub fn gated_invoke<'c: 'info, 'info>( + ctx: Context<'_, '_, 'c, 'info, GatedInvoke<'info>>, + args: GatedInvokeArgs, + ) -> Result<()> { + GatedInvoke::handle(ctx, args) + } + + #[access_control(ctx.accounts.validate())] + pub fn disable_gating(ctx: Context) -> Result<()> { + DisableGating::handle(ctx) + } + + #[access_control(ctx.accounts.validate())] + pub fn thaw_account(ctx: Context) -> Result<()> { + ThawAccount::handle(ctx) + } +} diff --git a/programs/gated_mint/src/state/gated_mint_config.rs b/programs/gated_mint/src/state/gated_mint_config.rs new file mode 100644 index 00000000..e4ea38eb --- /dev/null +++ b/programs/gated_mint/src/state/gated_mint_config.rs @@ -0,0 +1,13 @@ +use anchor_lang::prelude::*; + +pub const GATED_MINT_CONFIG_SEED: &[u8] = b"gated_mint_config"; + +#[account] +#[derive(InitSpace)] +pub struct GatedMintConfig { + pub mint: Pubkey, + pub admin: Pubkey, + pub gating_disabled: bool, + pub seq_num: u64, + pub bump: u8, +} diff --git a/programs/gated_mint/src/state/mod.rs b/programs/gated_mint/src/state/mod.rs new file mode 100644 index 00000000..3a58b7a1 --- /dev/null +++ b/programs/gated_mint/src/state/mod.rs @@ -0,0 +1,5 @@ +pub mod gated_mint_config; +pub mod whitelisted_user; + +pub use gated_mint_config::*; +pub use whitelisted_user::*; diff --git a/programs/gated_mint/src/state/whitelisted_user.rs b/programs/gated_mint/src/state/whitelisted_user.rs new file mode 100644 index 00000000..098e9100 --- /dev/null +++ b/programs/gated_mint/src/state/whitelisted_user.rs @@ -0,0 +1,11 @@ +use anchor_lang::prelude::*; + +pub const WHITELISTED_USER_SEED: &[u8] = b"whitelisted_user"; + +#[account] +#[derive(InitSpace)] +pub struct WhitelistedUser { + pub mint: Pubkey, + pub user: Pubkey, + pub bump: u8, +} diff --git a/programs/v08_launchpad/Cargo.toml b/programs/v08_launchpad/Cargo.toml index d2f0d277..bcd283ca 100644 --- a/programs/v08_launchpad/Cargo.toml +++ b/programs/v08_launchpad/Cargo.toml @@ -32,3 +32,4 @@ solana-security-txt = "1.1.1" squads-multisig-program = { git = "https://github.com/Squads-Protocol/v4", package = "squads-multisig-program", rev = "6d5235da621a2e9b7379ea358e48760e981053be", features = ["cpi"] } damm_v2_cpi = { path = "../damm_v2_cpi", features = ["cpi"] } bid_wall = { path = "../bid_wall", features = ["cpi"] } +gated_mint = { path = "../gated_mint", features = ["no-entrypoint"] } diff --git a/programs/v08_launchpad/src/instructions/initialize_launch.rs b/programs/v08_launchpad/src/instructions/initialize_launch.rs index 6ea5f626..753febd8 100644 --- a/programs/v08_launchpad/src/instructions/initialize_launch.rs +++ b/programs/v08_launchpad/src/instructions/initialize_launch.rs @@ -1,7 +1,9 @@ use anchor_lang::prelude::*; +use anchor_lang::solana_program::program_option::COption; use anchor_spl::associated_token::AssociatedToken; use anchor_spl::token::{self, Mint, MintTo, Token, TokenAccount}; +use gated_mint::GATED_MINT_CONFIG_SEED; use mint_governor::{ cpi::{ accounts::{InitializeMintGovernor, TransferAuthorityToGovernor}, @@ -143,10 +145,19 @@ impl InitializeLaunch<'_> { LaunchpadError::InvalidAccumulatorActivationDelaySeconds ); - require!( - self.base_mint.freeze_authority.is_none(), - LaunchpadError::FreezeAuthoritySet - ); + // Freeze authority must be either unset (classic launch) or the + // deterministic `gated_mint_config` PDA owned by the gated_mint program (gated launch). + if let COption::Some(freeze_authority) = self.base_mint.freeze_authority { + let (expected_gated_mint_config, _) = Pubkey::find_program_address( + &[GATED_MINT_CONFIG_SEED, self.base_mint.key().as_ref()], + &gated_mint::ID, + ); + require_keys_eq!( + freeze_authority, + expected_gated_mint_config, + LaunchpadError::FreezeAuthoritySet + ); + }; require_gte!( args.minimum_raise_amount, diff --git a/sdk/package.json b/sdk/package.json index 775a0ed1..d974bfe3 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -12,6 +12,7 @@ "./bid_wall": "./dist/bid_wall/index.js", "./conditional_vault": "./dist/conditional_vault/index.js", "./futarchy": "./dist/futarchy/index.js", + "./gated_mint": "./dist/gated_mint/index.js", "./launchpad": "./dist/launchpad/index.js", "./liquidation": "./dist/liquidation/index.js", "./mint_governor": "./dist/mint_governor/index.js", @@ -23,6 +24,7 @@ "./bid_wall/*": "./dist/bid_wall/*/index.js", "./conditional_vault/*": "./dist/conditional_vault/*/index.js", "./futarchy/*": "./dist/futarchy/*/index.js", + "./gated_mint/*": "./dist/gated_mint/*/index.js", "./launchpad/*": "./dist/launchpad/*/index.js", "./liquidation/*": "./dist/liquidation/*/index.js", "./mint_governor/*": "./dist/mint_governor/*/index.js", diff --git a/sdk/src/constants.ts b/sdk/src/constants.ts index 009dd57c..a9e431a8 100644 --- a/sdk/src/constants.ts +++ b/sdk/src/constants.ts @@ -59,6 +59,9 @@ export const BID_WALL_V0_7_PROGRAM_ID = new PublicKey( export const MINT_GOVERNOR_V0_7_PROGRAM_ID = new PublicKey( "gvnr27cVeyW3AVf3acL7VCJ5WjGAphytnsgcK1feHyH", ); +export const GATED_MINT_V0_1_PROGRAM_ID = new PublicKey( + "GaTEjZy6eMdHg2BcL8dk3iE78jkJ9sPtyw1q2tMNi8PA", +); export const PERFORMANCE_PACKAGE_V2_PROGRAM_ID = new PublicKey( "pPV2pfrxnmstSb9j7kEeCLny5BGj6SNwCWGd6xbGGzz", ); diff --git a/sdk/src/gated_mint/index.ts b/sdk/src/gated_mint/index.ts new file mode 100644 index 00000000..727de0c6 --- /dev/null +++ b/sdk/src/gated_mint/index.ts @@ -0,0 +1 @@ +export * from "./v0.1/index.js"; diff --git a/sdk/src/gated_mint/v0.1/GatedMintClient.ts b/sdk/src/gated_mint/v0.1/GatedMintClient.ts new file mode 100644 index 00000000..6e4574e0 --- /dev/null +++ b/sdk/src/gated_mint/v0.1/GatedMintClient.ts @@ -0,0 +1,199 @@ +import { AnchorProvider, Program } from "@coral-xyz/anchor"; +import { + AccountInfo, + PublicKey, + TransactionInstruction, +} from "@solana/web3.js"; +import { TOKEN_PROGRAM_ID } from "@solana/spl-token"; +import { GATED_MINT_V0_1_PROGRAM_ID } from "../../constants.js"; +import { getGatedMintConfigAddr, getWhitelistedUserAddr } from "./pda.js"; +import { + GatedMint as GatedMintProgram, + IDL as GatedMintIDL, +} from "./types/gated_mint.js"; +import type { + GatedMintConfigAccount, + WhitelistedUserAccount, +} from "./types/index.js"; + +export type CreateGatedMintClientParams = { + provider: AnchorProvider; + programId?: PublicKey; +}; + +export class GatedMintClient { + public readonly provider: AnchorProvider; + public readonly program: Program; + public readonly programId: PublicKey; + + constructor(provider: AnchorProvider, programId: PublicKey) { + this.provider = provider; + this.programId = programId; + this.program = new Program( + GatedMintIDL, + programId, + provider, + ); + } + + public static createClient( + params: CreateGatedMintClientParams, + ): GatedMintClient { + const { provider, programId } = params; + return new GatedMintClient( + provider, + programId || GATED_MINT_V0_1_PROGRAM_ID, + ); + } + + async fetchGatedMintConfig( + addr: PublicKey, + ): Promise { + return this.program.account.gatedMintConfig.fetchNullable(addr); + } + + async deserializeGatedMintConfig( + accountInfo: AccountInfo, + ): Promise { + return this.program.coder.accounts.decode( + "gatedMintConfig", + accountInfo.data, + ); + } + + async fetchWhitelistedUser( + addr: PublicKey, + ): Promise { + return this.program.account.whitelistedUser.fetchNullable(addr); + } + + async deserializeWhitelistedUser( + accountInfo: AccountInfo, + ): Promise { + return this.program.coder.accounts.decode( + "whitelistedUser", + accountInfo.data, + ); + } + + initializeGatedMintIx({ + mint, + currentFreezeAuthority, + admin, + payer = this.provider.publicKey, + }: { + mint: PublicKey; + currentFreezeAuthority: PublicKey; + admin: PublicKey; + payer?: PublicKey; + }) { + const [gatedMintConfig] = getGatedMintConfigAddr({ + programId: this.programId, + mint, + }); + + return this.program.methods.initializeGatedMint().accounts({ + mint, + gatedMintConfig, + currentFreezeAuthority, + admin, + payer, + tokenProgram: TOKEN_PROGRAM_ID, + }); + } + + addWhitelistedUserIx({ + mint, + admin, + user, + payer = this.provider.publicKey, + }: { + mint: PublicKey; + admin: PublicKey; + user: PublicKey; + payer?: PublicKey; + }) { + const [gatedMintConfig] = getGatedMintConfigAddr({ + programId: this.programId, + mint, + }); + const [whitelistedUser] = getWhitelistedUserAddr({ + programId: this.programId, + mint, + user, + }); + + return this.program.methods.addWhitelistedUser().accounts({ + gatedMintConfig, + admin, + mint, + user, + whitelistedUser, + payer, + }); + } + + gatedInvokeIx({ + caller, + mint, + instruction, + }: { + caller: PublicKey; + mint: PublicKey; + instruction: TransactionInstruction; + }) { + const [gatedMintConfig] = getGatedMintConfigAddr({ + programId: this.programId, + mint, + }); + const [whitelistedUser] = getWhitelistedUserAddr({ + programId: this.programId, + mint, + user: caller, + }); + + return this.program.methods + .gatedInvoke({ instructionData: instruction.data }) + .accounts({ + caller, + gatedMintConfig, + whitelistedUser, + mint, + targetProgram: instruction.programId, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .remainingAccounts(instruction.keys); + } + + disableGatingIx({ mint, admin }: { mint: PublicKey; admin: PublicKey }) { + const [gatedMintConfig] = getGatedMintConfigAddr({ + programId: this.programId, + mint, + }); + + return this.program.methods.disableGating().accounts({ + gatedMintConfig, + admin, + }); + } + + thawAccountIx({ + mint, + tokenAccount, + }: { + mint: PublicKey; + tokenAccount: PublicKey; + }) { + const [gatedMintConfig] = getGatedMintConfigAddr({ + programId: this.programId, + mint, + }); + + return this.program.methods.thawAccount().accounts({ + gatedMintConfig, + mint, + tokenAccount, + tokenProgram: TOKEN_PROGRAM_ID, + }); + } +} diff --git a/sdk/src/gated_mint/v0.1/index.ts b/sdk/src/gated_mint/v0.1/index.ts new file mode 100644 index 00000000..d62d1ec0 --- /dev/null +++ b/sdk/src/gated_mint/v0.1/index.ts @@ -0,0 +1,3 @@ +export * from "./types/index.js"; +export * from "./pda.js"; +export * from "./GatedMintClient.js"; diff --git a/sdk/src/gated_mint/v0.1/pda.ts b/sdk/src/gated_mint/v0.1/pda.ts new file mode 100644 index 00000000..f34cb35e --- /dev/null +++ b/sdk/src/gated_mint/v0.1/pda.ts @@ -0,0 +1,30 @@ +import { PublicKey } from "@solana/web3.js"; +import { GATED_MINT_V0_1_PROGRAM_ID } from "../../constants.js"; + +export const getGatedMintConfigAddr = ({ + programId = GATED_MINT_V0_1_PROGRAM_ID, + mint, +}: { + programId?: PublicKey; + mint: PublicKey; +}) => { + return PublicKey.findProgramAddressSync( + [Buffer.from("gated_mint_config"), mint.toBuffer()], + programId, + ); +}; + +export const getWhitelistedUserAddr = ({ + programId = GATED_MINT_V0_1_PROGRAM_ID, + mint, + user, +}: { + programId?: PublicKey; + mint: PublicKey; + user: PublicKey; +}) => { + return PublicKey.findProgramAddressSync( + [Buffer.from("whitelisted_user"), mint.toBuffer(), user.toBuffer()], + programId, + ); +}; diff --git a/sdk/src/gated_mint/v0.1/types/gated_mint.ts b/sdk/src/gated_mint/v0.1/types/gated_mint.ts new file mode 100644 index 00000000..0e815740 --- /dev/null +++ b/sdk/src/gated_mint/v0.1/types/gated_mint.ts @@ -0,0 +1,1023 @@ +export type GatedMint = { + version: "0.1.0"; + name: "gated_mint"; + instructions: [ + { + name: "initializeGatedMint"; + accounts: [ + { + name: "mint"; + isMut: true; + isSigner: false; + }, + { + name: "gatedMintConfig"; + isMut: true; + isSigner: false; + }, + { + name: "currentFreezeAuthority"; + isMut: false; + isSigner: true; + }, + { + name: "admin"; + isMut: false; + isSigner: false; + }, + { + name: "payer"; + isMut: true; + isSigner: true; + }, + { + 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: "addWhitelistedUser"; + accounts: [ + { + name: "gatedMintConfig"; + isMut: true; + isSigner: false; + }, + { + name: "admin"; + isMut: false; + isSigner: true; + }, + { + name: "mint"; + isMut: false; + isSigner: false; + }, + { + name: "user"; + isMut: false; + isSigner: false; + }, + { + name: "whitelistedUser"; + isMut: true; + isSigner: false; + }, + { + name: "payer"; + isMut: true; + isSigner: true; + }, + { + name: "systemProgram"; + isMut: false; + isSigner: false; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, + { + name: "gatedInvoke"; + accounts: [ + { + name: "caller"; + isMut: false; + isSigner: true; + }, + { + name: "gatedMintConfig"; + isMut: true; + isSigner: false; + }, + { + name: "whitelistedUser"; + isMut: false; + isSigner: false; + }, + { + name: "mint"; + isMut: false; + isSigner: false; + }, + { + name: "targetProgram"; + 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: "GatedInvokeArgs"; + }; + }, + ]; + }, + { + name: "disableGating"; + accounts: [ + { + name: "gatedMintConfig"; + isMut: true; + isSigner: false; + }, + { + name: "admin"; + isMut: false; + isSigner: true; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, + { + name: "thawAccount"; + accounts: [ + { + name: "gatedMintConfig"; + isMut: true; + isSigner: false; + }, + { + name: "mint"; + isMut: false; + isSigner: false; + }, + { + name: "tokenAccount"; + isMut: true; + isSigner: false; + }, + { + name: "tokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, + ]; + accounts: [ + { + name: "gatedMintConfig"; + type: { + kind: "struct"; + fields: [ + { + name: "mint"; + type: "publicKey"; + }, + { + name: "admin"; + type: "publicKey"; + }, + { + name: "gatingDisabled"; + type: "bool"; + }, + { + name: "seqNum"; + type: "u64"; + }, + { + name: "bump"; + type: "u8"; + }, + ]; + }; + }, + { + name: "whitelistedUser"; + type: { + kind: "struct"; + fields: [ + { + name: "mint"; + type: "publicKey"; + }, + { + name: "user"; + type: "publicKey"; + }, + { + name: "bump"; + type: "u8"; + }, + ]; + }; + }, + ]; + types: [ + { + name: "CommonFields"; + type: { + kind: "struct"; + fields: [ + { + name: "slot"; + type: "u64"; + }, + { + name: "unixTimestamp"; + type: "i64"; + }, + { + name: "gatedMintConfigSeqNum"; + type: "u64"; + }, + ]; + }; + }, + { + name: "GatedInvokeArgs"; + type: { + kind: "struct"; + fields: [ + { + name: "instructionData"; + type: "bytes"; + }, + ]; + }; + }, + ]; + events: [ + { + name: "GatedMintInitializedEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "gatedMintConfig"; + type: "publicKey"; + index: false; + }, + { + name: "mint"; + type: "publicKey"; + index: false; + }, + { + name: "admin"; + type: "publicKey"; + index: false; + }, + { + name: "previousFreezeAuthority"; + type: "publicKey"; + index: false; + }, + { + name: "pdaBump"; + type: "u8"; + index: false; + }, + ]; + }, + { + name: "WhitelistedUserAddedEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "gatedMintConfig"; + type: "publicKey"; + index: false; + }, + { + name: "whitelistedUser"; + type: "publicKey"; + index: false; + }, + { + name: "mint"; + type: "publicKey"; + index: false; + }, + { + name: "user"; + type: "publicKey"; + index: false; + }, + ]; + }, + { + name: "GatedInvokeEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "gatedMintConfig"; + type: "publicKey"; + index: false; + }, + { + name: "mint"; + type: "publicKey"; + index: false; + }, + { + name: "caller"; + type: "publicKey"; + index: false; + }, + { + name: "targetProgram"; + type: "publicKey"; + index: false; + }, + { + name: "thawedCount"; + type: "u32"; + index: false; + }, + { + name: "frozenCount"; + type: "u32"; + index: false; + }, + ]; + }, + { + name: "GatingDisabledEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "gatedMintConfig"; + type: "publicKey"; + index: false; + }, + { + name: "mint"; + type: "publicKey"; + index: false; + }, + ]; + }, + { + name: "AccountThawedEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "gatedMintConfig"; + type: "publicKey"; + index: false; + }, + { + name: "mint"; + type: "publicKey"; + index: false; + }, + { + name: "tokenAccount"; + type: "publicKey"; + index: false; + }, + ]; + }, + ]; + errors: [ + { + code: 6000; + name: "UnauthorizedAdmin"; + msg: "Unauthorized: signer is not the gated mint admin"; + }, + { + code: 6001; + name: "UnauthorizedFreezeAuthority"; + msg: "Unauthorized: signer is not the current freeze authority of the mint"; + }, + { + code: 6002; + name: "MintMismatch"; + msg: "Mint mismatch: account does not match the expected gated mint"; + }, + { + code: 6003; + name: "GatingDisabled"; + msg: "Gating is already disabled for this mint"; + }, + { + code: 6004; + name: "GatingNotDisabled"; + msg: "Gating must be disabled to call this instruction"; + }, + { + code: 6005; + name: "TargetProgramNotWhitelisted"; + msg: "Target program is not on the gated_mint whitelist"; + }, + { + code: 6006; + name: "SelfInvocation"; + msg: "Target program may not be the gated_mint program itself"; + }, + { + code: 6007; + name: "InvalidTokenAccount"; + msg: "Invalid token account: account is not a valid SPL Token account of the gated mint"; + }, + ]; +}; + +export const IDL: GatedMint = { + version: "0.1.0", + name: "gated_mint", + instructions: [ + { + name: "initializeGatedMint", + accounts: [ + { + name: "mint", + isMut: true, + isSigner: false, + }, + { + name: "gatedMintConfig", + isMut: true, + isSigner: false, + }, + { + name: "currentFreezeAuthority", + isMut: false, + isSigner: true, + }, + { + name: "admin", + isMut: false, + isSigner: false, + }, + { + name: "payer", + isMut: true, + isSigner: true, + }, + { + 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: "addWhitelistedUser", + accounts: [ + { + name: "gatedMintConfig", + isMut: true, + isSigner: false, + }, + { + name: "admin", + isMut: false, + isSigner: true, + }, + { + name: "mint", + isMut: false, + isSigner: false, + }, + { + name: "user", + isMut: false, + isSigner: false, + }, + { + name: "whitelistedUser", + isMut: true, + isSigner: false, + }, + { + name: "payer", + isMut: true, + isSigner: true, + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, + { + name: "gatedInvoke", + accounts: [ + { + name: "caller", + isMut: false, + isSigner: true, + }, + { + name: "gatedMintConfig", + isMut: true, + isSigner: false, + }, + { + name: "whitelistedUser", + isMut: false, + isSigner: false, + }, + { + name: "mint", + isMut: false, + isSigner: false, + }, + { + name: "targetProgram", + 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: "GatedInvokeArgs", + }, + }, + ], + }, + { + name: "disableGating", + accounts: [ + { + name: "gatedMintConfig", + isMut: true, + isSigner: false, + }, + { + name: "admin", + isMut: false, + isSigner: true, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, + { + name: "thawAccount", + accounts: [ + { + name: "gatedMintConfig", + isMut: true, + isSigner: false, + }, + { + name: "mint", + isMut: false, + isSigner: false, + }, + { + name: "tokenAccount", + isMut: true, + isSigner: false, + }, + { + name: "tokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, + ], + accounts: [ + { + name: "gatedMintConfig", + type: { + kind: "struct", + fields: [ + { + name: "mint", + type: "publicKey", + }, + { + name: "admin", + type: "publicKey", + }, + { + name: "gatingDisabled", + type: "bool", + }, + { + name: "seqNum", + type: "u64", + }, + { + name: "bump", + type: "u8", + }, + ], + }, + }, + { + name: "whitelistedUser", + type: { + kind: "struct", + fields: [ + { + name: "mint", + type: "publicKey", + }, + { + name: "user", + type: "publicKey", + }, + { + name: "bump", + type: "u8", + }, + ], + }, + }, + ], + types: [ + { + name: "CommonFields", + type: { + kind: "struct", + fields: [ + { + name: "slot", + type: "u64", + }, + { + name: "unixTimestamp", + type: "i64", + }, + { + name: "gatedMintConfigSeqNum", + type: "u64", + }, + ], + }, + }, + { + name: "GatedInvokeArgs", + type: { + kind: "struct", + fields: [ + { + name: "instructionData", + type: "bytes", + }, + ], + }, + }, + ], + events: [ + { + name: "GatedMintInitializedEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "gatedMintConfig", + type: "publicKey", + index: false, + }, + { + name: "mint", + type: "publicKey", + index: false, + }, + { + name: "admin", + type: "publicKey", + index: false, + }, + { + name: "previousFreezeAuthority", + type: "publicKey", + index: false, + }, + { + name: "pdaBump", + type: "u8", + index: false, + }, + ], + }, + { + name: "WhitelistedUserAddedEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "gatedMintConfig", + type: "publicKey", + index: false, + }, + { + name: "whitelistedUser", + type: "publicKey", + index: false, + }, + { + name: "mint", + type: "publicKey", + index: false, + }, + { + name: "user", + type: "publicKey", + index: false, + }, + ], + }, + { + name: "GatedInvokeEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "gatedMintConfig", + type: "publicKey", + index: false, + }, + { + name: "mint", + type: "publicKey", + index: false, + }, + { + name: "caller", + type: "publicKey", + index: false, + }, + { + name: "targetProgram", + type: "publicKey", + index: false, + }, + { + name: "thawedCount", + type: "u32", + index: false, + }, + { + name: "frozenCount", + type: "u32", + index: false, + }, + ], + }, + { + name: "GatingDisabledEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "gatedMintConfig", + type: "publicKey", + index: false, + }, + { + name: "mint", + type: "publicKey", + index: false, + }, + ], + }, + { + name: "AccountThawedEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "gatedMintConfig", + type: "publicKey", + index: false, + }, + { + name: "mint", + type: "publicKey", + index: false, + }, + { + name: "tokenAccount", + type: "publicKey", + index: false, + }, + ], + }, + ], + errors: [ + { + code: 6000, + name: "UnauthorizedAdmin", + msg: "Unauthorized: signer is not the gated mint admin", + }, + { + code: 6001, + name: "UnauthorizedFreezeAuthority", + msg: "Unauthorized: signer is not the current freeze authority of the mint", + }, + { + code: 6002, + name: "MintMismatch", + msg: "Mint mismatch: account does not match the expected gated mint", + }, + { + code: 6003, + name: "GatingDisabled", + msg: "Gating is already disabled for this mint", + }, + { + code: 6004, + name: "GatingNotDisabled", + msg: "Gating must be disabled to call this instruction", + }, + { + code: 6005, + name: "TargetProgramNotWhitelisted", + msg: "Target program is not on the gated_mint whitelist", + }, + { + code: 6006, + name: "SelfInvocation", + msg: "Target program may not be the gated_mint program itself", + }, + { + code: 6007, + name: "InvalidTokenAccount", + msg: "Invalid token account: account is not a valid SPL Token account of the gated mint", + }, + ], +}; diff --git a/sdk/src/gated_mint/v0.1/types/index.ts b/sdk/src/gated_mint/v0.1/types/index.ts new file mode 100644 index 00000000..7f84e3ba --- /dev/null +++ b/sdk/src/gated_mint/v0.1/types/index.ts @@ -0,0 +1,27 @@ +import { IdlAccounts, IdlEvents } from "@coral-xyz/anchor"; +import { + GatedMint as GatedMintProgram, + IDL as GatedMintIDL, +} from "./gated_mint.js"; +export { GatedMintProgram, GatedMintIDL }; + +export type GatedMintConfigAccount = + IdlAccounts["gatedMintConfig"]; +export type WhitelistedUserAccount = + IdlAccounts["whitelistedUser"]; + +export type GatedMintInitializedEvent = + IdlEvents["GatedMintInitializedEvent"]; +export type WhitelistedUserAddedEvent = + IdlEvents["WhitelistedUserAddedEvent"]; +export type GatedInvokeEvent = IdlEvents["GatedInvokeEvent"]; +export type GatingDisabledEvent = + IdlEvents["GatingDisabledEvent"]; +export type AccountThawedEvent = + IdlEvents["AccountThawedEvent"]; +export type GatedMintEvent = + | GatedMintInitializedEvent + | WhitelistedUserAddedEvent + | GatedInvokeEvent + | GatingDisabledEvent + | AccountThawedEvent; diff --git a/sdk/src/index.ts b/sdk/src/index.ts index bd08c833..b909c275 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -2,6 +2,7 @@ export * from "./bid_wall/index.js"; export * from "./conditional_vault/index.js"; export * from "./futarchy/index.js"; +export * from "./gated_mint/index.js"; export * from "./launchpad/index.js"; export * from "./liquidation/index.js"; export * from "./mint_governor/index.js"; diff --git a/sdk/sync-types.sh b/sdk/sync-types.sh index 14088280..d8eb5a75 100755 --- a/sdk/sync-types.sh +++ b/sdk/sync-types.sh @@ -6,6 +6,7 @@ TYPES_DIR="../target/types" cp "$TYPES_DIR/bid_wall.ts" ./src/bid_wall/v0.7/types/ cp "$TYPES_DIR/conditional_vault.ts" ./src/conditional_vault/v0.4/types/ cp "$TYPES_DIR/futarchy.ts" ./src/futarchy/v0.6/types/ +cp "$TYPES_DIR/gated_mint.ts" ./src/gated_mint/v0.1/types/ cp "$TYPES_DIR/launchpad.ts" ./src/launchpad/v0.6/types/ cp "$TYPES_DIR/launchpad_v7.ts" ./src/launchpad/v0.7/types/ cp "$TYPES_DIR/launchpad_v8.ts" ./src/launchpad/v0.8/types/ diff --git a/tests/gatedMint/main.test.ts b/tests/gatedMint/main.test.ts new file mode 100644 index 00000000..08e91bfb --- /dev/null +++ b/tests/gatedMint/main.test.ts @@ -0,0 +1,22 @@ +import initializeGatedMint from "./unit/initializeGatedMint.test.js"; +import addWhitelistedUser from "./unit/addWhitelistedUser.test.js"; +import gatedInvoke from "./unit/gatedInvoke.test.js"; +import disableGating from "./unit/disableGating.test.js"; +import thawAccount from "./unit/thawAccount.test.js"; +import { GatedMintClient } from "@metadaoproject/programs"; +import { BankrunProvider } from "anchor-bankrun"; + +export default function suite() { + before(async function () { + const provider = new BankrunProvider(this.context); + this.gatedMint = GatedMintClient.createClient({ + provider: provider as any, + }); + }); + + describe("#initialize_gated_mint", initializeGatedMint); + describe("#add_whitelisted_user", addWhitelistedUser); + describe("#gated_invoke", gatedInvoke); + describe("#disable_gating", disableGating); + describe("#thaw_account", thawAccount); +} diff --git a/tests/gatedMint/unit/addWhitelistedUser.test.ts b/tests/gatedMint/unit/addWhitelistedUser.test.ts new file mode 100644 index 00000000..a9594711 --- /dev/null +++ b/tests/gatedMint/unit/addWhitelistedUser.test.ts @@ -0,0 +1,164 @@ +import { ComputeBudgetProgram, Keypair, PublicKey } from "@solana/web3.js"; +import { assert } from "chai"; +import { + GatedMintClient, + getWhitelistedUserAddr, +} from "@metadaoproject/programs"; +import { setupGatedMint, whitelistUser } from "../utils.js"; +import { expectError } from "../../utils.js"; + +export default function suite() { + let gatedMintClient: GatedMintClient; + let admin: Keypair; + let mint: PublicKey; + let gatedMintConfig: PublicKey; + + before(async function () { + gatedMintClient = this.gatedMint; + }); + + beforeEach(async function () { + admin = Keypair.generate(); + ({ mint, gatedMintConfig } = await setupGatedMint( + this.banksClient, + gatedMintClient, + this.payer, + admin.publicKey, + )); + }); + + it("admin successfully whitelists a new user (payer != admin)", async function () { + const user = Keypair.generate().publicKey; + const [expectedAddr, expectedBump] = getWhitelistedUserAddr({ mint, user }); + + await gatedMintClient + .addWhitelistedUserIx({ + mint, + admin: admin.publicKey, + user, + payer: this.payer.publicKey, + }) + .signers([admin]) + .rpc(); + + const wu = await gatedMintClient.fetchWhitelistedUser(expectedAddr); + + assert.isNotNull(wu); + assert.equal(wu.mint.toBase58(), mint.toBase58()); + assert.equal(wu.user.toBase58(), user.toBase58()); + assert.equal(wu.bump, expectedBump); + + const cfg = await gatedMintClient.fetchGatedMintConfig(gatedMintConfig); + assert.equal(cfg.seqNum.toString(), "1"); + }); + + it("fails when signer is not the admin", async function () { + const fakeAdmin = Keypair.generate(); + const user = Keypair.generate().publicKey; + + const callbacks = expectError( + "UnauthorizedAdmin", + "Should have failed because signer is not the admin", + ); + + await gatedMintClient + .addWhitelistedUserIx({ + mint, + admin: fakeAdmin.publicKey, + user, + payer: this.payer.publicKey, + }) + .signers([fakeAdmin]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("fails when re-adding an existing user", async function () { + const user = Keypair.generate().publicKey; + + await whitelistUser(gatedMintClient, mint, admin, user, this.payer); + + try { + await gatedMintClient + .addWhitelistedUserIx({ + mint, + admin: admin.publicKey, + user, + payer: this.payer.publicKey, + }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_001 }), + ]) + .signers([admin]) + .rpc(); + + assert.fail("Should have failed because whitelisted_user already exists"); + } catch (e) { + assert.include(e.message, "custom program error: 0x0"); + } + }); + + it("fails after gating is disabled", async function () { + await gatedMintClient + .disableGatingIx({ mint, admin: admin.publicKey }) + .signers([admin]) + .rpc(); + + const user = Keypair.generate().publicKey; + + const callbacks = expectError( + "GatingDisabled", + "Should have failed because gating is disabled", + ); + + await gatedMintClient + .addWhitelistedUserIx({ + mint, + admin: admin.publicKey, + user, + payer: this.payer.publicKey, + }) + .signers([admin]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("distinct mints have independent whitelists", async function () { + const otherAdmin = Keypair.generate(); + const { mint: otherMint } = await setupGatedMint( + this.banksClient, + gatedMintClient, + this.payer, + otherAdmin.publicKey, + ); + + const user = Keypair.generate().publicKey; + + const wuAddrA = await whitelistUser( + gatedMintClient, + mint, + admin, + user, + this.payer, + ); + const wuAddrB = await whitelistUser( + gatedMintClient, + otherMint, + otherAdmin, + user, + this.payer, + ); + + assert.notEqual(wuAddrA.toBase58(), wuAddrB.toBase58()); + + const wuA = await gatedMintClient.fetchWhitelistedUser(wuAddrA); + const wuB = await gatedMintClient.fetchWhitelistedUser(wuAddrB); + + assert.isNotNull(wuA); + assert.isNotNull(wuB); + assert.equal(wuA.mint.toBase58(), mint.toBase58()); + assert.equal(wuB.mint.toBase58(), otherMint.toBase58()); + assert.equal(wuA.user.toBase58(), user.toBase58()); + assert.equal(wuB.user.toBase58(), user.toBase58()); + }); +} diff --git a/tests/gatedMint/unit/disableGating.test.ts b/tests/gatedMint/unit/disableGating.test.ts new file mode 100644 index 00000000..82a74d4a --- /dev/null +++ b/tests/gatedMint/unit/disableGating.test.ts @@ -0,0 +1,73 @@ +import { ComputeBudgetProgram, Keypair, PublicKey } from "@solana/web3.js"; +import { assert } from "chai"; +import { GatedMintClient } from "@metadaoproject/programs"; +import { setupGatedMint } from "../utils.js"; +import { expectError } from "../../utils.js"; + +export default function suite() { + let gatedMintClient: GatedMintClient; + let admin: Keypair; + let mint: PublicKey; + let gatedMintConfig: PublicKey; + + before(async function () { + gatedMintClient = this.gatedMint; + }); + + beforeEach(async function () { + admin = Keypair.generate(); + ({ mint, gatedMintConfig } = await setupGatedMint( + this.banksClient, + gatedMintClient, + this.payer, + admin.publicKey, + )); + }); + + it("admin successfully disables gating", async function () { + await gatedMintClient + .disableGatingIx({ mint, admin: admin.publicKey }) + .signers([admin]) + .rpc(); + + const cfg = await gatedMintClient.fetchGatedMintConfig(gatedMintConfig); + assert.equal(cfg.gatingDisabled, true); + assert.equal(cfg.seqNum.toString(), "1"); + }); + + it("fails when signer is not the admin", async function () { + const fakeAdmin = Keypair.generate(); + + const callbacks = expectError( + "UnauthorizedAdmin", + "Should have failed because signer is not the admin", + ); + + await gatedMintClient + .disableGatingIx({ mint, admin: fakeAdmin.publicKey }) + .signers([fakeAdmin]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("fails when disabling twice", async function () { + await gatedMintClient + .disableGatingIx({ mint, admin: admin.publicKey }) + .signers([admin]) + .rpc(); + + const callbacks = expectError( + "GatingDisabled", + "Should have failed because gating is already disabled", + ); + + await gatedMintClient + .disableGatingIx({ mint, admin: admin.publicKey }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_001 }), + ]) + .signers([admin]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); +} diff --git a/tests/gatedMint/unit/gatedInvoke.test.ts b/tests/gatedMint/unit/gatedInvoke.test.ts new file mode 100644 index 00000000..6e88d2e1 --- /dev/null +++ b/tests/gatedMint/unit/gatedInvoke.test.ts @@ -0,0 +1,471 @@ +import { + ComputeBudgetProgram, + Keypair, + PublicKey, + SystemProgram, + TransactionInstruction, +} from "@solana/web3.js"; +import * as token from "@solana/spl-token"; +import { assert } from "chai"; +import BN from "bn.js"; +import { + GATED_MINT_V0_1_PROGRAM_ID, + GatedMintClient, + MintGovernorClient, + MAINNET_USDC, + getGatedMintConfigAddr, + getWhitelistedUserAddr, +} from "@metadaoproject/programs"; +import { + createMintWithFreezeAuthority, + setupGatedMint, + whitelistUser, +} from "../utils.js"; +import { initializeMintGovernorWithDefaults } from "../../mintGovernor/utils.js"; +import { expectError } from "../../utils.js"; +import { mintTo } from "spl-token-bankrun"; + +const TOKEN_ACCOUNT_STATE_OFFSET = 108; +const TOKEN_STATE_INITIALIZED = 1; +const TOKEN_STATE_FROZEN = 2; + +async function getTokenAccountState( + banksClient: any, + tokenAccount: PublicKey, +): Promise { + const acc = await banksClient.getAccount(tokenAccount); + return acc.data[TOKEN_ACCOUNT_STATE_OFFSET]; +} + +async function forceFreezeAccount( + context: any, + banksClient: any, + tokenAccount: PublicKey, +): Promise { + const acc = await banksClient.getAccount(tokenAccount); + const data = Buffer.from(acc.data); + data[TOKEN_ACCOUNT_STATE_OFFSET] = TOKEN_STATE_FROZEN; + context.setAccount(tokenAccount, { + data, + executable: acc.executable, + owner: acc.owner, + lamports: acc.lamports, + }); +} + +export default function suite() { + let gatedMintClient: GatedMintClient; + let mintGovernorClient: MintGovernorClient; + let admin: Keypair; + let alice: Keypair; + let bob: Keypair; + let mint: PublicKey; + let aliceAta: PublicKey; + let bobAta: PublicKey; + + before(async function () { + gatedMintClient = this.gatedMint; + mintGovernorClient = MintGovernorClient.createClient({ + provider: this.provider, + }); + }); + + beforeEach(async function () { + admin = Keypair.generate(); + alice = Keypair.generate(); + bob = Keypair.generate(); + + ({ mint } = await setupGatedMint( + this.banksClient, + gatedMintClient, + this.payer, + admin.publicKey, + )); + + await whitelistUser( + gatedMintClient, + mint, + admin, + alice.publicKey, + this.payer, + ); + await whitelistUser( + gatedMintClient, + mint, + admin, + bob.publicKey, + this.payer, + ); + + aliceAta = await this.createTokenAccount(mint, alice.publicKey); + bobAta = await this.createTokenAccount(mint, bob.publicKey); + + await mintTo( + this.banksClient, + this.payer, + mint, + aliceAta, + this.payer, + 100_000_000n, + ); + + await forceFreezeAccount(this.context, this.banksClient, aliceAta); + await forceFreezeAccount(this.context, this.banksClient, bobAta); + }); + + it("transfers between whitelisted users with both ATAs frozen post-CPI", async function () { + const transferIx = token.createTransferInstruction( + aliceAta, + bobAta, + alice.publicKey, + 50_000_000n, + ); + + await gatedMintClient + .gatedInvokeIx({ + caller: alice.publicKey, + mint, + instruction: transferIx, + }) + .signers([alice]) + .rpc(); + + const aliceBalance = await this.getTokenBalance(mint, alice.publicKey); + const bobBalance = await this.getTokenBalance(mint, bob.publicKey); + assert.equal(aliceBalance.toString(), "50000000"); + assert.equal(bobBalance.toString(), "50000000"); + + assert.equal( + await getTokenAccountState(this.banksClient, aliceAta), + TOKEN_STATE_FROZEN, + ); + assert.equal( + await getTokenAccountState(this.banksClient, bobAta), + TOKEN_STATE_FROZEN, + ); + }); + + it("freezes a freshly-created ATA after gated_invoke(mint_governor::mint_tokens)", async function () { + const newMint = await createMintWithFreezeAuthority( + this.banksClient, + this.payer, + this.payer.publicKey, + this.payer.publicKey, + ); + + const newAdmin = Keypair.generate(); + await gatedMintClient + .initializeGatedMintIx({ + mint: newMint, + currentFreezeAuthority: this.payer.publicKey, + admin: newAdmin.publicKey, + payer: this.payer.publicKey, + }) + .rpc(); + + await whitelistUser( + gatedMintClient, + newMint, + newAdmin, + alice.publicKey, + this.payer, + ); + + const { mintGovernor } = await initializeMintGovernorWithDefaults( + this.banksClient, + mintGovernorClient, + this.payer, + newMint, + ); + + await mintGovernorClient + .transferAuthorityToGovernorIx({ + mintGovernor, + mint: newMint, + currentAuthority: this.payer.publicKey, + }) + .rpc(); + + await mintGovernorClient + .addMintAuthorityIx({ + mintGovernor, + admin: this.payer.publicKey, + authorizedMinter: alice.publicKey, + maxTotal: null, + }) + .rpc(); + + const recipientAta = token.getAssociatedTokenAddressSync( + newMint, + bob.publicKey, + ); + const createAtaIx = token.createAssociatedTokenAccountIdempotentInstruction( + this.payer.publicKey, + recipientAta, + bob.publicKey, + newMint, + ); + + const mintTokensIx = await mintGovernorClient + .mintTokensIx({ + mintGovernor, + mint: newMint, + destinationAta: recipientAta, + authorizedMinter: alice.publicKey, + amount: new BN(100_000_000), + }) + .instruction(); + + await gatedMintClient + .gatedInvokeIx({ + caller: alice.publicKey, + mint: newMint, + instruction: mintTokensIx, + }) + .preInstructions([createAtaIx]) + .signers([alice]) + .rpc(); + + const balance = await this.getTokenBalance(newMint, bob.publicKey); + assert.equal(balance.toString(), "100000000"); + + assert.equal( + await getTokenAccountState(this.banksClient, recipientAta), + TOKEN_STATE_FROZEN, + ); + }); + + it("handles aliased duplicate accounts in remaining_accounts", async function () { + const transferIx = token.createTransferInstruction( + aliceAta, + bobAta, + alice.publicKey, + 50_000_000n, + ); + + const aliasedAccounts = [ + ...transferIx.keys, + { pubkey: aliceAta, isSigner: false, isWritable: true }, + ]; + + const [gatedMintConfig] = getGatedMintConfigAddr({ mint }); + const [whitelistedUser] = getWhitelistedUserAddr({ + mint, + user: alice.publicKey, + }); + + await gatedMintClient.program.methods + .gatedInvoke({ instructionData: transferIx.data }) + .accounts({ + caller: alice.publicKey, + gatedMintConfig, + whitelistedUser, + mint, + targetProgram: transferIx.programId, + tokenProgram: token.TOKEN_PROGRAM_ID, + }) + .remainingAccounts(aliasedAccounts) + .signers([alice]) + .rpc(); + + assert.equal( + await getTokenAccountState(this.banksClient, aliceAta), + TOKEN_STATE_FROZEN, + ); + assert.equal( + await getTokenAccountState(this.banksClient, bobAta), + TOKEN_STATE_FROZEN, + ); + + const aliceBalance = await this.getTokenBalance(mint, alice.publicKey); + assert.equal(aliceBalance.toString(), "50000000"); + }); + + it("does not touch non-gated-mint accounts in remaining_accounts", async function () { + const usdcAta = token.getAssociatedTokenAddressSync( + MAINNET_USDC, + this.payer.publicKey, + ); + + const usdcStateBefore = await getTokenAccountState( + this.banksClient, + usdcAta, + ); + assert.equal(usdcStateBefore, TOKEN_STATE_INITIALIZED); + + const transferIx = token.createTransferInstruction( + aliceAta, + bobAta, + alice.publicKey, + 50_000_000n, + ); + + const accountsWithUsdc = [ + ...transferIx.keys, + { pubkey: usdcAta, isSigner: false, isWritable: false }, + ]; + + const [gatedMintConfig] = getGatedMintConfigAddr({ mint }); + const [whitelistedUser] = getWhitelistedUserAddr({ + mint, + user: alice.publicKey, + }); + + await gatedMintClient.program.methods + .gatedInvoke({ instructionData: transferIx.data }) + .accounts({ + caller: alice.publicKey, + gatedMintConfig, + whitelistedUser, + mint, + targetProgram: transferIx.programId, + tokenProgram: token.TOKEN_PROGRAM_ID, + }) + .remainingAccounts(accountsWithUsdc) + .signers([alice]) + .rpc(); + + const usdcStateAfter = await getTokenAccountState( + this.banksClient, + usdcAta, + ); + assert.equal(usdcStateAfter, TOKEN_STATE_INITIALIZED); + }); + + it("fails when target_program is not in the whitelist", async function () { + const transferIx = SystemProgram.transfer({ + fromPubkey: alice.publicKey, + toPubkey: bob.publicKey, + lamports: 1_000, + }); + + const callbacks = expectError( + "TargetProgramNotWhitelisted", + "Should have failed because system_program is not whitelisted", + ); + + await gatedMintClient + .gatedInvokeIx({ + caller: alice.publicKey, + mint, + instruction: transferIx, + }) + .signers([alice]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("fails when caller is not whitelisted", async function () { + const stranger = Keypair.generate(); + const strangerAta = await this.createTokenAccount(mint, stranger.publicKey); + + const transferIx = token.createTransferInstruction( + strangerAta, + bobAta, + stranger.publicKey, + 0n, + ); + + try { + await gatedMintClient + .gatedInvokeIx({ + caller: stranger.publicKey, + mint, + instruction: transferIx, + }) + .signers([stranger]) + .rpc(); + assert.fail("Should have failed because caller is not whitelisted"); + } catch (e) { + assert.include(e.message, "AccountNotInitialized"); + } + }); + + it("fails when caller is whitelisted for a different mint", async function () { + const otherAdmin = Keypair.generate(); + const { mint: otherMint } = await setupGatedMint( + this.banksClient, + gatedMintClient, + this.payer, + otherAdmin.publicKey, + ); + + const transferIx = token.createTransferInstruction( + aliceAta, + bobAta, + alice.publicKey, + 50_000_000n, + ); + + try { + await gatedMintClient + .gatedInvokeIx({ + caller: alice.publicKey, + mint: otherMint, + instruction: transferIx, + }) + .signers([alice]) + .rpc(); + assert.fail( + "Should have failed because caller is not whitelisted for this mint", + ); + } catch (e) { + assert.include(e.message, "AccountNotInitialized"); + } + }); + + it("fails when target_program == gated_mint::ID", async function () { + const dummyIx = new TransactionInstruction({ + programId: GATED_MINT_V0_1_PROGRAM_ID, + keys: [], + data: Buffer.from([]), + }); + + const callbacks = expectError( + "TargetProgramNotWhitelisted", + "Should have failed because target is gated_mint::ID", + ); + + await gatedMintClient + .gatedInvokeIx({ + caller: alice.publicKey, + mint, + instruction: dummyIx, + }) + .signers([alice]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("fails when gating is disabled", async function () { + await gatedMintClient + .disableGatingIx({ mint, admin: admin.publicKey }) + .signers([admin]) + .rpc(); + + const transferIx = token.createTransferInstruction( + aliceAta, + bobAta, + alice.publicKey, + 50_000_000n, + ); + + const callbacks = expectError( + "GatingDisabled", + "Should have failed because gating is disabled", + ); + + await gatedMintClient + .gatedInvokeIx({ + caller: alice.publicKey, + mint, + instruction: transferIx, + }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_001 }), + ]) + .signers([alice]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); +} diff --git a/tests/gatedMint/unit/initializeGatedMint.test.ts b/tests/gatedMint/unit/initializeGatedMint.test.ts new file mode 100644 index 00000000..2a39615a --- /dev/null +++ b/tests/gatedMint/unit/initializeGatedMint.test.ts @@ -0,0 +1,139 @@ +import { ComputeBudgetProgram, Keypair, PublicKey } from "@solana/web3.js"; +import * as token from "@solana/spl-token"; +import { assert } from "chai"; +import { + GatedMintClient, + getGatedMintConfigAddr, +} from "@metadaoproject/programs"; +import { createMintWithFreezeAuthority } from "../utils.js"; +import { createMintWithAuthority } from "../../mintGovernor/utils.js"; +import { expectError } from "../../utils.js"; + +export default function suite() { + let gatedMintClient: GatedMintClient; + let mint: PublicKey; + + before(async function () { + gatedMintClient = this.gatedMint; + }); + + beforeEach(async function () { + mint = await createMintWithFreezeAuthority( + this.banksClient, + this.payer, + this.payer.publicKey, + this.payer.publicKey, + 6, + ); + }); + + it("successfully initializes a gated mint", async function () { + const adminKey = Keypair.generate().publicKey; + const [gatedMintConfig, expectedBump] = getGatedMintConfigAddr({ mint }); + + await gatedMintClient + .initializeGatedMintIx({ + mint, + currentFreezeAuthority: this.payer.publicKey, + admin: adminKey, + payer: this.payer.publicKey, + }) + .rpc(); + + const cfg = await gatedMintClient.fetchGatedMintConfig(gatedMintConfig); + + assert.isNotNull(cfg); + assert.equal(cfg.mint.toBase58(), mint.toBase58()); + assert.equal(cfg.admin.toBase58(), adminKey.toBase58()); + assert.equal(cfg.gatingDisabled, false); + assert.equal(cfg.seqNum.toString(), "0"); + assert.equal(cfg.bump, expectedBump); + + const mintAccount = await this.banksClient.getAccount(mint); + const mintInfo = token.unpackMint(mint, { + data: Buffer.from(mintAccount.data), + owner: token.TOKEN_PROGRAM_ID, + executable: false, + lamports: mintAccount.lamports, + }); + assert.equal( + mintInfo.freezeAuthority.toBase58(), + gatedMintConfig.toBase58(), + ); + }); + + it("fails when mint has no freeze authority", async function () { + const noFreezeMint = await createMintWithAuthority( + this.banksClient, + this.payer, + this.payer.publicKey, + 6, + ); + + const callbacks = expectError( + "UnauthorizedFreezeAuthority", + "Should have failed because mint has no freeze authority", + ); + + await gatedMintClient + .initializeGatedMintIx({ + mint: noFreezeMint, + currentFreezeAuthority: this.payer.publicKey, + admin: this.payer.publicKey, + payer: this.payer.publicKey, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("fails when signer is not the current freeze authority", async function () { + const wrongSigner = Keypair.generate(); + + const callbacks = expectError( + "UnauthorizedFreezeAuthority", + "Should have failed because signer is not the current freeze authority", + ); + + await gatedMintClient + .initializeGatedMintIx({ + mint, + currentFreezeAuthority: wrongSigner.publicKey, + admin: this.payer.publicKey, + payer: this.payer.publicKey, + }) + .signers([wrongSigner]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("fails when re-initializing the same mint", async function () { + await gatedMintClient + .initializeGatedMintIx({ + mint, + currentFreezeAuthority: this.payer.publicKey, + admin: this.payer.publicKey, + payer: this.payer.publicKey, + }) + .rpc(); + + try { + await gatedMintClient + .initializeGatedMintIx({ + mint, + currentFreezeAuthority: this.payer.publicKey, + admin: this.payer.publicKey, + payer: this.payer.publicKey, + }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_001 }), + ]) + .rpc(); + + assert.fail( + "Should have failed because gated_mint_config already exists", + ); + } catch (e) { + assert.include(e.message, "custom program error: 0x0"); + } + }); +} diff --git a/tests/gatedMint/unit/thawAccount.test.ts b/tests/gatedMint/unit/thawAccount.test.ts new file mode 100644 index 00000000..c1c1d632 --- /dev/null +++ b/tests/gatedMint/unit/thawAccount.test.ts @@ -0,0 +1,139 @@ +import { ComputeBudgetProgram, Keypair, PublicKey } from "@solana/web3.js"; +import { assert } from "chai"; +import { GatedMintClient } from "@metadaoproject/programs"; +import { setupGatedMint } from "../utils.js"; +import { expectError } from "../../utils.js"; + +const TOKEN_ACCOUNT_STATE_OFFSET = 108; +const TOKEN_STATE_INITIALIZED = 1; +const TOKEN_STATE_FROZEN = 2; + +async function forceFreezeAccount( + context: any, + banksClient: any, + tokenAccount: PublicKey, +) { + const acc = await banksClient.getAccount(tokenAccount); + const data = Buffer.from(acc.data); + data[TOKEN_ACCOUNT_STATE_OFFSET] = TOKEN_STATE_FROZEN; + context.setAccount(tokenAccount, { + data, + executable: acc.executable, + owner: acc.owner, + lamports: acc.lamports, + }); +} + +async function getTokenAccountState( + banksClient: any, + tokenAccount: PublicKey, +): Promise { + const acc = await banksClient.getAccount(tokenAccount); + return acc.data[TOKEN_ACCOUNT_STATE_OFFSET]; +} + +export default function suite() { + let gatedMintClient: GatedMintClient; + let admin: Keypair; + let mint: PublicKey; + let gatedMintConfig: PublicKey; + let tokenAccount: PublicKey; + + before(async function () { + gatedMintClient = this.gatedMint; + }); + + beforeEach(async function () { + admin = Keypair.generate(); + ({ mint, gatedMintConfig } = await setupGatedMint( + this.banksClient, + gatedMintClient, + this.payer, + admin.publicKey, + )); + + tokenAccount = await this.createTokenAccount(mint, this.payer.publicKey); + await forceFreezeAccount(this.context, this.banksClient, tokenAccount); + }); + + it("fails before disable_gating", async function () { + const callbacks = expectError( + "GatingNotDisabled", + "Should have failed because gating is not disabled", + ); + + await gatedMintClient + .thawAccountIx({ mint, tokenAccount }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("permissionless caller can thaw after disable_gating", async function () { + await gatedMintClient + .disableGatingIx({ mint, admin: admin.publicKey }) + .signers([admin]) + .rpc(); + + const stateBefore = await getTokenAccountState( + this.banksClient, + tokenAccount, + ); + assert.equal(stateBefore, TOKEN_STATE_FROZEN); + + await gatedMintClient.thawAccountIx({ mint, tokenAccount }).rpc(); + + const stateAfter = await getTokenAccountState( + this.banksClient, + tokenAccount, + ); + assert.equal(stateAfter, TOKEN_STATE_INITIALIZED); + + const cfg = await gatedMintClient.fetchGatedMintConfig(gatedMintConfig); + assert.equal(cfg.seqNum.toString(), "2"); + }); + + it("returns SPL token error when thawing already-thawed account", async function () { + await gatedMintClient + .disableGatingIx({ mint, admin: admin.publicKey }) + .signers([admin]) + .rpc(); + + await gatedMintClient.thawAccountIx({ mint, tokenAccount }).rpc(); + + try { + await gatedMintClient + .thawAccountIx({ mint, tokenAccount }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_001 }), + ]) + .rpc(); + + assert.fail("Should have failed because account is already thawed"); + } catch (e) { + assert.include(e.message, "custom program error"); + } + }); + + it("fails when token_account.mint does not match", async function () { + const otherMint = await this.createMint(this.payer.publicKey, 6); + const otherTokenAccount = await this.createTokenAccount( + otherMint, + this.payer.publicKey, + ); + + await gatedMintClient + .disableGatingIx({ mint, admin: admin.publicKey }) + .signers([admin]) + .rpc(); + + const callbacks = expectError( + "MintMismatch", + "Should have failed because token account mint does not match gated mint", + ); + + await gatedMintClient + .thawAccountIx({ mint, tokenAccount: otherTokenAccount }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); +} diff --git a/tests/gatedMint/utils.ts b/tests/gatedMint/utils.ts new file mode 100644 index 00000000..ca7908fb --- /dev/null +++ b/tests/gatedMint/utils.ts @@ -0,0 +1,115 @@ +import { + PublicKey, + Keypair, + Transaction, + SystemProgram, +} from "@solana/web3.js"; +import * as token from "@solana/spl-token"; +import { BanksClient } from "solana-bankrun"; +import { + GatedMintClient, + getGatedMintConfigAddr, + getWhitelistedUserAddr, +} from "@metadaoproject/programs"; + +export async function createMintWithFreezeAuthority( + banksClient: BanksClient, + payer: Keypair, + mintAuthority: PublicKey, + freezeAuthority: PublicKey, + decimals: number = 6, +): Promise { + const mintKeypair = Keypair.generate(); + const rent = await banksClient.getRent(); + const lamports = Number(rent.minimumBalance(BigInt(token.MINT_SIZE))); + + const tx = new Transaction().add( + SystemProgram.createAccount({ + fromPubkey: payer.publicKey, + newAccountPubkey: mintKeypair.publicKey, + lamports, + 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; +} + +export async function setupGatedMint( + banksClient: BanksClient, + gatedMintClient: GatedMintClient, + payer: Keypair, + admin: PublicKey = payer.publicKey, + decimals: number = 6, +): Promise<{ + mint: PublicKey; + gatedMintConfig: PublicKey; + admin: PublicKey; +}> { + const mint = await createMintWithFreezeAuthority( + banksClient, + payer, + payer.publicKey, + payer.publicKey, + decimals, + ); + + await gatedMintClient + .initializeGatedMintIx({ + mint, + currentFreezeAuthority: payer.publicKey, + admin, + payer: payer.publicKey, + }) + .rpc(); + + const [gatedMintConfig] = getGatedMintConfigAddr({ mint }); + + return { mint, gatedMintConfig, admin }; +} + +export async function whitelistUser( + gatedMintClient: GatedMintClient, + mint: PublicKey, + admin: Keypair, + user: PublicKey, + payer: Keypair, +): Promise { + const providerKey = gatedMintClient.provider.publicKey; + const signers: Keypair[] = []; + if (!admin.publicKey.equals(providerKey)) { + signers.push(admin); + } + if ( + !payer.publicKey.equals(providerKey) && + !payer.publicKey.equals(admin.publicKey) + ) { + signers.push(payer); + } + + await gatedMintClient + .addWhitelistedUserIx({ + mint, + admin: admin.publicKey, + user, + payer: payer.publicKey, + }) + .signers(signers) + .rpc(); + + const [whitelistedUser] = getWhitelistedUserAddr({ mint, user }); + return whitelistedUser; +} diff --git a/tests/integration/gatedLaunchpadV8.test.ts b/tests/integration/gatedLaunchpadV8.test.ts new file mode 100644 index 00000000..c2a5eb6c --- /dev/null +++ b/tests/integration/gatedLaunchpadV8.test.ts @@ -0,0 +1,590 @@ +import { + ComputeBudgetProgram, + 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 { + DAMM_V2_PROGRAM_ID, + FutarchyClient, + GatedMintClient, + MAINNET_USDC, + LAUNCHPAD_V0_8_PROGRAM_ID, + LAUNCHPAD_V0_8_MAINNET_METEORA_CONFIG, + getGatedMintConfigAddr, +} from "@metadaoproject/programs"; +import { + LaunchpadClient, + getLaunchAddr, + getLaunchSignerAddr, +} from "@metadaoproject/programs/launchpad/v0.8"; +import { CpAmm } from "@meteora-ag/cp-amm-sdk"; +import { whitelistUser } from "../gatedMint/utils.js"; +import { createLookupTableForTransaction } from "../utils.js"; + +const TOKEN_ACCOUNT_STATE_OFFSET = 108; +const TOKEN_STATE_INITIALIZED = 1; +const TOKEN_STATE_FROZEN = 2; + +async function getTokenAccountState( + banksClient: any, + addr: PublicKey, +): Promise { + const acc = await banksClient.getAccount(addr); + return acc.data[TOKEN_ACCOUNT_STATE_OFFSET]; +} + +export default async function suite() { + before(async function () { + const dynamicConfig = await this.banksClient.getAccount( + new PublicKey("4mPQ4VuvvtYL3CeMPt14Uj1CLpBWcVdJoLoTH9ea4Kod"), + ); + + const poolCreatorAuthorityOffset = 8 + 32; + const configTypeOffset = 8 + 32 + 32 + 128 + 1 + 1; + + const [poolCreatorAuthority] = PublicKey.findProgramAddressSync( + [Buffer.from("damm_pool_creator_authority")], + LAUNCHPAD_V0_8_PROGRAM_ID, + ); + + dynamicConfig.data.set( + poolCreatorAuthority.toBuffer(), + poolCreatorAuthorityOffset, + ); + dynamicConfig.data.set([1], configTypeOffset); + + this.context.setAccount( + LAUNCHPAD_V0_8_MAINNET_METEORA_CONFIG, + dynamicConfig, + ); + }); + + it("gated launchpad v8 lifecycle: init → start → fund → settle → claim → disable → thaw", async function () { + const launchpadClient: LaunchpadClient = this.launchpad_v8; + const gatedMintClient: GatedMintClient = GatedMintClient.createClient({ + provider: this.provider, + }); + + const gatedMintAdmin = Keypair.generate(); + const launchAuthority = Keypair.generate(); + const funder1 = Keypair.generate(); + + // Fund operator keypairs with SOL — they pay for inner-ix account + // creation (launch, vaults, mint_governor, funding_record). + const fundSol = async (recipient: PublicKey, lamports: number) => { + const tx = new Transaction().add( + SystemProgram.transfer({ + fromPubkey: this.payer.publicKey, + toPubkey: recipient, + lamports, + }), + ); + tx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + tx.feePayer = this.payer.publicKey; + tx.sign(this.payer); + await this.banksClient.processTransaction(tx); + }; + await fundSol(gatedMintAdmin.publicKey, 5_000_000_000); + await fundSol(launchAuthority.publicKey, 5_000_000_000); + await fundSol(funder1.publicKey, 1_000_000_000); + + const minRaise = new BN(300_000 * 10 ** 6); + const launchPeriod = 60 * 60 * 24 * 2; + + // ===================== + // Setup: create base mint with mint authority = launchSigner, freeze authority = payer + // ===================== + const mintKeypair = Keypair.generate(); + const META = mintKeypair.publicKey; + const [launch] = getLaunchAddr(launchpadClient.getProgramId(), META); + const [launchSigner] = getLaunchSignerAddr( + launchpadClient.getProgramId(), + launch, + ); + + const rent = await this.banksClient.getRent(); + const mintLamports = Number(rent.minimumBalance(BigInt(token.MINT_SIZE))); + + const createMintTx = new Transaction().add( + SystemProgram.createAccount({ + fromPubkey: this.payer.publicKey, + newAccountPubkey: META, + lamports: mintLamports, + space: token.MINT_SIZE, + programId: token.TOKEN_PROGRAM_ID, + }), + token.createInitializeMint2Instruction( + META, + 6, + launchSigner, + this.payer.publicKey, + ), + ); + createMintTx.recentBlockhash = ( + await this.banksClient.getLatestBlockhash() + )[0]; + createMintTx.feePayer = this.payer.publicKey; + createMintTx.sign(this.payer, mintKeypair); + await this.banksClient.processTransaction(createMintTx); + + // ===================== + // initialize_gated_mint — freeze authority moves from payer → gated_mint_config PDA + // ===================== + await gatedMintClient + .initializeGatedMintIx({ + mint: META, + currentFreezeAuthority: this.payer.publicKey, + admin: gatedMintAdmin.publicKey, + payer: this.payer.publicKey, + }) + .rpc(); + + const [gatedMintConfig] = getGatedMintConfigAddr({ mint: META }); + const mintAfterInit = await this.getMint(META); + assert.ok(mintAfterInit.freezeAuthority.equals(gatedMintConfig)); + + // ===================== + // add_whitelisted_user × 3 + // ===================== + await whitelistUser( + gatedMintClient, + META, + gatedMintAdmin, + gatedMintAdmin.publicKey, + this.payer, + ); + await whitelistUser( + gatedMintClient, + META, + gatedMintAdmin, + launchAuthority.publicKey, + this.payer, + ); + await whitelistUser( + gatedMintClient, + META, + gatedMintAdmin, + funder1.publicKey, + this.payer, + ); + + // ===================== + // gated_invoke(initialize_launch) — caller = metadaoOps + // ===================== + const initLaunchIx = await launchpadClient + .initializeLaunchIx({ + tokenName: "META", + tokenSymbol: "META", + tokenUri: "https://example.com", + minimumRaiseAmount: minRaise, + secondsForLaunch: launchPeriod, + baseMint: META, + quoteMint: MAINNET_USDC, + monthlySpendingLimitAmount: new BN(25_000 * 10 ** 6), + monthlySpendingLimitMembers: [gatedMintAdmin.publicKey], + performancePackageGrantee: gatedMintAdmin.publicKey, + performancePackageTokenAmount: new BN(5_000_000 * 10 ** 6), + monthsUntilInsidersCanUnlock: 24, + teamAddress: PublicKey.default, + launchAuthority: launchAuthority.publicKey, + additionalTokensAmount: new BN(0), + hasBidWall: false, + payer: gatedMintAdmin.publicKey, + }) + .instruction(); + + const launchBaseVault = token.getAssociatedTokenAddressSync( + META, + launchSigner, + true, + ); + + await gatedMintClient + .gatedInvokeIx({ + caller: gatedMintAdmin.publicKey, + mint: META, + instruction: initLaunchIx, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 400_000 }), + ]) + .signers([gatedMintAdmin]) + .rpc(); + + let launchAccount = await launchpadClient.fetchLaunch(launch); + assert.deepEqual(launchAccount.state, { initialized: {} }); + + assert.equal( + await getTokenAccountState(this.banksClient, launchBaseVault), + TOKEN_STATE_FROZEN, + ); + + // ===================== + // start_launch (direct) + // ===================== + await launchpadClient + .startLaunchIx({ + launch, + launchAuthority: launchAuthority.publicKey, + }) + .signers([launchAuthority]) + .rpc(); + + launchAccount = await launchpadClient.fetchLaunch(launch); + assert.deepEqual(launchAccount.state, { live: {} }); + + // ===================== + // fund (direct, USDC only) + // ===================== + await this.createTokenAccount(MAINNET_USDC, funder1.publicKey); + await this.transfer( + MAINNET_USDC, + this.payer, + funder1.publicKey, + 500_000_000000, + ); + + await launchpadClient + .fundIx({ + launch, + amount: new BN(500_000_000000), + funder: funder1.publicKey, + payer: funder1.publicKey, + }) + .signers([funder1]) + .rpc(); + + // ===================== + // close_launch + set_funding_record_approval (direct) + // ===================== + await this.advanceBySeconds(launchPeriod + 1); + + await launchpadClient.closeLaunchIx({ launch }).rpc(); + + await launchpadClient + .setFundingRecordApprovalIx({ + launch, + funder: funder1.publicKey, + launchAuthority: launchAuthority.publicKey, + approvedAmount: new BN(500_000_000000), + }) + .signers([launchAuthority]) + .rpc(); + + // ===================== + // gated_invoke(settle_launch) — caller = launchAuthority + // ===================== + const settleIx = await launchpadClient + .settleLaunchIx({ + launch, + baseMint: META, + launchAuthority: launchAuthority.publicKey, + payer: launchAuthority.publicKey, + }) + .instruction(); + + const wrappedSettleTx = await gatedMintClient + .gatedInvokeIx({ + caller: launchAuthority.publicKey, + mint: META, + instruction: settleIx, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 1_400_000 }), + ComputeBudgetProgram.requestHeapFrame({ bytes: 255 * 1024 }), + ]) + .signers([launchAuthority]) + .transaction(); + + const lut = await createLookupTableForTransaction(wrappedSettleTx, this); + + const settleMessage = new TransactionMessage({ + payerKey: this.payer.publicKey, + recentBlockhash: (await this.banksClient.getLatestBlockhash())[0], + instructions: wrappedSettleTx.instructions, + }).compileToV0Message([lut]); + + const settleVersionedTx = new VersionedTransaction(settleMessage); + settleVersionedTx.sign([this.payer, launchAuthority]); + + await this.banksClient.processTransaction(settleVersionedTx); + + launchAccount = await launchpadClient.fetchLaunch(launch); + assert.deepEqual(launchAccount.state, { complete: {} }); + + // launch_base_vault still frozen + assert.equal( + await getTokenAccountState(this.banksClient, launchBaseVault), + TOKEN_STATE_FROZEN, + ); + + // futarchy AMM base vault frozen + const futarchyAmmBaseVault = token.getAssociatedTokenAddressSync( + META, + launchAccount.dao, + true, + ); + assert.equal( + await getTokenAccountState(this.banksClient, futarchyAmmBaseVault), + TOKEN_STATE_FROZEN, + ); + + // DAMM v2 token_a_vault frozen + const [dammPool] = PublicKey.findProgramAddressSync( + [ + Buffer.from("pool"), + LAUNCHPAD_V0_8_MAINNET_METEORA_CONFIG.toBuffer(), + Buffer.compare(META.toBuffer(), MAINNET_USDC.toBuffer()) === 1 + ? META.toBuffer() + : MAINNET_USDC.toBuffer(), + Buffer.compare(META.toBuffer(), MAINNET_USDC.toBuffer()) === 1 + ? MAINNET_USDC.toBuffer() + : META.toBuffer(), + ], + new PublicKey("cpamdpZCGKUy5JxQXB4dcpGPiikHawvSWAd6mEn1sGG"), + ); + const [dammTokenAVault] = PublicKey.findProgramAddressSync( + [Buffer.from("token_vault"), META.toBuffer(), dammPool.toBuffer()], + new PublicKey("cpamdpZCGKUy5JxQXB4dcpGPiikHawvSWAd6mEn1sGG"), + ); + assert.equal( + await getTokenAccountState(this.banksClient, dammTokenAVault), + TOKEN_STATE_FROZEN, + ); + + // ===================== + // gated_invoke(claim) — caller = funder1 + // ===================== + const claimIx = await launchpadClient + .claimIx({ + launch, + baseMint: META, + funder: funder1.publicKey, + }) + .instruction(); + + const funder1BaseAta = token.getAssociatedTokenAddressSync( + META, + funder1.publicKey, + ); + const createFunder1AtaIx = + token.createAssociatedTokenAccountIdempotentInstruction( + funder1.publicKey, + funder1BaseAta, + funder1.publicKey, + META, + ); + + await gatedMintClient + .gatedInvokeIx({ + caller: funder1.publicKey, + mint: META, + instruction: claimIx, + }) + .preInstructions([createFunder1AtaIx]) + .signers([funder1]) + .rpc(); + + const funder1Balance = await this.getTokenBalance(META, funder1.publicKey); + assert.equal(funder1Balance.toString(), (10_000_000 * 10 ** 6).toString()); + + assert.equal( + await getTokenAccountState(this.banksClient, funder1BaseAta), + TOKEN_STATE_FROZEN, + ); + + // ===================== + // gated_invoke(futarchy::spot_swap) — buy META on futarchy AMM + // ===================== + // Top up funder1's USDC for the swaps (they spent everything funding). + await this.transfer( + MAINNET_USDC, + this.payer, + funder1.publicKey, + 200_000_000_000, + ); + + const futarchyClient: FutarchyClient = this.futarchy; + const futarchyAmmQuoteVault = token.getAssociatedTokenAddressSync( + MAINNET_USDC, + launchAccount.dao, + true, + ); + + const futarchyAmmBaseBefore = ( + await this.banksClient.getAccount(futarchyAmmBaseVault) + ).data; + const ammBaseAmountBefore = Buffer.from( + futarchyAmmBaseBefore, + ).readBigUInt64LE(64); + + const funder1MetaBefore = await this.getTokenBalance( + META, + funder1.publicKey, + ); + + const spotSwapIx = await futarchyClient + .spotSwapIx({ + dao: launchAccount.dao, + baseMint: META, + quoteMint: MAINNET_USDC, + swapType: "buy", + inputAmount: new BN(100_000_000), + minOutputAmount: new BN(0), + trader: funder1.publicKey, + }) + .instruction(); + + await gatedMintClient + .gatedInvokeIx({ + caller: funder1.publicKey, + mint: META, + instruction: spotSwapIx, + }) + .signers([funder1]) + .rpc(); + + const funder1MetaAfterSpot = await this.getTokenBalance( + META, + funder1.publicKey, + ); + assert(funder1MetaAfterSpot > funder1MetaBefore); + + // Both gated-mint accounts touched by the swap end up frozen post-CPI. + assert.equal( + await getTokenAccountState(this.banksClient, funder1BaseAta), + TOKEN_STATE_FROZEN, + ); + assert.equal( + await getTokenAccountState(this.banksClient, futarchyAmmBaseVault), + TOKEN_STATE_FROZEN, + ); + + // Sanity check: the AMM moved META out (i.e., the swap actually executed, + // it wasn't silently a no-op against a still-frozen vault). + const futarchyAmmBaseAfter = ( + await this.banksClient.getAccount(futarchyAmmBaseVault) + ).data; + const ammBaseAmountAfter = + Buffer.from(futarchyAmmBaseAfter).readBigUInt64LE(64); + assert(ammBaseAmountAfter < ammBaseAmountBefore); + + // futarchyAmmQuoteVault sanity: not gated, balance increased by input amount + const ammQuoteBalance = await this.getTokenBalance( + MAINNET_USDC, + launchAccount.dao, + ); + assert.equal( + ammQuoteBalance.toString(), + (100_000 * 10 ** 6 + 100_000_000).toString(), + ); + + // ===================== + // gated_invoke(damm_v2::swap) — buy META on DAMM v2 pool + // ===================== + const cpAmm = new CpAmm(this.squadsConnection); + const funder1MetaBeforeDamm = await this.getTokenBalance( + META, + funder1.publicKey, + ); + const dammPoolMetaBefore = ( + await this.banksClient.getAccount(dammTokenAVault) + ).data; + const dammPoolMetaAmountBefore = + Buffer.from(dammPoolMetaBefore).readBigUInt64LE(64); + + const dammSwapIx = await cpAmm._program.methods + .swap({ + amountIn: new BN(100_000_000), + minimumAmountOut: new BN(0), + }) + .accounts({ + tokenAMint: META, + tokenBMint: MAINNET_USDC, + tokenAProgram: token.TOKEN_PROGRAM_ID, + tokenBProgram: token.TOKEN_PROGRAM_ID, + referralTokenAccount: null, + inputTokenAccount: token.getAssociatedTokenAddressSync( + MAINNET_USDC, + funder1.publicKey, + true, + ), + outputTokenAccount: funder1BaseAta, + payer: funder1.publicKey, + pool: dammPool, + program: DAMM_V2_PROGRAM_ID, + }) + .instruction(); + + await gatedMintClient + .gatedInvokeIx({ + caller: funder1.publicKey, + mint: META, + instruction: dammSwapIx, + }) + .signers([funder1]) + .rpc(); + + const funder1MetaAfterDamm = await this.getTokenBalance( + META, + funder1.publicKey, + ); + assert(funder1MetaAfterDamm > funder1MetaBeforeDamm); + + // Both gated-mint accounts touched end up frozen post-CPI. + assert.equal( + await getTokenAccountState(this.banksClient, funder1BaseAta), + TOKEN_STATE_FROZEN, + ); + assert.equal( + await getTokenAccountState(this.banksClient, dammTokenAVault), + TOKEN_STATE_FROZEN, + ); + + // Sanity: DAMM v2 vault drained some META (swap really executed). + const dammPoolMetaAfter = ( + await this.banksClient.getAccount(dammTokenAVault) + ).data; + const dammPoolMetaAmountAfter = + Buffer.from(dammPoolMetaAfter).readBigUInt64LE(64); + assert(dammPoolMetaAmountAfter < dammPoolMetaAmountBefore); + + // ===================== + // disable_gating + // ===================== + await gatedMintClient + .disableGatingIx({ mint: META, admin: gatedMintAdmin.publicKey }) + .signers([gatedMintAdmin]) + .rpc(); + + const cfg = await gatedMintClient.fetchGatedMintConfig(gatedMintConfig); + assert.equal(cfg.gatingDisabled, true); + + // ===================== + // thaw_account from a fresh keypair (permissionless after disable) + // ===================== + const freshThawer = Keypair.generate(); + await fundSol(freshThawer.publicKey, 100_000_000); + + const thawIx = await gatedMintClient + .thawAccountIx({ mint: META, tokenAccount: funder1BaseAta }) + .instruction(); + const thawTx = new Transaction().add(thawIx); + thawTx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + thawTx.feePayer = freshThawer.publicKey; + thawTx.sign(freshThawer); + await this.banksClient.processTransaction(thawTx); + + assert.equal( + await getTokenAccountState(this.banksClient, funder1BaseAta), + TOKEN_STATE_INITIALIZED, + ); + }); +} diff --git a/tests/main.test.ts b/tests/main.test.ts index 7b69d89b..7c4d6307 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -8,6 +8,7 @@ import bidWall from "./bidWall/main.test.js"; 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 { BanksClient, @@ -37,6 +38,7 @@ import { LAUNCHPAD_V0_7_MAINNET_METEORA_CONFIG, BidWallClient, MintGovernorClient, + GatedMintClient, LiquidationClient, LOW_FEE_RAYDIUM_CONFIG, sha256, @@ -81,6 +83,7 @@ import mintAndSwap from "./integration/mintAndSwap.test.js"; import fullLaunch from "./integration/fullLaunch.test.js"; import fullLaunch_v7 from "./integration/fullLaunch_v7.test.js"; import fullLaunch_v8 from "./integration/launchpad_v8_full_lifecycle.test.js"; +import gatedLaunchpadV8 from "./integration/gatedLaunchpadV8.test.js"; import trancheLifecycle_v8 from "./integration/launchpad_v8_tranche_lifecycle.test.js"; import { BN } from "bn.js"; @@ -98,6 +101,7 @@ export interface TestContext { priceBasedPerformancePackage: PriceBasedPerformancePackageClient; bidWall: BidWallClient; mintGovernor: MintGovernorClient; + gatedMint: GatedMintClient; liquidation: LiquidationClient; payer: Keypair; squadsConnection: Connection; @@ -757,10 +761,12 @@ describe("bid_wall", bidWall); describe("mint_governor", mintGovernor); describe("performance_package_v2", performancePackageV2); describe("liquidation", liquidation); +describe("gated_mint", gatedMint); describe("project-wide integration tests", function () { it.skip("mint and swap in a single transaction", mintAndSwap); describe("full launch v6", fullLaunch); describe("full launch v7", fullLaunch_v7); describe("full launch v8", fullLaunch_v8); + describe("gated_mint + launchpad v8", gatedLaunchpadV8); describe("full launch v8 - tranche lifecycle", trancheLifecycle_v8); });