diff --git a/Anchor.toml b/Anchor.toml index 10a9ab03..3788dee5 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -11,6 +11,7 @@ conditional_vault = "VLTX1ishMBbcX3rdBWGssxawAo1Q2X2qxYFYqiGodVg" futarchy = "FUTARELBfJfQ8RDGhg1wdhddq1odMAJUePHFuBYfUxKq" launchpad = "MooNyh4CBUYEKyXVnjGYQ8mEiJDpGvJMdvrZx1iGeHV" launchpad_v7 = "moontUzsdepotRGe5xsfip7vLPTJnVuafqdUWexVnPM" +mint_governor = "gvnr27cVeyW3AVf3acL7VCJ5WjGAphytnsgcK1feHyH" price_based_performance_package = "pbPPQH7jyKoSLu8QYs3rSY3YkDRXEBojKbTgnUg7NDS" [registry] diff --git a/Cargo.lock b/Cargo.lock index d20734a3..38e8c003 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1309,6 +1309,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "mint_governor" +version = "0.7.0" +dependencies = [ + "anchor-lang", + "anchor-spl", + "solana-security-txt", +] + [[package]] name = "mpl-token-metadata" version = "3.2.3" diff --git a/programs/mint_governor/Cargo.toml b/programs/mint_governor/Cargo.toml new file mode 100644 index 00000000..a905ea84 --- /dev/null +++ b/programs/mint_governor/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "mint_governor" +version = "0.7.0" +description = "Created with Anchor" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "mint_governor" + +[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-security-txt = "1.1.1" diff --git a/programs/mint_governor/src/constants.rs b/programs/mint_governor/src/constants.rs new file mode 100644 index 00000000..f76c2314 --- /dev/null +++ b/programs/mint_governor/src/constants.rs @@ -0,0 +1,2 @@ +pub const MINT_GOVERNOR_SEED: &[u8] = b"mint_governor"; +pub const MINT_AUTHORITY_SEED: &[u8] = b"mint_authority"; diff --git a/programs/mint_governor/src/error.rs b/programs/mint_governor/src/error.rs new file mode 100644 index 00000000..c76ba101 --- /dev/null +++ b/programs/mint_governor/src/error.rs @@ -0,0 +1,16 @@ +use super::*; + +#[error_code] +pub enum MintGovernorError { + #[msg("Unauthorized: signer is not the admin")] + UnauthorizedAdmin, + + #[msg("Unauthorized: signer is not the authorized minter")] + UnauthorizedMinter, + + #[msg("Mint mismatch: mint_governor.mint does not match provided mint")] + MintMismatch, + + #[msg("Mint limit exceeded: would exceed max_total")] + MintLimitExceeded, +} diff --git a/programs/mint_governor/src/events.rs b/programs/mint_governor/src/events.rs new file mode 100644 index 00000000..f38f9555 --- /dev/null +++ b/programs/mint_governor/src/events.rs @@ -0,0 +1,78 @@ +use anchor_lang::prelude::*; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct CommonFields { + pub slot: u64, + pub unix_timestamp: i64, + pub mint_governor_seq_num: u64, +} + +#[event] +pub struct MintGovernorInitializedEvent { + pub common: CommonFields, + pub mint_governor: Pubkey, + pub mint: Pubkey, + pub admin: Pubkey, + pub create_key: Pubkey, + pub pda_bump: u8, +} + +#[event] +pub struct MintAuthorityTransferredEvent { + pub common: CommonFields, + pub mint_governor: Pubkey, + pub mint: Pubkey, +} + +#[event] +pub struct MintAuthorityAddedEvent { + pub common: CommonFields, + pub mint_governor: Pubkey, + pub mint_authority: Pubkey, + pub authorized_minter: Pubkey, + pub max_total: Option, +} + +#[event] +pub struct TokensMintedEvent { + pub common: CommonFields, + pub mint_governor: Pubkey, + pub mint: Pubkey, + pub authorized_minter: Pubkey, + pub destination_ata: Pubkey, + pub amount: u64, + pub post_total_minted: u64, + pub post_mint_supply: u64, +} + +#[event] +pub struct MintAuthorityUpdatedEvent { + pub common: CommonFields, + pub mint_governor: Pubkey, + pub mint_authority: Pubkey, + pub authorized_minter: Pubkey, + pub max_total: Option, +} + +#[event] +pub struct MintAuthorityRemovedEvent { + pub common: CommonFields, + pub mint_governor: Pubkey, + pub authorized_minter: Pubkey, + pub total_minted: u64, +} + +#[event] +pub struct MintGovernorAdminUpdatedEvent { + pub common: CommonFields, + pub mint_governor: Pubkey, + pub new_admin: Pubkey, +} + +#[event] +pub struct MintAuthorityReclaimedEvent { + pub common: CommonFields, + pub mint_governor: Pubkey, + pub mint: Pubkey, + pub new_authority: Pubkey, +} diff --git a/programs/mint_governor/src/instructions/add_mint_authority.rs b/programs/mint_governor/src/instructions/add_mint_authority.rs new file mode 100644 index 00000000..91e08e96 --- /dev/null +++ b/programs/mint_governor/src/instructions/add_mint_authority.rs @@ -0,0 +1,74 @@ +use anchor_lang::prelude::*; + +use crate::{ + CommonFields, MintAuthority, MintAuthorityAddedEvent, MintGovernor, MintGovernorError, + MINT_AUTHORITY_SEED, +}; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct AddMintAuthorityArgs { + pub max_total: Option, +} + +#[derive(Accounts)] +pub struct AddMintAuthority<'info> { + #[account(mut)] + pub mint_governor: Account<'info, MintGovernor>, + + #[account( + init, + payer = payer, + space = 8 + MintAuthority::INIT_SPACE, + seeds = [MINT_AUTHORITY_SEED, mint_governor.key().as_ref(), authorized_minter.key().as_ref()], + bump + )] + pub mint_authority: Account<'info, MintAuthority>, + + #[account(address = mint_governor.admin @ MintGovernorError::UnauthorizedAdmin)] + pub admin: Signer<'info>, + + /// CHECK: This is the address receiving minting rights, no validation needed + pub authorized_minter: UncheckedAccount<'info>, + + #[account(mut)] + pub payer: Signer<'info>, + + pub system_program: Program<'info, System>, +} + +impl AddMintAuthority<'_> { + pub fn validate(&self, _args: &AddMintAuthorityArgs) -> Result<()> { + Ok(()) + } + + pub fn handle(ctx: Context, args: AddMintAuthorityArgs) -> Result<()> { + let mint_governor = &mut ctx.accounts.mint_governor; + let mint_authority = &mut ctx.accounts.mint_authority; + + mint_authority.set_inner(MintAuthority { + mint_governor: mint_governor.key(), + authorized_minter: ctx.accounts.authorized_minter.key(), + max_total: args.max_total, + total_minted: 0, + bump: ctx.bumps.mint_authority, + }); + + mint_governor.seq_num += 1; + + let clock = Clock::get()?; + + emit!(MintAuthorityAddedEvent { + common: CommonFields { + slot: clock.slot, + unix_timestamp: clock.unix_timestamp, + mint_governor_seq_num: mint_governor.seq_num, + }, + mint_governor: mint_governor.key(), + mint_authority: ctx.accounts.mint_authority.key(), + authorized_minter: ctx.accounts.authorized_minter.key(), + max_total: args.max_total, + }); + + Ok(()) + } +} diff --git a/programs/mint_governor/src/instructions/initialize_mint_governor.rs b/programs/mint_governor/src/instructions/initialize_mint_governor.rs new file mode 100644 index 00000000..63461dd8 --- /dev/null +++ b/programs/mint_governor/src/instructions/initialize_mint_governor.rs @@ -0,0 +1,62 @@ +use anchor_lang::prelude::*; +use anchor_spl::token::Mint; + +use crate::{CommonFields, MintGovernor, MintGovernorInitializedEvent, MINT_GOVERNOR_SEED}; + +#[derive(Accounts)] +pub struct InitializeMintGovernor<'info> { + pub mint: Account<'info, Mint>, + + #[account( + init, + payer = payer, + space = 8 + MintGovernor::INIT_SPACE, + seeds = [MINT_GOVERNOR_SEED, mint.key().as_ref(), create_key.key().as_ref()], + bump + )] + pub mint_governor: Account<'info, MintGovernor>, + + pub create_key: Signer<'info>, + + /// CHECK: This is the future admin, no validation needed + pub admin: UncheckedAccount<'info>, + + #[account(mut)] + pub payer: Signer<'info>, + + pub system_program: Program<'info, System>, +} + +impl InitializeMintGovernor<'_> { + pub fn validate(&self) -> Result<()> { + Ok(()) + } + + pub fn handle(ctx: Context) -> Result<()> { + ctx.accounts.mint_governor.set_inner(MintGovernor { + mint: ctx.accounts.mint.key(), + admin: ctx.accounts.admin.key(), + create_key: ctx.accounts.create_key.key(), + seq_num: 0, + bump: ctx.bumps.mint_governor, + }); + + let clock = Clock::get()?; + let mint_governor = &ctx.accounts.mint_governor; + + emit!(MintGovernorInitializedEvent { + common: CommonFields { + slot: clock.slot, + unix_timestamp: clock.unix_timestamp, + mint_governor_seq_num: mint_governor.seq_num, + }, + mint_governor: mint_governor.key(), + mint: mint_governor.mint, + admin: mint_governor.admin, + create_key: mint_governor.create_key, + pda_bump: mint_governor.bump, + }); + + Ok(()) + } +} diff --git a/programs/mint_governor/src/instructions/mint_tokens.rs b/programs/mint_governor/src/instructions/mint_tokens.rs new file mode 100644 index 00000000..29c882c6 --- /dev/null +++ b/programs/mint_governor/src/instructions/mint_tokens.rs @@ -0,0 +1,113 @@ +use anchor_lang::prelude::*; +use anchor_spl::token::{self, Mint, MintTo, Token, TokenAccount}; + +use crate::{ + CommonFields, MintAuthority, MintGovernor, MintGovernorError, TokensMintedEvent, + MINT_GOVERNOR_SEED, +}; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct MintTokensArgs { + pub amount: u64, +} + +#[derive(Accounts)] +pub struct MintTokens<'info> { + #[account(mut)] + pub mint_governor: Account<'info, MintGovernor>, + + #[account( + mut, + has_one = mint_governor + )] + pub mint_authority: Account<'info, MintAuthority>, + + #[account( + mut, + address = mint_governor.mint @ MintGovernorError::MintMismatch + )] + pub mint: Account<'info, Mint>, + + #[account(mut, has_one = mint)] + pub destination_ata: Account<'info, TokenAccount>, + + #[account(address = mint_authority.authorized_minter @ MintGovernorError::UnauthorizedMinter)] + pub authorized_minter: Signer<'info>, + + pub token_program: Program<'info, Token>, +} + +impl MintTokens<'_> { + pub fn validate(&self, args: &MintTokensArgs) -> Result<()> { + // Check mint limit if max_total is set + if let Some(max_total) = self.mint_authority.max_total { + require_gte!( + max_total, + self.mint_authority.total_minted + args.amount, + MintGovernorError::MintLimitExceeded + ); + } + + Ok(()) + } + + pub fn handle(ctx: Context, args: MintTokensArgs) -> Result<()> { + let mint_governor = &mut ctx.accounts.mint_governor; + let mint_authority = &mut ctx.accounts.mint_authority; + + // Build PDA signer seeds + let mint_key = mint_governor.mint; + let create_key = mint_governor.create_key; + let bump = mint_governor.bump; + let seeds = &[ + MINT_GOVERNOR_SEED, + mint_key.as_ref(), + create_key.as_ref(), + &[bump], + ]; + let signer_seeds = &[&seeds[..]]; + + // CPI to mint_to with mint_governor PDA as authority + token::mint_to( + CpiContext::new_with_signer( + ctx.accounts.token_program.to_account_info(), + MintTo { + mint: ctx.accounts.mint.to_account_info(), + to: ctx.accounts.destination_ata.to_account_info(), + authority: mint_governor.to_account_info(), + }, + signer_seeds, + ), + args.amount, + )?; + + // Update total_minted + mint_authority.total_minted += args.amount; + + // Increment seq_num + mint_governor.seq_num += 1; + + // Emit event + let clock = Clock::get()?; + + // Reload mint to get post-mint supply + ctx.accounts.mint.reload()?; + + emit!(TokensMintedEvent { + common: CommonFields { + slot: clock.slot, + unix_timestamp: clock.unix_timestamp, + mint_governor_seq_num: mint_governor.seq_num, + }, + mint_governor: mint_governor.key(), + mint: mint_governor.mint, + authorized_minter: ctx.accounts.authorized_minter.key(), + destination_ata: ctx.accounts.destination_ata.key(), + amount: args.amount, + post_total_minted: mint_authority.total_minted, + post_mint_supply: ctx.accounts.mint.supply, + }); + + Ok(()) + } +} diff --git a/programs/mint_governor/src/instructions/mod.rs b/programs/mint_governor/src/instructions/mod.rs new file mode 100644 index 00000000..fc359217 --- /dev/null +++ b/programs/mint_governor/src/instructions/mod.rs @@ -0,0 +1,17 @@ +pub mod add_mint_authority; +pub mod initialize_mint_governor; +pub mod mint_tokens; +pub mod reclaim_authority; +pub mod remove_mint_authority; +pub mod transfer_authority_to_governor; +pub mod update_mint_authority; +pub mod update_mint_governor_admin; + +pub use add_mint_authority::*; +pub use initialize_mint_governor::*; +pub use mint_tokens::*; +pub use reclaim_authority::*; +pub use remove_mint_authority::*; +pub use transfer_authority_to_governor::*; +pub use update_mint_authority::*; +pub use update_mint_governor_admin::*; diff --git a/programs/mint_governor/src/instructions/reclaim_authority.rs b/programs/mint_governor/src/instructions/reclaim_authority.rs new file mode 100644 index 00000000..db0c18a0 --- /dev/null +++ b/programs/mint_governor/src/instructions/reclaim_authority.rs @@ -0,0 +1,84 @@ +use anchor_lang::prelude::*; +use anchor_spl::token::spl_token::instruction::AuthorityType; +use anchor_spl::token::{self, Mint, SetAuthority, Token}; + +use crate::{ + CommonFields, MintAuthorityReclaimedEvent, MintGovernor, MintGovernorError, MINT_GOVERNOR_SEED, +}; + +#[derive(Accounts)] +pub struct ReclaimAuthority<'info> { + #[account(mut)] + pub mint_governor: Account<'info, MintGovernor>, + + #[account( + mut, + address = mint_governor.mint @ MintGovernorError::MintMismatch + )] + pub mint: Account<'info, Mint>, + + #[account(address = mint_governor.admin @ MintGovernorError::UnauthorizedAdmin)] + pub admin: Signer<'info>, + + /// CHECK: This is the new authority address, no validation needed + pub new_authority: UncheckedAccount<'info>, + + pub token_program: Program<'info, Token>, +} + +impl ReclaimAuthority<'_> { + pub fn validate(&self) -> Result<()> { + Ok(()) + } + + pub fn handle(ctx: Context) -> Result<()> { + // NOTE: After reclaim, MintGovernor and MintAuthority accounts remain but become + // non-functional. This is accepted behavior - no cleanup mechanism is provided. + // Callers attempting mint_tokens after reclaim will fail at CPI. + let mint_governor = &mut ctx.accounts.mint_governor; + + // Build PDA signer seeds + let mint_key = mint_governor.mint; + let create_key = mint_governor.create_key; + let bump = mint_governor.bump; + let signer_seeds: &[&[&[u8]]] = &[&[ + MINT_GOVERNOR_SEED, + mint_key.as_ref(), + create_key.as_ref(), + &[bump], + ]]; + + // CPI to set_authority to transfer mint authority to new_authority + token::set_authority( + CpiContext::new_with_signer( + ctx.accounts.token_program.to_account_info(), + SetAuthority { + current_authority: mint_governor.to_account_info(), + account_or_mint: ctx.accounts.mint.to_account_info(), + }, + signer_seeds, + ), + AuthorityType::MintTokens, + Some(ctx.accounts.new_authority.key()), + )?; + + // Increment seq_num + mint_governor.seq_num += 1; + + // Emit event + let clock = Clock::get()?; + + emit!(MintAuthorityReclaimedEvent { + common: CommonFields { + slot: clock.slot, + unix_timestamp: clock.unix_timestamp, + mint_governor_seq_num: mint_governor.seq_num, + }, + mint_governor: mint_governor.key(), + mint: mint_governor.mint, + new_authority: ctx.accounts.new_authority.key(), + }); + + Ok(()) + } +} diff --git a/programs/mint_governor/src/instructions/remove_mint_authority.rs b/programs/mint_governor/src/instructions/remove_mint_authority.rs new file mode 100644 index 00000000..cde94789 --- /dev/null +++ b/programs/mint_governor/src/instructions/remove_mint_authority.rs @@ -0,0 +1,57 @@ +use anchor_lang::prelude::*; + +use crate::{ + CommonFields, MintAuthority, MintAuthorityRemovedEvent, MintGovernor, MintGovernorError, +}; + +#[derive(Accounts)] +pub struct RemoveMintAuthority<'info> { + #[account(mut)] + pub mint_governor: Account<'info, MintGovernor>, + + #[account( + mut, + close = rent_destination, + has_one = mint_governor, + )] + pub mint_authority: Account<'info, MintAuthority>, + + #[account(address = mint_governor.admin @ MintGovernorError::UnauthorizedAdmin)] + pub admin: Signer<'info>, + + /// CHECK: Receives rent lamports from closed account + #[account(mut)] + pub rent_destination: UncheckedAccount<'info>, +} + +impl RemoveMintAuthority<'_> { + pub fn validate(&self) -> Result<()> { + Ok(()) + } + + pub fn handle(ctx: Context) -> Result<()> { + let mint_governor = &mut ctx.accounts.mint_governor; + let mint_authority = &ctx.accounts.mint_authority; + + // Increment seq num + mint_governor.seq_num += 1; + + // Emit event + let clock = Clock::get()?; + + emit!(MintAuthorityRemovedEvent { + common: CommonFields { + slot: clock.slot, + unix_timestamp: clock.unix_timestamp, + mint_governor_seq_num: mint_governor.seq_num, + }, + mint_governor: mint_governor.key(), + authorized_minter: mint_authority.authorized_minter, + total_minted: mint_authority.total_minted, + }); + + // Mint authority account gets closed using close constraint + + Ok(()) + } +} diff --git a/programs/mint_governor/src/instructions/transfer_authority_to_governor.rs b/programs/mint_governor/src/instructions/transfer_authority_to_governor.rs new file mode 100644 index 00000000..f62719f6 --- /dev/null +++ b/programs/mint_governor/src/instructions/transfer_authority_to_governor.rs @@ -0,0 +1,62 @@ +use anchor_lang::prelude::*; +use anchor_spl::token::spl_token::instruction::AuthorityType; +use anchor_spl::token::{self, Mint, SetAuthority, Token}; + +use crate::{CommonFields, MintAuthorityTransferredEvent, MintGovernor, MintGovernorError}; + +#[derive(Accounts)] +pub struct TransferAuthorityToGovernor<'info> { + #[account(mut)] + pub mint_governor: Account<'info, MintGovernor>, + + #[account( + mut, + address = mint_governor.mint @ MintGovernorError::MintMismatch + )] + pub mint: Account<'info, Mint>, + + pub current_authority: Signer<'info>, + + pub token_program: Program<'info, Token>, +} + +impl TransferAuthorityToGovernor<'_> { + pub fn validate(&self) -> Result<()> { + Ok(()) + } + + pub fn handle(ctx: Context) -> Result<()> { + let mint_governor = &mut ctx.accounts.mint_governor; + + // CPI to set_authority to transfer mint authority to mint_governor PDA + token::set_authority( + CpiContext::new( + ctx.accounts.token_program.to_account_info(), + SetAuthority { + current_authority: ctx.accounts.current_authority.to_account_info(), + account_or_mint: ctx.accounts.mint.to_account_info(), + }, + ), + AuthorityType::MintTokens, + Some(mint_governor.key()), + )?; + + // Increment seq_num + mint_governor.seq_num += 1; + + // Emit event + let clock = Clock::get()?; + + emit!(MintAuthorityTransferredEvent { + common: CommonFields { + slot: clock.slot, + unix_timestamp: clock.unix_timestamp, + mint_governor_seq_num: mint_governor.seq_num, + }, + mint_governor: mint_governor.key(), + mint: mint_governor.mint, + }); + + Ok(()) + } +} diff --git a/programs/mint_governor/src/instructions/update_mint_authority.rs b/programs/mint_governor/src/instructions/update_mint_authority.rs new file mode 100644 index 00000000..72960a28 --- /dev/null +++ b/programs/mint_governor/src/instructions/update_mint_authority.rs @@ -0,0 +1,59 @@ +use anchor_lang::prelude::*; + +use crate::{ + CommonFields, MintAuthority, MintAuthorityUpdatedEvent, MintGovernor, MintGovernorError, +}; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct UpdateMintAuthorityArgs { + pub max_total: Option, +} + +#[derive(Accounts)] +pub struct UpdateMintAuthority<'info> { + #[account(mut)] + pub mint_governor: Account<'info, MintGovernor>, + + #[account(mut, has_one = mint_governor)] + pub mint_authority: Account<'info, MintAuthority>, + + #[account(address = mint_governor.admin @ MintGovernorError::UnauthorizedAdmin)] + pub admin: Signer<'info>, +} + +impl UpdateMintAuthority<'_> { + pub fn validate(&self) -> Result<()> { + Ok(()) + } + + pub fn handle(ctx: Context, args: UpdateMintAuthorityArgs) -> Result<()> { + let mint_governor = &mut ctx.accounts.mint_governor; + let mint_authority = &mut ctx.accounts.mint_authority; + + // Update max total + // NOTE: Admin can intentionally: + // - Set max_total below total_minted (freezes minter's ability to mint) + // - Set max_total to None (upgrades limited minter to unlimited) + mint_authority.max_total = args.max_total; + + // Increment seq num + mint_governor.seq_num += 1; + + // Emit event + let clock = Clock::get()?; + + emit!(MintAuthorityUpdatedEvent { + common: CommonFields { + slot: clock.slot, + unix_timestamp: clock.unix_timestamp, + mint_governor_seq_num: mint_governor.seq_num, + }, + mint_governor: mint_governor.key(), + mint_authority: mint_authority.key(), + authorized_minter: mint_authority.authorized_minter, + max_total: mint_authority.max_total, + }); + + Ok(()) + } +} diff --git a/programs/mint_governor/src/instructions/update_mint_governor_admin.rs b/programs/mint_governor/src/instructions/update_mint_governor_admin.rs new file mode 100644 index 00000000..561525aa --- /dev/null +++ b/programs/mint_governor/src/instructions/update_mint_governor_admin.rs @@ -0,0 +1,42 @@ +use anchor_lang::prelude::*; + +use crate::{CommonFields, MintGovernor, MintGovernorAdminUpdatedEvent, MintGovernorError}; + +#[derive(Accounts)] +pub struct UpdateMintGovernorAdmin<'info> { + #[account(mut)] + pub mint_governor: Account<'info, MintGovernor>, + + #[account(address = mint_governor.admin @ MintGovernorError::UnauthorizedAdmin)] + pub admin: Signer<'info>, + + /// CHECK: This is the new admin address, no validation needed + pub new_admin: UncheckedAccount<'info>, +} + +impl UpdateMintGovernorAdmin<'_> { + pub fn validate(&self) -> Result<()> { + Ok(()) + } + + pub fn handle(ctx: Context) -> Result<()> { + let mint_governor = &mut ctx.accounts.mint_governor; + + mint_governor.admin = ctx.accounts.new_admin.key(); + mint_governor.seq_num += 1; + + let clock = Clock::get()?; + + emit!(MintGovernorAdminUpdatedEvent { + common: CommonFields { + slot: clock.slot, + unix_timestamp: clock.unix_timestamp, + mint_governor_seq_num: mint_governor.seq_num, + }, + mint_governor: mint_governor.key(), + new_admin: ctx.accounts.new_admin.key(), + }); + + Ok(()) + } +} diff --git a/programs/mint_governor/src/lib.rs b/programs/mint_governor/src/lib.rs new file mode 100644 index 00000000..6309208e --- /dev/null +++ b/programs/mint_governor/src/lib.rs @@ -0,0 +1,85 @@ +//! Mint Governor +//! +//! This program manages minting authority for SPL tokens, allowing an admin +//! to delegate minting rights to multiple authorized minters with optional limits. + +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: "mint_governor", + project_url: "https://metadao.fi", + contacts: "telegram:metaproph3t,telegram:kollan_house", + source_code: "https://github.com/metaDAOproject/programs", + source_release: "v0.7.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!("gvnr27cVeyW3AVf3acL7VCJ5WjGAphytnsgcK1feHyH"); + +#[program] +pub mod mint_governor { + use super::*; + + #[access_control(ctx.accounts.validate())] + pub fn initialize_mint_governor(ctx: Context) -> Result<()> { + InitializeMintGovernor::handle(ctx) + } + + #[access_control(ctx.accounts.validate())] + pub fn transfer_authority_to_governor(ctx: Context) -> Result<()> { + TransferAuthorityToGovernor::handle(ctx) + } + + #[access_control(ctx.accounts.validate(&args))] + pub fn add_mint_authority( + ctx: Context, + args: AddMintAuthorityArgs, + ) -> Result<()> { + AddMintAuthority::handle(ctx, args) + } + + #[access_control(ctx.accounts.validate(&args))] + pub fn mint_tokens(ctx: Context, args: MintTokensArgs) -> Result<()> { + MintTokens::handle(ctx, args) + } + + #[access_control(ctx.accounts.validate())] + pub fn update_mint_authority( + ctx: Context, + args: UpdateMintAuthorityArgs, + ) -> Result<()> { + UpdateMintAuthority::handle(ctx, args) + } + + #[access_control(ctx.accounts.validate())] + pub fn remove_mint_authority(ctx: Context) -> Result<()> { + RemoveMintAuthority::handle(ctx) + } + + #[access_control(ctx.accounts.validate())] + pub fn update_mint_governor_admin(ctx: Context) -> Result<()> { + UpdateMintGovernorAdmin::handle(ctx) + } + + #[access_control(ctx.accounts.validate())] + pub fn reclaim_authority(ctx: Context) -> Result<()> { + ReclaimAuthority::handle(ctx) + } +} diff --git a/programs/mint_governor/src/state/mint_authority.rs b/programs/mint_governor/src/state/mint_authority.rs new file mode 100644 index 00000000..dc8757f6 --- /dev/null +++ b/programs/mint_governor/src/state/mint_authority.rs @@ -0,0 +1,11 @@ +use anchor_lang::prelude::*; + +#[account] +#[derive(InitSpace)] +pub struct MintAuthority { + pub mint_governor: Pubkey, + pub authorized_minter: Pubkey, + pub max_total: Option, + pub total_minted: u64, + pub bump: u8, +} diff --git a/programs/mint_governor/src/state/mint_governor.rs b/programs/mint_governor/src/state/mint_governor.rs new file mode 100644 index 00000000..faadb1b7 --- /dev/null +++ b/programs/mint_governor/src/state/mint_governor.rs @@ -0,0 +1,11 @@ +use anchor_lang::prelude::*; + +#[account] +#[derive(InitSpace)] +pub struct MintGovernor { + pub mint: Pubkey, + pub admin: Pubkey, + pub create_key: Pubkey, + pub seq_num: u64, + pub bump: u8, +} diff --git a/programs/mint_governor/src/state/mod.rs b/programs/mint_governor/src/state/mod.rs new file mode 100644 index 00000000..a5727c10 --- /dev/null +++ b/programs/mint_governor/src/state/mod.rs @@ -0,0 +1,5 @@ +pub mod mint_governor; +pub use mint_governor::*; + +pub mod mint_authority; +pub use mint_authority::*; diff --git a/sdk/src/v0.7/MintGovernorClient.ts b/sdk/src/v0.7/MintGovernorClient.ts new file mode 100644 index 00000000..b2368986 --- /dev/null +++ b/sdk/src/v0.7/MintGovernorClient.ts @@ -0,0 +1,251 @@ +import { AnchorProvider, Program } from "@coral-xyz/anchor"; +import { AccountInfo, PublicKey } from "@solana/web3.js"; +import { TOKEN_PROGRAM_ID } from "@solana/spl-token"; +import { MINT_GOVERNOR_PROGRAM_ID } from "./constants.js"; +import { getMintGovernorAddr, getMintAuthorityAddr } from "./utils/pda.js"; +import BN from "bn.js"; +import { + MintGovernor as MintGovernorProgram, + IDL as MintGovernorIDL, +} from "./types/mint_governor.js"; +import type { + MintGovernorAccount, + MintAuthorityAccount, +} from "./types/index.js"; + +export type CreateMintGovernorClientParams = { + provider: AnchorProvider; + programId?: PublicKey; +}; + +export class MintGovernorClient { + 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( + MintGovernorIDL, + programId, + provider, + ); + } + + public static createClient( + params: CreateMintGovernorClientParams, + ): MintGovernorClient { + const { provider, programId } = params; + return new MintGovernorClient( + provider, + programId || MINT_GOVERNOR_PROGRAM_ID, + ); + } + + async fetchMintGovernor( + mintGovernor: PublicKey, + ): Promise { + return this.program.account.mintGovernor.fetchNullable(mintGovernor); + } + + async deserializeMintGovernor( + accountInfo: AccountInfo, + ): Promise { + return this.program.coder.accounts.decode("mintGovernor", accountInfo.data); + } + + async fetchMintAuthority( + mintAuthority: PublicKey, + ): Promise { + return this.program.account.mintAuthority.fetchNullable(mintAuthority); + } + + async deserializeMintAuthority( + accountInfo: AccountInfo, + ): Promise { + return this.program.coder.accounts.decode( + "mintAuthority", + accountInfo.data, + ); + } + + initializeMintGovernorIx({ + mint, + createKey, + admin, + payer = this.provider.publicKey, + }: { + mint: PublicKey; + createKey: PublicKey; + admin: PublicKey; + payer?: PublicKey; + }) { + const [mintGovernor] = getMintGovernorAddr({ + programId: this.programId, + mint, + createKey, + }); + + return this.program.methods.initializeMintGovernor().accounts({ + mint, + mintGovernor, + createKey, + admin, + payer, + }); + } + + transferAuthorityToGovernorIx({ + mintGovernor, + mint, + currentAuthority, + tokenProgram = TOKEN_PROGRAM_ID, + }: { + mintGovernor: PublicKey; + mint: PublicKey; + currentAuthority: PublicKey; + tokenProgram?: PublicKey; + }) { + return this.program.methods.transferAuthorityToGovernor().accounts({ + mintGovernor, + mint, + currentAuthority, + tokenProgram, + }); + } + + addMintAuthorityIx({ + mintGovernor, + admin, + authorizedMinter, + payer = this.provider.publicKey, + maxTotal, + }: { + mintGovernor: PublicKey; + admin: PublicKey; + authorizedMinter: PublicKey; + payer?: PublicKey; + maxTotal: BN | null; + }) { + const [mintAuthority] = getMintAuthorityAddr({ + programId: this.programId, + mintGovernor, + authorizedMinter, + }); + + return this.program.methods.addMintAuthority({ maxTotal }).accounts({ + mintGovernor, + mintAuthority, + admin, + authorizedMinter, + payer, + }); + } + + updateMintAuthorityIx({ + mintGovernor, + mintAuthority, + admin, + maxTotal, + }: { + mintGovernor: PublicKey; + mintAuthority: PublicKey; + admin: PublicKey; + maxTotal: BN | null; + }) { + return this.program.methods.updateMintAuthority({ maxTotal }).accounts({ + mintGovernor, + mintAuthority, + admin, + }); + } + + removeMintAuthorityIx({ + mintGovernor, + mintAuthority, + admin, + rentDestination, + }: { + mintGovernor: PublicKey; + mintAuthority: PublicKey; + admin: PublicKey; + rentDestination: PublicKey; + }) { + return this.program.methods.removeMintAuthority().accounts({ + mintGovernor, + mintAuthority, + admin, + rentDestination, + }); + } + + mintTokensIx({ + mintGovernor, + mint, + destinationAta, + authorizedMinter, + tokenProgram = TOKEN_PROGRAM_ID, + amount, + }: { + mintGovernor: PublicKey; + mint: PublicKey; + destinationAta: PublicKey; + authorizedMinter: PublicKey; + tokenProgram?: PublicKey; + amount: BN; + }) { + const [mintAuthority] = getMintAuthorityAddr({ + programId: this.programId, + mintGovernor, + authorizedMinter, + }); + + return this.program.methods.mintTokens({ amount }).accounts({ + mintGovernor, + mintAuthority, + mint, + destinationAta, + authorizedMinter, + tokenProgram, + }); + } + + updateMintGovernorAdminIx({ + mintGovernor, + admin, + newAdmin, + }: { + mintGovernor: PublicKey; + admin: PublicKey; + newAdmin: PublicKey; + }) { + return this.program.methods.updateMintGovernorAdmin().accounts({ + mintGovernor, + admin, + newAdmin, + }); + } + + reclaimAuthorityIx({ + mintGovernor, + mint, + admin, + newAuthority, + tokenProgram = TOKEN_PROGRAM_ID, + }: { + mintGovernor: PublicKey; + mint: PublicKey; + admin: PublicKey; + newAuthority: PublicKey; + tokenProgram?: PublicKey; + }) { + return this.program.methods.reclaimAuthority().accounts({ + mintGovernor, + mint, + admin, + newAuthority, + tokenProgram, + }); + } +} diff --git a/sdk/src/v0.7/constants.ts b/sdk/src/v0.7/constants.ts index d3df833a..5da05320 100644 --- a/sdk/src/v0.7/constants.ts +++ b/sdk/src/v0.7/constants.ts @@ -23,6 +23,9 @@ export const PRICE_BASED_PERFORMANCE_PACKAGE_PROGRAM_ID = new PublicKey( export const BID_WALL_PROGRAM_ID = new PublicKey( "WALL8ucBuUyL46QYxwYJjidaFYhdvxUFrgvBxPshERx", ); +export const MINT_GOVERNOR_PROGRAM_ID = new PublicKey( + "gvnr27cVeyW3AVf3acL7VCJ5WjGAphytnsgcK1feHyH", +); export const MPL_TOKEN_METADATA_PROGRAM_ID = new PublicKey( "metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s", diff --git a/sdk/src/v0.7/index.ts b/sdk/src/v0.7/index.ts index 13141df0..30173ae8 100644 --- a/sdk/src/v0.7/index.ts +++ b/sdk/src/v0.7/index.ts @@ -6,3 +6,4 @@ export * from "./FutarchyClient.js"; export * from "./ConditionalVaultClient.js"; export * from "./LaunchpadClient.js"; export * from "./PriceBasedPerformancePackageClient.js"; +export * from "./MintGovernorClient.js"; diff --git a/sdk/src/v0.7/types/index.ts b/sdk/src/v0.7/types/index.ts index ad7e3187..c280f991 100644 --- a/sdk/src/v0.7/types/index.ts +++ b/sdk/src/v0.7/types/index.ts @@ -25,6 +25,12 @@ export { PriceBasedPerformancePackageProgram, PriceBasedPerformancePackageIDL }; import { BidWall as BidWallProgram, IDL as BidWallIDL } from "./bid_wall.js"; export { BidWallProgram, BidWallIDL }; +import { + MintGovernor as MintGovernorProgram, + IDL as MintGovernorIDL, +} from "./mint_governor.js"; +export { MintGovernorProgram, MintGovernorIDL }; + export { LowercaseKeys } from "./utils.js"; import type { IdlAccounts, IdlTypes, IdlEvents } from "@coral-xyz/anchor"; @@ -59,6 +65,11 @@ export type Tranche = IdlTypes["Tranche"]; export type BidWall = IdlAccounts["bidWall"]; +export type MintGovernorAccount = + IdlAccounts["mintGovernor"]; +export type MintAuthorityAccount = + IdlAccounts["mintAuthority"]; + export type BidWallInitializedEvent = IdlEvents["BidWallInitializedEvent"]; export type BidWallTokensSoldEvent = @@ -199,3 +210,29 @@ export type PriceBasedPerformancePackageEvent = | ChangeProposedEvent | ChangeExecutedEvent | PerformancePackageAuthorityChangedEvent; + +export type MintGovernorInitializedEvent = + IdlEvents["MintGovernorInitializedEvent"]; +export type MintAuthorityTransferredEvent = + IdlEvents["MintAuthorityTransferredEvent"]; +export type MintAuthorityAddedEvent = + IdlEvents["MintAuthorityAddedEvent"]; +export type TokensMintedEvent = + IdlEvents["TokensMintedEvent"]; +export type MintAuthorityUpdatedEvent = + IdlEvents["MintAuthorityUpdatedEvent"]; +export type MintAuthorityRemovedEvent = + IdlEvents["MintAuthorityRemovedEvent"]; +export type MintGovernorAdminUpdatedEvent = + IdlEvents["MintGovernorAdminUpdatedEvent"]; +export type MintAuthorityReclaimedEvent = + IdlEvents["MintAuthorityReclaimedEvent"]; +export type MintGovernorEvent = + | MintGovernorInitializedEvent + | MintAuthorityTransferredEvent + | MintAuthorityAddedEvent + | TokensMintedEvent + | MintAuthorityUpdatedEvent + | MintAuthorityRemovedEvent + | MintGovernorAdminUpdatedEvent + | MintAuthorityReclaimedEvent; diff --git a/sdk/src/v0.7/types/mint_governor.ts b/sdk/src/v0.7/types/mint_governor.ts new file mode 100644 index 00000000..b6107843 --- /dev/null +++ b/sdk/src/v0.7/types/mint_governor.ts @@ -0,0 +1,1313 @@ +export type MintGovernor = { + version: "0.7.0"; + name: "mint_governor"; + instructions: [ + { + name: "initializeMintGovernor"; + accounts: [ + { + name: "mint"; + isMut: false; + isSigner: false; + }, + { + name: "mintGovernor"; + isMut: true; + isSigner: false; + }, + { + name: "createKey"; + isMut: false; + isSigner: true; + }, + { + name: "admin"; + isMut: false; + isSigner: false; + }, + { + name: "payer"; + isMut: true; + isSigner: true; + }, + { + name: "systemProgram"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, + { + name: "transferAuthorityToGovernor"; + accounts: [ + { + name: "mintGovernor"; + isMut: true; + isSigner: false; + }, + { + name: "mint"; + isMut: true; + isSigner: false; + }, + { + name: "currentAuthority"; + isMut: false; + isSigner: true; + }, + { + name: "tokenProgram"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, + { + name: "addMintAuthority"; + accounts: [ + { + name: "mintGovernor"; + isMut: true; + isSigner: false; + }, + { + name: "mintAuthority"; + isMut: true; + isSigner: false; + }, + { + name: "admin"; + isMut: false; + isSigner: true; + }, + { + name: "authorizedMinter"; + isMut: false; + isSigner: false; + }, + { + name: "payer"; + isMut: true; + isSigner: true; + }, + { + name: "systemProgram"; + isMut: false; + isSigner: false; + }, + ]; + args: [ + { + name: "args"; + type: { + defined: "AddMintAuthorityArgs"; + }; + }, + ]; + }, + { + name: "mintTokens"; + accounts: [ + { + name: "mintGovernor"; + isMut: true; + isSigner: false; + }, + { + name: "mintAuthority"; + isMut: true; + isSigner: false; + }, + { + name: "mint"; + isMut: true; + isSigner: false; + }, + { + name: "destinationAta"; + isMut: true; + isSigner: false; + }, + { + name: "authorizedMinter"; + isMut: false; + isSigner: true; + }, + { + name: "tokenProgram"; + isMut: false; + isSigner: false; + }, + ]; + args: [ + { + name: "args"; + type: { + defined: "MintTokensArgs"; + }; + }, + ]; + }, + { + name: "updateMintAuthority"; + accounts: [ + { + name: "mintGovernor"; + isMut: true; + isSigner: false; + }, + { + name: "mintAuthority"; + isMut: true; + isSigner: false; + }, + { + name: "admin"; + isMut: false; + isSigner: true; + }, + ]; + args: [ + { + name: "args"; + type: { + defined: "UpdateMintAuthorityArgs"; + }; + }, + ]; + }, + { + name: "removeMintAuthority"; + accounts: [ + { + name: "mintGovernor"; + isMut: true; + isSigner: false; + }, + { + name: "mintAuthority"; + isMut: true; + isSigner: false; + }, + { + name: "admin"; + isMut: false; + isSigner: true; + }, + { + name: "rentDestination"; + isMut: true; + isSigner: false; + }, + ]; + args: []; + }, + { + name: "updateMintGovernorAdmin"; + accounts: [ + { + name: "mintGovernor"; + isMut: true; + isSigner: false; + }, + { + name: "admin"; + isMut: false; + isSigner: true; + }, + { + name: "newAdmin"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, + { + name: "reclaimAuthority"; + accounts: [ + { + name: "mintGovernor"; + isMut: true; + isSigner: false; + }, + { + name: "mint"; + isMut: true; + isSigner: false; + }, + { + name: "admin"; + isMut: false; + isSigner: true; + }, + { + name: "newAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "tokenProgram"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, + ]; + accounts: [ + { + name: "mintAuthority"; + type: { + kind: "struct"; + fields: [ + { + name: "mintGovernor"; + type: "publicKey"; + }, + { + name: "authorizedMinter"; + type: "publicKey"; + }, + { + name: "maxTotal"; + type: { + option: "u64"; + }; + }, + { + name: "totalMinted"; + type: "u64"; + }, + { + name: "bump"; + type: "u8"; + }, + ]; + }; + }, + { + name: "mintGovernor"; + type: { + kind: "struct"; + fields: [ + { + name: "mint"; + type: "publicKey"; + }, + { + name: "admin"; + type: "publicKey"; + }, + { + name: "createKey"; + type: "publicKey"; + }, + { + name: "seqNum"; + type: "u64"; + }, + { + name: "bump"; + type: "u8"; + }, + ]; + }; + }, + ]; + types: [ + { + name: "CommonFields"; + type: { + kind: "struct"; + fields: [ + { + name: "slot"; + type: "u64"; + }, + { + name: "unixTimestamp"; + type: "i64"; + }, + { + name: "mintGovernorSeqNum"; + type: "u64"; + }, + ]; + }; + }, + { + name: "AddMintAuthorityArgs"; + type: { + kind: "struct"; + fields: [ + { + name: "maxTotal"; + type: { + option: "u64"; + }; + }, + ]; + }; + }, + { + name: "MintTokensArgs"; + type: { + kind: "struct"; + fields: [ + { + name: "amount"; + type: "u64"; + }, + ]; + }; + }, + { + name: "UpdateMintAuthorityArgs"; + type: { + kind: "struct"; + fields: [ + { + name: "maxTotal"; + type: { + option: "u64"; + }; + }, + ]; + }; + }, + ]; + events: [ + { + name: "MintGovernorInitializedEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "mintGovernor"; + type: "publicKey"; + index: false; + }, + { + name: "mint"; + type: "publicKey"; + index: false; + }, + { + name: "admin"; + type: "publicKey"; + index: false; + }, + { + name: "createKey"; + type: "publicKey"; + index: false; + }, + { + name: "pdaBump"; + type: "u8"; + index: false; + }, + ]; + }, + { + name: "MintAuthorityTransferredEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "mintGovernor"; + type: "publicKey"; + index: false; + }, + { + name: "mint"; + type: "publicKey"; + index: false; + }, + ]; + }, + { + name: "MintAuthorityAddedEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "mintGovernor"; + type: "publicKey"; + index: false; + }, + { + name: "mintAuthority"; + type: "publicKey"; + index: false; + }, + { + name: "authorizedMinter"; + type: "publicKey"; + index: false; + }, + { + name: "maxTotal"; + type: { + option: "u64"; + }; + index: false; + }, + ]; + }, + { + name: "TokensMintedEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "mintGovernor"; + type: "publicKey"; + index: false; + }, + { + name: "mint"; + type: "publicKey"; + index: false; + }, + { + name: "authorizedMinter"; + type: "publicKey"; + index: false; + }, + { + name: "destinationAta"; + type: "publicKey"; + index: false; + }, + { + name: "amount"; + type: "u64"; + index: false; + }, + { + name: "postTotalMinted"; + type: "u64"; + index: false; + }, + { + name: "postMintSupply"; + type: "u64"; + index: false; + }, + ]; + }, + { + name: "MintAuthorityUpdatedEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "mintGovernor"; + type: "publicKey"; + index: false; + }, + { + name: "mintAuthority"; + type: "publicKey"; + index: false; + }, + { + name: "authorizedMinter"; + type: "publicKey"; + index: false; + }, + { + name: "maxTotal"; + type: { + option: "u64"; + }; + index: false; + }, + ]; + }, + { + name: "MintAuthorityRemovedEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "mintGovernor"; + type: "publicKey"; + index: false; + }, + { + name: "authorizedMinter"; + type: "publicKey"; + index: false; + }, + { + name: "totalMinted"; + type: "u64"; + index: false; + }, + ]; + }, + { + name: "MintGovernorAdminUpdatedEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "mintGovernor"; + type: "publicKey"; + index: false; + }, + { + name: "newAdmin"; + type: "publicKey"; + index: false; + }, + ]; + }, + { + name: "MintAuthorityReclaimedEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "mintGovernor"; + type: "publicKey"; + index: false; + }, + { + name: "mint"; + type: "publicKey"; + index: false; + }, + { + name: "newAuthority"; + type: "publicKey"; + index: false; + }, + ]; + }, + ]; + errors: [ + { + code: 6000; + name: "UnauthorizedAdmin"; + msg: "Unauthorized: signer is not the admin"; + }, + { + code: 6001; + name: "UnauthorizedMinter"; + msg: "Unauthorized: signer is not the authorized minter"; + }, + { + code: 6002; + name: "MintMismatch"; + msg: "Mint mismatch: mint_governor.mint does not match provided mint"; + }, + { + code: 6003; + name: "MintLimitExceeded"; + msg: "Mint limit exceeded: would exceed max_total"; + }, + ]; +}; + +export const IDL: MintGovernor = { + version: "0.7.0", + name: "mint_governor", + instructions: [ + { + name: "initializeMintGovernor", + accounts: [ + { + name: "mint", + isMut: false, + isSigner: false, + }, + { + name: "mintGovernor", + isMut: true, + isSigner: false, + }, + { + name: "createKey", + isMut: false, + isSigner: true, + }, + { + name: "admin", + isMut: false, + isSigner: false, + }, + { + name: "payer", + isMut: true, + isSigner: true, + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, + { + name: "transferAuthorityToGovernor", + accounts: [ + { + name: "mintGovernor", + isMut: true, + isSigner: false, + }, + { + name: "mint", + isMut: true, + isSigner: false, + }, + { + name: "currentAuthority", + isMut: false, + isSigner: true, + }, + { + name: "tokenProgram", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, + { + name: "addMintAuthority", + accounts: [ + { + name: "mintGovernor", + isMut: true, + isSigner: false, + }, + { + name: "mintAuthority", + isMut: true, + isSigner: false, + }, + { + name: "admin", + isMut: false, + isSigner: true, + }, + { + name: "authorizedMinter", + isMut: false, + isSigner: false, + }, + { + name: "payer", + isMut: true, + isSigner: true, + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: "args", + type: { + defined: "AddMintAuthorityArgs", + }, + }, + ], + }, + { + name: "mintTokens", + accounts: [ + { + name: "mintGovernor", + isMut: true, + isSigner: false, + }, + { + name: "mintAuthority", + isMut: true, + isSigner: false, + }, + { + name: "mint", + isMut: true, + isSigner: false, + }, + { + name: "destinationAta", + isMut: true, + isSigner: false, + }, + { + name: "authorizedMinter", + isMut: false, + isSigner: true, + }, + { + name: "tokenProgram", + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: "args", + type: { + defined: "MintTokensArgs", + }, + }, + ], + }, + { + name: "updateMintAuthority", + accounts: [ + { + name: "mintGovernor", + isMut: true, + isSigner: false, + }, + { + name: "mintAuthority", + isMut: true, + isSigner: false, + }, + { + name: "admin", + isMut: false, + isSigner: true, + }, + ], + args: [ + { + name: "args", + type: { + defined: "UpdateMintAuthorityArgs", + }, + }, + ], + }, + { + name: "removeMintAuthority", + accounts: [ + { + name: "mintGovernor", + isMut: true, + isSigner: false, + }, + { + name: "mintAuthority", + isMut: true, + isSigner: false, + }, + { + name: "admin", + isMut: false, + isSigner: true, + }, + { + name: "rentDestination", + isMut: true, + isSigner: false, + }, + ], + args: [], + }, + { + name: "updateMintGovernorAdmin", + accounts: [ + { + name: "mintGovernor", + isMut: true, + isSigner: false, + }, + { + name: "admin", + isMut: false, + isSigner: true, + }, + { + name: "newAdmin", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, + { + name: "reclaimAuthority", + accounts: [ + { + name: "mintGovernor", + isMut: true, + isSigner: false, + }, + { + name: "mint", + isMut: true, + isSigner: false, + }, + { + name: "admin", + isMut: false, + isSigner: true, + }, + { + name: "newAuthority", + isMut: false, + isSigner: false, + }, + { + name: "tokenProgram", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, + ], + accounts: [ + { + name: "mintAuthority", + type: { + kind: "struct", + fields: [ + { + name: "mintGovernor", + type: "publicKey", + }, + { + name: "authorizedMinter", + type: "publicKey", + }, + { + name: "maxTotal", + type: { + option: "u64", + }, + }, + { + name: "totalMinted", + type: "u64", + }, + { + name: "bump", + type: "u8", + }, + ], + }, + }, + { + name: "mintGovernor", + type: { + kind: "struct", + fields: [ + { + name: "mint", + type: "publicKey", + }, + { + name: "admin", + type: "publicKey", + }, + { + name: "createKey", + type: "publicKey", + }, + { + name: "seqNum", + type: "u64", + }, + { + name: "bump", + type: "u8", + }, + ], + }, + }, + ], + types: [ + { + name: "CommonFields", + type: { + kind: "struct", + fields: [ + { + name: "slot", + type: "u64", + }, + { + name: "unixTimestamp", + type: "i64", + }, + { + name: "mintGovernorSeqNum", + type: "u64", + }, + ], + }, + }, + { + name: "AddMintAuthorityArgs", + type: { + kind: "struct", + fields: [ + { + name: "maxTotal", + type: { + option: "u64", + }, + }, + ], + }, + }, + { + name: "MintTokensArgs", + type: { + kind: "struct", + fields: [ + { + name: "amount", + type: "u64", + }, + ], + }, + }, + { + name: "UpdateMintAuthorityArgs", + type: { + kind: "struct", + fields: [ + { + name: "maxTotal", + type: { + option: "u64", + }, + }, + ], + }, + }, + ], + events: [ + { + name: "MintGovernorInitializedEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "mintGovernor", + type: "publicKey", + index: false, + }, + { + name: "mint", + type: "publicKey", + index: false, + }, + { + name: "admin", + type: "publicKey", + index: false, + }, + { + name: "createKey", + type: "publicKey", + index: false, + }, + { + name: "pdaBump", + type: "u8", + index: false, + }, + ], + }, + { + name: "MintAuthorityTransferredEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "mintGovernor", + type: "publicKey", + index: false, + }, + { + name: "mint", + type: "publicKey", + index: false, + }, + ], + }, + { + name: "MintAuthorityAddedEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "mintGovernor", + type: "publicKey", + index: false, + }, + { + name: "mintAuthority", + type: "publicKey", + index: false, + }, + { + name: "authorizedMinter", + type: "publicKey", + index: false, + }, + { + name: "maxTotal", + type: { + option: "u64", + }, + index: false, + }, + ], + }, + { + name: "TokensMintedEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "mintGovernor", + type: "publicKey", + index: false, + }, + { + name: "mint", + type: "publicKey", + index: false, + }, + { + name: "authorizedMinter", + type: "publicKey", + index: false, + }, + { + name: "destinationAta", + type: "publicKey", + index: false, + }, + { + name: "amount", + type: "u64", + index: false, + }, + { + name: "postTotalMinted", + type: "u64", + index: false, + }, + { + name: "postMintSupply", + type: "u64", + index: false, + }, + ], + }, + { + name: "MintAuthorityUpdatedEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "mintGovernor", + type: "publicKey", + index: false, + }, + { + name: "mintAuthority", + type: "publicKey", + index: false, + }, + { + name: "authorizedMinter", + type: "publicKey", + index: false, + }, + { + name: "maxTotal", + type: { + option: "u64", + }, + index: false, + }, + ], + }, + { + name: "MintAuthorityRemovedEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "mintGovernor", + type: "publicKey", + index: false, + }, + { + name: "authorizedMinter", + type: "publicKey", + index: false, + }, + { + name: "totalMinted", + type: "u64", + index: false, + }, + ], + }, + { + name: "MintGovernorAdminUpdatedEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "mintGovernor", + type: "publicKey", + index: false, + }, + { + name: "newAdmin", + type: "publicKey", + index: false, + }, + ], + }, + { + name: "MintAuthorityReclaimedEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "mintGovernor", + type: "publicKey", + index: false, + }, + { + name: "mint", + type: "publicKey", + index: false, + }, + { + name: "newAuthority", + type: "publicKey", + index: false, + }, + ], + }, + ], + errors: [ + { + code: 6000, + name: "UnauthorizedAdmin", + msg: "Unauthorized: signer is not the admin", + }, + { + code: 6001, + name: "UnauthorizedMinter", + msg: "Unauthorized: signer is not the authorized minter", + }, + { + code: 6002, + name: "MintMismatch", + msg: "Mint mismatch: mint_governor.mint does not match provided mint", + }, + { + code: 6003, + name: "MintLimitExceeded", + msg: "Mint limit exceeded: would exceed max_total", + }, + ], +}; diff --git a/sdk/src/v0.7/utils/pda.ts b/sdk/src/v0.7/utils/pda.ts index c6a48b82..c06c9d3a 100644 --- a/sdk/src/v0.7/utils/pda.ts +++ b/sdk/src/v0.7/utils/pda.ts @@ -15,11 +15,10 @@ import { PRICE_BASED_PERFORMANCE_PACKAGE_PROGRAM_ID, RAYDIUM_CP_SWAP_PROGRAM_ID, SHARED_LIQUIDITY_MANAGER_PROGRAM_ID, -} from "../constants.js"; -import { LAUNCHPAD_PROGRAM_ID, FUTARCHY_PROGRAM_ID, BID_WALL_PROGRAM_ID, + MINT_GOVERNOR_PROGRAM_ID, } from "../constants.js"; export const getEventAuthorityAddr = (programId: PublicKey) => { @@ -262,3 +261,37 @@ export const getBidWallAddr = ({ programId, ); }; + +export const getMintGovernorAddr = ({ + programId = MINT_GOVERNOR_PROGRAM_ID, + mint, + createKey, +}: { + programId?: PublicKey; + mint: PublicKey; + createKey: PublicKey; +}) => { + return PublicKey.findProgramAddressSync( + [Buffer.from("mint_governor"), mint.toBuffer(), createKey.toBuffer()], + programId, + ); +}; + +export const getMintAuthorityAddr = ({ + programId = MINT_GOVERNOR_PROGRAM_ID, + mintGovernor, + authorizedMinter, +}: { + programId?: PublicKey; + mintGovernor: PublicKey; + authorizedMinter: PublicKey; +}) => { + return PublicKey.findProgramAddressSync( + [ + Buffer.from("mint_authority"), + mintGovernor.toBuffer(), + authorizedMinter.toBuffer(), + ], + programId, + ); +}; diff --git a/tests/main.test.ts b/tests/main.test.ts index a6a6cbc2..c06145e7 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -4,6 +4,7 @@ import launchpad from "./launchpad/main.test.js"; import launchpad_v7 from "./launchpad_v7/main.test.js"; import priceBasedPerformancePackage from "./priceBasedPerformancePackage/main.test.js"; import bidWall from "./bidWall/main.test.js"; +import mintGovernor from "./mintGovernor/main.test.js"; import { BanksClient, @@ -34,6 +35,7 @@ import { LAUNCHPAD_PROGRAM_ID, MAINNET_METEORA_CONFIG, BidWallClient, + MintGovernorClient, } from "@metadaoproject/futarchy/v0.7"; import { LaunchpadClient as LaunchpadClientV6 } from "@metadaoproject/futarchy/v0.6"; @@ -89,6 +91,7 @@ export interface TestContext { launchpad_v6: LaunchpadClientV6; priceBasedPerformancePackage: PriceBasedPerformancePackageClient; bidWall: BidWallClient; + mintGovernor: MintGovernorClient; payer: Keypair; squadsConnection: Connection; createTokenAccount: (mint: PublicKey, owner: PublicKey) => Promise; @@ -737,6 +740,7 @@ describe("price_based_performance_package", priceBasedPerformancePackage); describe("conditional_vault", conditionalVault); describe("futarchy", futarchy); describe("bid_wall", bidWall); +describe("mint_governor", mintGovernor); describe("project-wide integration tests", function () { it.skip("mint and swap in a single transaction", mintAndSwap); describe("full launch v6", fullLaunch); diff --git a/tests/mintGovernor/main.test.ts b/tests/mintGovernor/main.test.ts new file mode 100644 index 00000000..8c13c97b --- /dev/null +++ b/tests/mintGovernor/main.test.ts @@ -0,0 +1,28 @@ +import initializeMintGovernor from "./unit/initializeMintGovernor.test.js"; +import transferAuthorityToGovernor from "./unit/transferAuthorityToGovernor.test.js"; +import addMintAuthority from "./unit/addMintAuthority.test.js"; +import updateMintAuthority from "./unit/updateMintAuthority.test.js"; +import removeMintAuthority from "./unit/removeMintAuthority.test.js"; +import mintTokens from "./unit/mintTokens.test.js"; +import updateMintGovernorAdmin from "./unit/updateMintGovernorAdmin.test.js"; +import reclaimAuthority from "./unit/reclaimAuthority.test.js"; +import { MintGovernorClient } from "@metadaoproject/futarchy/v0.7"; +import { BankrunProvider } from "anchor-bankrun"; + +export default function suite() { + before(async function () { + const provider = new BankrunProvider(this.context); + this.mintGovernor = MintGovernorClient.createClient({ + provider: provider as any, + }); + }); + + describe("#initialize_mint_governor", initializeMintGovernor); + describe("#transfer_authority_to_governor", transferAuthorityToGovernor); + describe("#add_mint_authority", addMintAuthority); + describe("#update_mint_authority", updateMintAuthority); + describe("#remove_mint_authority", removeMintAuthority); + describe("#mint_tokens", mintTokens); + describe("#update_mint_governor_admin", updateMintGovernorAdmin); + describe("#reclaim_authority", reclaimAuthority); +} diff --git a/tests/mintGovernor/unit/addMintAuthority.test.ts b/tests/mintGovernor/unit/addMintAuthority.test.ts new file mode 100644 index 00000000..5f77a04e --- /dev/null +++ b/tests/mintGovernor/unit/addMintAuthority.test.ts @@ -0,0 +1,143 @@ +import { Keypair, PublicKey } from "@solana/web3.js"; +import BN from "bn.js"; +import { assert } from "chai"; +import { + MintGovernorClient, + getMintAuthorityAddr, +} from "@metadaoproject/futarchy/v0.7"; +import { setupMintWithGovernor } from "../utils.js"; +import { expectError } from "../../utils.js"; + +export default function suite() { + let mintGovernorClient: MintGovernorClient; + let mintGovernor: PublicKey; + let authorizedMinter: Keypair; + + before(async function () { + mintGovernorClient = this.mintGovernor; + }); + + beforeEach(async function () { + ({ mintGovernor } = await setupMintWithGovernor( + this.banksClient, + mintGovernorClient, + this.payer, + )); + authorizedMinter = Keypair.generate(); + }); + + it("successfully adds mint authority with max_total", async function () { + const maxTotal = new BN(1000); + + await mintGovernorClient + .addMintAuthorityIx({ + mintGovernor, + admin: this.payer.publicKey, + authorizedMinter: authorizedMinter.publicKey, + maxTotal, + }) + .rpc(); + + const [mintAuthority] = getMintAuthorityAddr({ + mintGovernor, + authorizedMinter: authorizedMinter.publicKey, + }); + + const mintAuthorityAccount = + await mintGovernorClient.fetchMintAuthority(mintAuthority); + + assert.isNotNull(mintAuthorityAccount); + assert.equal( + mintAuthorityAccount.mintGovernor.toBase58(), + mintGovernor.toBase58(), + ); + assert.equal( + mintAuthorityAccount.authorizedMinter.toBase58(), + authorizedMinter.publicKey.toBase58(), + ); + assert.equal(mintAuthorityAccount.maxTotal.toString(), maxTotal.toString()); + assert.equal(mintAuthorityAccount.totalMinted.toString(), "0"); + + const mintGovernorAccount = + await mintGovernorClient.fetchMintGovernor(mintGovernor); + assert.equal(mintGovernorAccount.seqNum.toString(), "2"); // 1 from transfer, 1 from add + }); + + it("successfully adds mint authority without max_total (unlimited)", async function () { + await mintGovernorClient + .addMintAuthorityIx({ + mintGovernor, + admin: this.payer.publicKey, + authorizedMinter: authorizedMinter.publicKey, + maxTotal: null, + }) + .rpc(); + + const [mintAuthority] = getMintAuthorityAddr({ + mintGovernor, + authorizedMinter: authorizedMinter.publicKey, + }); + + const mintAuthorityAccount = + await mintGovernorClient.fetchMintAuthority(mintAuthority); + + assert.isNotNull(mintAuthorityAccount); + assert.equal( + mintAuthorityAccount.mintGovernor.toBase58(), + mintGovernor.toBase58(), + ); + assert.equal( + mintAuthorityAccount.authorizedMinter.toBase58(), + authorizedMinter.publicKey.toBase58(), + ); + assert.isNull(mintAuthorityAccount.maxTotal); + assert.equal(mintAuthorityAccount.totalMinted.toString(), "0"); + }); + + it("fails when admin is not the governor's admin", async function () { + const fakeAdmin = Keypair.generate(); + + const callbacks = expectError( + "UnauthorizedAdmin", + "Should have failed due to unauthorized admin", + ); + + await mintGovernorClient + .addMintAuthorityIx({ + mintGovernor, + admin: fakeAdmin.publicKey, + authorizedMinter: authorizedMinter.publicKey, + maxTotal: new BN(1000), + }) + .signers([fakeAdmin]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("fails when mint_authority already exists", async function () { + await mintGovernorClient + .addMintAuthorityIx({ + mintGovernor, + admin: this.payer.publicKey, + authorizedMinter: authorizedMinter.publicKey, + maxTotal: new BN(1000), + }) + .rpc(); + + try { + await mintGovernorClient + .addMintAuthorityIx({ + mintGovernor, + admin: this.payer.publicKey, + authorizedMinter: authorizedMinter.publicKey, + maxTotal: new BN(500), + }) + .rpc(); + + assert.fail("Should have failed because mint_authority already exists"); + } catch (e) { + // The init constraint will fail because the account already exists (system program error 0x0) + assert.include(e.message, "custom program error: 0x0"); + } + }); +} diff --git a/tests/mintGovernor/unit/initializeMintGovernor.test.ts b/tests/mintGovernor/unit/initializeMintGovernor.test.ts new file mode 100644 index 00000000..a80ecb52 --- /dev/null +++ b/tests/mintGovernor/unit/initializeMintGovernor.test.ts @@ -0,0 +1,78 @@ +import { Keypair, PublicKey } from "@solana/web3.js"; +import { assert } from "chai"; +import { + MintGovernorClient, + getMintGovernorAddr, +} from "@metadaoproject/futarchy/v0.7"; +import { createMintWithAuthority } from "../utils.js"; + +export default function suite() { + let mintGovernorClient: MintGovernorClient; + let mint: PublicKey; + let createKey: Keypair; + + before(async function () { + mintGovernorClient = this.mintGovernor; + }); + + beforeEach(async function () { + mint = await createMintWithAuthority( + this.banksClient, + this.payer, + this.payer.publicKey, + 6, + ); + createKey = Keypair.generate(); + }); + + it("successfully initializes a mint governor", async function () { + const [mintGovernor, expectedBump] = getMintGovernorAddr({ + mint, + createKey: createKey.publicKey, + }); + + await mintGovernorClient + .initializeMintGovernorIx({ + mint, + createKey: createKey.publicKey, + admin: this.payer.publicKey, + payer: this.payer.publicKey, + }) + .signers([createKey]) + .rpc(); + + const mintGovernorAccount = + await mintGovernorClient.fetchMintGovernor(mintGovernor); + + assert.isNotNull(mintGovernorAccount); + assert.equal(mintGovernorAccount.mint.toBase58(), mint.toBase58()); + assert.equal( + mintGovernorAccount.admin.toBase58(), + this.payer.publicKey.toBase58(), + ); + assert.equal( + mintGovernorAccount.createKey.toBase58(), + createKey.publicKey.toBase58(), + ); + assert.equal(mintGovernorAccount.seqNum.toString(), "0"); + assert.equal(mintGovernorAccount.bump, expectedBump); + }); + + it("fails when create_key does not sign", async function () { + try { + await mintGovernorClient + .initializeMintGovernorIx({ + mint, + createKey: createKey.publicKey, + admin: this.payer.publicKey, + payer: this.payer.publicKey, + }) + // Intentionally NOT adding createKey as a signer + .rpc(); + + assert.fail("Should have failed due to missing createKey signature"); + } catch (e) { + assert.include(e.message, "Signature verification failed"); + } + }); +} diff --git a/tests/mintGovernor/unit/mintTokens.test.ts b/tests/mintGovernor/unit/mintTokens.test.ts new file mode 100644 index 00000000..399147a8 --- /dev/null +++ b/tests/mintGovernor/unit/mintTokens.test.ts @@ -0,0 +1,458 @@ +import { Keypair, PublicKey } from "@solana/web3.js"; +import * as token from "@solana/spl-token"; +import BN from "bn.js"; +import { assert } from "chai"; +import { + MintGovernorClient, + getMintAuthorityAddr, +} from "@metadaoproject/futarchy/v0.7"; +import { + setupMintWithGovernor, + createMintAndGovernor, + createMintWithAuthority, +} from "../utils.js"; +import { expectError } from "../../utils.js"; + +export default function suite() { + let mintGovernorClient: MintGovernorClient; + let mint: PublicKey; + let mintGovernor: PublicKey; + let authorizedMinter: Keypair; + let destinationAta: PublicKey; + + before(async function () { + mintGovernorClient = this.mintGovernor; + }); + + beforeEach(async function () { + ({ mint, mintGovernor } = await setupMintWithGovernor( + this.banksClient, + mintGovernorClient, + this.payer, + )); + authorizedMinter = Keypair.generate(); + destinationAta = await this.createTokenAccount(mint, this.payer.publicKey); + }); + + it("successfully mints tokens within limit", async function () { + const maxTotal = new BN(1000); + const mintAmount = new BN(500); + + await mintGovernorClient + .addMintAuthorityIx({ + mintGovernor, + admin: this.payer.publicKey, + authorizedMinter: authorizedMinter.publicKey, + maxTotal, + }) + .rpc(); + + await mintGovernorClient + .mintTokensIx({ + mintGovernor, + mint, + destinationAta, + authorizedMinter: authorizedMinter.publicKey, + amount: mintAmount, + }) + .signers([authorizedMinter]) + .rpc(); + + const balance = await this.getTokenBalance(mint, this.payer.publicKey); + assert.equal(balance.toString(), mintAmount.toString()); + + const [mintAuthority] = getMintAuthorityAddr({ + mintGovernor, + authorizedMinter: authorizedMinter.publicKey, + }); + const mintAuthorityAccount = + await mintGovernorClient.fetchMintAuthority(mintAuthority); + assert.equal( + mintAuthorityAccount.totalMinted.toString(), + mintAmount.toString(), + ); + }); + + it("successfully mints tokens with unlimited authority", async function () { + const mintAmount = new BN(5000); + + await mintGovernorClient + .addMintAuthorityIx({ + mintGovernor, + admin: this.payer.publicKey, + authorizedMinter: authorizedMinter.publicKey, + maxTotal: null, + }) + .rpc(); + + await mintGovernorClient + .mintTokensIx({ + mintGovernor, + mint, + destinationAta, + authorizedMinter: authorizedMinter.publicKey, + amount: mintAmount, + }) + .signers([authorizedMinter]) + .rpc(); + + const balance = await this.getTokenBalance(mint, this.payer.publicKey); + assert.equal(balance.toString(), mintAmount.toString()); + + const [mintAuthority] = getMintAuthorityAddr({ + mintGovernor, + authorizedMinter: authorizedMinter.publicKey, + }); + const mintAuthorityAccount = + await mintGovernorClient.fetchMintAuthority(mintAuthority); + assert.isNull(mintAuthorityAccount.maxTotal); + assert.equal( + mintAuthorityAccount.totalMinted.toString(), + mintAmount.toString(), + ); + }); + + it("successfully mints tokens up to exact limit", async function () { + const maxTotal = new BN(1000); + + await mintGovernorClient + .addMintAuthorityIx({ + mintGovernor, + admin: this.payer.publicKey, + authorizedMinter: authorizedMinter.publicKey, + maxTotal, + }) + .rpc(); + + await mintGovernorClient + .mintTokensIx({ + mintGovernor, + mint, + destinationAta, + authorizedMinter: authorizedMinter.publicKey, + amount: maxTotal, + }) + .signers([authorizedMinter]) + .rpc(); + + const balance = await this.getTokenBalance(mint, this.payer.publicKey); + assert.equal(balance.toString(), maxTotal.toString()); + + const [mintAuthority] = getMintAuthorityAddr({ + mintGovernor, + authorizedMinter: authorizedMinter.publicKey, + }); + const mintAuthorityAccount = + await mintGovernorClient.fetchMintAuthority(mintAuthority); + assert.equal( + mintAuthorityAccount.totalMinted.toString(), + maxTotal.toString(), + ); + }); + + it("successfully mints multiple times accumulating total_minted", async function () { + const maxTotal = new BN(1000); + const firstMint = new BN(300); + const secondMint = new BN(400); + const thirdMint = new BN(200); + + await mintGovernorClient + .addMintAuthorityIx({ + mintGovernor, + admin: this.payer.publicKey, + authorizedMinter: authorizedMinter.publicKey, + maxTotal, + }) + .rpc(); + + await mintGovernorClient + .mintTokensIx({ + mintGovernor, + mint, + destinationAta, + authorizedMinter: authorizedMinter.publicKey, + amount: firstMint, + }) + .signers([authorizedMinter]) + .rpc(); + + const [mintAuthority] = getMintAuthorityAddr({ + mintGovernor, + authorizedMinter: authorizedMinter.publicKey, + }); + let mintAuthorityAccount = + await mintGovernorClient.fetchMintAuthority(mintAuthority); + assert.equal( + mintAuthorityAccount.totalMinted.toString(), + firstMint.toString(), + ); + + await mintGovernorClient + .mintTokensIx({ + mintGovernor, + mint, + destinationAta, + authorizedMinter: authorizedMinter.publicKey, + amount: secondMint, + }) + .signers([authorizedMinter]) + .rpc(); + + mintAuthorityAccount = + await mintGovernorClient.fetchMintAuthority(mintAuthority); + assert.equal( + mintAuthorityAccount.totalMinted.toString(), + firstMint.add(secondMint).toString(), + ); + + await mintGovernorClient + .mintTokensIx({ + mintGovernor, + mint, + destinationAta, + authorizedMinter: authorizedMinter.publicKey, + amount: thirdMint, + }) + .signers([authorizedMinter]) + .rpc(); + + mintAuthorityAccount = + await mintGovernorClient.fetchMintAuthority(mintAuthority); + assert.equal( + mintAuthorityAccount.totalMinted.toString(), + firstMint.add(secondMint).add(thirdMint).toString(), + ); + + const balance = await this.getTokenBalance(mint, this.payer.publicKey); + assert.equal( + balance.toString(), + firstMint.add(secondMint).add(thirdMint).toString(), + ); + }); + + it("fails when amount exceeds remaining quota", async function () { + const maxTotal = new BN(1000); + const firstMint = new BN(800); + const secondMint = new BN(300); // Would exceed by 100 + + await mintGovernorClient + .addMintAuthorityIx({ + mintGovernor, + admin: this.payer.publicKey, + authorizedMinter: authorizedMinter.publicKey, + maxTotal, + }) + .rpc(); + + await mintGovernorClient + .mintTokensIx({ + mintGovernor, + mint, + destinationAta, + authorizedMinter: authorizedMinter.publicKey, + amount: firstMint, + }) + .signers([authorizedMinter]) + .rpc(); + + const callbacks = expectError( + "MintLimitExceeded", + "Should have failed due to mint limit exceeded", + ); + + await mintGovernorClient + .mintTokensIx({ + mintGovernor, + mint, + destinationAta, + authorizedMinter: authorizedMinter.publicKey, + amount: secondMint, + }) + .signers([authorizedMinter]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("fails when authorized_minter is not the signer", async function () { + const maxTotal = new BN(1000); + const fakeMinter = Keypair.generate(); + + await mintGovernorClient + .addMintAuthorityIx({ + mintGovernor, + admin: this.payer.publicKey, + authorizedMinter: authorizedMinter.publicKey, + maxTotal, + }) + .rpc(); + + // Get the mint_authority PDA that was created for the real authorizedMinter + const [mintAuthority] = getMintAuthorityAddr({ + mintGovernor, + authorizedMinter: authorizedMinter.publicKey, + }); + + const callbacks = expectError( + "UnauthorizedMinter", + "Should have failed due to unauthorized minter", + ); + + // Manually construct the instruction to pass fakeMinter as signer + // but use the mint_authority created for the real authorizedMinter + await mintGovernorClient.program.methods + .mintTokens({ amount: new BN(500) }) + .accounts({ + mintGovernor, + mintAuthority, + mint, + destinationAta, + authorizedMinter: fakeMinter.publicKey, + tokenProgram: token.TOKEN_PROGRAM_ID, + }) + .signers([fakeMinter]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("fails when governor does not hold mint authority", async function () { + // Create a governor without transferring mint authority + const { mint: mintWithoutAuth, mintGovernor: governorWithoutAuth } = + await createMintAndGovernor( + this.banksClient, + mintGovernorClient, + this.payer, + ); + + await mintGovernorClient + .addMintAuthorityIx({ + mintGovernor: governorWithoutAuth, + admin: this.payer.publicKey, + authorizedMinter: authorizedMinter.publicKey, + maxTotal: new BN(1000), + }) + .rpc(); + + const destAccount = await this.createTokenAccount( + mintWithoutAuth, + this.payer.publicKey, + ); + + try { + await mintGovernorClient + .mintTokensIx({ + mintGovernor: governorWithoutAuth, + mint: mintWithoutAuth, + destinationAta: destAccount, + authorizedMinter: authorizedMinter.publicKey, + amount: new BN(500), + }) + .signers([authorizedMinter]) + .rpc(); + + assert.fail( + "Should have failed because governor does not hold mint authority", + ); + } catch (e) { + // The mint_to CPI will fail because the governor PDA is not the mint authority + // Token program returns error code 0x4 ("owner does not match") + assert.include(e.message, "custom program error: 0x4"); + } + }); + + it("fails when mint_authority.mint_governor does not match", async function () { + // Create a second governor + const { mintGovernor: otherGovernor } = await setupMintWithGovernor( + this.banksClient, + mintGovernorClient, + this.payer, + ); + + // Add authority to the other governor + await mintGovernorClient + .addMintAuthorityIx({ + mintGovernor: otherGovernor, + admin: this.payer.publicKey, + authorizedMinter: authorizedMinter.publicKey, + maxTotal: new BN(1000), + }) + .rpc(); + + // Try to mint using the first governor but with authority from the second + const [wrongMintAuthority] = getMintAuthorityAddr({ + mintGovernor: otherGovernor, + authorizedMinter: authorizedMinter.publicKey, + }); + + try { + // Manually construct the instruction to pass the wrong mint_authority + await mintGovernorClient.program.methods + .mintTokens({ amount: new BN(500) }) + .accounts({ + mintGovernor, + mintAuthority: wrongMintAuthority, + mint, + destinationAta, + authorizedMinter: authorizedMinter.publicKey, + tokenProgram: token.TOKEN_PROGRAM_ID, + }) + .signers([authorizedMinter]) + .rpc(); + + assert.fail( + "Should have failed due to mint_authority.mint_governor mismatch", + ); + } catch (e) { + // Anchor's has_one constraint will check that mint_authority.mint_governor == mint_governor + assert.include(e.message, "A has one constraint was violated"); + } + }); + + it("fails when destination token account has wrong mint", async function () { + const maxTotal = new BN(1000); + + // Create a different mint + const wrongMint = await createMintWithAuthority( + this.banksClient, + this.payer, + this.payer.publicKey, + ); + + // Create destination for the WRONG mint + const wrongDestination = await this.createTokenAccount( + wrongMint, + this.payer.publicKey, + ); + + await mintGovernorClient + .addMintAuthorityIx({ + mintGovernor, + admin: this.payer.publicKey, + authorizedMinter: authorizedMinter.publicKey, + maxTotal, + }) + .rpc(); + + try { + await mintGovernorClient.program.methods + .mintTokens({ amount: new BN(500) }) + .accounts({ + mintGovernor, + mintAuthority: getMintAuthorityAddr({ + mintGovernor, + authorizedMinter: authorizedMinter.publicKey, + })[0], + mint, + destinationAta: wrongDestination, // Wrong mint! + authorizedMinter: authorizedMinter.publicKey, + tokenProgram: token.TOKEN_PROGRAM_ID, + }) + .signers([authorizedMinter]) + .rpc(); + + assert.fail("Should have failed due to destination mint mismatch"); + } catch (e) { + // Anchor's has_one constraint will check that destination_ata.mint == mint + assert.include(e.message, "A has one constraint was violated"); + } + }); +} diff --git a/tests/mintGovernor/unit/reclaimAuthority.test.ts b/tests/mintGovernor/unit/reclaimAuthority.test.ts new file mode 100644 index 00000000..c0b247b1 --- /dev/null +++ b/tests/mintGovernor/unit/reclaimAuthority.test.ts @@ -0,0 +1,262 @@ +import { ComputeBudgetProgram, Keypair, PublicKey } from "@solana/web3.js"; +import * as token from "@solana/spl-token"; +import BN from "bn.js"; +import { assert } from "chai"; +import { + MintGovernorClient, + getMintAuthorityAddr, +} from "@metadaoproject/futarchy/v0.7"; +import { setupMintWithGovernor, createMintAndGovernor } from "../utils.js"; +import { expectError } from "../../utils.js"; + +export default function suite() { + let mintGovernorClient: MintGovernorClient; + let mint: PublicKey; + let mintGovernor: PublicKey; + + before(async function () { + mintGovernorClient = this.mintGovernor; + }); + + beforeEach(async function () { + ({ mint, mintGovernor } = await setupMintWithGovernor( + this.banksClient, + mintGovernorClient, + this.payer, + )); + }); + + it("successfully reclaims authority to new address", async function () { + const newAuthority = Keypair.generate(); + + // Verify governor currently holds mint authority + const mintAccountBefore = await token.getMint( + this.provider.connection, + mint, + ); + assert.isTrue(mintAccountBefore.mintAuthority.equals(mintGovernor)); + + await mintGovernorClient + .reclaimAuthorityIx({ + mintGovernor, + mint, + admin: this.payer.publicKey, + newAuthority: newAuthority.publicKey, + }) + .rpc(); + + // Verify mint authority was transferred to new address + const mintAccountAfter = await token.getMint( + this.provider.connection, + mint, + ); + assert.isTrue( + mintAccountAfter.mintAuthority.equals(newAuthority.publicKey), + ); + + // Verify seq_num was incremented + const mintGovernorAccount = + await mintGovernorClient.fetchMintGovernor(mintGovernor); + // seq_num: 1 from transfer_authority_to_governor, 1 from reclaim = 2 + assert.equal(mintGovernorAccount.seqNum.toString(), "2"); + }); + + it("successfully reclaims authority back to admin", async function () { + // Verify governor currently holds mint authority + const mintAccountBefore = await token.getMint( + this.provider.connection, + mint, + ); + assert.isTrue(mintAccountBefore.mintAuthority.equals(mintGovernor)); + + await mintGovernorClient + .reclaimAuthorityIx({ + mintGovernor, + mint, + admin: this.payer.publicKey, + newAuthority: this.payer.publicKey, + }) + .rpc(); + + // Verify mint authority was transferred back to admin + const mintAccountAfter = await token.getMint( + this.provider.connection, + mint, + ); + assert.isTrue(mintAccountAfter.mintAuthority.equals(this.payer.publicKey)); + }); + + it("existing mint authorities cannot mint after reclaim", async function () { + const authorizedMinter = Keypair.generate(); + const newAuthority = Keypair.generate(); + + // Add a mint authority + await mintGovernorClient + .addMintAuthorityIx({ + mintGovernor, + admin: this.payer.publicKey, + authorizedMinter: authorizedMinter.publicKey, + maxTotal: new BN(1000), + }) + .rpc(); + + // Create destination token account + const destinationAta = await this.createTokenAccount( + mint, + this.payer.publicKey, + ); + + // Verify minting works before reclaim + await mintGovernorClient + .mintTokensIx({ + mintGovernor, + mint, + destinationAta, + authorizedMinter: authorizedMinter.publicKey, + amount: new BN(100), + }) + .signers([authorizedMinter]) + .rpc(); + + const balanceBefore = await this.getTokenBalance( + mint, + this.payer.publicKey, + ); + assert.equal(balanceBefore.toString(), "100"); + + // Reclaim authority + await mintGovernorClient + .reclaimAuthorityIx({ + mintGovernor, + mint, + admin: this.payer.publicKey, + newAuthority: newAuthority.publicKey, + }) + .rpc(); + + // Try to mint with the existing mint authority - should fail + try { + await mintGovernorClient + .mintTokensIx({ + mintGovernor, + mint, + destinationAta, + authorizedMinter: authorizedMinter.publicKey, + amount: new BN(100), + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 2_000_001 }), + ]) + .signers([authorizedMinter]) + .rpc(); + + assert.fail( + "Should have failed because governor no longer holds mint authority", + ); + } catch (e) { + // The mint_to CPI will fail because the governor PDA is no longer the mint authority + // Token program returns error code 0x4 ("owner does not match") + assert.include(e.message, "custom program error: 0x4"); + } + }); + + it("mint authorities can still be removed after reclaim", async function () { + const authorizedMinter = Keypair.generate(); + const newAuthority = Keypair.generate(); + + // Add a mint authority + await mintGovernorClient + .addMintAuthorityIx({ + mintGovernor, + admin: this.payer.publicKey, + authorizedMinter: authorizedMinter.publicKey, + maxTotal: new BN(1000), + }) + .rpc(); + + const [mintAuthority] = getMintAuthorityAddr({ + mintGovernor, + authorizedMinter: authorizedMinter.publicKey, + }); + + // Verify the mint authority exists + const mintAuthorityAccountBefore = + await this.banksClient.getAccount(mintAuthority); + assert.isNotNull(mintAuthorityAccountBefore); + + // Reclaim authority + await mintGovernorClient + .reclaimAuthorityIx({ + mintGovernor, + mint, + admin: this.payer.publicKey, + newAuthority: newAuthority.publicKey, + }) + .rpc(); + + // Remove the mint authority - should succeed even after reclaim + await mintGovernorClient + .removeMintAuthorityIx({ + mintGovernor, + mintAuthority, + admin: this.payer.publicKey, + rentDestination: this.payer.publicKey, + }) + .rpc(); + + // Verify the mint authority account was closed + const mintAuthorityAccountAfter = + await this.banksClient.getAccount(mintAuthority); + assert.isNull(mintAuthorityAccountAfter); + }); + + it("fails when admin is not the governor's admin", async function () { + const fakeAdmin = Keypair.generate(); + const newAuthority = Keypair.generate(); + + const callbacks = expectError( + "UnauthorizedAdmin", + "Should have failed due to unauthorized admin", + ); + + await mintGovernorClient + .reclaimAuthorityIx({ + mintGovernor, + mint, + admin: fakeAdmin.publicKey, + newAuthority: newAuthority.publicKey, + }) + .signers([fakeAdmin]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("fails when governor does not currently hold mint authority", async function () { + // Create a governor without transferring mint authority + const { mint: mintWithoutAuth, mintGovernor: governorWithoutAuth } = + await createMintAndGovernor( + this.banksClient, + mintGovernorClient, + this.payer, + ); + + try { + await mintGovernorClient + .reclaimAuthorityIx({ + mintGovernor: governorWithoutAuth, + mint: mintWithoutAuth, + admin: this.payer.publicKey, + newAuthority: this.payer.publicKey, + }) + .rpc(); + + assert.fail( + "Should have failed because governor does not hold mint authority", + ); + } catch (e) { + // The set_authority CPI will fail because the governor PDA is not the mint authority + // Token program returns error code 0x4 ("owner does not match") + assert.include(e.message, "custom program error: 0x4"); + } + }); +} diff --git a/tests/mintGovernor/unit/removeMintAuthority.test.ts b/tests/mintGovernor/unit/removeMintAuthority.test.ts new file mode 100644 index 00000000..c060ccfc --- /dev/null +++ b/tests/mintGovernor/unit/removeMintAuthority.test.ts @@ -0,0 +1,156 @@ +import { Keypair, PublicKey } from "@solana/web3.js"; +import BN from "bn.js"; +import { assert } from "chai"; +import { + MintGovernorClient, + getMintAuthorityAddr, +} from "@metadaoproject/futarchy/v0.7"; +import { setupMintWithGovernor } from "../utils.js"; +import { expectError } from "../../utils.js"; + +export default function suite() { + let mintGovernorClient: MintGovernorClient; + let authorizedMinter: Keypair; + let mintAuthority: PublicKey; + + before(async function () { + mintGovernorClient = this.mintGovernor; + }); + + beforeEach(async function () { + const result = await setupMintWithGovernor( + this.banksClient, + mintGovernorClient, + this.payer, + ); + this.mint = result.mint; + this.mintGovernorAddr = result.mintGovernor; + authorizedMinter = Keypair.generate(); + + // Add a mint authority for testing removal + await mintGovernorClient + .addMintAuthorityIx({ + mintGovernor: this.mintGovernorAddr, + admin: this.payer.publicKey, + authorizedMinter: authorizedMinter.publicKey, + maxTotal: new BN(1000), + }) + .rpc(); + + [mintAuthority] = getMintAuthorityAddr({ + mintGovernor: this.mintGovernorAddr, + authorizedMinter: authorizedMinter.publicKey, + }); + }); + + it("successfully removes mint authority", async function () { + // Verify the mint authority exists before removal + const mintAuthorityAccountBefore = + await this.banksClient.getAccount(mintAuthority); + assert.isNotNull(mintAuthorityAccountBefore); + + await mintGovernorClient + .removeMintAuthorityIx({ + mintGovernor: this.mintGovernorAddr, + mintAuthority, + admin: this.payer.publicKey, + rentDestination: this.payer.publicKey, + }) + .rpc(); + + // Verify the mint authority account was closed + const mintAuthorityAccountAfter = + await this.banksClient.getAccount(mintAuthority); + assert.isNull(mintAuthorityAccountAfter); + + // Verify seq_num was incremented + const mintGovernorAccount = await mintGovernorClient.fetchMintGovernor( + this.mintGovernorAddr, + ); + // seq_num: 1 from transfer, 1 from add, 1 from remove = 3 + assert.equal(mintGovernorAccount.seqNum.toString(), "3"); + }); + + it("successfully removes mint authority that has minted tokens", async function () { + // First, mint some tokens + const destinationAta = await this.createTokenAccount( + this.mint, + this.payer.publicKey, + ); + + await mintGovernorClient + .mintTokensIx({ + mintGovernor: this.mintGovernorAddr, + mint: this.mint, + destinationAta, + authorizedMinter: authorizedMinter.publicKey, + amount: new BN(500), + }) + .signers([authorizedMinter]) + .rpc(); + + // Verify tokens were minted + const mintAuthorityAccountBefore = + await mintGovernorClient.fetchMintAuthority(mintAuthority); + assert.equal(mintAuthorityAccountBefore.totalMinted.toString(), "500"); + + // Now remove the mint authority + await mintGovernorClient + .removeMintAuthorityIx({ + mintGovernor: this.mintGovernorAddr, + mintAuthority, + admin: this.payer.publicKey, + rentDestination: this.payer.publicKey, + }) + .rpc(); + + // Verify the mint authority account was closed + const mintAuthorityAccountAfter = + await this.banksClient.getAccount(mintAuthority); + assert.isNull(mintAuthorityAccountAfter); + }); + + it("fails when admin is not the governor's admin", async function () { + const fakeAdmin = Keypair.generate(); + + const callbacks = expectError( + "UnauthorizedAdmin", + "Should have failed due to unauthorized admin", + ); + + await mintGovernorClient + .removeMintAuthorityIx({ + mintGovernor: this.mintGovernorAddr, + mintAuthority, + admin: fakeAdmin.publicKey, + rentDestination: fakeAdmin.publicKey, + }) + .signers([fakeAdmin]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("fails when mint_authority does not exist", async function () { + const nonExistentMinter = Keypair.generate(); + const [nonExistentMintAuthority] = getMintAuthorityAddr({ + mintGovernor: this.mintGovernorAddr, + authorizedMinter: nonExistentMinter.publicKey, + }); + + try { + await mintGovernorClient + .removeMintAuthorityIx({ + mintGovernor: this.mintGovernorAddr, + mintAuthority: nonExistentMintAuthority, + admin: this.payer.publicKey, + rentDestination: this.payer.publicKey, + }) + .rpc(); + + assert.fail("Should have failed because mint_authority does not exist"); + } catch (e) { + // Account does not exist error + assert.include(e.message, "AccountNotInitialized"); + } + }); +} diff --git a/tests/mintGovernor/unit/transferAuthorityToGovernor.test.ts b/tests/mintGovernor/unit/transferAuthorityToGovernor.test.ts new file mode 100644 index 00000000..d34823c2 --- /dev/null +++ b/tests/mintGovernor/unit/transferAuthorityToGovernor.test.ts @@ -0,0 +1,164 @@ +import { Keypair, PublicKey } from "@solana/web3.js"; +import * as token from "@solana/spl-token"; +import { assert } from "chai"; +import { MintGovernorClient } from "@metadaoproject/futarchy/v0.7"; +import { createMintWithAuthority, createMintAndGovernor } from "../utils.js"; +import { expectError } from "../../utils.js"; + +export default function suite() { + let mintGovernorClient: MintGovernorClient; + let mint: PublicKey; + let mintGovernor: PublicKey; + + before(async function () { + mintGovernorClient = this.mintGovernor; + }); + + beforeEach(async function () { + ({ mint, mintGovernor } = await createMintAndGovernor( + this.banksClient, + mintGovernorClient, + this.payer, + )); + }); + + it("successfully transfers mint authority to governor", async function () { + // Verify mint authority is currently the payer + const mintAccountBefore = await this.banksClient.getAccount(mint); + const mintInfoBefore = token.unpackMint(mint, { + data: Buffer.from(mintAccountBefore.data), + owner: token.TOKEN_PROGRAM_ID, + executable: false, + lamports: mintAccountBefore.lamports, + }); + assert.equal( + mintInfoBefore.mintAuthority.toBase58(), + this.payer.publicKey.toBase58(), + ); + + await mintGovernorClient + .transferAuthorityToGovernorIx({ + mintGovernor, + mint, + currentAuthority: this.payer.publicKey, + }) + .rpc(); + + // Verify mint authority is now the governor PDA + const mintAccountAfter = await this.banksClient.getAccount(mint); + const mintInfoAfter = token.unpackMint(mint, { + data: Buffer.from(mintAccountAfter.data), + owner: token.TOKEN_PROGRAM_ID, + executable: false, + lamports: mintAccountAfter.lamports, + }); + assert.equal( + mintInfoAfter.mintAuthority.toBase58(), + mintGovernor.toBase58(), + ); + + const mintGovernorAccount = + await mintGovernorClient.fetchMintGovernor(mintGovernor); + assert.equal(mintGovernorAccount.seqNum.toString(), "1"); + }); + + it("fails when current_authority is not the actual mint authority", async function () { + const fakeAuthority = Keypair.generate(); + + try { + await mintGovernorClient + .transferAuthorityToGovernorIx({ + mintGovernor, + mint, + currentAuthority: fakeAuthority.publicKey, + }) + .signers([fakeAuthority]) + .rpc(); + + assert.fail( + "Should have failed because fakeAuthority is not the mint authority", + ); + } catch (e) { + // Token program error indicating wrong owner/authority (error code 0x4) + assert.include(e.message, "custom program error: 0x4"); + } + }); + + it("fails when mint_governor.mint does not match mint", async function () { + // Create a different mint with payer as authority + const mintB = await createMintWithAuthority( + this.banksClient, + this.payer, + this.payer.publicKey, + 6, + ); + + // Attempt to transfer authority for mintB using mintGovernor (which is for mint) + const callbacks = expectError( + "MintMismatch", + "Should have failed because mint_governor.mint does not match the provided mint", + ); + + await mintGovernorClient + .transferAuthorityToGovernorIx({ + mintGovernor, + mint: mintB, // Wrong mint - governor is for mint + currentAuthority: this.payer.publicKey, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("fails when governor does not hold authority after previous reclaim", async function () { + // Transfer authority to governor + await mintGovernorClient + .transferAuthorityToGovernorIx({ + mintGovernor, + mint, + currentAuthority: this.payer.publicKey, + }) + .rpc(); + + // Reclaim authority back to payer + await mintGovernorClient + .reclaimAuthorityIx({ + mintGovernor, + mint, + admin: this.payer.publicKey, + newAuthority: this.payer.publicKey, + }) + .rpc(); + + // Verify mint authority is back to payer + 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.mintAuthority.toBase58(), + this.payer.publicKey.toBase58(), + ); + + // Attempt to transfer authority using governor PDA as current authority + // This should fail because the governor no longer holds the authority + try { + await mintGovernorClient + .transferAuthorityToGovernorIx({ + mintGovernor, + mint, + currentAuthority: mintGovernor, // Governor no longer has authority + }) + .rpc(); + + assert.fail( + "Should have failed because governor no longer holds mint authority", + ); + } catch (e) { + // The PDA cannot sign as a regular signer + assert.include(e.message, "Signature verification failed"); + } + }); +} diff --git a/tests/mintGovernor/unit/updateMintAuthority.test.ts b/tests/mintGovernor/unit/updateMintAuthority.test.ts new file mode 100644 index 00000000..9aff2b6a --- /dev/null +++ b/tests/mintGovernor/unit/updateMintAuthority.test.ts @@ -0,0 +1,172 @@ +import { Keypair, PublicKey } from "@solana/web3.js"; +import BN from "bn.js"; +import { assert } from "chai"; +import { + MintGovernorClient, + getMintAuthorityAddr, +} from "@metadaoproject/futarchy/v0.7"; +import { setupMintWithGovernor } from "../utils.js"; +import { expectError } from "../../utils.js"; + +export default function suite() { + let mintGovernorClient: MintGovernorClient; + let authorizedMinter: Keypair; + let mintAuthority: PublicKey; + + before(async function () { + mintGovernorClient = this.mintGovernor; + }); + + beforeEach(async function () { + const result = await setupMintWithGovernor( + this.banksClient, + mintGovernorClient, + this.payer, + ); + this.mint = result.mint; + this.mintGovernorAddr = result.mintGovernor; + authorizedMinter = Keypair.generate(); + + // Add a mint authority for testing updates + await mintGovernorClient + .addMintAuthorityIx({ + mintGovernor: this.mintGovernorAddr, + admin: this.payer.publicKey, + authorizedMinter: authorizedMinter.publicKey, + maxTotal: new BN(1000), + }) + .rpc(); + + [mintAuthority] = getMintAuthorityAddr({ + mintGovernor: this.mintGovernorAddr, + authorizedMinter: authorizedMinter.publicKey, + }); + }); + + it("successfully updates max_total to a new value", async function () { + const newMaxTotal = new BN(2000); + + await mintGovernorClient + .updateMintAuthorityIx({ + mintGovernor: this.mintGovernorAddr, + mintAuthority, + admin: this.payer.publicKey, + maxTotal: newMaxTotal, + }) + .rpc(); + + const mintAuthorityAccount = + await mintGovernorClient.fetchMintAuthority(mintAuthority); + + assert.equal( + mintAuthorityAccount.maxTotal.toString(), + newMaxTotal.toString(), + ); + + const mintGovernorAccount = await mintGovernorClient.fetchMintGovernor( + this.mintGovernorAddr, + ); + // seq_num: 1 from transfer, 1 from add, 1 from update = 3 + assert.equal(mintGovernorAccount.seqNum.toString(), "3"); + }); + + it("successfully updates max_total to None (unlimited)", async function () { + await mintGovernorClient + .updateMintAuthorityIx({ + mintGovernor: this.mintGovernorAddr, + mintAuthority, + admin: this.payer.publicKey, + maxTotal: null, + }) + .rpc(); + + const mintAuthorityAccount = + await mintGovernorClient.fetchMintAuthority(mintAuthority); + + assert.isNull(mintAuthorityAccount.maxTotal); + }); + + it("successfully updates max_total to value <= total_minted (soft revoke)", async function () { + // First, mint some tokens to increase total_minted + const destinationAta = await this.createTokenAccount( + this.mint, + this.payer.publicKey, + ); + + await mintGovernorClient + .mintTokensIx({ + mintGovernor: this.mintGovernorAddr, + mint: this.mint, + destinationAta, + authorizedMinter: authorizedMinter.publicKey, + amount: new BN(500), + }) + .signers([authorizedMinter]) + .rpc(); + + // Verify tokens were minted + let mintAuthorityAccount = + await mintGovernorClient.fetchMintAuthority(mintAuthority); + assert.equal(mintAuthorityAccount.totalMinted.toString(), "500"); + + // Now update max_total to be equal to total_minted (soft revoke) + await mintGovernorClient + .updateMintAuthorityIx({ + mintGovernor: this.mintGovernorAddr, + mintAuthority, + admin: this.payer.publicKey, + maxTotal: new BN(500), + }) + .rpc(); + + mintAuthorityAccount = + await mintGovernorClient.fetchMintAuthority(mintAuthority); + + assert.equal(mintAuthorityAccount.maxTotal.toString(), "500"); + assert.equal(mintAuthorityAccount.totalMinted.toString(), "500"); + }); + + it("fails when admin is not the governor's admin", async function () { + const fakeAdmin = Keypair.generate(); + + const callbacks = expectError( + "UnauthorizedAdmin", + "Should have failed due to unauthorized admin", + ); + + await mintGovernorClient + .updateMintAuthorityIx({ + mintGovernor: this.mintGovernorAddr, + mintAuthority, + admin: fakeAdmin.publicKey, + maxTotal: new BN(2000), + }) + .signers([fakeAdmin]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("fails when mint_authority does not exist", async function () { + const nonExistentMinter = Keypair.generate(); + const [nonExistentMintAuthority] = getMintAuthorityAddr({ + mintGovernor: this.mintGovernorAddr, + authorizedMinter: nonExistentMinter.publicKey, + }); + + try { + await mintGovernorClient + .updateMintAuthorityIx({ + mintGovernor: this.mintGovernorAddr, + mintAuthority: nonExistentMintAuthority, + admin: this.payer.publicKey, + maxTotal: new BN(2000), + }) + .rpc(); + + assert.fail("Should have failed because mint_authority does not exist"); + } catch (e) { + // Account does not exist error + assert.include(e.message, "AccountNotInitialized"); + } + }); +} diff --git a/tests/mintGovernor/unit/updateMintGovernorAdmin.test.ts b/tests/mintGovernor/unit/updateMintGovernorAdmin.test.ts new file mode 100644 index 00000000..f30481c6 --- /dev/null +++ b/tests/mintGovernor/unit/updateMintGovernorAdmin.test.ts @@ -0,0 +1,126 @@ +import { Keypair, PublicKey } from "@solana/web3.js"; +import BN from "bn.js"; +import { assert } from "chai"; +import { MintGovernorClient } from "@metadaoproject/futarchy/v0.7"; +import { setupMintWithGovernor } from "../utils.js"; +import { expectError } from "../../utils.js"; + +export default function suite() { + let mintGovernorClient: MintGovernorClient; + let mintGovernor: PublicKey; + let newAdmin: Keypair; + + before(async function () { + mintGovernorClient = this.mintGovernor; + }); + + beforeEach(async function () { + ({ mintGovernor } = await setupMintWithGovernor( + this.banksClient, + mintGovernorClient, + this.payer, + )); + newAdmin = Keypair.generate(); + }); + + it("successfully updates admin", async function () { + const governorBefore = + await mintGovernorClient.fetchMintGovernor(mintGovernor); + const seqNumBefore = governorBefore.seqNum; + + await mintGovernorClient + .updateMintGovernorAdminIx({ + mintGovernor, + admin: this.payer.publicKey, + newAdmin: newAdmin.publicKey, + }) + .rpc(); + + const governorAfter = + await mintGovernorClient.fetchMintGovernor(mintGovernor); + + assert.equal(governorAfter.admin.toBase58(), newAdmin.publicKey.toBase58()); + assert.equal( + governorAfter.seqNum.toString(), + seqNumBefore.addn(1).toString(), + ); + }); + + it("new admin can perform admin actions", async function () { + // Transfer admin to newAdmin + await mintGovernorClient + .updateMintGovernorAdminIx({ + mintGovernor, + admin: this.payer.publicKey, + newAdmin: newAdmin.publicKey, + }) + .rpc(); + + // New admin should be able to add a mint authority + const authorizedMinter = Keypair.generate(); + + await mintGovernorClient + .addMintAuthorityIx({ + mintGovernor, + admin: newAdmin.publicKey, + authorizedMinter: authorizedMinter.publicKey, + maxTotal: new BN(1000), + }) + .signers([newAdmin]) + .rpc(); + + // Verify the mint authority was created + const governorAfter = + await mintGovernorClient.fetchMintGovernor(mintGovernor); + // seqNum: 1 (transfer authority) + 1 (update admin) + 1 (add mint authority) = 3 + assert.equal(governorAfter.seqNum.toString(), "3"); + }); + + it("old admin cannot perform admin actions after transfer", async function () { + // Transfer admin to newAdmin + await mintGovernorClient + .updateMintGovernorAdminIx({ + mintGovernor, + admin: this.payer.publicKey, + newAdmin: newAdmin.publicKey, + }) + .rpc(); + + // Old admin (payer) should no longer be able to add a mint authority + const authorizedMinter = Keypair.generate(); + + const callbacks = expectError( + "UnauthorizedAdmin", + "Should have failed because old admin is no longer authorized", + ); + + await mintGovernorClient + .addMintAuthorityIx({ + mintGovernor, + admin: this.payer.publicKey, + authorizedMinter: authorizedMinter.publicKey, + maxTotal: new BN(1000), + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("fails when admin is not the current admin", async function () { + const fakeAdmin = Keypair.generate(); + + const callbacks = expectError( + "UnauthorizedAdmin", + "Should have failed due to unauthorized admin", + ); + + await mintGovernorClient + .updateMintGovernorAdminIx({ + mintGovernor, + admin: fakeAdmin.publicKey, + newAdmin: newAdmin.publicKey, + }) + .signers([fakeAdmin]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); +} diff --git a/tests/mintGovernor/utils.ts b/tests/mintGovernor/utils.ts new file mode 100644 index 00000000..e9193a8f --- /dev/null +++ b/tests/mintGovernor/utils.ts @@ -0,0 +1,162 @@ +import { + PublicKey, + Keypair, + Transaction, + SystemProgram, +} from "@solana/web3.js"; +import * as token from "@solana/spl-token"; +import { BanksClient } from "solana-bankrun"; +import { + MintGovernorClient, + getMintGovernorAddr, +} from "@metadaoproject/futarchy/v0.7"; + +/** + * Creates a mint with the payer as the mint authority + */ +export async function createMintWithAuthority( + banksClient: BanksClient, + payer: Keypair, + mintAuthority: 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, + null, // freeze authority + ), + ); + + tx.recentBlockhash = (await banksClient.getLatestBlockhash())[0]; + tx.feePayer = payer.publicKey; + tx.sign(payer, mintKeypair); + + await banksClient.processTransaction(tx); + + return mintKeypair.publicKey; +} + +/** + * Initializes a mint governor for a given mint with the payer as admin + */ +export async function initializeMintGovernorWithDefaults( + _banksClient: BanksClient, + mintGovernorClient: MintGovernorClient, + payer: Keypair, + mint: PublicKey, + admin: PublicKey = payer.publicKey, +): Promise<{ + mintGovernor: PublicKey; + createKey: Keypair; +}> { + const createKey = Keypair.generate(); + + const [mintGovernor] = getMintGovernorAddr({ + mint, + createKey: createKey.publicKey, + }); + + await mintGovernorClient + .initializeMintGovernorIx({ + mint, + createKey: createKey.publicKey, + admin, + payer: payer.publicKey, + }) + .signers([createKey]) + .rpc(); + + return { + mintGovernor, + createKey, + }; +} + +/** + * Creates a mint and initializes a mint governor for it + */ +export async function createMintAndGovernor( + banksClient: BanksClient, + mintGovernorClient: MintGovernorClient, + payer: Keypair, + admin: PublicKey = payer.publicKey, + decimals: number = 6, +): Promise<{ + mint: PublicKey; + mintGovernor: PublicKey; + createKey: Keypair; +}> { + // Create the mint with payer as authority initially + const mint = await createMintWithAuthority( + banksClient, + payer, + payer.publicKey, + decimals, + ); + + // Initialize the governor + const { mintGovernor, createKey } = await initializeMintGovernorWithDefaults( + banksClient, + mintGovernorClient, + payer, + mint, + admin, + ); + + return { + mint, + mintGovernor, + createKey, + }; +} + +/** + * Creates a mint, initializes a governor, and transfers authority to the governor + */ +export async function setupMintWithGovernor( + banksClient: BanksClient, + mintGovernorClient: MintGovernorClient, + payer: Keypair, + admin: PublicKey = payer.publicKey, + decimals: number = 6, +): Promise<{ + mint: PublicKey; + mintGovernor: PublicKey; + createKey: Keypair; +}> { + const { mint, mintGovernor, createKey } = await createMintAndGovernor( + banksClient, + mintGovernorClient, + payer, + admin, + decimals, + ); + + // Transfer authority to the governor + await mintGovernorClient + .transferAuthorityToGovernorIx({ + mintGovernor, + mint, + currentAuthority: payer.publicKey, + }) + .rpc(); + + return { + mint, + mintGovernor, + createKey, + }; +}