From 50669b435d40e5fe82f93f38a2e6ccef873861cf Mon Sep 17 00:00:00 2001 From: Pileks Date: Thu, 22 Jan 2026 13:23:53 -0800 Subject: [PATCH 01/27] scaffolding --- Anchor.toml | 1 + Cargo.lock | 9 + programs/mint_governor/Cargo.toml | 22 ++ programs/mint_governor/src/constants.rs | 2 + programs/mint_governor/src/error.rs | 7 + programs/mint_governor/src/events.rs | 8 + .../mint_governor/src/instructions/mod.rs | 1 + programs/mint_governor/src/lib.rs | 42 ++++ .../mint_governor/src/state/mint_authority.rs | 11 + .../mint_governor/src/state/mint_governor.rs | 11 + programs/mint_governor/src/state/mod.rs | 5 + sdk/src/v0.7/types/mint_governor.ts | 189 ++++++++++++++++++ 12 files changed, 308 insertions(+) create mode 100644 programs/mint_governor/Cargo.toml create mode 100644 programs/mint_governor/src/constants.rs create mode 100644 programs/mint_governor/src/error.rs create mode 100644 programs/mint_governor/src/events.rs create mode 100644 programs/mint_governor/src/instructions/mod.rs create mode 100644 programs/mint_governor/src/lib.rs create mode 100644 programs/mint_governor/src/state/mint_authority.rs create mode 100644 programs/mint_governor/src/state/mint_governor.rs create mode 100644 programs/mint_governor/src/state/mod.rs create mode 100644 sdk/src/v0.7/types/mint_governor.ts diff --git a/Anchor.toml b/Anchor.toml index 10a9ab039..3788dee5f 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 d20734a3c..38e8c0030 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 000000000..a905ea84a --- /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 000000000..f76c2314c --- /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 000000000..a93b571a8 --- /dev/null +++ b/programs/mint_governor/src/error.rs @@ -0,0 +1,7 @@ +use super::*; + +#[error_code] +pub enum MintGovernorError { + #[msg("Placeholder error - will be replaced in Phase 3")] + Placeholder, +} diff --git a/programs/mint_governor/src/events.rs b/programs/mint_governor/src/events.rs new file mode 100644 index 000000000..a7243e0fa --- /dev/null +++ b/programs/mint_governor/src/events.rs @@ -0,0 +1,8 @@ +use anchor_lang::prelude::*; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct CommonFields { + pub slot: u64, + pub unix_timestamp: i64, + pub mint_governor_seq_num: u64, +} diff --git a/programs/mint_governor/src/instructions/mod.rs b/programs/mint_governor/src/instructions/mod.rs new file mode 100644 index 000000000..0320547ba --- /dev/null +++ b/programs/mint_governor/src/instructions/mod.rs @@ -0,0 +1 @@ +// Instructions - see Phase 4 in implementation plan diff --git a/programs/mint_governor/src/lib.rs b/programs/mint_governor/src/lib.rs new file mode 100644 index 000000000..70ed5702a --- /dev/null +++ b/programs/mint_governor/src/lib.rs @@ -0,0 +1,42 @@ +//! 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 use constants::*; + +pub mod events; +pub use events::*; + +pub mod error; +pub use error::*; + +pub mod state; +pub use state::*; + +pub mod instructions; +pub use instructions::*; + +#[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::*; +} 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 000000000..dc8757f65 --- /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 000000000..faadb1b7f --- /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 000000000..a5727c10d --- /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/types/mint_governor.ts b/sdk/src/v0.7/types/mint_governor.ts new file mode 100644 index 000000000..060acdcd4 --- /dev/null +++ b/sdk/src/v0.7/types/mint_governor.ts @@ -0,0 +1,189 @@ +export type MintGovernor = { + version: "0.7.0"; + name: "mint_governor"; + instructions: []; + 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"; + }, + ]; + }; + }, + ]; + errors: [ + { + code: 6000; + name: "Placeholder"; + msg: "Placeholder error - will be replaced in Phase 3"; + }, + ]; +}; + +export const IDL: MintGovernor = { + version: "0.7.0", + name: "mint_governor", + instructions: [], + 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", + }, + ], + }, + }, + ], + errors: [ + { + code: 6000, + name: "Placeholder", + msg: "Placeholder error - will be replaced in Phase 3", + }, + ], +}; From b48768fc34e66b8dc0532955ea640de1a0e92e24 Mon Sep 17 00:00:00 2001 From: Pileks Date: Thu, 22 Jan 2026 15:34:46 -0800 Subject: [PATCH 02/27] mint governor instructions 1st pass --- programs/mint_governor/src/error.rs | 13 +- programs/mint_governor/src/events.rs | 73 ++++++++++++ .../src/instructions/add_mint_authority.rs | 74 ++++++++++++ .../instructions/initialize_mint_governor.rs | 62 ++++++++++ .../src/instructions/mint_tokens.rs | 111 ++++++++++++++++++ .../mint_governor/src/instructions/mod.rs | 18 ++- .../src/instructions/reclaim_authority.rs | 81 +++++++++++++ .../src/instructions/remove_mint_authority.rs | 57 +++++++++ .../transfer_authority_to_governor.rs | 64 ++++++++++ .../src/instructions/update_mint_authority.rs | 59 ++++++++++ .../update_mint_governor_admin.rs | 45 +++++++ programs/mint_governor/src/lib.rs | 61 ++++++++-- 12 files changed, 706 insertions(+), 12 deletions(-) create mode 100644 programs/mint_governor/src/instructions/add_mint_authority.rs create mode 100644 programs/mint_governor/src/instructions/initialize_mint_governor.rs create mode 100644 programs/mint_governor/src/instructions/mint_tokens.rs create mode 100644 programs/mint_governor/src/instructions/reclaim_authority.rs create mode 100644 programs/mint_governor/src/instructions/remove_mint_authority.rs create mode 100644 programs/mint_governor/src/instructions/transfer_authority_to_governor.rs create mode 100644 programs/mint_governor/src/instructions/update_mint_authority.rs create mode 100644 programs/mint_governor/src/instructions/update_mint_governor_admin.rs diff --git a/programs/mint_governor/src/error.rs b/programs/mint_governor/src/error.rs index a93b571a8..c76ba101c 100644 --- a/programs/mint_governor/src/error.rs +++ b/programs/mint_governor/src/error.rs @@ -2,6 +2,15 @@ use super::*; #[error_code] pub enum MintGovernorError { - #[msg("Placeholder error - will be replaced in Phase 3")] - Placeholder, + #[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 index a7243e0fa..24291e47f 100644 --- a/programs/mint_governor/src/events.rs +++ b/programs/mint_governor/src/events.rs @@ -6,3 +6,76 @@ pub struct CommonFields { 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, + pub previous_authority: 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: 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 previous_max_total: Option, + pub new_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 previous_admin: 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 000000000..91e08e964 --- /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 000000000..63461dd8c --- /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 000000000..15fd73115 --- /dev/null +++ b/programs/mint_governor/src/instructions/mint_tokens.rs @@ -0,0 +1,111 @@ +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)] + pub destination: 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!( + self.mint_authority.total_minted + args.amount <= max_total, + 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.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: ctx.accounts.destination.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 index 0320547ba..fc3592177 100644 --- a/programs/mint_governor/src/instructions/mod.rs +++ b/programs/mint_governor/src/instructions/mod.rs @@ -1 +1,17 @@ -// Instructions - see Phase 4 in implementation plan +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 000000000..31ece0726 --- /dev/null +++ b/programs/mint_governor/src/instructions/reclaim_authority.rs @@ -0,0 +1,81 @@ +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<()> { + 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 000000000..cde94789b --- /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 000000000..0e6295ccc --- /dev/null +++ b/programs/mint_governor/src/instructions/transfer_authority_to_governor.rs @@ -0,0 +1,64 @@ +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; + let previous_authority = ctx.accounts.current_authority.key(); + + // 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, + previous_authority, + }); + + 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 000000000..d043da7d6 --- /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; + + let previous_max_total = mint_authority.max_total; + + // Update max total + 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, + previous_max_total, + new_max_total: args.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 000000000..c9f9cf53b --- /dev/null +++ b/programs/mint_governor/src/instructions/update_mint_governor_admin.rs @@ -0,0 +1,45 @@ +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; + + let previous_admin = mint_governor.admin; + + 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(), + previous_admin, + new_admin: ctx.accounts.new_admin.key(), + }); + + Ok(()) + } +} diff --git a/programs/mint_governor/src/lib.rs b/programs/mint_governor/src/lib.rs index 70ed5702a..6309208e9 100644 --- a/programs/mint_governor/src/lib.rs +++ b/programs/mint_governor/src/lib.rs @@ -6,19 +6,16 @@ use anchor_lang::prelude::*; pub mod constants; -pub use constants::*; - -pub mod events; -pub use events::*; - pub mod error; -pub use error::*; - +pub mod events; +pub mod instructions; pub mod state; -pub use state::*; -pub mod instructions; +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; @@ -39,4 +36,50 @@ 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) + } } From 36b338d822e908d6dd429c2803b1ea80ca857e91 Mon Sep 17 00:00:00 2001 From: Pileks Date: Thu, 22 Jan 2026 16:44:21 -0800 Subject: [PATCH 03/27] mint governor client 1st pass --- sdk/src/v0.7/MintGovernorClient.ts | 251 ++++++ sdk/src/v0.7/constants.ts | 3 + sdk/src/v0.7/index.ts | 1 + sdk/src/v0.7/types/index.ts | 37 + sdk/src/v0.7/types/mint_governor.ts | 1170 ++++++++++++++++++++++++++- sdk/src/v0.7/utils/pda.ts | 37 +- 6 files changed, 1491 insertions(+), 8 deletions(-) create mode 100644 sdk/src/v0.7/MintGovernorClient.ts diff --git a/sdk/src/v0.7/MintGovernorClient.ts b/sdk/src/v0.7/MintGovernorClient.ts new file mode 100644 index 000000000..4de45aefd --- /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, + destination, + authorizedMinter, + tokenProgram = TOKEN_PROGRAM_ID, + amount, + }: { + mintGovernor: PublicKey; + mint: PublicKey; + destination: 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, + destination, + 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 d3df833af..5da053207 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 13141df03..30173ae87 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 ad7e31874..c280f9917 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 index 060acdcd4..f841f38a4 100644 --- a/sdk/src/v0.7/types/mint_governor.ts +++ b/sdk/src/v0.7/types/mint_governor.ts @@ -1,7 +1,262 @@ export type MintGovernor = { version: "0.7.0"; name: "mint_governor"; - instructions: []; + 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: "destination"; + 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"; @@ -83,12 +338,336 @@ export type MintGovernor = { ]; }; }, + { + 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: "previousAuthority"; + 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: "destination"; + 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: "previousMaxTotal"; + type: { + option: "u64"; + }; + index: false; + }, + { + name: "newMaxTotal"; + 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: "previousAdmin"; + 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: "Placeholder"; - msg: "Placeholder error - will be replaced in Phase 3"; + 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"; }, ]; }; @@ -96,7 +675,262 @@ export type MintGovernor = { export const IDL: MintGovernor = { version: "0.7.0", name: "mint_governor", - instructions: [], + 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: "destination", + 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", @@ -178,12 +1012,336 @@ export const IDL: MintGovernor = { ], }, }, + { + 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: "previousAuthority", + 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: "destination", + 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: "previousMaxTotal", + type: { + option: "u64", + }, + index: false, + }, + { + name: "newMaxTotal", + 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: "previousAdmin", + 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: "Placeholder", - msg: "Placeholder error - will be replaced in Phase 3", + 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 c6a48b82d..c06c9d3a0 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, + ); +}; From 2255e61bbd0fe7eb0eae9861e407b846c7840548 Mon Sep 17 00:00:00 2001 From: Pileks Date: Thu, 22 Jan 2026 19:23:49 -0800 Subject: [PATCH 04/27] mint authority tests first pass --- tests/main.test.ts | 4 + tests/mintGovernor/main.test.ts | 28 ++ .../unit/addMintAuthority.test.ts | 143 +++++++ .../unit/initializeMintGovernor.test.ts | 78 ++++ tests/mintGovernor/unit/mintTokens.test.ts | 405 ++++++++++++++++++ .../unit/reclaimAuthority.test.ts | 259 +++++++++++ .../unit/removeMintAuthority.test.ts | 156 +++++++ .../unit/transferAuthorityToGovernor.test.ts | 164 +++++++ .../unit/updateMintAuthority.test.ts | 172 ++++++++ .../unit/updateMintGovernorAdmin.test.ts | 126 ++++++ tests/mintGovernor/utils.ts | 162 +++++++ 11 files changed, 1697 insertions(+) create mode 100644 tests/mintGovernor/main.test.ts create mode 100644 tests/mintGovernor/unit/addMintAuthority.test.ts create mode 100644 tests/mintGovernor/unit/initializeMintGovernor.test.ts create mode 100644 tests/mintGovernor/unit/mintTokens.test.ts create mode 100644 tests/mintGovernor/unit/reclaimAuthority.test.ts create mode 100644 tests/mintGovernor/unit/removeMintAuthority.test.ts create mode 100644 tests/mintGovernor/unit/transferAuthorityToGovernor.test.ts create mode 100644 tests/mintGovernor/unit/updateMintAuthority.test.ts create mode 100644 tests/mintGovernor/unit/updateMintGovernorAdmin.test.ts create mode 100644 tests/mintGovernor/utils.ts diff --git a/tests/main.test.ts b/tests/main.test.ts index a6a6cbc2b..c06145e78 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 000000000..8c13c97b1 --- /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 000000000..5f77a04e6 --- /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 000000000..a80ecb52f --- /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 000000000..2aef3f17e --- /dev/null +++ b/tests/mintGovernor/unit/mintTokens.test.ts @@ -0,0 +1,405 @@ +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 } 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 destination: PublicKey; + + before(async function () { + mintGovernorClient = this.mintGovernor; + }); + + beforeEach(async function () { + ({ mint, mintGovernor } = await setupMintWithGovernor( + this.banksClient, + mintGovernorClient, + this.payer, + )); + authorizedMinter = Keypair.generate(); + destination = 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, + destination, + 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, + destination, + 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, + destination, + 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, + destination, + 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, + destination, + 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, + destination, + 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, + destination, + 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, + destination, + 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, + destination, + 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, + destination: 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, + destination, + 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"); + } + }); +} diff --git a/tests/mintGovernor/unit/reclaimAuthority.test.ts b/tests/mintGovernor/unit/reclaimAuthority.test.ts new file mode 100644 index 000000000..21336031e --- /dev/null +++ b/tests/mintGovernor/unit/reclaimAuthority.test.ts @@ -0,0 +1,259 @@ +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 } 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 destination = await this.createTokenAccount( + mint, + this.payer.publicKey, + ); + + // Verify minting works before reclaim + await mintGovernorClient + .mintTokensIx({ + mintGovernor, + mint, + destination, + 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, + destination, + authorizedMinter: authorizedMinter.publicKey, + amount: new BN(100), + }) + .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 000000000..287be1b15 --- /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 destination = await this.createTokenAccount( + this.mint, + this.payer.publicKey, + ); + + await mintGovernorClient + .mintTokensIx({ + mintGovernor: this.mintGovernorAddr, + mint: this.mint, + destination, + 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 000000000..d34823c2e --- /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 000000000..b169ca8d4 --- /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 destination = await this.createTokenAccount( + this.mint, + this.payer.publicKey, + ); + + await mintGovernorClient + .mintTokensIx({ + mintGovernor: this.mintGovernorAddr, + mint: this.mint, + destination, + 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 000000000..f30481c69 --- /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 000000000..e9193a8f6 --- /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, + }; +} From 07928311b93b12c657ebdc4cfe7b98f28832cdcb Mon Sep 17 00:00:00 2001 From: Pileks Date: Fri, 23 Jan 2026 12:29:22 -0800 Subject: [PATCH 05/27] mint governor - cleanup pass --- programs/mint_governor/src/events.rs | 7 +- .../src/instructions/mint_tokens.rs | 14 ++-- .../src/instructions/reclaim_authority.rs | 3 + .../transfer_authority_to_governor.rs | 2 - .../src/instructions/update_mint_authority.rs | 8 +- .../update_mint_governor_admin.rs | 3 - sdk/src/v0.7/MintGovernorClient.ts | 6 +- sdk/src/v0.7/types/mint_governor.ts | 46 ++--------- tests/mintGovernor/unit/mintTokens.test.ts | 81 +++++++++++++++---- .../unit/reclaimAuthority.test.ts | 11 ++- .../unit/removeMintAuthority.test.ts | 4 +- .../unit/updateMintAuthority.test.ts | 4 +- 12 files changed, 104 insertions(+), 85 deletions(-) diff --git a/programs/mint_governor/src/events.rs b/programs/mint_governor/src/events.rs index 24291e47f..f38f95552 100644 --- a/programs/mint_governor/src/events.rs +++ b/programs/mint_governor/src/events.rs @@ -22,7 +22,6 @@ pub struct MintAuthorityTransferredEvent { pub common: CommonFields, pub mint_governor: Pubkey, pub mint: Pubkey, - pub previous_authority: Pubkey, } #[event] @@ -40,7 +39,7 @@ pub struct TokensMintedEvent { pub mint_governor: Pubkey, pub mint: Pubkey, pub authorized_minter: Pubkey, - pub destination: Pubkey, + pub destination_ata: Pubkey, pub amount: u64, pub post_total_minted: u64, pub post_mint_supply: u64, @@ -52,8 +51,7 @@ pub struct MintAuthorityUpdatedEvent { pub mint_governor: Pubkey, pub mint_authority: Pubkey, pub authorized_minter: Pubkey, - pub previous_max_total: Option, - pub new_max_total: Option, + pub max_total: Option, } #[event] @@ -68,7 +66,6 @@ pub struct MintAuthorityRemovedEvent { pub struct MintGovernorAdminUpdatedEvent { pub common: CommonFields, pub mint_governor: Pubkey, - pub previous_admin: Pubkey, pub new_admin: Pubkey, } diff --git a/programs/mint_governor/src/instructions/mint_tokens.rs b/programs/mint_governor/src/instructions/mint_tokens.rs index 15fd73115..29c882c61 100644 --- a/programs/mint_governor/src/instructions/mint_tokens.rs +++ b/programs/mint_governor/src/instructions/mint_tokens.rs @@ -28,8 +28,8 @@ pub struct MintTokens<'info> { )] pub mint: Account<'info, Mint>, - #[account(mut)] - pub destination: Account<'info, TokenAccount>, + #[account(mut, has_one = mint)] + pub destination_ata: Account<'info, TokenAccount>, #[account(address = mint_authority.authorized_minter @ MintGovernorError::UnauthorizedMinter)] pub authorized_minter: Signer<'info>, @@ -41,11 +41,13 @@ 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!( - self.mint_authority.total_minted + args.amount <= max_total, + require_gte!( + max_total, + self.mint_authority.total_minted + args.amount, MintGovernorError::MintLimitExceeded ); } + Ok(()) } @@ -71,7 +73,7 @@ impl MintTokens<'_> { ctx.accounts.token_program.to_account_info(), MintTo { mint: ctx.accounts.mint.to_account_info(), - to: ctx.accounts.destination.to_account_info(), + to: ctx.accounts.destination_ata.to_account_info(), authority: mint_governor.to_account_info(), }, signer_seeds, @@ -100,7 +102,7 @@ impl MintTokens<'_> { mint_governor: mint_governor.key(), mint: mint_governor.mint, authorized_minter: ctx.accounts.authorized_minter.key(), - destination: ctx.accounts.destination.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, diff --git a/programs/mint_governor/src/instructions/reclaim_authority.rs b/programs/mint_governor/src/instructions/reclaim_authority.rs index 31ece0726..db0c18a00 100644 --- a/programs/mint_governor/src/instructions/reclaim_authority.rs +++ b/programs/mint_governor/src/instructions/reclaim_authority.rs @@ -32,6 +32,9 @@ impl ReclaimAuthority<'_> { } 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 diff --git a/programs/mint_governor/src/instructions/transfer_authority_to_governor.rs b/programs/mint_governor/src/instructions/transfer_authority_to_governor.rs index 0e6295ccc..f62719f6b 100644 --- a/programs/mint_governor/src/instructions/transfer_authority_to_governor.rs +++ b/programs/mint_governor/src/instructions/transfer_authority_to_governor.rs @@ -27,7 +27,6 @@ impl TransferAuthorityToGovernor<'_> { pub fn handle(ctx: Context) -> Result<()> { let mint_governor = &mut ctx.accounts.mint_governor; - let previous_authority = ctx.accounts.current_authority.key(); // CPI to set_authority to transfer mint authority to mint_governor PDA token::set_authority( @@ -56,7 +55,6 @@ impl TransferAuthorityToGovernor<'_> { }, mint_governor: mint_governor.key(), mint: mint_governor.mint, - previous_authority, }); Ok(()) diff --git a/programs/mint_governor/src/instructions/update_mint_authority.rs b/programs/mint_governor/src/instructions/update_mint_authority.rs index d043da7d6..72960a28c 100644 --- a/programs/mint_governor/src/instructions/update_mint_authority.rs +++ b/programs/mint_governor/src/instructions/update_mint_authority.rs @@ -30,9 +30,10 @@ impl UpdateMintAuthority<'_> { let mint_governor = &mut ctx.accounts.mint_governor; let mint_authority = &mut ctx.accounts.mint_authority; - let previous_max_total = mint_authority.max_total; - // 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 @@ -50,8 +51,7 @@ impl UpdateMintAuthority<'_> { mint_governor: mint_governor.key(), mint_authority: mint_authority.key(), authorized_minter: mint_authority.authorized_minter, - previous_max_total, - new_max_total: args.max_total, + 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 index c9f9cf53b..561525aaf 100644 --- a/programs/mint_governor/src/instructions/update_mint_governor_admin.rs +++ b/programs/mint_governor/src/instructions/update_mint_governor_admin.rs @@ -22,8 +22,6 @@ impl UpdateMintGovernorAdmin<'_> { pub fn handle(ctx: Context) -> Result<()> { let mint_governor = &mut ctx.accounts.mint_governor; - let previous_admin = mint_governor.admin; - mint_governor.admin = ctx.accounts.new_admin.key(); mint_governor.seq_num += 1; @@ -36,7 +34,6 @@ impl UpdateMintGovernorAdmin<'_> { mint_governor_seq_num: mint_governor.seq_num, }, mint_governor: mint_governor.key(), - previous_admin, new_admin: ctx.accounts.new_admin.key(), }); diff --git a/sdk/src/v0.7/MintGovernorClient.ts b/sdk/src/v0.7/MintGovernorClient.ts index 4de45aefd..b23689866 100644 --- a/sdk/src/v0.7/MintGovernorClient.ts +++ b/sdk/src/v0.7/MintGovernorClient.ts @@ -183,14 +183,14 @@ export class MintGovernorClient { mintTokensIx({ mintGovernor, mint, - destination, + destinationAta, authorizedMinter, tokenProgram = TOKEN_PROGRAM_ID, amount, }: { mintGovernor: PublicKey; mint: PublicKey; - destination: PublicKey; + destinationAta: PublicKey; authorizedMinter: PublicKey; tokenProgram?: PublicKey; amount: BN; @@ -205,7 +205,7 @@ export class MintGovernorClient { mintGovernor, mintAuthority, mint, - destination, + destinationAta, authorizedMinter, tokenProgram, }); diff --git a/sdk/src/v0.7/types/mint_governor.ts b/sdk/src/v0.7/types/mint_governor.ts index f841f38a4..b6107843a 100644 --- a/sdk/src/v0.7/types/mint_governor.ts +++ b/sdk/src/v0.7/types/mint_governor.ts @@ -126,7 +126,7 @@ export type MintGovernor = { isSigner: false; }, { - name: "destination"; + name: "destinationAta"; isMut: true; isSigner: false; }, @@ -437,11 +437,6 @@ export type MintGovernor = { type: "publicKey"; index: false; }, - { - name: "previousAuthority"; - type: "publicKey"; - index: false; - }, ]; }, { @@ -504,7 +499,7 @@ export type MintGovernor = { index: false; }, { - name: "destination"; + name: "destinationAta"; type: "publicKey"; index: false; }, @@ -551,14 +546,7 @@ export type MintGovernor = { index: false; }, { - name: "previousMaxTotal"; - type: { - option: "u64"; - }; - index: false; - }, - { - name: "newMaxTotal"; + name: "maxTotal"; type: { option: "u64"; }; @@ -608,11 +596,6 @@ export type MintGovernor = { type: "publicKey"; index: false; }, - { - name: "previousAdmin"; - type: "publicKey"; - index: false; - }, { name: "newAdmin"; type: "publicKey"; @@ -800,7 +783,7 @@ export const IDL: MintGovernor = { isSigner: false, }, { - name: "destination", + name: "destinationAta", isMut: true, isSigner: false, }, @@ -1111,11 +1094,6 @@ export const IDL: MintGovernor = { type: "publicKey", index: false, }, - { - name: "previousAuthority", - type: "publicKey", - index: false, - }, ], }, { @@ -1178,7 +1156,7 @@ export const IDL: MintGovernor = { index: false, }, { - name: "destination", + name: "destinationAta", type: "publicKey", index: false, }, @@ -1225,14 +1203,7 @@ export const IDL: MintGovernor = { index: false, }, { - name: "previousMaxTotal", - type: { - option: "u64", - }, - index: false, - }, - { - name: "newMaxTotal", + name: "maxTotal", type: { option: "u64", }, @@ -1282,11 +1253,6 @@ export const IDL: MintGovernor = { type: "publicKey", index: false, }, - { - name: "previousAdmin", - type: "publicKey", - index: false, - }, { name: "newAdmin", type: "publicKey", diff --git a/tests/mintGovernor/unit/mintTokens.test.ts b/tests/mintGovernor/unit/mintTokens.test.ts index 2aef3f17e..399147a8a 100644 --- a/tests/mintGovernor/unit/mintTokens.test.ts +++ b/tests/mintGovernor/unit/mintTokens.test.ts @@ -6,7 +6,11 @@ import { MintGovernorClient, getMintAuthorityAddr, } from "@metadaoproject/futarchy/v0.7"; -import { setupMintWithGovernor, createMintAndGovernor } from "../utils.js"; +import { + setupMintWithGovernor, + createMintAndGovernor, + createMintWithAuthority, +} from "../utils.js"; import { expectError } from "../../utils.js"; export default function suite() { @@ -14,7 +18,7 @@ export default function suite() { let mint: PublicKey; let mintGovernor: PublicKey; let authorizedMinter: Keypair; - let destination: PublicKey; + let destinationAta: PublicKey; before(async function () { mintGovernorClient = this.mintGovernor; @@ -27,7 +31,7 @@ export default function suite() { this.payer, )); authorizedMinter = Keypair.generate(); - destination = await this.createTokenAccount(mint, this.payer.publicKey); + destinationAta = await this.createTokenAccount(mint, this.payer.publicKey); }); it("successfully mints tokens within limit", async function () { @@ -47,7 +51,7 @@ export default function suite() { .mintTokensIx({ mintGovernor, mint, - destination, + destinationAta, authorizedMinter: authorizedMinter.publicKey, amount: mintAmount, }) @@ -85,7 +89,7 @@ export default function suite() { .mintTokensIx({ mintGovernor, mint, - destination, + destinationAta, authorizedMinter: authorizedMinter.publicKey, amount: mintAmount, }) @@ -124,7 +128,7 @@ export default function suite() { .mintTokensIx({ mintGovernor, mint, - destination, + destinationAta, authorizedMinter: authorizedMinter.publicKey, amount: maxTotal, }) @@ -165,7 +169,7 @@ export default function suite() { .mintTokensIx({ mintGovernor, mint, - destination, + destinationAta, authorizedMinter: authorizedMinter.publicKey, amount: firstMint, }) @@ -187,7 +191,7 @@ export default function suite() { .mintTokensIx({ mintGovernor, mint, - destination, + destinationAta, authorizedMinter: authorizedMinter.publicKey, amount: secondMint, }) @@ -205,7 +209,7 @@ export default function suite() { .mintTokensIx({ mintGovernor, mint, - destination, + destinationAta, authorizedMinter: authorizedMinter.publicKey, amount: thirdMint, }) @@ -244,7 +248,7 @@ export default function suite() { .mintTokensIx({ mintGovernor, mint, - destination, + destinationAta, authorizedMinter: authorizedMinter.publicKey, amount: firstMint, }) @@ -260,7 +264,7 @@ export default function suite() { .mintTokensIx({ mintGovernor, mint, - destination, + destinationAta, authorizedMinter: authorizedMinter.publicKey, amount: secondMint, }) @@ -301,7 +305,7 @@ export default function suite() { mintGovernor, mintAuthority, mint, - destination, + destinationAta, authorizedMinter: fakeMinter.publicKey, tokenProgram: token.TOKEN_PROGRAM_ID, }) @@ -338,7 +342,7 @@ export default function suite() { .mintTokensIx({ mintGovernor: governorWithoutAuth, mint: mintWithoutAuth, - destination: destAccount, + destinationAta: destAccount, authorizedMinter: authorizedMinter.publicKey, amount: new BN(500), }) @@ -387,7 +391,7 @@ export default function suite() { mintGovernor, mintAuthority: wrongMintAuthority, mint, - destination, + destinationAta, authorizedMinter: authorizedMinter.publicKey, tokenProgram: token.TOKEN_PROGRAM_ID, }) @@ -402,4 +406,53 @@ export default function suite() { 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 index 21336031e..c0b247b12 100644 --- a/tests/mintGovernor/unit/reclaimAuthority.test.ts +++ b/tests/mintGovernor/unit/reclaimAuthority.test.ts @@ -1,4 +1,4 @@ -import { Keypair, PublicKey } from "@solana/web3.js"; +import { ComputeBudgetProgram, Keypair, PublicKey } from "@solana/web3.js"; import * as token from "@solana/spl-token"; import BN from "bn.js"; import { assert } from "chai"; @@ -101,7 +101,7 @@ export default function suite() { .rpc(); // Create destination token account - const destination = await this.createTokenAccount( + const destinationAta = await this.createTokenAccount( mint, this.payer.publicKey, ); @@ -111,7 +111,7 @@ export default function suite() { .mintTokensIx({ mintGovernor, mint, - destination, + destinationAta, authorizedMinter: authorizedMinter.publicKey, amount: new BN(100), }) @@ -140,10 +140,13 @@ export default function suite() { .mintTokensIx({ mintGovernor, mint, - destination, + destinationAta, authorizedMinter: authorizedMinter.publicKey, amount: new BN(100), }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 2_000_001 }), + ]) .signers([authorizedMinter]) .rpc(); diff --git a/tests/mintGovernor/unit/removeMintAuthority.test.ts b/tests/mintGovernor/unit/removeMintAuthority.test.ts index 287be1b15..c060ccfcd 100644 --- a/tests/mintGovernor/unit/removeMintAuthority.test.ts +++ b/tests/mintGovernor/unit/removeMintAuthority.test.ts @@ -73,7 +73,7 @@ export default function suite() { it("successfully removes mint authority that has minted tokens", async function () { // First, mint some tokens - const destination = await this.createTokenAccount( + const destinationAta = await this.createTokenAccount( this.mint, this.payer.publicKey, ); @@ -82,7 +82,7 @@ export default function suite() { .mintTokensIx({ mintGovernor: this.mintGovernorAddr, mint: this.mint, - destination, + destinationAta, authorizedMinter: authorizedMinter.publicKey, amount: new BN(500), }) diff --git a/tests/mintGovernor/unit/updateMintAuthority.test.ts b/tests/mintGovernor/unit/updateMintAuthority.test.ts index b169ca8d4..9aff2b6a8 100644 --- a/tests/mintGovernor/unit/updateMintAuthority.test.ts +++ b/tests/mintGovernor/unit/updateMintAuthority.test.ts @@ -88,7 +88,7 @@ export default function suite() { it("successfully updates max_total to value <= total_minted (soft revoke)", async function () { // First, mint some tokens to increase total_minted - const destination = await this.createTokenAccount( + const destinationAta = await this.createTokenAccount( this.mint, this.payer.publicKey, ); @@ -97,7 +97,7 @@ export default function suite() { .mintTokensIx({ mintGovernor: this.mintGovernorAddr, mint: this.mint, - destination, + destinationAta, authorizedMinter: authorizedMinter.publicKey, amount: new BN(500), }) From 6225b85c203617d47dadece3a47e12c99c44901f Mon Sep 17 00:00:00 2001 From: Pileks Date: Thu, 29 Jan 2026 14:01:10 -0800 Subject: [PATCH 06/27] prepare tasks --- CLAUDE.md | 182 ++++++++ rebuild.sh | 10 + vibes/000-mint-governor.md | 466 +++++++++++++++++++ vibes/001-performance-package-v2.md | 691 ++++++++++++++++++++++++++++ vibes/task-template.md | 54 +++ vibes/tasks.md | 295 ++++++++++++ 6 files changed, 1698 insertions(+) create mode 100644 CLAUDE.md create mode 100755 rebuild.sh create mode 100644 vibes/000-mint-governor.md create mode 100644 vibes/001-performance-package-v2.md create mode 100644 vibes/task-template.md create mode 100644 vibes/tasks.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..6c7ff0d82 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,182 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +MetaDAO Futarchy Protocol - Solana programs for market-driven governance and token launches. Uses Anchor 0.29.0, Solana 1.17.34, Rust 1.78.0. + +## Build & Test Commands + +```bash +# Build all programs +anchor build + +# Build specific program +anchor build -p futarchy +anchor build -p conditional_vault +# ...et cetera. + +# Rebuild Programs, Rebuild SDK and lint (also surfaces any errors within SDK) +./rebuild.sh + +# Run all tests (includes build) +anchor test + +# Run tests without rebuilding (faster iteration) +anchor test --skip-build +``` + +## Project Structure + +``` +programs/ # Solana programs (Anchor) +├── futarchy/ # DAO governance with TWAP oracles +├── conditional_vault/ # Conditional tokens for prediction markets +├── v07_launchpad/ # Token launch platform (current) +├── v06_launchpad/ # Previous launchpad version +├── bid_wall/ # Price floor mechanism +├── price_based_performance_package/ # Milestone-based rewards +├── mint_governor/ # Delegated minting authority management +└── damm_v2_cpi/ # Meteora AMM CPI wrapper + +sdk/ # TypeScript client library +├── src/v0.3/ - v0.7/ # Versioned SDKs (backward compatible) +└── package.json + +tests/ # TypeScript tests (bankrun + mocha) +├── conditionalVault/ # Unit + integration tests per program +├── futarchy/ +├── launchpad/ +├── bidWall/ +├── integration/ # Cross-program workflow tests +├── fixtures/ # Pre-compiled external programs (.so) +└── utils.ts # Testing utilities + +scripts/ # Deployment & setup scripts +└── v0.3/ - v0.7/ # Version-specific scripts + +vibes/ # Design documents and specs +``` + +## Program Development Patterns + +### Instruction Structure (Anchor) +```rust +// In lib.rs - without params +#[program] +pub mod my_program { + #[access_control(ctx.accounts.validate())] + pub fn initialize(ctx: Context) -> Result<()> { + Initialize::handle(ctx) + } + + // With params - use an Args struct + #[access_control(ctx.accounts.validate(&args))] + pub fn do_something(ctx: Context, args: DoSomethingArgs) -> Result<()> { + DoSomething::handle(ctx, args) + } +} + +// In instructions/initialize.rs - no params needed +#[derive(Accounts)] +pub struct Initialize<'info> { /* account constraints */ } + +impl Initialize<'_> { + pub fn validate(&self) -> Result<()> { + // Validation logic (or just Ok(())) + Ok(()) + } + + pub fn handle(ctx: Context) -> Result<()> { + // Implementation + Ok(()) + } +} + +// In instructions/do_something.rs - with params +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct DoSomethingArgs { + pub amount: u64, +} + +#[derive(Accounts)] +pub struct DoSomething<'info> { /* account constraints */ } + +impl DoSomething<'_> { + pub fn validate(&self, args: &DoSomethingArgs) -> Result<()> { + // Validation that needs args + require_gte!(args.amount, 1, MyError::InvalidAmount); + Ok(()) + } + + pub fn handle(ctx: Context, args: DoSomethingArgs) -> Result<()> { + // Implementation using args + Ok(()) + } +} +``` + +### Adding New Instructions +1. Add instruction to Rust program in `programs/[program]/src/instructions/` +2. Update client methods in SDK (`sdk/src/v0.7/`) +3. Add unit tests in `tests/[program]/unit/` +4. Run './rebuild' to sync types + +### Testing with Bankrun +Tests use `solana-bankrun` for deterministic testing without external RPC: +- `setupBasicDao()` - Create a test DAO with mints +- `advanceBySlots()` - Simulate time progression +- Time constants: `TEN_SECONDS_IN_SLOTS`, `ONE_MINUTE_IN_SLOTS`, `HOUR_IN_SLOTS`, `DAY_IN_SLOTS` + +## SDK Usage + +```typescript +// Import versioned clients +import { FutarchyClient, ConditionalVaultClient } from "@metadaoproject/futarchy/v0.7"; + +// Key utilities in sdk/src/v0.7/ +// - constants.ts: Program IDs, MAINNET_USDC, SQUADS_PROGRAM_ID +// - PDA derivation: getDaoAddr, getProposalAddr, etc. +// - PriceMath.getAmmPrice for price calculations +``` + +**Important:** Always use SDK v0.7 imports (`@metadaoproject/futarchy/v0.7`) for new code. Do not use older SDK versions (v0.3-v0.6). + +## Key External Dependencies + +- **Squads Multisig v4** - Governance authority for admin functions +- **Meteora DAMM** - Concentrated AMM for launches (via damm_v2_cpi) +- **OpenBook v2** - DEX integration (fixture in tests) + +## Test Fixtures + +External programs required for tests. These are pre-compiled `.so` files in `tests/fixtures/`: + +**Critical dependencies (tests will fail without these):** +- `squads_multisig.so` - Squads Multisig v4 (`SQUADS_PROGRAM_ID`) +- `cp_amm.so` - Meteora DAMM v2 (`DAMM_V2_PROGRAM_ID`) +- `mpl_token_metadata.so` - Metaplex token metadata + +**Other fixtures:** +- `openbook_v2.so`, `openbook_twap.so` - OpenBook DEX integration +- `raydium_cp_swap.so` - Raydium integration + +## Troubleshooting + +**"blockstore error"**: `rm -rf .anchor/test-ledger test-ledger` + +**Module resolution errors**: `cd sdk && yarn build-local && cd .. && yarn install --force` + +**Tests timeout**: Increase `startup_wait` in `Anchor.toml` + +## Mainnet Program IDs + +| Program | Version | ID | +|---------|---------|-----| +| launchpad | v0.7.0 | `moontUzsdepotRGe5xsfip7vLPTJnVuafqdUWexVnPM` | +| bid_wall | v0.7.0 | `WALL8ucBuUyL46QYxwYJjidaFYhdvxUFrgvBxPshERx` | +| futarchy | v0.6.0 | `FUTARELBfJfQ8RDGhg1wdhddq1odMAJUePHFuBYfUxKq` | +| conditional_vault | v0.4 | `VLTX1ishMBbcX3rdBWGssxawAo1Q2X2qxYFYqiGodVg` | +| price_based_performance_package | v0.6.0 | `pbPPQH7jyKoSLu8QYs3rSY3YkDRXEBojKbTgnUg7NDS` | +| mint_governor | v0.7.0 | `gvnr27cVeyW3AVf3acL7VCJ5WjGAphytnsgcK1feHyH` | diff --git a/rebuild.sh b/rebuild.sh new file mode 100755 index 000000000..8fe5fdcbd --- /dev/null +++ b/rebuild.sh @@ -0,0 +1,10 @@ +# rebuild.sh +#!/bin/bash +set -e +anchor build +cd sdk +yarn build-local +cd .. +yarn install --force +yarn lint:fix +echo "✅ SDK types synced successfully" \ No newline at end of file diff --git a/vibes/000-mint-governor.md b/vibes/000-mint-governor.md new file mode 100644 index 000000000..be9cfea28 --- /dev/null +++ b/vibes/000-mint-governor.md @@ -0,0 +1,466 @@ +# Mint Governor Program + +## Overview + +The Mint Governor program allows a DAO to transfer the mint authority of its token to a program-controlled PDA. This enables granular delegation of minting rights to multiple addresses with configurable limits, while maintaining a single admin that controls all delegations. + +**Key Features:** +- Transfer mint authority to a program-controlled PDA +- Admin can grant/revoke mint rights to other addresses +- Optional total mint limits for delegated minters +- Admin can reclaim full mint authority if needed +- Tracking of total minted amounts per authorized minter + +**MetaDAO-specific details** + +- DAO’s Squads Multisig Vault would be the admin for the Mint Governor (or DAO, but this is less likely) +- Mint Authority rights can then be given to: + - The Squads Multisig Vault + - Price-based performance package + - ??? + +--- + +## Account Structure + +### MintGovernor (PDA) +The main account that holds configuration for a governed mint. Seeds: `["mint_governor", mint, create_key]` + +The `create_key` is required to prevent frontrunning attacks. Without it, an attacker could race to create a MintGovernor for any mint and set themselves as admin before the legitimate party. The `create_key` must sign during initialization, ensuring only the intended party can create that specific PDA. + +Note: Multiple MintGovernors can exist for the same mint (with different create_keys), but only one can hold the actual mint authority at a time. + +```rust +pub struct MintGovernor { + pub mint: Pubkey, // The token mint being governed + pub admin: Pubkey, // Admin who can grant/revoke mint rights + pub create_key: Pubkey, // Key used in PDA derivation (anti-frontrun) + pub seq_num: u64, // Sequence number for event ordering (starts at 0) + pub bump: u8, // PDA bump +} +``` + +### MintAuthority +Represents an address that has been granted minting rights. Seeds: `["mint_authority", mint_governor, authorized_minter]` + +```rust +pub struct MintAuthority { + pub mint_governor: Pubkey, // Reference to the governor + pub authorized_minter: Pubkey, // Address that can mint + pub max_total: Option, // Max total tokens this minter can ever mint (None = unlimited) + pub total_minted: u64, // Running total of tokens minted by this authority + pub bump: u8, +} +``` + +--- + +## Instructions + +### 1. `initialize_mint_governor` +Creates a MintGovernor account for a mint. Does NOT transfer authority yet. + +**Accounts:** +- `mint` - The token mint +- `mint_governor` - PDA to create (seeds: `["mint_governor", mint, create_key]`) +- `create_key` - Key used in PDA derivation, prevents frontrunning (signer) +- `admin` - Will become the admin +- `payer` - Pays for account creation +- `system_program` + +**Args:** None + +**Emits:** `MintGovernorInitializedEvent` + +--- + +### 2. `transfer_authority_to_governor` +Transfers the mint authority from current authority to the MintGovernor PDA. + +**Accounts:** +- `mint` - The token mint (mut) +- `mint_governor` - The governor PDA (mut, for seq_num increment) +- `current_authority` - Current mint authority (signer) +- `token_program` + +**Args:** None + +**Checks:** +- `mint_governor.mint == mint.key()` - Ensures the governor is for this specific mint + +**Emits:** `MintAuthorityTransferredEvent` + +--- + +### 3. `add_mint_authority` +Admin grants minting rights to an address. + +**Accounts:** +- `mint_governor` - The governor (mut, for seq_num increment) +- `mint_authority` - PDA to create for the authorized minter +- `admin` - Must be governor's admin (signer) +- `authorized_minter` - Address receiving mint rights +- `payer` - Pays for account creation +- `system_program` + +**Args:** +- `max_total: Option` - Optional lifetime limit + +**Emits:** `MintAuthorityAddedEvent` + +--- + +### 4. `update_mint_authority` +Admin updates the limits for an existing authorized minter. + +**Accounts:** +- `mint_governor` - The governor (mut, for seq_num increment) +- `mint_authority` - Existing authority account (mut) +- `admin` - Must be governor's admin (signer) + +**Args:** +- `max_total: Option` - New lifetime limit + +**Notes:** +- Setting `max_total` to a value less than or equal to `total_minted` acts as a "soft revoke" - the minter can no longer mint but their account and history remain intact. + +**Emits:** `MintAuthorityUpdatedEvent` + +--- + +### 5. `remove_mint_authority` +Admin revokes minting rights from an address, closing the account. + +**Accounts:** +- `mint_governor` - The governor (mut, for seq_num increment) +- `mint_authority` - Authority account to close (mut) +- `admin` - Must be governor's admin (signer) +- `rent_destination` - Receives closed account rent + +**Args:** None + +**Emits:** `MintAuthorityRemovedEvent` + +--- + +### 6. `mint_tokens` +An authorized minter mints tokens to a destination. + +**Accounts:** +- `mint_governor` - The governor (mut, for seq_num increment) +- `mint_authority` - Minter's authority record (mut, for updating total_minted) +- `mint` - The token mint (mut) +- `destination` - Token account to mint to (mut) +- `authorized_minter` - Must match mint_authority.authorized_minter (signer) +- `token_program` + +**Args:** +- `amount: u64` - Amount to mint + +**Checks:** +- `total_minted + amount <= max_total` (if set) + +**Emits:** `TokensMintedEvent` + +--- + +### 7. `update_mint_governor_admin` +Admin transfers admin rights to a new address. + +**Accounts:** +- `mint_governor` - The governor (mut, for admin update and seq_num increment) +- `admin` - Current admin (signer) +- `new_admin` - New admin address + +**Args:** None + +**Emits:** `MintGovernorAdminUpdatedEvent` + +--- + +### 8. `reclaim_authority` +Admin reclaims the mint authority back from the program to any address. + +**Accounts:** +- `mint_governor` - The governor (mut, for seq_num increment) +- `mint` - The token mint (mut) +- `admin` - Must be governor's admin (signer) +- `new_authority` - Address to receive mint authority +- `token_program` + +**Args:** None + +**Notes:** +- Existing MintAuthority accounts become non-functional after this call (they can no longer mint since the governor no longer holds authority). These accounts are intentionally left in place to preserve historical records and can be closed via `remove_mint_authority` if desired. + +**Emits:** `MintAuthorityReclaimedEvent` + +--- + +## Events + +All events include `CommonFields` for consistent metadata: + +```rust +#[derive(AnchorSerialize, AnchorDeserialize)] +pub struct CommonFields { + pub slot: u64, + pub unix_timestamp: i64, + pub mint_governor_seq_num: u64, +} + +impl CommonFields { + pub fn new(clock: &Clock, mint_governor_seq_num: u64) -> Self { + Self { + slot: clock.slot, + unix_timestamp: clock.unix_timestamp, + mint_governor_seq_num, + } + } +} +``` + +### MintGovernorInitializedEvent +Emitted by: `initialize_mint_governor` + +```rust +#[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, +} +``` + +### MintAuthorityTransferredEvent +Emitted by: `transfer_authority_to_governor` + +```rust +#[event] +pub struct MintAuthorityTransferredEvent { + pub common: CommonFields, + pub mint_governor: Pubkey, + pub mint: Pubkey, + pub previous_authority: Pubkey, +} +``` + +### MintAuthorityAddedEvent +Emitted by: `add_mint_authority` + +```rust +#[event] +pub struct MintAuthorityAddedEvent { + pub common: CommonFields, + pub mint_governor: Pubkey, + pub mint_authority: Pubkey, + pub authorized_minter: Pubkey, + pub max_total: Option, +} +``` + +### MintAuthorityUpdatedEvent +Emitted by: `update_mint_authority` + +```rust +#[event] +pub struct MintAuthorityUpdatedEvent { + pub common: CommonFields, + pub mint_governor: Pubkey, + pub mint_authority: Pubkey, + pub authorized_minter: Pubkey, + pub previous_max_total: Option, + pub new_max_total: Option, +} +``` + +### MintAuthorityRemovedEvent +Emitted by: `remove_mint_authority` + +```rust +#[event] +pub struct MintAuthorityRemovedEvent { + pub common: CommonFields, + pub mint_governor: Pubkey, + pub authorized_minter: Pubkey, + pub total_minted: u64, // Final total before revocation +} +``` + +### TokensMintedEvent +Emitted by: `mint_tokens` + +```rust +#[event] +pub struct TokensMintedEvent { + pub common: CommonFields, + pub mint_governor: Pubkey, + pub mint: Pubkey, + pub authorized_minter: Pubkey, + pub destination: Pubkey, + pub amount: u64, + pub post_total_minted: u64, // Updated total for this minter + pub post_mint_supply: u64, // Updated total supply of the mint +} +``` + +### MintGovernorAdminUpdatedEvent +Emitted by: `update_mint_governor_admin` + +```rust +#[event] +pub struct MintGovernorAdminUpdatedEvent { + pub common: CommonFields, + pub mint_governor: Pubkey, + pub previous_admin: Pubkey, + pub new_admin: Pubkey, +} +``` + +### MintAuthorityReclaimedEvent +Emitted by: `reclaim_authority` + +```rust +#[event] +pub struct MintAuthorityReclaimedEvent { + pub common: CommonFields, + pub mint_governor: Pubkey, + pub mint: Pubkey, + pub new_authority: Pubkey, +} +``` + +--- + +## Testing + +Tests are organized as unit tests per instruction, following the pattern established in `tests/bidWall/`. + +### Test Structure + +``` +tests/mintGovernor/ +├── main.test.ts # Imports and describes all unit test suites +├── utils.ts # Shared helper functions +└── unit/ + ├── initializeMintGovernor.test.ts + ├── transferAuthorityToGovernor.test.ts + ├── addMintAuthority.test.ts + ├── updateMintAuthority.test.ts + ├── removeMintAuthority.test.ts + ├── mintTokens.test.ts + ├── updateMintGovernorAdmin.test.ts + └── reclaimAuthority.test.ts +``` + +### Unit Tests by Instruction + +#### `initialize_mint_governor` + +| Test Case | Description | +|-----------|-------------| +| successfully initializes a mint governor | Creates MintGovernor PDA with correct fields (mint, admin, create_key, seq_num=0, bump) | +| fails when create_key does not sign | Rejects if the create_key account is not a signer | + +#### `transfer_authority_to_governor` + +| Test Case | Description | +|-----------|-------------| +| successfully transfers mint authority to governor | Transfers authority from current_authority to MintGovernor PDA | +| fails when current_authority is not the actual mint authority | Rejects if signer doesn't own mint authority | +| fails when mint_governor.mint does not match mint | Rejects if wrong governor PDA is provided | +| fails when governor does not hold authority after previous reclaim | Ensures transfer works correctly on re-transfer scenario | + +#### `add_mint_authority` + +| Test Case | Description | +|-----------|-------------| +| successfully adds mint authority with max_total | Creates MintAuthority PDA with limit set | +| successfully adds mint authority without max_total (unlimited) | Creates MintAuthority PDA with None limit | +| fails when admin is not the governor's admin | Rejects unauthorized admin | +| fails when mint_authority already exists | Rejects duplicate creation | + +#### `update_mint_authority` + +| Test Case | Description | +|-----------|-------------| +| successfully updates max_total to a new value | Updates limit from one value to another | +| successfully updates max_total to None (unlimited) | Removes limit | +| successfully updates max_total to value <= total_minted (soft revoke) | Sets limit that prevents further minting | +| fails when admin is not the governor's admin | Rejects unauthorized admin | +| fails when mint_authority does not exist | Rejects update on non-existent authority | + +#### `remove_mint_authority` + +| Test Case | Description | +|-----------|-------------| +| successfully removes mint authority | Closes MintAuthority account and returns rent | +| successfully removes mint authority that has minted tokens | Confirms historical minting doesn't block removal | +| fails when admin is not the governor's admin | Rejects unauthorized admin | +| fails when mint_authority does not exist | Rejects removal of non-existent authority | + +#### `mint_tokens` + +| Test Case | Description | +|-----------|-------------| +| successfully mints tokens within limit | Mints amount that stays under max_total | +| successfully mints tokens with unlimited authority | Mints with None max_total | +| successfully mints tokens up to exact limit | Mints exactly remaining quota | +| successfully mints multiple times accumulating total_minted | Verifies total_minted tracks correctly across calls | +| fails when amount exceeds remaining quota | Rejects mint that would exceed max_total | +| fails when authorized_minter is not the signer | Rejects unauthorized minter | +| fails when governor does not hold mint authority | Rejects if authority was reclaimed | +| fails when mint_authority.mint_governor does not match | Rejects mismatched authority/governor | + +#### `update_mint_governor_admin` + +| Test Case | Description | +|-----------|-------------| +| successfully updates admin | Transfers admin rights to new address | +| new admin can perform admin actions | Verifies new admin can add/remove authorities | +| old admin cannot perform admin actions after transfer | Verifies old admin is rejected | +| fails when admin is not the current admin | Rejects unauthorized admin change | + +#### `reclaim_authority` + +| Test Case | Description | +|-----------|-------------| +| successfully reclaims authority to new address | Transfers mint authority from PDA to new_authority | +| successfully reclaims authority back to admin | Admin can reclaim to themselves | +| existing mint authorities cannot mint after reclaim | Verifies MintAuthority accounts become non-functional | +| mint authorities can still be removed after reclaim | Verifies cleanup still works | +| fails when admin is not the governor's admin | Rejects unauthorized reclaim | +| fails when governor does not currently hold mint authority | Rejects if authority already transferred away | + +--- + +## Potential Improvements + +### Combine `add_mint_authority` and `update_mint_authority` into `set_mint_authority` + +The `add_mint_authority` and `update_mint_authority` instructions could be combined into a single `set_mint_authority` instruction using Anchor's `init_if_needed` constraint. This would: + +- Simplify the API from two instructions to one +- Make the instruction idempotent (calling with the same params produces consistent results) +- Reduce code duplication + +**Implementation approach:** +```rust +#[account( + init_if_needed, + 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>, +``` + +The handler would detect if the account was freshly initialized (e.g., by checking if `bump == 0` before setting fields) and: +- On init: set all fields (`mint_governor`, `authorized_minter`, `max_total`, `total_minted = 0`, `bump`) +- On update: only update `max_total` + +Different events could be emitted based on whether it was an init or update, or a single `MintAuthoritySetEvent` could include a boolean flag indicating if it was newly created. diff --git a/vibes/001-performance-package-v2.md b/vibes/001-performance-package-v2.md new file mode 100644 index 000000000..5c5bbeb3f --- /dev/null +++ b/vibes/001-performance-package-v2.md @@ -0,0 +1,691 @@ +# Performance Package v2 Program + +## Overview + +Performance Package v2 (PP v2) is a token minting program that rewards teams based on achieved milestones. It is the spiritual successor to `price_based_performance_package` (v1), with key architectural differences: + +**Key Features:** +- Modular oracle system via inline enum `OracleReader` variants +- Configurable reward calculations via inline `RewardFunction` variants +- Two-phase lifecycle: Locked → Unlocking → Locked (repeats) +- Two-party approval for critical changes (authority + recipient must agree) +- Integration with `mint_governor` for token minting + +**Comparison with v1:** + +| Aspect | v1 | v2 | +|--------|----|----| +| Token source | Pre-funded vault | Minted via `mint_governor` | +| Reward logic | Fixed price tranches | Modular OracleReader + RewardFunction | +| Oracle types | Single Switchboard oracle | Multiple oracle types supported | +| Coexistence | Existing deployments remain | New launches use v2 | + +--- + +## Constants + +### MetaDAO Operational Multisig + +The admin address for privileged operations (e.g., `close_performance_package`). Uses the same pattern as v1's `burn_performance_package`: + +```rust +pub mod admin { + use anchor_lang::prelude::declare_id; + + // MetaDAO operational multisig + declare_id!("6awyHMshBGVjJ3ozdSJdyyDE1CTAXUwrpNMaRGMsb4sf"); +} +``` + +--- + +## Account Structure + +### PerformancePackage (PDA) +The main account representing a performance package. Acts as the `authorized_minter` in mint_governor. Seeds: `["performance_package", create_key]` + +```rust +pub struct PerformancePackage { + // === Core References === + pub mint: Pubkey, // Token mint controlled by mint_governor + pub mint_governor: Pubkey, // MintGovernor account + pub mint_authority: Pubkey, // MintAuthority PDA for this PP + + // === Authorities === + pub authority: Pubkey, // DAO multisig vault - can modify PP + pub recipient: Pubkey, // Team multisig - receives minted tokens + + // === Inline Configuration === + pub oracle_reader: OracleReader, // Stores start/end snapshots + pub reward_function: RewardFunction, // How to calculate rewards + + // === Lifecycle === + pub status: PackageStatus, // Locked or Unlocking + pub min_unlock_timestamp: i64, // Can't start before this time + + // === Payout Tracking === + pub total_rewards_paid_out: u64, // Cumulative tokens minted to recipient + pub seq_num: u64, // Event sequence number + + // === PDA === + pub create_key: Pubkey, // Used for PDA derivation + pub bump: u8, +} +``` + +**Payout Logic:** `tokens_to_mint = reward_function.calculate(value) - total_rewards_paid_out` + +### PackageStatus (Enum) +Lifecycle state for the performance package. + +```rust +pub enum PackageStatus { + Locked, // Ready to start (or waiting for min_unlock_timestamp) + Unlocking, // Unlock in progress, waiting for min_duration +} +``` + +### OracleReader (Inline Enum) +An inline enum that knows how to read from an external oracle account. Extracts a `value: u128` and records snapshots for TWAP calculations. + +```rust +pub enum OracleReader { + /// Reads current timestamp from Clock::get() + /// No state needed - just reads current time on demand + Time, + + /// Reads accumulator from Futarchy AMM, computes TWAP + /// Two snapshots: start (on start_unlock) and end (on complete_unlock) + /// TWAP = (end_value - start_value) / (end_time - start_time) + FutarchyTwap { + amm: Pubkey, // The Futarchy AMM account to read + min_duration: u32, // Minimum seconds between start and end + // Start snapshot (recorded on start_unlock) + start_value: u128, + start_time: i64, + // End snapshot (recorded on complete_unlock) + end_value: u128, + end_time: i64, + }, +} +``` + +**External Oracle Accounts:** + +| OracleReader Variant | External Account(s) Needed | What It Reads | +|---------------------|---------------------------|---------------| +| `Time` | None (`Clock::get()`) | `unix_timestamp` | +| `FutarchyTwap` | Futarchy AMM | Price accumulator | + +**Note:** All oracle sources are read via `remaining_accounts`. The `OracleReader` variant determines how many accounts to consume and how to interpret them. + +### RewardFunction (Inline Enum) +An inline enum that calculates cumulative rewards from oracle values. Returns total tokens deserved so far (not incremental). + +```rust +pub enum RewardFunction { + /// Cliff + Linear: cliff_amount at cliff_value, then linear accrual to total_amount at end_value + /// Works with any oracle value (e.g., time, price, or other metrics) + /// For no-cliff behavior, set cliff_value = start_value and cliff_amount = 0 + CliffLinear { + start_value: u128, + cliff_value: u128, + end_value: u128, + cliff_amount: u64, + total_amount: u64, // Includes cliff + }, + + /// Threshold-based tranches (similar to v1) + /// Each tranche: if value >= threshold, cumulative reward = amount + Threshold { + tranches: Vec, // Must be sorted by threshold ascending + }, +} + +pub struct ThresholdTranche { + pub threshold: u128, // Oracle value threshold + pub cumulative_amount: u64, // Total tokens at this level (not incremental) +} +``` + +### ChangeRequest (PDA) +Temporary account for two-party approval flow. Seeds: `["change_request", performance_package, proposer, pda_nonce.to_le_bytes()]` + +```rust +pub struct ChangeRequest { + pub performance_package: Pubkey, + pub proposer_type: ProposerType, // Who proposed + pub proposed_at: i64, // When proposed + pub pda_nonce: u32, // For unique PDA derivation + pub bump: u8, + + // === Optional Changes (at least one must be Some) === + pub new_recipient: Option, + pub new_oracle_reader: Option, + pub new_reward_function: Option, +} + +pub enum ProposerType { + Authority, + Recipient, +} +``` + +--- + +## Instructions + +### 1. `initialize_performance_package` +Creates a PerformancePackage account linked to a mint_governor. + +**Accounts:** +- `performance_package` - PDA to create (seeds: `["performance_package", create_key]`) +- `mint` - The token mint +- `mint_governor` - The MintGovernor for this mint +- `mint_authority` - The MintAuthority PDA for this PP (must exist) +- `create_key` - Key used in PDA derivation (signer) +- `authority` - DAO multisig that will control the PP +- `recipient` - Team multisig that receives minted tokens +- `payer` - Pays for account creation +- `system_program` + +**Args:** +- `oracle_reader: OracleReader` - Oracle configuration +- `reward_function: RewardFunction` - Reward calculation configuration +- `min_unlock_timestamp: i64` - Earliest time unlock can be started + +**Checks:** +- `mint_governor.mint == mint.key()` - Governor controls correct mint +- `mint_authority.mint_governor == mint_governor.key()` - Authority belongs to governor +- `mint_authority.authorized_minter == performance_package.key()` - PP is the authorized minter +- Validates reward_function configuration (e.g., tranches sorted, vesting values ordered correctly) + +**Emits:** `PerformancePackageCreatedEvent` + +--- + +### 2. `start_unlock` +Begins the unlock period (starts oracle recording). + +**Accounts:** +- `performance_package` - The PP (mut) +- `signer` - Must be authority or recipient (signer) + +**Remaining Accounts:** +- `Time` → none +- `FutarchyTwap` → Futarchy AMM account + +**Args:** None + +**Checks:** +- `signer == pp.authority || signer == pp.recipient` +- `pp.status == Locked` +- `Clock::get().unix_timestamp >= pp.min_unlock_timestamp` + +**Logic:** +1. Call `pp.oracle_reader.record_start(remaining_accounts)` +2. Set `pp.status = Unlocking` + +**Emits:** `UnlockStartedEvent` + +--- + +### 3. `complete_unlock` +Completes the unlock period, calculates rewards, mints tokens, and resets for next cycle. + +**Accounts:** +- `performance_package` - The PP (mut, signer via PDA for CPI) +- `mint_governor` - Referenced by `pp.mint_governor` +- `mint_authority` - Referenced by `pp.mint_authority` +- `mint` - Referenced by `pp.mint` (mut) +- `recipient_ata` - Token account for `pp.recipient` (mut) +- `signer` - Must be authority or recipient (signer) +- `token_program` + +**Remaining Accounts:** +- `Time` → none +- `FutarchyTwap` → Futarchy AMM account + +**Args:** None + +**Checks:** +- `signer == pp.authority || signer == pp.recipient` +- `pp.status == Unlocking` +- `pp.oracle_reader.can_end(Clock::get().unix_timestamp)` - min_duration passed +- `pp.mint_governor == mint_governor.key()` +- `pp.mint_authority == mint_authority.key()` +- `pp.mint == mint.key()` + +**Logic:** +1. Call `pp.oracle_reader.record_end(remaining_accounts)` +2. Compute `value = pp.oracle_reader.compute_value()` +3. Compute `cumulative_rewards = pp.reward_function.calculate(value)` +4. If `cumulative_rewards > pp.total_rewards_paid_out`: (rewards only increase) + - `mint_amount = cumulative_rewards - pp.total_rewards_paid_out` + - CPI to `mint_governor::mint_tokens(mint_amount, recipient_ata)` + - `pp.total_rewards_paid_out = cumulative_rewards` +5. Call `pp.oracle_reader.reset()` - prepare for next cycle +6. Set `pp.status = Locked` + +**Emits:** `UnlockCompletedEvent` + +--- + +### 4. `change_authority` +Transfers authority to a new address. + +**Accounts:** +- `performance_package` - The PP (mut) +- `authority` - Must be PP's current authority (signer) +- `new_authority` - The new authority address + +**Args:** None + +**Checks:** +- `authority == pp.authority` + +**Logic:** +1. `pp.authority = new_authority.key()` + +**Notes:** +- Single-signer instruction - current authority can unilaterally transfer authority +- No approval from recipient required + +**Emits:** `AuthorityChangedEvent` + +--- + +### 5. `propose_change` +Proposes a change that requires two-party approval. + +**Accounts:** +- `performance_package` - The PP (mut, for seq_num) +- `change_request` - PDA to create (seeds: `["change_request", pp, proposer, pda_nonce.to_le_bytes()]`) +- `proposer` - Must be authority or recipient (signer) +- `payer` - Pays for account creation +- `system_program` + +**Args:** +- `pda_nonce: u32` - Unique nonce for PDA derivation (allows multiple concurrent proposals) +- `new_recipient: Option` - New recipient address (if changing) +- `new_oracle_reader: Option` - New oracle configuration (if changing) +- `new_reward_function: Option` - New reward function (if changing) + +**Checks:** +- `proposer == pp.authority || proposer == pp.recipient` +- At least one of `new_recipient`, `new_oracle_reader`, or `new_reward_function` must be `Some` +- If `new_oracle_reader.is_some()`: validates the oracle configuration +- If `new_reward_function.is_some()`: validates the reward function configuration + +**Logic:** +1. Determine `proposer_type` based on whether proposer is authority or recipient +2. Create ChangeRequest with optional fields, proposer_type, current timestamp, and pda_nonce + +**Emits:** `ChangeProposedEvent` + +--- + +### 6. `execute_change` +Executes a proposed change (opposite party must sign). + +**Accounts:** +- `performance_package` - The PP (mut) +- `change_request` - The ChangeRequest account (mut, will be closed) +- `executor` - Must be opposite party from proposer (signer) +- `rent_destination` - Receives closed account rent + +**Args:** None + +**Checks:** +- `change_request.performance_package == pp.key()` +- If `proposer_type == Authority`, then `executor == pp.recipient` +- If `proposer_type == Recipient`, then `executor == pp.authority` +- If `new_oracle_reader.is_some() || new_reward_function.is_some()`: `pp.status == Locked` (can only update when not unlocking) + +**Logic:** +Apply all `Some` fields from `change_request`: +- If `new_recipient.is_some()`: `pp.recipient = new_recipient.unwrap()` +- If `new_oracle_reader.is_some()`: `pp.oracle_reader = new_oracle_reader.unwrap()` +- If `new_reward_function.is_some()`: `pp.reward_function = new_reward_function.unwrap()` + +Close `change_request` account after execution. + +**Emits:** `ChangeExecutedEvent` + +--- + +### 7. `close_performance_package` +Closes the PP (admin-only operation). + +**Accounts:** +- `performance_package` - The PP (mut, will be closed) +- `admin` - MetaDAO operational multisig (signer) +- `rent_destination` - Receives closed account rent + +**Args:** None + +**Checks:** +- `admin == METADAO_ADMIN` (hardcoded operational multisig address) +- `pp.status == Locked` - Cannot close while unlocking + +**Notes:** +- This is a destructive operation - any unpaid rewards are forfeited +- Consider doing a final `complete_unlock` before closing to claim any pending rewards +- Similar to v1's `burn_performance_package` instruction + +**Emits:** `PerformancePackageClosedEvent` + +--- + +## Events + +All events include common fields for consistent metadata: + +```rust +pub struct CommonFields { + pub slot: u64, + pub unix_timestamp: i64, + pub performance_package_seq_num: u64, +} +``` + +### PerformancePackageCreatedEvent +Emitted by: `initialize_performance_package` + +```rust +#[event] +pub struct PerformancePackageCreatedEvent { + pub common: CommonFields, + pub performance_package: Pubkey, + pub mint: Pubkey, + pub mint_governor: Pubkey, + pub authority: Pubkey, + pub recipient: Pubkey, + pub create_key: Pubkey, + pub pda_bump: u8, +} +``` + +### UnlockStartedEvent +Emitted by: `start_unlock` + +```rust +#[event] +pub struct UnlockStartedEvent { + pub common: CommonFields, + pub performance_package: Pubkey, + pub start_time: i64, +} +``` + +### UnlockCompletedEvent +Emitted by: `complete_unlock` + +```rust +#[event] +pub struct UnlockCompletedEvent { + pub common: CommonFields, + pub performance_package: Pubkey, + pub oracle_value: u128, + pub recipient: Pubkey, + pub amount_minted: u64, + pub total_rewards_paid_out: u64, // Cumulative after this unlock +} +``` + +### AuthorityChangedEvent +Emitted by: `change_authority` + +```rust +#[event] +pub struct AuthorityChangedEvent { + pub common: CommonFields, + pub performance_package: Pubkey, + pub old_authority: Pubkey, + pub new_authority: Pubkey, +} +``` + +### ChangeProposedEvent +Emitted by: `propose_change` + +```rust +#[event] +pub struct ChangeProposedEvent { + pub common: CommonFields, + pub performance_package: Pubkey, + pub change_request: Pubkey, + pub proposer_type: ProposerType, + pub pda_nonce: u32, + pub new_recipient: Option, + pub new_oracle_reader: Option, + pub new_reward_function: Option, +} +``` + +### ChangeExecutedEvent +Emitted by: `execute_change` + +```rust +#[event] +pub struct ChangeExecutedEvent { + pub common: CommonFields, + pub performance_package: Pubkey, + pub executed_by: Pubkey, + pub new_recipient: Option, + pub new_oracle_reader: Option, + pub new_reward_function: Option, +} +``` + +### PerformancePackageClosedEvent +Emitted by: `close_performance_package` + +```rust +#[event] +pub struct PerformancePackageClosedEvent { + pub common: CommonFields, + pub performance_package: Pubkey, + pub total_rewards_paid_out: u64, // Final cumulative amount paid +} +``` + +--- + +## Testing + +Tests are organized as unit tests per instruction, following the pattern established in `tests/bidWall/` and `tests/mintGovernor/`. + +### Test Structure + +``` +tests/performancePackageV2/ +├── main.test.ts # Imports and describes all unit test suites +├── utils.ts # Shared helper functions +└── unit/ + ├── initializePerformancePackage.test.ts + ├── startUnlock.test.ts + ├── completeUnlock.test.ts + ├── changeAuthority.test.ts + ├── proposeChange.test.ts + ├── executeChange.test.ts + └── closePerformancePackage.test.ts +``` + +### Unit Tests by Instruction + +#### `initialize_performance_package` + +| Test Case | Description | +|-----------|-------------| +| successfully initializes with Time oracle and CliffLinear reward function | Creates PP with Time + CliffLinear | +| successfully initializes with FutarchyTwap oracle and CliffLinear reward function | Creates PP with FutarchyTwap + CliffLinear | +| successfully initializes with FutarchyTwap oracle and Threshold reward function | Creates PP with FutarchyTwap + Threshold | +| fails when create_key does not sign | Rejects if the create_key account is not a signer | +| fails when mint_authority.authorized_minter does not match PP | Rejects if MintAuthority wasn't set up for this PP | +| fails when mint_governor.mint does not match mint | Rejects if wrong governor is provided | +| fails with invalid reward function config | Rejects unsorted tranches, invalid timestamps, etc. | + +#### `start_unlock` + +| Test Case | Description | +|-----------|-------------| +| successfully starts when called by authority | Transitions Locked → Unlocking | +| successfully starts when called by recipient | Transitions Locked → Unlocking | +| records start snapshot for FutarchyTwap | Verifies start_value and start_time are set | +| fails when status is not Locked | Rejects if already Unlocking | +| fails when min_unlock_timestamp not reached | Rejects if current time < min_unlock_timestamp | +| fails when signer is neither authority nor recipient | Rejects unauthorized caller | +| fails when AMM account doesn't match for FutarchyTwap | Rejects wrong remaining account | + +#### `complete_unlock` + +| Test Case | Description | +|-----------|-------------| +| successfully completes unlock and mints tokens | Transitions Unlocking → Locked, mints tokens | +| records end snapshot for FutarchyTwap | Verifies end_value and end_time are set | +| correctly computes TWAP for FutarchyTwap | Verifies (end_value - start_value) / (end_time - start_time) | +| mints correct amount to recipient | Mints cumulative_rewards - total_rewards_paid_out | +| updates total_rewards_paid_out | Verifies tracking equals cumulative rewards after mint | +| resets oracle state | Verifies start/end values reset to 0 | +| rewards only increase (never decrease) | Verifies lower oracle value doesn't reduce rewards | +| succeeds with zero mint amount | No-op mint when rewards already paid | +| can be started again after complete | Verifies cycle can repeat | +| fails when status is not Unlocking | Rejects if Locked | +| fails when min_duration not reached | Rejects if oracle's min_duration hasn't passed | +| fails when signer is neither authority nor recipient | Rejects unauthorized caller | +| fails when mint_governor doesn't match | Rejects wrong governor | + +#### `change_authority` + +| Test Case | Description | +|-----------|-------------| +| successfully changes authority | Updates pp.authority to new address | +| new authority can perform authority actions | Verifies new authority can call start_unlock, etc. | +| old authority cannot perform authority actions after change | Verifies old authority is rejected | +| fails when signer is not current authority | Rejects unauthorized caller | + +#### `propose_change` + +| Test Case | Description | +|-----------|-------------| +| successfully proposes change when called by authority | Creates ChangeRequest with ProposerType::Authority | +| successfully proposes change when called by recipient | Creates ChangeRequest with ProposerType::Recipient | +| successfully proposes oracle change | Creates ChangeRequest with new_oracle_reader | +| successfully proposes reward function change | Creates ChangeRequest with new_reward_function | +| successfully proposes multiple changes at once | Creates ChangeRequest with multiple Some fields | +| allows multiple concurrent proposals with different nonces | Verifies pda_nonce uniqueness | +| fails when all optional fields are None | Rejects proposal with no changes | +| fails when signer is neither authority nor recipient | Rejects unauthorized caller | +| fails with invalid oracle config | Rejects invalid oracle_reader | +| fails with invalid reward function config | Rejects invalid reward_function | + +#### `execute_change` + +| Test Case | Description | +|-----------|-------------| +| successfully executes change (authority proposed, recipient signs) | Applies proposed changes to PP | +| successfully executes change (recipient proposed, authority signs) | Applies proposed changes to PP | +| successfully executes oracle change | Updates pp.oracle_reader | +| successfully executes reward function change | Updates pp.reward_function | +| successfully executes multiple changes at once | Applies all Some fields | +| closes change_request account | Verifies account closed and rent returned | +| fails when same party tries to propose and execute | Rejects self-approval | +| fails when change_request doesn't exist | Rejects missing proposal | +| fails when oracle change attempted while Unlocking | Rejects if pp.status != Locked | +| fails when reward function change attempted while Unlocking | Rejects if pp.status != Locked | + +#### `close_performance_package` + +| Test Case | Description | +|-----------|-------------| +| successfully closes PP when called by admin | Closes PP account | +| fails when caller is not admin | Rejects unauthorized caller | +| fails when status is Unlocking | Rejects if unlock in progress | + +--- + +## Integration with mint_governor + +PP v2 integrates with `mint_governor` as an authorized minter: + +``` +Setup: +1. Create MintGovernor for token mint (if not exists) +2. Admin calls mint_governor::add_mint_authority for PP's PDA + - authorized_minter = PP PDA + - max_total = optional cap for this PP +3. Create PP with references to mint_governor and mint_authority + +Minting: +1. PP::complete_unlock calculates tokens to mint +2. CPI to mint_governor::mint_tokens + - PP PDA signs as authorized_minter + - Tokens minted to recipient's ATA +3. mint_governor updates MintAuthority.total_minted +4. PP updates its own tracking +``` + +**Caps:** +- `MintAuthority.max_total`: Package-level cap (optional, set by admin) +- `RewardFunction` max: Per-function cap (embedded in function parameters) + +--- + +## Error Conditions + +```rust +pub enum PerformancePackageError { + // Authorization + Unauthorized, // Signer is neither authority nor recipient + InvalidExecutor, // Executor is not the opposite party from proposer + + // State + NotLocked, // Expected Locked status + NotUnlocking, // Expected Unlocking status + + // Oracle + OracleMissingAccount, // Expected remaining_accounts not provided + OracleInvalidAccount, // Account pubkey doesn't match expected + OracleParseError, // Failed to parse account data + OracleInvalidState, // Oracle state invalid (e.g., time_delta == 0) + OracleMinDurationNotReached, // min_duration hasn't passed yet + + // Time + UnlockTimestampNotReached, // min_unlock_timestamp not yet reached + + // Rewards + RewardCalculationOverflow, // Math overflow in reward function + + // Configuration + InvalidTranches, // Tranches not sorted or empty + InvalidVestingSchedule, // Cliff value after end value, start > cliff, etc. + + // Change Requests + ChangeRequestNotFound, // Missing proposal for execute + NoChangesProposed, // All optional change fields are None +} +``` + +--- + +## Potential Improvements + +### Add ChangeRequest rejection instruction + +Allow the non-proposing party to reject (close) a ChangeRequest, refunding SOL to proposer. This provides an explicit "no" signal rather than leaving proposals hanging indefinitely. + +### Support for multiple oracle sources + +Some reward scenarios might benefit from combining multiple oracle values (e.g., price AND time conditions). This could be implemented as a composite oracle reader that consumes multiple accounts from `remaining_accounts`: + +```rust +OracleReader::Composite { + oracle_count: u8, // Number of oracle accounts to read from remaining_accounts + combiner: CombineFunction, // Min, Max, Average, etc. +} +``` + +### Multiple PPs for complex schedules + +For scenarios requiring multiple independent reward schedules (e.g., price milestones AND time vesting), create separate Performance Packages. Each PP operates independently with its own oracle and reward function. diff --git a/vibes/task-template.md b/vibes/task-template.md new file mode 100644 index 000000000..85574d7c5 --- /dev/null +++ b/vibes/task-template.md @@ -0,0 +1,54 @@ +# [Feature Name] Implementation Tasks + +## Instructions for Claude + +**READ THIS FIRST:** + +1. Look at this file and find the task marked with `[NEXT]` +2. Read the referenced section in `vibes/[implementation-plan].md` for full context +3. Do ONLY that task - nothing else +4. After completing the task, verify with `[verification command]` +5. If successful, remove the completed task from this file +6. Mark the next task with `[NEXT]` +7. Stop and wait for the user + +**DO NOT:** +- Do multiple tasks at once +- Skip ahead +- Forget to verify + +**Reference:** Full implementation plan is in `vibes/[implementation-plan].md` + +--- + +## Tasks + +### Phase N: [Phase Name] + +> Reference: `[implementation-plan].md` → "Phase N: [Phase Name]" + +- [NEXT] N.1 [Task title] + - [Detail about what to do] + - [Another detail] + - [File to create/modify] + +- [ ] N.2 [Task title] + - Reference: Section N.2 + - [Detail about what to do] + - [Accounts/Args/Other specifics] + +- [ ] N.3 [Task title] + - Reference: Section N.3 + - [Detail 1] + - [Detail 2] + - [Detail 3] + +### Phase M: [Next Phase Name] + +> Reference: `[implementation-plan].md` → "Phase M: [Next Phase Name]" + +- [ ] M.1 [Task title] + - [Details...] + +- [ ] M.2 [Task title] + - [Details...] diff --git a/vibes/tasks.md b/vibes/tasks.md new file mode 100644 index 000000000..d1140661c --- /dev/null +++ b/vibes/tasks.md @@ -0,0 +1,295 @@ +# Performance Package v2 Implementation Tasks + +## Instructions for Claude + +**READ THIS FIRST:** + +1. Look at this file and find the task marked with `[NEXT]` +2. Read the referenced section in `vibes/001-performance-package-v2.md` for full context +3. Do ONLY that task - nothing else +4. After completing the task, if necessary, verify: + - First run `./rebuild.sh` (rebuilds SDK, runs typecheck and lint) + - Then run `anchor test --skip-build` to execute tests + - Run only the performancePackageV2 tests by specifying `describe.only("performance_package_v2" ...)` inside `tests/main.test.ts` and then remove the `.only` when you're done + - Since you will be fixing tests individually, confirm they work by using `it.only` +5. Once done, remove the completed task from this file entirely +6. Mark the next task with `[NEXT]` +7. Stop and wait for the user + +**DO NOT:** +- Do multiple tasks at once +- Skip ahead +- Forget to verify + +**Reference:** Full spec is in `vibes/001-performance-package-v2.md` + +**Note:** We implement Time oracle first. FutarchyTwap is added in Phase 9 after the core flow works. + +--- + +## Tasks + +### Phase 1: Program Scaffolding + +> Reference: `001-performance-package-v2.md` → Account structures, errors, events + +- [NEXT] 1.1 Create program scaffold + - Create `programs/performance_package_v2/` directory structure + - Add `Cargo.toml` with dependencies (anchor-lang, anchor-spl, solana-security-txt) + - Create `src/lib.rs` with program declaration, module imports, security_txt, and empty `#[program]` block + - Create empty module files: `constants.rs`, `error.rs`, `events.rs`, `state/mod.rs`, `instructions/mod.rs` + - Add program to `Anchor.toml` and workspace `Cargo.toml` + - Verify with `anchor build -p performance_package_v2` + +- [ ] 1.2 Define account structures + - Create `state/performance_package.rs` with `PerformancePackage` struct + - Create `state/change_request.rs` with `ChangeRequest` struct + - Define enums: `PackageStatus`, `ProposerType` + - Define `OracleReader` enum (Time variant only for now - FutarchyTwap added in Phase 9) + - Define `RewardFunction` enum with `CliffLinear` and `Threshold` variants + - Define `ThresholdTranche` struct + - Export all from `state/mod.rs` + +- [ ] 1.3 Define constants and errors + - Add admin pubkey constant in `constants.rs` (MetaDAO operational multisig) + - Define all error variants in `error.rs` (see spec's Error Conditions section) + +- [ ] 1.4 Define events and CommonFields + - Define `CommonFields` struct in `events.rs` + - Define all event structs (see spec's Events section) + - Export from `events.rs` + +- [ ] 1.5 Set up test scaffolding + - Create `tests/performancePackageV2/` directory + - Create `main.test.ts` that imports test suites (empty for now) + - Create `utils.ts` with helper functions (initially empty, will be populated as needed) + - Create `unit/` subdirectory + - Add import to `tests/main.test.ts` + - Verify tests run (even if empty) with `anchor test --skip-build` + +- [ ] 1.6 Add SDK scaffolding + - Create `sdk/src/v0.7/PerformancePackageV2Client.ts` with class skeleton + - Add PDA helpers to `sdk/src/v0.7/utils/pda.ts`: `getPerformancePackageAddr`, `getChangeRequestAddr` + - Export from `sdk/src/v0.7/index.ts` + - Run `./rebuild.sh` to verify types are generated + +### Phase 2: initialize_performance_package + +> Reference: `001-performance-package-v2.md` → Instruction 1 + +- [ ] 2.1 Write initialize_performance_package instruction + - Create `instructions/initialize_performance_package.rs` + - Implement `InitializePerformancePackage` accounts struct with constraints + - Implement `InitializePerformancePackageArgs` struct + - Implement `validate()` and `handle()` methods + - Add validation for reward function configuration + - Emit `PerformancePackageCreatedEvent` + - Export from `instructions/mod.rs` and wire up in `lib.rs` + +- [ ] 2.2 Add SDK method for initialize_performance_package + - Add `initializePerformancePackageIx()` method to `PerformancePackageV2Client` + - Add `fetchPerformancePackage()` and `deserializePerformancePackage()` methods + - Add type exports for `PerformancePackageAccount`, `OracleReader`, `RewardFunction`, etc. + - Run `./rebuild.sh` + +- [ ] 2.3 Write unit tests for initialize_performance_package + - Create `tests/performancePackageV2/unit/initializePerformancePackage.test.ts` + - Add test utils in `utils.ts` (e.g., `setupMintGovernorWithAuthority`) + - Test: successfully initializes with Time oracle and CliffLinear reward function + - Test: successfully initializes with Time oracle and Threshold reward function + - Test: fails when create_key does not sign + - Test: fails when mint_authority.authorized_minter does not match PP + - Test: fails when mint_governor.mint does not match mint + - Test: fails with invalid reward function config (unsorted tranches, invalid values) + - Import test suite in `main.test.ts` + +### Phase 3: start_unlock + +> Reference: `001-performance-package-v2.md` → Instruction 2 + +- [ ] 3.1 Write start_unlock instruction + - Create `instructions/start_unlock.rs` + - Implement accounts struct with authority/recipient check + - Implement `validate()`: check status == Locked, min_unlock_timestamp reached + - Implement `handle()`: call `oracle_reader.record_start()`, set status = Unlocking + - Add `record_start()` method to `OracleReader` (Time variant: no-op) + - Emit `UnlockStartedEvent` + - Wire up in `lib.rs` + +- [ ] 3.2 Add SDK method for start_unlock + - Add `startUnlockIx()` method to `PerformancePackageV2Client` + - Run `./rebuild.sh` + +- [ ] 3.3 Write unit tests for start_unlock + - Create `tests/performancePackageV2/unit/startUnlock.test.ts` + - Test: successfully starts when called by authority + - Test: successfully starts when called by recipient + - Test: fails when status is not Locked + - Test: fails when min_unlock_timestamp not reached + - Test: fails when signer is neither authority nor recipient + +### Phase 4: complete_unlock + +> Reference: `001-performance-package-v2.md` → Instruction 3 + +- [ ] 4.1 Write complete_unlock instruction + - Create `instructions/complete_unlock.rs` + - Implement accounts struct with mint_governor CPI accounts + - Implement `validate()`: check status == Unlocking, can_end(), account matches + - Implement `handle()`: record_end, compute_value, calculate rewards, CPI mint, reset, set Locked + - Add `record_end()`, `can_end()`, `compute_value()`, `reset()` to `OracleReader` + - Add `calculate()` method to `RewardFunction` (both variants) + - Emit `UnlockCompletedEvent` + - Wire up in `lib.rs` + +- [ ] 4.2 Add SDK method for complete_unlock + - Add `completeUnlockIx()` method to `PerformancePackageV2Client` + - Run `./rebuild.sh` + +- [ ] 4.3 Write unit tests for complete_unlock + - Create `tests/performancePackageV2/unit/completeUnlock.test.ts` + - Test: successfully completes unlock and mints tokens (CliffLinear) + - Test: successfully completes unlock and mints tokens (Threshold) + - Test: mints correct amount to recipient (cumulative - already_paid) + - Test: updates total_rewards_paid_out + - Test: resets oracle state (for Time: no state to reset) + - Test: rewards only increase (never decrease) + - Test: succeeds with zero mint amount when rewards already paid + - Test: can be started again after complete (cycle repeats) + - Test: fails when status is not Unlocking + - Test: fails when signer is neither authority nor recipient + - Test: fails when mint_governor doesn't match + +### Phase 5: change_authority + +> Reference: `001-performance-package-v2.md` → Instruction 4 + +- [ ] 5.1 Write change_authority instruction + - Create `instructions/change_authority.rs` + - Implement accounts struct with authority signer check + - Implement `validate()` and `handle()` + - Emit `AuthorityChangedEvent` + - Wire up in `lib.rs` + +- [ ] 5.2 Add SDK method for change_authority + - Add `changeAuthorityIx()` method to `PerformancePackageV2Client` + - Run `./rebuild.sh` + +- [ ] 5.3 Write unit tests for change_authority + - Create `tests/performancePackageV2/unit/changeAuthority.test.ts` + - Test: successfully changes authority + - Test: new authority can perform authority actions + - Test: old authority cannot perform authority actions after change + - Test: fails when signer is not current authority + +### Phase 6: propose_change + +> Reference: `001-performance-package-v2.md` → Instruction 5 + +- [ ] 6.1 Write propose_change instruction + - Create `instructions/propose_change.rs` + - Implement accounts struct with ChangeRequest PDA init + - Implement `ProposeChangeArgs` with optional fields and pda_nonce + - Implement `validate()`: at least one Some field, validate configs + - Implement `handle()`: create ChangeRequest + - Emit `ChangeProposedEvent` + - Wire up in `lib.rs` + +- [ ] 6.2 Add SDK method for propose_change + - Add `proposeChangeIx()` method to `PerformancePackageV2Client` + - Add `fetchChangeRequest()` method + - Run `./rebuild.sh` + +- [ ] 6.3 Write unit tests for propose_change + - Create `tests/performancePackageV2/unit/proposeChange.test.ts` + - Test: successfully proposes change when called by authority + - Test: successfully proposes change when called by recipient + - Test: successfully proposes recipient change + - Test: successfully proposes oracle change + - Test: successfully proposes reward function change + - Test: successfully proposes multiple changes at once + - Test: allows multiple concurrent proposals with different nonces + - Test: fails when all optional fields are None + - Test: fails when signer is neither authority nor recipient + +### Phase 7: execute_change + +> Reference: `001-performance-package-v2.md` → Instruction 6 + +- [ ] 7.1 Write execute_change instruction + - Create `instructions/execute_change.rs` + - Implement accounts struct with ChangeRequest validation + - Implement `validate()`: opposite party check, Locked status for config changes + - Implement `handle()`: apply changes, close ChangeRequest + - Emit `ChangeExecutedEvent` + - Wire up in `lib.rs` + +- [ ] 7.2 Add SDK method for execute_change + - Add `executeChangeIx()` method to `PerformancePackageV2Client` + - Run `./rebuild.sh` + +- [ ] 7.3 Write unit tests for execute_change + - Create `tests/performancePackageV2/unit/executeChange.test.ts` + - Test: successfully executes (authority proposed, recipient signs) + - Test: successfully executes (recipient proposed, authority signs) + - Test: successfully executes recipient change + - Test: successfully executes oracle change + - Test: successfully executes reward function change + - Test: successfully executes multiple changes at once + - Test: closes change_request account and returns rent + - Test: fails when same party tries to propose and execute + - Test: fails when oracle change attempted while Unlocking + - Test: fails when reward function change attempted while Unlocking + +### Phase 8: close_performance_package + +> Reference: `001-performance-package-v2.md` → Instruction 7 + +- [ ] 8.1 Write close_performance_package instruction + - Create `instructions/close_performance_package.rs` + - Implement accounts struct with admin check + - Implement `validate()`: admin == METADAO_ADMIN, status == Locked + - Implement `handle()`: close account + - Emit `PerformancePackageClosedEvent` + - Wire up in `lib.rs` + +- [ ] 8.2 Add SDK method for close_performance_package + - Add `closePerformancePackageIx()` method to `PerformancePackageV2Client` + - Run `./rebuild.sh` + +- [ ] 8.3 Write unit tests for close_performance_package + - Create `tests/performancePackageV2/unit/closePerformancePackage.test.ts` + - Test: successfully closes PP when called by admin + - Test: fails when caller is not admin + - Test: fails when status is Unlocking + +### Phase 9: FutarchyTwap Oracle Support + +> Reference: `001-performance-package-v2.md` → OracleReader variants + +- [ ] 9.1 Add FutarchyTwap variant to OracleReader + - Add `FutarchyTwap` variant with fields: amm, min_duration, start_value/time, end_value/time + - Implement `record_start()` for FutarchyTwap (read accumulator from AMM remaining_account) + - Implement `record_end()` for FutarchyTwap + - Implement `can_end()` for FutarchyTwap (check min_duration) + - Implement `compute_value()` for FutarchyTwap (TWAP calculation) + - Implement `reset()` for FutarchyTwap + +- [ ] 9.2 Update instructions for FutarchyTwap + - Update `start_unlock` to handle remaining_accounts for FutarchyTwap + - Update `complete_unlock` to handle remaining_accounts for FutarchyTwap + - Add validation that AMM account matches oracle_reader.amm + +- [ ] 9.3 Update SDK for FutarchyTwap + - Update `startUnlockIx()` to accept optional AMM account + - Update `completeUnlockIx()` to accept optional AMM account + - Run `./rebuild.sh` + +- [ ] 9.4 Write unit tests for FutarchyTwap + - Update `initializePerformancePackage.test.ts`: add test for FutarchyTwap + CliffLinear + - Update `initializePerformancePackage.test.ts`: add test for FutarchyTwap + Threshold + - Update `startUnlock.test.ts`: add test for recording start snapshot + - Update `startUnlock.test.ts`: add test for wrong AMM account failure + - Update `completeUnlock.test.ts`: add test for recording end snapshot + - Update `completeUnlock.test.ts`: add test for TWAP computation + - Update `completeUnlock.test.ts`: add test for min_duration not reached failure From 1fc23a5703e7b4617fc0826198454fa80d8e14d2 Mon Sep 17 00:00:00 2001 From: Pileks Date: Thu, 29 Jan 2026 14:17:06 -0800 Subject: [PATCH 07/27] scaffolding --- Anchor.toml | 1 + Cargo.lock | 9 +++++ programs/performance_package_v2/Cargo.toml | 22 +++++++++++ .../performance_package_v2/src/constants.rs | 2 + programs/performance_package_v2/src/error.rs | 7 ++++ programs/performance_package_v2/src/events.rs | 8 ++++ .../src/instructions/mod.rs | 1 + programs/performance_package_v2/src/lib.rs | 39 +++++++++++++++++++ .../performance_package_v2/src/state/mod.rs | 1 + 9 files changed, 90 insertions(+) create mode 100644 programs/performance_package_v2/Cargo.toml create mode 100644 programs/performance_package_v2/src/constants.rs create mode 100644 programs/performance_package_v2/src/error.rs create mode 100644 programs/performance_package_v2/src/events.rs create mode 100644 programs/performance_package_v2/src/instructions/mod.rs create mode 100644 programs/performance_package_v2/src/lib.rs create mode 100644 programs/performance_package_v2/src/state/mod.rs diff --git a/Anchor.toml b/Anchor.toml index 3788dee5f..22c20597f 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -12,6 +12,7 @@ futarchy = "FUTARELBfJfQ8RDGhg1wdhddq1odMAJUePHFuBYfUxKq" launchpad = "MooNyh4CBUYEKyXVnjGYQ8mEiJDpGvJMdvrZx1iGeHV" launchpad_v7 = "moontUzsdepotRGe5xsfip7vLPTJnVuafqdUWexVnPM" mint_governor = "gvnr27cVeyW3AVf3acL7VCJ5WjGAphytnsgcK1feHyH" +performance_package_v2 = "pPV2pfrxnmstSb9j7kEeCLny5BGj6SNwCWGd6xbGGzz" price_based_performance_package = "pbPPQH7jyKoSLu8QYs3rSY3YkDRXEBojKbTgnUg7NDS" [registry] diff --git a/Cargo.lock b/Cargo.lock index 38e8c0030..ec5095428 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1488,6 +1488,15 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +[[package]] +name = "performance_package_v2" +version = "0.7.0" +dependencies = [ + "anchor-lang", + "anchor-spl", + "solana-security-txt", +] + [[package]] name = "polyval" version = "0.5.3" diff --git a/programs/performance_package_v2/Cargo.toml b/programs/performance_package_v2/Cargo.toml new file mode 100644 index 000000000..3edb66b6f --- /dev/null +++ b/programs/performance_package_v2/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "performance_package_v2" +version = "0.7.0" +description = "Created with Anchor" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "performance_package_v2" + +[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/performance_package_v2/src/constants.rs b/programs/performance_package_v2/src/constants.rs new file mode 100644 index 000000000..54689db4b --- /dev/null +++ b/programs/performance_package_v2/src/constants.rs @@ -0,0 +1,2 @@ +pub const PERFORMANCE_PACKAGE_SEED: &[u8] = b"performance_package"; +pub const CHANGE_REQUEST_SEED: &[u8] = b"change_request"; diff --git a/programs/performance_package_v2/src/error.rs b/programs/performance_package_v2/src/error.rs new file mode 100644 index 000000000..c5456ee0c --- /dev/null +++ b/programs/performance_package_v2/src/error.rs @@ -0,0 +1,7 @@ +use anchor_lang::prelude::*; + +#[error_code] +pub enum PerformancePackageError { + #[msg("Placeholder error")] + Placeholder, +} diff --git a/programs/performance_package_v2/src/events.rs b/programs/performance_package_v2/src/events.rs new file mode 100644 index 000000000..59b14679a --- /dev/null +++ b/programs/performance_package_v2/src/events.rs @@ -0,0 +1,8 @@ +use anchor_lang::prelude::*; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct CommonFields { + pub slot: u64, + pub unix_timestamp: i64, + pub performance_package_seq_num: u64, +} diff --git a/programs/performance_package_v2/src/instructions/mod.rs b/programs/performance_package_v2/src/instructions/mod.rs new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/programs/performance_package_v2/src/instructions/mod.rs @@ -0,0 +1 @@ + diff --git a/programs/performance_package_v2/src/lib.rs b/programs/performance_package_v2/src/lib.rs new file mode 100644 index 000000000..364dadf52 --- /dev/null +++ b/programs/performance_package_v2/src/lib.rs @@ -0,0 +1,39 @@ +//! Performance Package v2 +//! +//! A token minting program that rewards teams based on achieved milestones. +//! Uses modular oracle readers and configurable reward functions. + +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: "performance_package_v2", + 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!("pPV2pfrxnmstSb9j7kEeCLny5BGj6SNwCWGd6xbGGzz"); + +#[program] +pub mod performance_package_v2 { + use super::*; +} diff --git a/programs/performance_package_v2/src/state/mod.rs b/programs/performance_package_v2/src/state/mod.rs new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/programs/performance_package_v2/src/state/mod.rs @@ -0,0 +1 @@ + From 18ed11dd12d81319ca3187814278fdfee13830b5 Mon Sep 17 00:00:00 2001 From: Pileks Date: Thu, 29 Jan 2026 14:26:35 -0800 Subject: [PATCH 08/27] more scaffolding --- .../performance_package_v2/src/constants.rs | 5 + .../src/state/change_request.rs | 34 + .../performance_package_v2/src/state/mod.rs | 4 + .../src/state/performance_package.rs | 99 +++ sdk/src/v0.7/types/performance_package_v2.ts | 619 ++++++++++++++++++ vibes/tasks.md | 19 +- 6 files changed, 762 insertions(+), 18 deletions(-) create mode 100644 programs/performance_package_v2/src/state/change_request.rs create mode 100644 programs/performance_package_v2/src/state/performance_package.rs create mode 100644 sdk/src/v0.7/types/performance_package_v2.ts diff --git a/programs/performance_package_v2/src/constants.rs b/programs/performance_package_v2/src/constants.rs index 54689db4b..1146d282e 100644 --- a/programs/performance_package_v2/src/constants.rs +++ b/programs/performance_package_v2/src/constants.rs @@ -1,2 +1,7 @@ +use anchor_lang::prelude::*; + pub const PERFORMANCE_PACKAGE_SEED: &[u8] = b"performance_package"; pub const CHANGE_REQUEST_SEED: &[u8] = b"change_request"; + +#[constant] +pub const MAX_TRANCHES: usize = 10; diff --git a/programs/performance_package_v2/src/state/change_request.rs b/programs/performance_package_v2/src/state/change_request.rs new file mode 100644 index 000000000..838e35c3e --- /dev/null +++ b/programs/performance_package_v2/src/state/change_request.rs @@ -0,0 +1,34 @@ +use anchor_lang::prelude::*; + +use super::{OracleReader, RewardFunction}; + +/// Who proposed the change. +#[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, Copy, PartialEq, Eq, InitSpace)] +pub enum ProposerType { + Authority, + Recipient, +} + +/// Temporary account for two-party approval flow. +/// Seeds: `["change_request", performance_package, proposer, pda_nonce.to_le_bytes()]` +#[account] +#[derive(InitSpace, Debug)] +pub struct ChangeRequest { + /// The performance package this change applies to + pub performance_package: Pubkey, + /// Who proposed this change + pub proposer_type: ProposerType, + /// When the change was proposed + pub proposed_at: i64, + /// For unique PDA derivation (allows multiple concurrent proposals) + pub pda_nonce: u32, + pub bump: u8, + + // === Optional Changes (at least one must be Some) === + /// New recipient address (if changing) + pub new_recipient: Option, + /// New oracle configuration (if changing) + pub new_oracle_reader: Option, + /// New reward function (if changing) + pub new_reward_function: Option, +} diff --git a/programs/performance_package_v2/src/state/mod.rs b/programs/performance_package_v2/src/state/mod.rs index 8b1378917..0c6612b86 100644 --- a/programs/performance_package_v2/src/state/mod.rs +++ b/programs/performance_package_v2/src/state/mod.rs @@ -1 +1,5 @@ +pub mod change_request; +pub mod performance_package; +pub use change_request::*; +pub use performance_package::*; diff --git a/programs/performance_package_v2/src/state/performance_package.rs b/programs/performance_package_v2/src/state/performance_package.rs new file mode 100644 index 000000000..31c064fe7 --- /dev/null +++ b/programs/performance_package_v2/src/state/performance_package.rs @@ -0,0 +1,99 @@ +use anchor_lang::prelude::*; + +use crate::MAX_TRANCHES; + +/// Lifecycle state for the performance package. +#[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, Copy, PartialEq, Eq, InitSpace)] +pub enum PackageStatus { + /// Ready to start (or waiting for min_unlock_timestamp) + Locked, + /// Unlock in progress, waiting for min_duration + Unlocking, +} + +/// Oracle reader that knows how to read from an external oracle account. +/// Extracts a `value: u128` for reward calculations. +#[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, PartialEq, Eq, InitSpace)] +pub enum OracleReader { + /// Reads current timestamp from Clock::get() + /// No state needed - just reads current time on demand + Time, + // FutarchyTwap variant will be added in Phase 9 +} + +/// A threshold tranche for step-based rewards. +#[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, PartialEq, Eq, InitSpace)] +pub struct ThresholdTranche { + /// Oracle value threshold + pub threshold: u128, + /// Total tokens at this level (cumulative, not incremental) + pub cumulative_amount: u64, +} + +/// Reward function that calculates cumulative rewards from oracle values. +/// Returns total tokens deserved so far (not incremental). +#[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, PartialEq, Eq, InitSpace)] +pub enum RewardFunction { + /// Cliff + Linear: cliff_amount at cliff_value, then linear accrual to total_amount at end_value + /// Works with any oracle value (e.g., time, price, or other metrics) + /// For no-cliff behavior, set cliff_value = start_value and cliff_amount = 0 + CliffLinear { + start_value: u128, + cliff_value: u128, + end_value: u128, + cliff_amount: u64, + /// Total amount including cliff + total_amount: u64, + }, + /// Threshold-based tranches (similar to v1) + /// Each tranche: if value >= threshold, cumulative reward = amount + Threshold { + /// Must be sorted by threshold ascending + #[max_len(MAX_TRANCHES)] + tranches: Vec, + }, +} + +/// The main account representing a performance package. +/// Acts as the `authorized_minter` in mint_governor. +/// Seeds: `["performance_package", create_key]` +#[account] +#[derive(InitSpace, Debug)] +pub struct PerformancePackage { + // === Core References === + /// Token mint controlled by mint_governor + pub mint: Pubkey, + /// MintGovernor account + pub mint_governor: Pubkey, + /// MintAuthority PDA for this PP + pub mint_authority: Pubkey, + + // === Authorities === + /// DAO multisig vault - can modify PP + pub authority: Pubkey, + /// Team multisig - receives minted tokens + pub recipient: Pubkey, + + // === Inline Configuration === + /// Stores start/end snapshots for oracle calculations + pub oracle_reader: OracleReader, + /// How to calculate rewards + pub reward_function: RewardFunction, + + // === Lifecycle === + /// Locked or Unlocking + pub status: PackageStatus, + /// Can't start unlock before this time + pub min_unlock_timestamp: i64, + + // === Payout Tracking === + /// Cumulative tokens minted to recipient + pub total_rewards_paid_out: u64, + /// Event sequence number + pub seq_num: u64, + + // === PDA === + /// Used for PDA derivation + pub create_key: Pubkey, + pub bump: u8, +} diff --git a/sdk/src/v0.7/types/performance_package_v2.ts b/sdk/src/v0.7/types/performance_package_v2.ts new file mode 100644 index 000000000..de396a2e2 --- /dev/null +++ b/sdk/src/v0.7/types/performance_package_v2.ts @@ -0,0 +1,619 @@ +export type PerformancePackageV2 = { + version: "0.7.0"; + name: "performance_package_v2"; + constants: [ + { + name: "MAX_TRANCHES"; + type: { + defined: "usize"; + }; + value: "10"; + }, + ]; + instructions: []; + accounts: [ + { + name: "changeRequest"; + docs: [ + "Temporary account for two-party approval flow.", + 'Seeds: `["change_request", performance_package, proposer, pda_nonce.to_le_bytes()]`', + ]; + type: { + kind: "struct"; + fields: [ + { + name: "performancePackage"; + docs: ["The performance package this change applies to"]; + type: "publicKey"; + }, + { + name: "proposerType"; + docs: ["Who proposed this change"]; + type: { + defined: "ProposerType"; + }; + }, + { + name: "proposedAt"; + docs: ["When the change was proposed"]; + type: "i64"; + }, + { + name: "pdaNonce"; + docs: [ + "For unique PDA derivation (allows multiple concurrent proposals)", + ]; + type: "u32"; + }, + { + name: "bump"; + type: "u8"; + }, + { + name: "newRecipient"; + docs: ["New recipient address (if changing)"]; + type: { + option: "publicKey"; + }; + }, + { + name: "newOracleReader"; + docs: ["New oracle configuration (if changing)"]; + type: { + option: { + defined: "OracleReader"; + }; + }; + }, + { + name: "newRewardFunction"; + docs: ["New reward function (if changing)"]; + type: { + option: { + defined: "RewardFunction"; + }; + }; + }, + ]; + }; + }, + { + name: "performancePackage"; + docs: [ + "The main account representing a performance package.", + "Acts as the `authorized_minter` in mint_governor.", + 'Seeds: `["performance_package", create_key]`', + ]; + type: { + kind: "struct"; + fields: [ + { + name: "mint"; + docs: ["Token mint controlled by mint_governor"]; + type: "publicKey"; + }, + { + name: "mintGovernor"; + docs: ["MintGovernor account"]; + type: "publicKey"; + }, + { + name: "mintAuthority"; + docs: ["MintAuthority PDA for this PP"]; + type: "publicKey"; + }, + { + name: "authority"; + docs: ["DAO multisig vault - can modify PP"]; + type: "publicKey"; + }, + { + name: "recipient"; + docs: ["Team multisig - receives minted tokens"]; + type: "publicKey"; + }, + { + name: "oracleReader"; + docs: ["Stores start/end snapshots for oracle calculations"]; + type: { + defined: "OracleReader"; + }; + }, + { + name: "rewardFunction"; + docs: ["How to calculate rewards"]; + type: { + defined: "RewardFunction"; + }; + }, + { + name: "status"; + docs: ["Locked or Unlocking"]; + type: { + defined: "PackageStatus"; + }; + }, + { + name: "minUnlockTimestamp"; + docs: ["Can't start unlock before this time"]; + type: "i64"; + }, + { + name: "totalRewardsPaidOut"; + docs: ["Cumulative tokens minted to recipient"]; + type: "u64"; + }, + { + name: "seqNum"; + docs: ["Event sequence number"]; + type: "u64"; + }, + { + name: "createKey"; + docs: ["Used for PDA derivation"]; + type: "publicKey"; + }, + { + name: "bump"; + type: "u8"; + }, + ]; + }; + }, + ]; + types: [ + { + name: "CommonFields"; + type: { + kind: "struct"; + fields: [ + { + name: "slot"; + type: "u64"; + }, + { + name: "unixTimestamp"; + type: "i64"; + }, + { + name: "performancePackageSeqNum"; + type: "u64"; + }, + ]; + }; + }, + { + name: "ThresholdTranche"; + docs: ["A threshold tranche for step-based rewards."]; + type: { + kind: "struct"; + fields: [ + { + name: "threshold"; + docs: ["Oracle value threshold"]; + type: "u128"; + }, + { + name: "cumulativeAmount"; + docs: ["Total tokens at this level (cumulative, not incremental)"]; + type: "u64"; + }, + ]; + }; + }, + { + name: "ProposerType"; + docs: ["Who proposed the change."]; + type: { + kind: "enum"; + variants: [ + { + name: "Authority"; + }, + { + name: "Recipient"; + }, + ]; + }; + }, + { + name: "PackageStatus"; + docs: ["Lifecycle state for the performance package."]; + type: { + kind: "enum"; + variants: [ + { + name: "Locked"; + }, + { + name: "Unlocking"; + }, + ]; + }; + }, + { + name: "OracleReader"; + docs: [ + "Oracle reader that knows how to read from an external oracle account.", + "Extracts a `value: u128` for reward calculations.", + ]; + type: { + kind: "enum"; + variants: [ + { + name: "Time"; + }, + ]; + }; + }, + { + name: "RewardFunction"; + docs: [ + "Reward function that calculates cumulative rewards from oracle values.", + "Returns total tokens deserved so far (not incremental).", + ]; + type: { + kind: "enum"; + variants: [ + { + name: "CliffLinear"; + fields: [ + { + name: "startValue"; + type: "u128"; + }, + { + name: "cliffValue"; + type: "u128"; + }, + { + name: "endValue"; + type: "u128"; + }, + { + name: "cliffAmount"; + type: "u64"; + }, + { + name: "totalAmount"; + docs: ["Total amount including cliff"]; + type: "u64"; + }, + ]; + }, + { + name: "Threshold"; + fields: [ + { + name: "tranches"; + docs: ["Must be sorted by threshold ascending"]; + type: { + vec: { + defined: "ThresholdTranche"; + }; + }; + }, + ]; + }, + ]; + }; + }, + ]; + errors: [ + { + code: 6000; + name: "Placeholder"; + msg: "Placeholder error"; + }, + ]; +}; + +export const IDL: PerformancePackageV2 = { + version: "0.7.0", + name: "performance_package_v2", + constants: [ + { + name: "MAX_TRANCHES", + type: { + defined: "usize", + }, + value: "10", + }, + ], + instructions: [], + accounts: [ + { + name: "changeRequest", + docs: [ + "Temporary account for two-party approval flow.", + 'Seeds: `["change_request", performance_package, proposer, pda_nonce.to_le_bytes()]`', + ], + type: { + kind: "struct", + fields: [ + { + name: "performancePackage", + docs: ["The performance package this change applies to"], + type: "publicKey", + }, + { + name: "proposerType", + docs: ["Who proposed this change"], + type: { + defined: "ProposerType", + }, + }, + { + name: "proposedAt", + docs: ["When the change was proposed"], + type: "i64", + }, + { + name: "pdaNonce", + docs: [ + "For unique PDA derivation (allows multiple concurrent proposals)", + ], + type: "u32", + }, + { + name: "bump", + type: "u8", + }, + { + name: "newRecipient", + docs: ["New recipient address (if changing)"], + type: { + option: "publicKey", + }, + }, + { + name: "newOracleReader", + docs: ["New oracle configuration (if changing)"], + type: { + option: { + defined: "OracleReader", + }, + }, + }, + { + name: "newRewardFunction", + docs: ["New reward function (if changing)"], + type: { + option: { + defined: "RewardFunction", + }, + }, + }, + ], + }, + }, + { + name: "performancePackage", + docs: [ + "The main account representing a performance package.", + "Acts as the `authorized_minter` in mint_governor.", + 'Seeds: `["performance_package", create_key]`', + ], + type: { + kind: "struct", + fields: [ + { + name: "mint", + docs: ["Token mint controlled by mint_governor"], + type: "publicKey", + }, + { + name: "mintGovernor", + docs: ["MintGovernor account"], + type: "publicKey", + }, + { + name: "mintAuthority", + docs: ["MintAuthority PDA for this PP"], + type: "publicKey", + }, + { + name: "authority", + docs: ["DAO multisig vault - can modify PP"], + type: "publicKey", + }, + { + name: "recipient", + docs: ["Team multisig - receives minted tokens"], + type: "publicKey", + }, + { + name: "oracleReader", + docs: ["Stores start/end snapshots for oracle calculations"], + type: { + defined: "OracleReader", + }, + }, + { + name: "rewardFunction", + docs: ["How to calculate rewards"], + type: { + defined: "RewardFunction", + }, + }, + { + name: "status", + docs: ["Locked or Unlocking"], + type: { + defined: "PackageStatus", + }, + }, + { + name: "minUnlockTimestamp", + docs: ["Can't start unlock before this time"], + type: "i64", + }, + { + name: "totalRewardsPaidOut", + docs: ["Cumulative tokens minted to recipient"], + type: "u64", + }, + { + name: "seqNum", + docs: ["Event sequence number"], + type: "u64", + }, + { + name: "createKey", + docs: ["Used for PDA derivation"], + type: "publicKey", + }, + { + name: "bump", + type: "u8", + }, + ], + }, + }, + ], + types: [ + { + name: "CommonFields", + type: { + kind: "struct", + fields: [ + { + name: "slot", + type: "u64", + }, + { + name: "unixTimestamp", + type: "i64", + }, + { + name: "performancePackageSeqNum", + type: "u64", + }, + ], + }, + }, + { + name: "ThresholdTranche", + docs: ["A threshold tranche for step-based rewards."], + type: { + kind: "struct", + fields: [ + { + name: "threshold", + docs: ["Oracle value threshold"], + type: "u128", + }, + { + name: "cumulativeAmount", + docs: ["Total tokens at this level (cumulative, not incremental)"], + type: "u64", + }, + ], + }, + }, + { + name: "ProposerType", + docs: ["Who proposed the change."], + type: { + kind: "enum", + variants: [ + { + name: "Authority", + }, + { + name: "Recipient", + }, + ], + }, + }, + { + name: "PackageStatus", + docs: ["Lifecycle state for the performance package."], + type: { + kind: "enum", + variants: [ + { + name: "Locked", + }, + { + name: "Unlocking", + }, + ], + }, + }, + { + name: "OracleReader", + docs: [ + "Oracle reader that knows how to read from an external oracle account.", + "Extracts a `value: u128` for reward calculations.", + ], + type: { + kind: "enum", + variants: [ + { + name: "Time", + }, + ], + }, + }, + { + name: "RewardFunction", + docs: [ + "Reward function that calculates cumulative rewards from oracle values.", + "Returns total tokens deserved so far (not incremental).", + ], + type: { + kind: "enum", + variants: [ + { + name: "CliffLinear", + fields: [ + { + name: "startValue", + type: "u128", + }, + { + name: "cliffValue", + type: "u128", + }, + { + name: "endValue", + type: "u128", + }, + { + name: "cliffAmount", + type: "u64", + }, + { + name: "totalAmount", + docs: ["Total amount including cliff"], + type: "u64", + }, + ], + }, + { + name: "Threshold", + fields: [ + { + name: "tranches", + docs: ["Must be sorted by threshold ascending"], + type: { + vec: { + defined: "ThresholdTranche", + }, + }, + }, + ], + }, + ], + }, + }, + ], + errors: [ + { + code: 6000, + name: "Placeholder", + msg: "Placeholder error", + }, + ], +}; diff --git a/vibes/tasks.md b/vibes/tasks.md index d1140661c..1f8a298c7 100644 --- a/vibes/tasks.md +++ b/vibes/tasks.md @@ -33,24 +33,7 @@ > Reference: `001-performance-package-v2.md` → Account structures, errors, events -- [NEXT] 1.1 Create program scaffold - - Create `programs/performance_package_v2/` directory structure - - Add `Cargo.toml` with dependencies (anchor-lang, anchor-spl, solana-security-txt) - - Create `src/lib.rs` with program declaration, module imports, security_txt, and empty `#[program]` block - - Create empty module files: `constants.rs`, `error.rs`, `events.rs`, `state/mod.rs`, `instructions/mod.rs` - - Add program to `Anchor.toml` and workspace `Cargo.toml` - - Verify with `anchor build -p performance_package_v2` - -- [ ] 1.2 Define account structures - - Create `state/performance_package.rs` with `PerformancePackage` struct - - Create `state/change_request.rs` with `ChangeRequest` struct - - Define enums: `PackageStatus`, `ProposerType` - - Define `OracleReader` enum (Time variant only for now - FutarchyTwap added in Phase 9) - - Define `RewardFunction` enum with `CliffLinear` and `Threshold` variants - - Define `ThresholdTranche` struct - - Export all from `state/mod.rs` - -- [ ] 1.3 Define constants and errors +- [NEXT] 1.3 Define constants and errors - Add admin pubkey constant in `constants.rs` (MetaDAO operational multisig) - Define all error variants in `error.rs` (see spec's Error Conditions section) From bc1351788fec496d2d4862fe67c5033b1add8404 Mon Sep 17 00:00:00 2001 From: Pileks Date: Thu, 29 Jan 2026 15:57:10 -0800 Subject: [PATCH 09/27] scaffolding --- .../performance_package_v2/src/constants.rs | 7 + programs/performance_package_v2/src/error.rs | 45 +- programs/performance_package_v2/src/events.rs | 78 ++ sdk/src/v0.7/PerformancePackageV2Client.ts | 98 +++ sdk/src/v0.7/constants.ts | 3 + sdk/src/v0.7/index.ts | 1 + sdk/src/v0.7/types/index.ts | 21 + sdk/src/v0.7/types/performance_package_v2.ts | 676 +++++++++++++++++- sdk/src/v0.7/utils/pda.ts | 36 + tests/main.test.ts | 2 + tests/performancePackageV2/main.test.ts | 7 + tests/performancePackageV2/utils.ts | 2 + vibes/tasks.md | 29 +- 13 files changed, 971 insertions(+), 34 deletions(-) create mode 100644 sdk/src/v0.7/PerformancePackageV2Client.ts create mode 100644 tests/performancePackageV2/main.test.ts create mode 100644 tests/performancePackageV2/utils.ts diff --git a/programs/performance_package_v2/src/constants.rs b/programs/performance_package_v2/src/constants.rs index 1146d282e..730d0ccef 100644 --- a/programs/performance_package_v2/src/constants.rs +++ b/programs/performance_package_v2/src/constants.rs @@ -5,3 +5,10 @@ pub const CHANGE_REQUEST_SEED: &[u8] = b"change_request"; #[constant] pub const MAX_TRANCHES: usize = 10; + +pub mod admin { + use anchor_lang::prelude::declare_id; + + // MetaDAO operational multisig + declare_id!("6awyHMshBGVjJ3ozdSJdyyDE1CTAXUwrpNMaRGMsb4sf"); +} diff --git a/programs/performance_package_v2/src/error.rs b/programs/performance_package_v2/src/error.rs index c5456ee0c..f87f71fb9 100644 --- a/programs/performance_package_v2/src/error.rs +++ b/programs/performance_package_v2/src/error.rs @@ -2,6 +2,47 @@ use anchor_lang::prelude::*; #[error_code] pub enum PerformancePackageError { - #[msg("Placeholder error")] - Placeholder, + // Authorization + #[msg("Signer is neither authority nor recipient")] + Unauthorized, + #[msg("Executor is not the opposite party from proposer")] + InvalidExecutor, + + // State + #[msg("Expected Locked status")] + NotLocked, + #[msg("Expected Unlocking status")] + NotUnlocking, + + // Oracle + #[msg("Expected remaining_accounts not provided")] + OracleMissingAccount, + #[msg("Account pubkey doesn't match expected")] + OracleInvalidAccount, + #[msg("Failed to parse account data")] + OracleParseError, + #[msg("Oracle state invalid")] + OracleInvalidState, + #[msg("Minimum duration hasn't passed yet")] + OracleMinDurationNotReached, + + // Time + #[msg("Minimum unlock timestamp not yet reached")] + UnlockTimestampNotReached, + + // Rewards + #[msg("Math overflow in reward function")] + RewardCalculationOverflow, + + // Configuration + #[msg("Tranches not sorted or empty")] + InvalidTranches, + #[msg("Invalid vesting schedule configuration")] + InvalidVestingSchedule, + + // Change Requests + #[msg("Missing proposal for execute")] + ChangeRequestNotFound, + #[msg("All optional change fields are None")] + NoChangesProposed, } diff --git a/programs/performance_package_v2/src/events.rs b/programs/performance_package_v2/src/events.rs index 59b14679a..b727482a4 100644 --- a/programs/performance_package_v2/src/events.rs +++ b/programs/performance_package_v2/src/events.rs @@ -1,8 +1,86 @@ use anchor_lang::prelude::*; +use crate::{OracleReader, ProposerType, RewardFunction}; + +/// Common fields included in all events for consistent metadata. #[derive(AnchorSerialize, AnchorDeserialize, Clone)] pub struct CommonFields { pub slot: u64, pub unix_timestamp: i64, pub performance_package_seq_num: u64, } + +/// Emitted by: `initialize_performance_package` +#[event] +pub struct PerformancePackageCreatedEvent { + pub common: CommonFields, + pub performance_package: Pubkey, + pub mint: Pubkey, + pub mint_governor: Pubkey, + pub authority: Pubkey, + pub recipient: Pubkey, + pub create_key: Pubkey, + pub pda_bump: u8, +} + +/// Emitted by: `start_unlock` +#[event] +pub struct UnlockStartedEvent { + pub common: CommonFields, + pub performance_package: Pubkey, + pub start_time: i64, +} + +/// Emitted by: `complete_unlock` +#[event] +pub struct UnlockCompletedEvent { + pub common: CommonFields, + pub performance_package: Pubkey, + pub oracle_value: u128, + pub recipient: Pubkey, + pub amount_minted: u64, + /// Cumulative after this unlock + pub total_rewards_paid_out: u64, +} + +/// Emitted by: `change_authority` +#[event] +pub struct AuthorityChangedEvent { + pub common: CommonFields, + pub performance_package: Pubkey, + pub old_authority: Pubkey, + pub new_authority: Pubkey, +} + +/// Emitted by: `propose_change` +#[event] +pub struct ChangeProposedEvent { + pub common: CommonFields, + pub performance_package: Pubkey, + pub change_request: Pubkey, + pub proposer_type: ProposerType, + pub pda_nonce: u32, + pub new_recipient: Option, + pub new_oracle_reader: Option, + pub new_reward_function: Option, +} + +/// Emitted by: `execute_change` +#[event] +pub struct ChangeExecutedEvent { + pub common: CommonFields, + pub performance_package: Pubkey, + pub executed_by: Pubkey, + pub new_recipient: Option, + pub new_oracle_reader: Option, + pub new_reward_function: Option, +} + +/// Emitted by: `close_performance_package` +#[event] +pub struct PerformancePackageClosedEvent { + pub common: CommonFields, + pub performance_package: Pubkey, + /// Final cumulative amount paid + pub total_rewards_paid_out: u64, +} diff --git a/sdk/src/v0.7/PerformancePackageV2Client.ts b/sdk/src/v0.7/PerformancePackageV2Client.ts new file mode 100644 index 000000000..39c5675ce --- /dev/null +++ b/sdk/src/v0.7/PerformancePackageV2Client.ts @@ -0,0 +1,98 @@ +import { AnchorProvider, Program } from "@coral-xyz/anchor"; +import { AccountInfo, PublicKey } from "@solana/web3.js"; +import { PERFORMANCE_PACKAGE_V2_PROGRAM_ID } from "./constants.js"; +import { + getPerformancePackageV2Addr, + getChangeRequestV2Addr, +} from "./utils/pda.js"; +import { + PerformancePackageV2 as PerformancePackageV2Program, + IDL as PerformancePackageV2IDL, +} from "./types/performance_package_v2.js"; +import type { + PerformancePackageV2Account, + ChangeRequestV2Account, +} from "./types/index.js"; + +export type CreatePerformancePackageV2ClientParams = { + provider: AnchorProvider; + programId?: PublicKey; +}; + +export class PerformancePackageV2Client { + 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( + PerformancePackageV2IDL, + programId, + provider, + ); + } + + public static createClient( + params: CreatePerformancePackageV2ClientParams, + ): PerformancePackageV2Client { + const { provider, programId } = params; + return new PerformancePackageV2Client( + provider, + programId || PERFORMANCE_PACKAGE_V2_PROGRAM_ID, + ); + } + + getPerformancePackageAddr(createKey: PublicKey): [PublicKey, number] { + return getPerformancePackageV2Addr({ + programId: this.programId, + createKey, + }); + } + + getChangeRequestAddr( + performancePackage: PublicKey, + proposer: PublicKey, + pdaNonce: number, + ): [PublicKey, number] { + return getChangeRequestV2Addr({ + programId: this.programId, + performancePackage, + proposer, + pdaNonce, + }); + } + + async fetchPerformancePackage( + performancePackage: PublicKey, + ): Promise { + return this.program.account.performancePackage.fetchNullable( + performancePackage, + ); + } + + async deserializePerformancePackage( + accountInfo: AccountInfo, + ): Promise { + return this.program.coder.accounts.decode( + "performancePackage", + accountInfo.data, + ); + } + + async fetchChangeRequest( + changeRequest: PublicKey, + ): Promise { + return this.program.account.changeRequest.fetchNullable(changeRequest); + } + + async deserializeChangeRequest( + accountInfo: AccountInfo, + ): Promise { + return this.program.coder.accounts.decode( + "changeRequest", + accountInfo.data, + ); + } +} diff --git a/sdk/src/v0.7/constants.ts b/sdk/src/v0.7/constants.ts index 5da053207..8b2c4b76b 100644 --- a/sdk/src/v0.7/constants.ts +++ b/sdk/src/v0.7/constants.ts @@ -26,6 +26,9 @@ export const BID_WALL_PROGRAM_ID = new PublicKey( export const MINT_GOVERNOR_PROGRAM_ID = new PublicKey( "gvnr27cVeyW3AVf3acL7VCJ5WjGAphytnsgcK1feHyH", ); +export const PERFORMANCE_PACKAGE_V2_PROGRAM_ID = new PublicKey( + "pPV2pfrxnmstSb9j7kEeCLny5BGj6SNwCWGd6xbGGzz", +); 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 30173ae87..1f5987bbc 100644 --- a/sdk/src/v0.7/index.ts +++ b/sdk/src/v0.7/index.ts @@ -7,3 +7,4 @@ export * from "./ConditionalVaultClient.js"; export * from "./LaunchpadClient.js"; export * from "./PriceBasedPerformancePackageClient.js"; export * from "./MintGovernorClient.js"; +export * from "./PerformancePackageV2Client.js"; diff --git a/sdk/src/v0.7/types/index.ts b/sdk/src/v0.7/types/index.ts index c280f9917..b7f0e92b9 100644 --- a/sdk/src/v0.7/types/index.ts +++ b/sdk/src/v0.7/types/index.ts @@ -31,6 +31,12 @@ import { } from "./mint_governor.js"; export { MintGovernorProgram, MintGovernorIDL }; +import { + PerformancePackageV2 as PerformancePackageV2Program, + IDL as PerformancePackageV2IDL, +} from "./performance_package_v2.js"; +export { PerformancePackageV2Program, PerformancePackageV2IDL }; + export { LowercaseKeys } from "./utils.js"; import type { IdlAccounts, IdlTypes, IdlEvents } from "@coral-xyz/anchor"; @@ -70,6 +76,21 @@ export type MintGovernorAccount = export type MintAuthorityAccount = IdlAccounts["mintAuthority"]; +export type PerformancePackageV2Account = + IdlAccounts["performancePackage"]; +export type ChangeRequestV2Account = + IdlAccounts["changeRequest"]; +export type OracleReaderV2 = + IdlTypes["OracleReader"]; +export type RewardFunctionV2 = + IdlTypes["RewardFunction"]; +export type PackageStatusV2 = + IdlTypes["PackageStatus"]; +export type ProposerTypeV2 = + IdlTypes["ProposerType"]; +export type ThresholdTrancheV2 = + IdlTypes["ThresholdTranche"]; + export type BidWallInitializedEvent = IdlEvents["BidWallInitializedEvent"]; export type BidWallTokensSoldEvent = diff --git a/sdk/src/v0.7/types/performance_package_v2.ts b/sdk/src/v0.7/types/performance_package_v2.ts index de396a2e2..e4037104c 100644 --- a/sdk/src/v0.7/types/performance_package_v2.ts +++ b/sdk/src/v0.7/types/performance_package_v2.ts @@ -164,6 +164,7 @@ export type PerformancePackageV2 = { types: [ { name: "CommonFields"; + docs: ["Common fields included in all events for consistent metadata."]; type: { kind: "struct"; fields: [ @@ -299,11 +300,344 @@ export type PerformancePackageV2 = { }; }, ]; + events: [ + { + name: "PerformancePackageCreatedEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "performancePackage"; + type: "publicKey"; + index: false; + }, + { + name: "mint"; + type: "publicKey"; + index: false; + }, + { + name: "mintGovernor"; + type: "publicKey"; + index: false; + }, + { + name: "authority"; + type: "publicKey"; + index: false; + }, + { + name: "recipient"; + type: "publicKey"; + index: false; + }, + { + name: "createKey"; + type: "publicKey"; + index: false; + }, + { + name: "pdaBump"; + type: "u8"; + index: false; + }, + ]; + }, + { + name: "UnlockStartedEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "performancePackage"; + type: "publicKey"; + index: false; + }, + { + name: "startTime"; + type: "i64"; + index: false; + }, + ]; + }, + { + name: "UnlockCompletedEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "performancePackage"; + type: "publicKey"; + index: false; + }, + { + name: "oracleValue"; + type: "u128"; + index: false; + }, + { + name: "recipient"; + type: "publicKey"; + index: false; + }, + { + name: "amountMinted"; + type: "u64"; + index: false; + }, + { + name: "totalRewardsPaidOut"; + type: "u64"; + index: false; + }, + ]; + }, + { + name: "AuthorityChangedEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "performancePackage"; + type: "publicKey"; + index: false; + }, + { + name: "oldAuthority"; + type: "publicKey"; + index: false; + }, + { + name: "newAuthority"; + type: "publicKey"; + index: false; + }, + ]; + }, + { + name: "ChangeProposedEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "performancePackage"; + type: "publicKey"; + index: false; + }, + { + name: "changeRequest"; + type: "publicKey"; + index: false; + }, + { + name: "proposerType"; + type: { + defined: "ProposerType"; + }; + index: false; + }, + { + name: "pdaNonce"; + type: "u32"; + index: false; + }, + { + name: "newRecipient"; + type: { + option: "publicKey"; + }; + index: false; + }, + { + name: "newOracleReader"; + type: { + option: { + defined: "OracleReader"; + }; + }; + index: false; + }, + { + name: "newRewardFunction"; + type: { + option: { + defined: "RewardFunction"; + }; + }; + index: false; + }, + ]; + }, + { + name: "ChangeExecutedEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "performancePackage"; + type: "publicKey"; + index: false; + }, + { + name: "executedBy"; + type: "publicKey"; + index: false; + }, + { + name: "newRecipient"; + type: { + option: "publicKey"; + }; + index: false; + }, + { + name: "newOracleReader"; + type: { + option: { + defined: "OracleReader"; + }; + }; + index: false; + }, + { + name: "newRewardFunction"; + type: { + option: { + defined: "RewardFunction"; + }; + }; + index: false; + }, + ]; + }, + { + name: "PerformancePackageClosedEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "performancePackage"; + type: "publicKey"; + index: false; + }, + { + name: "totalRewardsPaidOut"; + type: "u64"; + index: false; + }, + ]; + }, + ]; errors: [ { code: 6000; - name: "Placeholder"; - msg: "Placeholder error"; + name: "Unauthorized"; + msg: "Signer is neither authority nor recipient"; + }, + { + code: 6001; + name: "InvalidExecutor"; + msg: "Executor is not the opposite party from proposer"; + }, + { + code: 6002; + name: "NotLocked"; + msg: "Expected Locked status"; + }, + { + code: 6003; + name: "NotUnlocking"; + msg: "Expected Unlocking status"; + }, + { + code: 6004; + name: "OracleMissingAccount"; + msg: "Expected remaining_accounts not provided"; + }, + { + code: 6005; + name: "OracleInvalidAccount"; + msg: "Account pubkey doesn't match expected"; + }, + { + code: 6006; + name: "OracleParseError"; + msg: "Failed to parse account data"; + }, + { + code: 6007; + name: "OracleInvalidState"; + msg: "Oracle state invalid"; + }, + { + code: 6008; + name: "OracleMinDurationNotReached"; + msg: "Minimum duration hasn't passed yet"; + }, + { + code: 6009; + name: "UnlockTimestampNotReached"; + msg: "Minimum unlock timestamp not yet reached"; + }, + { + code: 6010; + name: "RewardCalculationOverflow"; + msg: "Math overflow in reward function"; + }, + { + code: 6011; + name: "InvalidTranches"; + msg: "Tranches not sorted or empty"; + }, + { + code: 6012; + name: "InvalidVestingSchedule"; + msg: "Invalid vesting schedule configuration"; + }, + { + code: 6013; + name: "ChangeRequestNotFound"; + msg: "Missing proposal for execute"; + }, + { + code: 6014; + name: "NoChangesProposed"; + msg: "All optional change fields are None"; }, ]; }; @@ -474,6 +808,7 @@ export const IDL: PerformancePackageV2 = { types: [ { name: "CommonFields", + docs: ["Common fields included in all events for consistent metadata."], type: { kind: "struct", fields: [ @@ -609,11 +944,344 @@ export const IDL: PerformancePackageV2 = { }, }, ], + events: [ + { + name: "PerformancePackageCreatedEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "performancePackage", + type: "publicKey", + index: false, + }, + { + name: "mint", + type: "publicKey", + index: false, + }, + { + name: "mintGovernor", + type: "publicKey", + index: false, + }, + { + name: "authority", + type: "publicKey", + index: false, + }, + { + name: "recipient", + type: "publicKey", + index: false, + }, + { + name: "createKey", + type: "publicKey", + index: false, + }, + { + name: "pdaBump", + type: "u8", + index: false, + }, + ], + }, + { + name: "UnlockStartedEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "performancePackage", + type: "publicKey", + index: false, + }, + { + name: "startTime", + type: "i64", + index: false, + }, + ], + }, + { + name: "UnlockCompletedEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "performancePackage", + type: "publicKey", + index: false, + }, + { + name: "oracleValue", + type: "u128", + index: false, + }, + { + name: "recipient", + type: "publicKey", + index: false, + }, + { + name: "amountMinted", + type: "u64", + index: false, + }, + { + name: "totalRewardsPaidOut", + type: "u64", + index: false, + }, + ], + }, + { + name: "AuthorityChangedEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "performancePackage", + type: "publicKey", + index: false, + }, + { + name: "oldAuthority", + type: "publicKey", + index: false, + }, + { + name: "newAuthority", + type: "publicKey", + index: false, + }, + ], + }, + { + name: "ChangeProposedEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "performancePackage", + type: "publicKey", + index: false, + }, + { + name: "changeRequest", + type: "publicKey", + index: false, + }, + { + name: "proposerType", + type: { + defined: "ProposerType", + }, + index: false, + }, + { + name: "pdaNonce", + type: "u32", + index: false, + }, + { + name: "newRecipient", + type: { + option: "publicKey", + }, + index: false, + }, + { + name: "newOracleReader", + type: { + option: { + defined: "OracleReader", + }, + }, + index: false, + }, + { + name: "newRewardFunction", + type: { + option: { + defined: "RewardFunction", + }, + }, + index: false, + }, + ], + }, + { + name: "ChangeExecutedEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "performancePackage", + type: "publicKey", + index: false, + }, + { + name: "executedBy", + type: "publicKey", + index: false, + }, + { + name: "newRecipient", + type: { + option: "publicKey", + }, + index: false, + }, + { + name: "newOracleReader", + type: { + option: { + defined: "OracleReader", + }, + }, + index: false, + }, + { + name: "newRewardFunction", + type: { + option: { + defined: "RewardFunction", + }, + }, + index: false, + }, + ], + }, + { + name: "PerformancePackageClosedEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "performancePackage", + type: "publicKey", + index: false, + }, + { + name: "totalRewardsPaidOut", + type: "u64", + index: false, + }, + ], + }, + ], errors: [ { code: 6000, - name: "Placeholder", - msg: "Placeholder error", + name: "Unauthorized", + msg: "Signer is neither authority nor recipient", + }, + { + code: 6001, + name: "InvalidExecutor", + msg: "Executor is not the opposite party from proposer", + }, + { + code: 6002, + name: "NotLocked", + msg: "Expected Locked status", + }, + { + code: 6003, + name: "NotUnlocking", + msg: "Expected Unlocking status", + }, + { + code: 6004, + name: "OracleMissingAccount", + msg: "Expected remaining_accounts not provided", + }, + { + code: 6005, + name: "OracleInvalidAccount", + msg: "Account pubkey doesn't match expected", + }, + { + code: 6006, + name: "OracleParseError", + msg: "Failed to parse account data", + }, + { + code: 6007, + name: "OracleInvalidState", + msg: "Oracle state invalid", + }, + { + code: 6008, + name: "OracleMinDurationNotReached", + msg: "Minimum duration hasn't passed yet", + }, + { + code: 6009, + name: "UnlockTimestampNotReached", + msg: "Minimum unlock timestamp not yet reached", + }, + { + code: 6010, + name: "RewardCalculationOverflow", + msg: "Math overflow in reward function", + }, + { + code: 6011, + name: "InvalidTranches", + msg: "Tranches not sorted or empty", + }, + { + code: 6012, + name: "InvalidVestingSchedule", + msg: "Invalid vesting schedule configuration", + }, + { + code: 6013, + name: "ChangeRequestNotFound", + msg: "Missing proposal for execute", + }, + { + code: 6014, + name: "NoChangesProposed", + msg: "All optional change fields are None", }, ], }; diff --git a/sdk/src/v0.7/utils/pda.ts b/sdk/src/v0.7/utils/pda.ts index c06c9d3a0..9280f6a65 100644 --- a/sdk/src/v0.7/utils/pda.ts +++ b/sdk/src/v0.7/utils/pda.ts @@ -13,6 +13,7 @@ import { DEVNET_RAYDIUM_CP_SWAP_PROGRAM_ID, MPL_TOKEN_METADATA_PROGRAM_ID, PRICE_BASED_PERFORMANCE_PACKAGE_PROGRAM_ID, + PERFORMANCE_PACKAGE_V2_PROGRAM_ID, RAYDIUM_CP_SWAP_PROGRAM_ID, SHARED_LIQUIDITY_MANAGER_PROGRAM_ID, LAUNCHPAD_PROGRAM_ID, @@ -295,3 +296,38 @@ export const getMintAuthorityAddr = ({ programId, ); }; + +export const getPerformancePackageV2Addr = ({ + programId = PERFORMANCE_PACKAGE_V2_PROGRAM_ID, + createKey, +}: { + programId?: PublicKey; + createKey: PublicKey; +}) => { + return PublicKey.findProgramAddressSync( + [Buffer.from("performance_package"), createKey.toBuffer()], + programId, + ); +}; + +export const getChangeRequestV2Addr = ({ + programId = PERFORMANCE_PACKAGE_V2_PROGRAM_ID, + performancePackage, + proposer, + pdaNonce, +}: { + programId?: PublicKey; + performancePackage: PublicKey; + proposer: PublicKey; + pdaNonce: number; +}) => { + return PublicKey.findProgramAddressSync( + [ + Buffer.from("change_request"), + performancePackage.toBuffer(), + proposer.toBuffer(), + Buffer.from(new Uint8Array(new Uint32Array([pdaNonce]).buffer)), + ], + programId, + ); +}; diff --git a/tests/main.test.ts b/tests/main.test.ts index c06145e78..35875f199 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -5,6 +5,7 @@ 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 performancePackageV2 from "./performancePackageV2/main.test.js"; import { BanksClient, @@ -741,6 +742,7 @@ describe("conditional_vault", conditionalVault); describe("futarchy", futarchy); describe("bid_wall", bidWall); describe("mint_governor", mintGovernor); +describe("performance_package_v2", performancePackageV2); 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/performancePackageV2/main.test.ts b/tests/performancePackageV2/main.test.ts new file mode 100644 index 000000000..4cdd46954 --- /dev/null +++ b/tests/performancePackageV2/main.test.ts @@ -0,0 +1,7 @@ +// Import unit test suites here as they are created +// import initializePerformancePackage from "./unit/initializePerformancePackage.test.js"; + +export default function suite() { + // Unit tests will be added as instructions are implemented + // describe("#initialize_performance_package", initializePerformancePackage); +} diff --git a/tests/performancePackageV2/utils.ts b/tests/performancePackageV2/utils.ts new file mode 100644 index 000000000..a4517ba24 --- /dev/null +++ b/tests/performancePackageV2/utils.ts @@ -0,0 +1,2 @@ +// Helper functions for performance_package_v2 tests +// Will be populated as needed during test implementation diff --git a/vibes/tasks.md b/vibes/tasks.md index 1f8a298c7..5e878f268 100644 --- a/vibes/tasks.md +++ b/vibes/tasks.md @@ -29,38 +29,11 @@ ## Tasks -### Phase 1: Program Scaffolding - -> Reference: `001-performance-package-v2.md` → Account structures, errors, events - -- [NEXT] 1.3 Define constants and errors - - Add admin pubkey constant in `constants.rs` (MetaDAO operational multisig) - - Define all error variants in `error.rs` (see spec's Error Conditions section) - -- [ ] 1.4 Define events and CommonFields - - Define `CommonFields` struct in `events.rs` - - Define all event structs (see spec's Events section) - - Export from `events.rs` - -- [ ] 1.5 Set up test scaffolding - - Create `tests/performancePackageV2/` directory - - Create `main.test.ts` that imports test suites (empty for now) - - Create `utils.ts` with helper functions (initially empty, will be populated as needed) - - Create `unit/` subdirectory - - Add import to `tests/main.test.ts` - - Verify tests run (even if empty) with `anchor test --skip-build` - -- [ ] 1.6 Add SDK scaffolding - - Create `sdk/src/v0.7/PerformancePackageV2Client.ts` with class skeleton - - Add PDA helpers to `sdk/src/v0.7/utils/pda.ts`: `getPerformancePackageAddr`, `getChangeRequestAddr` - - Export from `sdk/src/v0.7/index.ts` - - Run `./rebuild.sh` to verify types are generated - ### Phase 2: initialize_performance_package > Reference: `001-performance-package-v2.md` → Instruction 1 -- [ ] 2.1 Write initialize_performance_package instruction +- [NEXT] 2.1 Write initialize_performance_package instruction - Create `instructions/initialize_performance_package.rs` - Implement `InitializePerformancePackage` accounts struct with constraints - Implement `InitializePerformancePackageArgs` struct From 1e159ec965c6146f53edbe81879183e0a3bcd393 Mon Sep 17 00:00:00 2001 From: Pileks Date: Thu, 29 Jan 2026 16:22:48 -0800 Subject: [PATCH 10/27] initialize_performance_package ix --- CLAUDE.md | 20 +- Cargo.lock | 1 + programs/performance_package_v2/Cargo.toml | 1 + programs/performance_package_v2/src/error.rs | 6 + .../initialize_performance_package.rs | 147 +++++++++++ .../src/instructions/mod.rs | 2 + programs/performance_package_v2/src/lib.rs | 8 + sdk/src/v0.7/types/performance_package_v2.ts | 238 ++++++++++++++++-- vibes/tasks.md | 11 +- 9 files changed, 397 insertions(+), 37 deletions(-) create mode 100644 programs/performance_package_v2/src/instructions/initialize_performance_package.rs diff --git a/CLAUDE.md b/CLAUDE.md index 6c7ff0d82..ca130a5c1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,11 +117,27 @@ impl DoSomething<'_> { } ``` +### Account Constraints +When writing Anchor account constraints, prefer more specific constraint types over generic `constraint`: +1. `has_one` - when checking `account.field == other_account.key()` and field name matches account name +2. `address` - when checking against a known/constant address +3. `constraint` - only when the above don't apply (e.g., field name differs from account name) + +```rust +// Good - uses has_one since field name matches account name +#[account(has_one = mint @ MyError::InvalidMint)] +pub mint_governor: Account<'info, MintGovernor>, + +// Necessary - field name (authorized_minter) differs from account name (performance_package) +#[account(constraint = mint_authority.authorized_minter == performance_package.key() @ MyError::Invalid)] +pub mint_authority: Account<'info, MintAuthority>, +``` + ### Adding New Instructions 1. Add instruction to Rust program in `programs/[program]/src/instructions/` 2. Update client methods in SDK (`sdk/src/v0.7/`) 3. Add unit tests in `tests/[program]/unit/` -4. Run './rebuild' to sync types +4. Run `./rebuild.sh` to sync types ### Testing with Bankrun Tests use `solana-bankrun` for deterministic testing without external RPC: @@ -170,6 +186,8 @@ External programs required for tests. These are pre-compiled `.so` files in `tes **Tests timeout**: Increase `startup_wait` in `Anchor.toml` +**Cargo.lock version error**: If `Cargo.lock` ends up with `version = 4`, simply change it back to `version = 3` to fix lockfile issues. You don't have to remove the lockfile. + ## Mainnet Program IDs | Program | Version | ID | diff --git a/Cargo.lock b/Cargo.lock index ec5095428..4618de50f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1494,6 +1494,7 @@ version = "0.7.0" dependencies = [ "anchor-lang", "anchor-spl", + "mint_governor", "solana-security-txt", ] diff --git a/programs/performance_package_v2/Cargo.toml b/programs/performance_package_v2/Cargo.toml index 3edb66b6f..faee19864 100644 --- a/programs/performance_package_v2/Cargo.toml +++ b/programs/performance_package_v2/Cargo.toml @@ -20,3 +20,4 @@ production = [] anchor-lang = { version = "0.29.0", features = ["init-if-needed", "event-cpi"] } anchor-spl = "0.29.0" solana-security-txt = "1.1.1" +mint_governor = { path = "../mint_governor", features = ["cpi"] } diff --git a/programs/performance_package_v2/src/error.rs b/programs/performance_package_v2/src/error.rs index f87f71fb9..4bba830c4 100644 --- a/programs/performance_package_v2/src/error.rs +++ b/programs/performance_package_v2/src/error.rs @@ -8,6 +8,12 @@ pub enum PerformancePackageError { #[msg("Executor is not the opposite party from proposer")] InvalidExecutor, + // Account validation + #[msg("Mint governor does not match the provided mint")] + InvalidMintGovernor, + #[msg("Mint authority does not match expected configuration")] + InvalidMintAuthority, + // State #[msg("Expected Locked status")] NotLocked, diff --git a/programs/performance_package_v2/src/instructions/initialize_performance_package.rs b/programs/performance_package_v2/src/instructions/initialize_performance_package.rs new file mode 100644 index 000000000..b418447e9 --- /dev/null +++ b/programs/performance_package_v2/src/instructions/initialize_performance_package.rs @@ -0,0 +1,147 @@ +use anchor_lang::prelude::*; +use anchor_spl::token::Mint; +use mint_governor::state::{MintAuthority, MintGovernor}; + +use crate::{ + CommonFields, OracleReader, PackageStatus, PerformancePackage, PerformancePackageCreatedEvent, + PerformancePackageError, RewardFunction, PERFORMANCE_PACKAGE_SEED, +}; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct InitializePerformancePackageArgs { + pub oracle_reader: OracleReader, + pub reward_function: RewardFunction, + pub min_unlock_timestamp: i64, +} + +#[derive(Accounts)] +pub struct InitializePerformancePackage<'info> { + #[account( + init, + payer = payer, + space = 8 + PerformancePackage::INIT_SPACE, + seeds = [PERFORMANCE_PACKAGE_SEED, create_key.key().as_ref()], + bump + )] + pub performance_package: Account<'info, PerformancePackage>, + + pub mint: Account<'info, Mint>, + + #[account( + has_one = mint @ PerformancePackageError::InvalidMintGovernor + )] + pub mint_governor: Account<'info, MintGovernor>, + + #[account( + has_one = mint_governor @ PerformancePackageError::InvalidMintAuthority, + constraint = mint_authority.authorized_minter == performance_package.key() @ PerformancePackageError::InvalidMintAuthority + )] + pub mint_authority: Account<'info, MintAuthority>, + + pub create_key: Signer<'info>, + + /// CHECK: This is the authority address, no validation needed + pub authority: UncheckedAccount<'info>, + + /// CHECK: This is the recipient address, no validation needed + pub recipient: UncheckedAccount<'info>, + + #[account(mut)] + pub payer: Signer<'info>, + + pub system_program: Program<'info, System>, +} + +impl InitializePerformancePackage<'_> { + pub fn validate(&self, args: &InitializePerformancePackageArgs) -> Result<()> { + validate_reward_function(&args.reward_function)?; + Ok(()) + } + + pub fn handle(ctx: Context, args: InitializePerformancePackageArgs) -> Result<()> { + ctx.accounts + .performance_package + .set_inner(PerformancePackage { + mint: ctx.accounts.mint.key(), + mint_governor: ctx.accounts.mint_governor.key(), + mint_authority: ctx.accounts.mint_authority.key(), + authority: ctx.accounts.authority.key(), + recipient: ctx.accounts.recipient.key(), + oracle_reader: args.oracle_reader, + reward_function: args.reward_function, + status: PackageStatus::Locked, + min_unlock_timestamp: args.min_unlock_timestamp, + total_rewards_paid_out: 0, + seq_num: 0, + create_key: ctx.accounts.create_key.key(), + bump: ctx.bumps.performance_package, + }); + + let clock = Clock::get()?; + let pp = &ctx.accounts.performance_package; + + emit!(PerformancePackageCreatedEvent { + common: CommonFields { + slot: clock.slot, + unix_timestamp: clock.unix_timestamp, + performance_package_seq_num: pp.seq_num, + }, + performance_package: pp.key(), + mint: pp.mint, + mint_governor: pp.mint_governor, + authority: pp.authority, + recipient: pp.recipient, + create_key: pp.create_key, + pda_bump: pp.bump, + }); + + Ok(()) + } +} + +/// Validates the reward function configuration. +fn validate_reward_function(reward_function: &RewardFunction) -> Result<()> { + match reward_function { + RewardFunction::CliffLinear { + start_value, + cliff_value, + end_value, + cliff_amount, + total_amount, + } => { + // start_value <= cliff_value <= end_value + require!( + start_value <= cliff_value && cliff_value <= end_value, + PerformancePackageError::InvalidVestingSchedule + ); + // cliff_amount <= total_amount + require!( + cliff_amount <= total_amount, + PerformancePackageError::InvalidVestingSchedule + ); + } + RewardFunction::Threshold { tranches } => { + // Must have at least one tranche + require!( + !tranches.is_empty(), + PerformancePackageError::InvalidTranches + ); + + // Tranches must be sorted by threshold ascending + // and cumulative_amount must be non-decreasing + for window in tranches.windows(2) { + let prev = &window[0]; + let curr = &window[1]; + require!( + prev.threshold < curr.threshold, + PerformancePackageError::InvalidTranches + ); + require!( + prev.cumulative_amount <= curr.cumulative_amount, + PerformancePackageError::InvalidTranches + ); + } + } + } + Ok(()) +} diff --git a/programs/performance_package_v2/src/instructions/mod.rs b/programs/performance_package_v2/src/instructions/mod.rs index 8b1378917..ee105203f 100644 --- a/programs/performance_package_v2/src/instructions/mod.rs +++ b/programs/performance_package_v2/src/instructions/mod.rs @@ -1 +1,3 @@ +pub mod initialize_performance_package; +pub use initialize_performance_package::*; diff --git a/programs/performance_package_v2/src/lib.rs b/programs/performance_package_v2/src/lib.rs index 364dadf52..9b2bf0063 100644 --- a/programs/performance_package_v2/src/lib.rs +++ b/programs/performance_package_v2/src/lib.rs @@ -36,4 +36,12 @@ declare_id!("pPV2pfrxnmstSb9j7kEeCLny5BGj6SNwCWGd6xbGGzz"); #[program] pub mod performance_package_v2 { use super::*; + + #[access_control(ctx.accounts.validate(&args))] + pub fn initialize_performance_package( + ctx: Context, + args: InitializePerformancePackageArgs, + ) -> Result<()> { + InitializePerformancePackage::handle(ctx, args) + } } diff --git a/sdk/src/v0.7/types/performance_package_v2.ts b/sdk/src/v0.7/types/performance_package_v2.ts index e4037104c..2ea311d64 100644 --- a/sdk/src/v0.7/types/performance_package_v2.ts +++ b/sdk/src/v0.7/types/performance_package_v2.ts @@ -10,7 +10,66 @@ export type PerformancePackageV2 = { value: "10"; }, ]; - instructions: []; + instructions: [ + { + name: "initializePerformancePackage"; + accounts: [ + { + name: "performancePackage"; + isMut: true; + isSigner: false; + }, + { + name: "mint"; + isMut: false; + isSigner: false; + }, + { + name: "mintGovernor"; + isMut: false; + isSigner: false; + }, + { + name: "mintAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "createKey"; + isMut: false; + isSigner: true; + }, + { + name: "authority"; + isMut: false; + isSigner: false; + }, + { + name: "recipient"; + isMut: false; + isSigner: false; + }, + { + name: "payer"; + isMut: true; + isSigner: true; + }, + { + name: "systemProgram"; + isMut: false; + isSigner: false; + }, + ]; + args: [ + { + name: "args"; + type: { + defined: "InitializePerformancePackageArgs"; + }; + }, + ]; + }, + ]; accounts: [ { name: "changeRequest"; @@ -183,6 +242,30 @@ export type PerformancePackageV2 = { ]; }; }, + { + name: "InitializePerformancePackageArgs"; + type: { + kind: "struct"; + fields: [ + { + name: "oracleReader"; + type: { + defined: "OracleReader"; + }; + }, + { + name: "rewardFunction"; + type: { + defined: "RewardFunction"; + }; + }, + { + name: "minUnlockTimestamp"; + type: "i64"; + }, + ]; + }; + }, { name: "ThresholdTranche"; docs: ["A threshold tranche for step-based rewards."]; @@ -576,66 +659,76 @@ export type PerformancePackageV2 = { }, { code: 6002; + name: "InvalidMintGovernor"; + msg: "Mint governor does not match the provided mint"; + }, + { + code: 6003; + name: "InvalidMintAuthority"; + msg: "Mint authority does not match expected configuration"; + }, + { + code: 6004; name: "NotLocked"; msg: "Expected Locked status"; }, { - code: 6003; + code: 6005; name: "NotUnlocking"; msg: "Expected Unlocking status"; }, { - code: 6004; + code: 6006; name: "OracleMissingAccount"; msg: "Expected remaining_accounts not provided"; }, { - code: 6005; + code: 6007; name: "OracleInvalidAccount"; msg: "Account pubkey doesn't match expected"; }, { - code: 6006; + code: 6008; name: "OracleParseError"; msg: "Failed to parse account data"; }, { - code: 6007; + code: 6009; name: "OracleInvalidState"; msg: "Oracle state invalid"; }, { - code: 6008; + code: 6010; name: "OracleMinDurationNotReached"; msg: "Minimum duration hasn't passed yet"; }, { - code: 6009; + code: 6011; name: "UnlockTimestampNotReached"; msg: "Minimum unlock timestamp not yet reached"; }, { - code: 6010; + code: 6012; name: "RewardCalculationOverflow"; msg: "Math overflow in reward function"; }, { - code: 6011; + code: 6013; name: "InvalidTranches"; msg: "Tranches not sorted or empty"; }, { - code: 6012; + code: 6014; name: "InvalidVestingSchedule"; msg: "Invalid vesting schedule configuration"; }, { - code: 6013; + code: 6015; name: "ChangeRequestNotFound"; msg: "Missing proposal for execute"; }, { - code: 6014; + code: 6016; name: "NoChangesProposed"; msg: "All optional change fields are None"; }, @@ -654,7 +747,66 @@ export const IDL: PerformancePackageV2 = { value: "10", }, ], - instructions: [], + instructions: [ + { + name: "initializePerformancePackage", + accounts: [ + { + name: "performancePackage", + isMut: true, + isSigner: false, + }, + { + name: "mint", + isMut: false, + isSigner: false, + }, + { + name: "mintGovernor", + isMut: false, + isSigner: false, + }, + { + name: "mintAuthority", + isMut: false, + isSigner: false, + }, + { + name: "createKey", + isMut: false, + isSigner: true, + }, + { + name: "authority", + isMut: false, + isSigner: false, + }, + { + name: "recipient", + isMut: false, + isSigner: false, + }, + { + name: "payer", + isMut: true, + isSigner: true, + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: "args", + type: { + defined: "InitializePerformancePackageArgs", + }, + }, + ], + }, + ], accounts: [ { name: "changeRequest", @@ -827,6 +979,30 @@ export const IDL: PerformancePackageV2 = { ], }, }, + { + name: "InitializePerformancePackageArgs", + type: { + kind: "struct", + fields: [ + { + name: "oracleReader", + type: { + defined: "OracleReader", + }, + }, + { + name: "rewardFunction", + type: { + defined: "RewardFunction", + }, + }, + { + name: "minUnlockTimestamp", + type: "i64", + }, + ], + }, + }, { name: "ThresholdTranche", docs: ["A threshold tranche for step-based rewards."], @@ -1220,66 +1396,76 @@ export const IDL: PerformancePackageV2 = { }, { code: 6002, + name: "InvalidMintGovernor", + msg: "Mint governor does not match the provided mint", + }, + { + code: 6003, + name: "InvalidMintAuthority", + msg: "Mint authority does not match expected configuration", + }, + { + code: 6004, name: "NotLocked", msg: "Expected Locked status", }, { - code: 6003, + code: 6005, name: "NotUnlocking", msg: "Expected Unlocking status", }, { - code: 6004, + code: 6006, name: "OracleMissingAccount", msg: "Expected remaining_accounts not provided", }, { - code: 6005, + code: 6007, name: "OracleInvalidAccount", msg: "Account pubkey doesn't match expected", }, { - code: 6006, + code: 6008, name: "OracleParseError", msg: "Failed to parse account data", }, { - code: 6007, + code: 6009, name: "OracleInvalidState", msg: "Oracle state invalid", }, { - code: 6008, + code: 6010, name: "OracleMinDurationNotReached", msg: "Minimum duration hasn't passed yet", }, { - code: 6009, + code: 6011, name: "UnlockTimestampNotReached", msg: "Minimum unlock timestamp not yet reached", }, { - code: 6010, + code: 6012, name: "RewardCalculationOverflow", msg: "Math overflow in reward function", }, { - code: 6011, + code: 6013, name: "InvalidTranches", msg: "Tranches not sorted or empty", }, { - code: 6012, + code: 6014, name: "InvalidVestingSchedule", msg: "Invalid vesting schedule configuration", }, { - code: 6013, + code: 6015, name: "ChangeRequestNotFound", msg: "Missing proposal for execute", }, { - code: 6014, + code: 6016, name: "NoChangesProposed", msg: "All optional change fields are None", }, diff --git a/vibes/tasks.md b/vibes/tasks.md index 5e878f268..1e8d9ce00 100644 --- a/vibes/tasks.md +++ b/vibes/tasks.md @@ -33,16 +33,7 @@ > Reference: `001-performance-package-v2.md` → Instruction 1 -- [NEXT] 2.1 Write initialize_performance_package instruction - - Create `instructions/initialize_performance_package.rs` - - Implement `InitializePerformancePackage` accounts struct with constraints - - Implement `InitializePerformancePackageArgs` struct - - Implement `validate()` and `handle()` methods - - Add validation for reward function configuration - - Emit `PerformancePackageCreatedEvent` - - Export from `instructions/mod.rs` and wire up in `lib.rs` - -- [ ] 2.2 Add SDK method for initialize_performance_package +- [NEXT] 2.2 Add SDK method for initialize_performance_package - Add `initializePerformancePackageIx()` method to `PerformancePackageV2Client` - Add `fetchPerformancePackage()` and `deserializePerformancePackage()` methods - Add type exports for `PerformancePackageAccount`, `OracleReader`, `RewardFunction`, etc. From 0563620f40eef2d9e78c34a6ecfda3cc54df7077 Mon Sep 17 00:00:00 2001 From: Pileks Date: Thu, 29 Jan 2026 16:58:50 -0800 Subject: [PATCH 11/27] initialize perf package tests --- sdk/src/v0.7/PerformancePackageV2Client.ts | 49 +- tests/performancePackageV2/main.test.ts | 21 +- .../unit/initializePerformancePackage.test.ts | 621 ++++++++++++++++++ tests/performancePackageV2/utils.ts | 245 ++++++- vibes/tasks.md | 23 +- 5 files changed, 930 insertions(+), 29 deletions(-) create mode 100644 tests/performancePackageV2/unit/initializePerformancePackage.test.ts diff --git a/sdk/src/v0.7/PerformancePackageV2Client.ts b/sdk/src/v0.7/PerformancePackageV2Client.ts index 39c5675ce..d651674b0 100644 --- a/sdk/src/v0.7/PerformancePackageV2Client.ts +++ b/sdk/src/v0.7/PerformancePackageV2Client.ts @@ -1,5 +1,6 @@ import { AnchorProvider, Program } from "@coral-xyz/anchor"; -import { AccountInfo, PublicKey } from "@solana/web3.js"; +import { AccountInfo, PublicKey, SystemProgram } from "@solana/web3.js"; +import BN from "bn.js"; import { PERFORMANCE_PACKAGE_V2_PROGRAM_ID } from "./constants.js"; import { getPerformancePackageV2Addr, @@ -12,6 +13,8 @@ import { import type { PerformancePackageV2Account, ChangeRequestV2Account, + OracleReaderV2, + RewardFunctionV2, } from "./types/index.js"; export type CreatePerformancePackageV2ClientParams = { @@ -95,4 +98,48 @@ export class PerformancePackageV2Client { accountInfo.data, ); } + + initializePerformancePackageIx({ + createKey, + mint, + mintGovernor, + mintAuthority, + authority, + recipient, + payer = this.provider.publicKey, + oracleReader, + rewardFunction, + minUnlockTimestamp, + }: { + createKey: PublicKey; + mint: PublicKey; + mintGovernor: PublicKey; + mintAuthority: PublicKey; + authority: PublicKey; + recipient: PublicKey; + payer?: PublicKey; + oracleReader: OracleReaderV2; + rewardFunction: RewardFunctionV2; + minUnlockTimestamp: BN; + }) { + const [performancePackage] = this.getPerformancePackageAddr(createKey); + + return this.program.methods + .initializePerformancePackage({ + oracleReader, + rewardFunction, + minUnlockTimestamp, + }) + .accounts({ + performancePackage, + mint, + mintGovernor, + mintAuthority, + createKey, + authority, + recipient, + payer, + systemProgram: SystemProgram.programId, + }); + } } diff --git a/tests/performancePackageV2/main.test.ts b/tests/performancePackageV2/main.test.ts index 4cdd46954..8b466cf88 100644 --- a/tests/performancePackageV2/main.test.ts +++ b/tests/performancePackageV2/main.test.ts @@ -1,7 +1,20 @@ -// Import unit test suites here as they are created -// import initializePerformancePackage from "./unit/initializePerformancePackage.test.js"; +import initializePerformancePackage from "./unit/initializePerformancePackage.test.js"; +import { + MintGovernorClient, + PerformancePackageV2Client, +} from "@metadaoproject/futarchy/v0.7"; +import { BankrunProvider } from "anchor-bankrun"; export default function suite() { - // Unit tests will be added as instructions are implemented - // describe("#initialize_performance_package", initializePerformancePackage); + before(async function () { + const provider = new BankrunProvider(this.context); + this.mintGovernor = MintGovernorClient.createClient({ + provider: provider as any, + }); + this.performancePackageV2 = PerformancePackageV2Client.createClient({ + provider: provider as any, + }); + }); + + describe("#initialize_performance_package", initializePerformancePackage); } diff --git a/tests/performancePackageV2/unit/initializePerformancePackage.test.ts b/tests/performancePackageV2/unit/initializePerformancePackage.test.ts new file mode 100644 index 000000000..e850f15b4 --- /dev/null +++ b/tests/performancePackageV2/unit/initializePerformancePackage.test.ts @@ -0,0 +1,621 @@ +import { Keypair } from "@solana/web3.js"; +import BN from "bn.js"; +import { assert } from "chai"; +import { + MintGovernorClient, + PerformancePackageV2Client, + getPerformancePackageV2Addr, +} from "@metadaoproject/futarchy/v0.7"; +import { + setupMintGovernorWithAuthority, + createCliffLinearReward, + createThresholdReward, + createMintWithAuthority, +} from "../utils.js"; +import { expectError } from "../../utils.js"; + +export default function suite() { + let mintGovernorClient: MintGovernorClient; + let ppClient: PerformancePackageV2Client; + + before(async function () { + mintGovernorClient = this.mintGovernor; + ppClient = this.performancePackageV2; + }); + + it("successfully initializes with Time oracle and CliffLinear reward function", async function () { + const createKey = Keypair.generate(); + const [performancePackage] = getPerformancePackageV2Addr({ + createKey: createKey.publicKey, + }); + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + + const { mint, mintGovernor, mintAuthority } = + await setupMintGovernorWithAuthority( + this.banksClient, + mintGovernorClient, + this.payer, + performancePackage, + ); + + const minUnlockTimestamp = new BN(1000); + const rewardFunction = createCliffLinearReward({ + startValue: new BN(0), + cliffValue: new BN(100), + endValue: new BN(1000), + cliffAmount: new BN(100_000_000), + totalAmount: new BN(1_000_000_000), + }); + + await ppClient + .initializePerformancePackageIx({ + createKey: createKey.publicKey, + mint, + mintGovernor, + mintAuthority, + authority: authority.publicKey, + recipient: recipient.publicKey, + payer: this.payer.publicKey, + oracleReader: { time: {} }, + rewardFunction, + minUnlockTimestamp, + }) + .signers([createKey]) + .rpc(); + + const ppAccount = + await ppClient.fetchPerformancePackage(performancePackage); + + assert.isNotNull(ppAccount); + assert.equal(ppAccount.mint.toBase58(), mint.toBase58()); + assert.equal(ppAccount.mintGovernor.toBase58(), mintGovernor.toBase58()); + assert.equal(ppAccount.mintAuthority.toBase58(), mintAuthority.toBase58()); + assert.equal( + ppAccount.authority.toBase58(), + authority.publicKey.toBase58(), + ); + assert.equal( + ppAccount.recipient.toBase58(), + recipient.publicKey.toBase58(), + ); + assert.equal( + ppAccount.minUnlockTimestamp.toString(), + minUnlockTimestamp.toString(), + ); + assert.equal(ppAccount.totalRewardsPaidOut.toString(), "0"); + assert.equal(ppAccount.seqNum.toString(), "0"); + assert.equal( + ppAccount.createKey.toBase58(), + createKey.publicKey.toBase58(), + ); + assert.isDefined(ppAccount.oracleReader.time); + assert.isDefined(ppAccount.rewardFunction.cliffLinear); + assert.isDefined(ppAccount.status.locked); + + // Verify CliffLinear properties match what we defined + const cliffLinear = ppAccount.rewardFunction.cliffLinear; + assert.equal(cliffLinear.startValue.toString(), "0"); + assert.equal(cliffLinear.cliffValue.toString(), "100"); + assert.equal(cliffLinear.endValue.toString(), "1000"); + assert.equal(cliffLinear.cliffAmount.toString(), "100000000"); + assert.equal(cliffLinear.totalAmount.toString(), "1000000000"); + }); + + it("successfully initializes with Time oracle and Threshold reward function", async function () { + const createKey = Keypair.generate(); + const [performancePackage] = getPerformancePackageV2Addr({ + createKey: createKey.publicKey, + }); + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + + const { mint, mintGovernor, mintAuthority } = + await setupMintGovernorWithAuthority( + this.banksClient, + mintGovernorClient, + this.payer, + performancePackage, + ); + + const rewardFunction = createThresholdReward([ + { threshold: new BN(100), cumulativeAmount: new BN(100_000_000) }, + { threshold: new BN(200), cumulativeAmount: new BN(200_000_000) }, + { threshold: new BN(300), cumulativeAmount: new BN(300_000_000) }, + ]); + + await ppClient + .initializePerformancePackageIx({ + createKey: createKey.publicKey, + mint, + mintGovernor, + mintAuthority, + authority: authority.publicKey, + recipient: recipient.publicKey, + payer: this.payer.publicKey, + oracleReader: { time: {} }, + rewardFunction, + minUnlockTimestamp: new BN(0), + }) + .signers([createKey]) + .rpc(); + + const ppAccount = + await ppClient.fetchPerformancePackage(performancePackage); + + assert.isNotNull(ppAccount); + assert.isDefined(ppAccount.oracleReader.time); + assert.isDefined(ppAccount.rewardFunction.threshold); + assert.equal(ppAccount.rewardFunction.threshold.tranches.length, 3); + + // Verify all 3 tranches + const tranches = ppAccount.rewardFunction.threshold.tranches; + assert.equal(tranches[0].threshold.toString(), "100"); + assert.equal(tranches[0].cumulativeAmount.toString(), "100000000"); + assert.equal(tranches[1].threshold.toString(), "200"); + assert.equal(tranches[1].cumulativeAmount.toString(), "200000000"); + assert.equal(tranches[2].threshold.toString(), "300"); + assert.equal(tranches[2].cumulativeAmount.toString(), "300000000"); + }); + + it("successfully initializes with 10 threshold tranches", async function () { + const createKey = Keypair.generate(); + const [performancePackage] = getPerformancePackageV2Addr({ + createKey: createKey.publicKey, + }); + + const { mint, mintGovernor, mintAuthority } = + await setupMintGovernorWithAuthority( + this.banksClient, + mintGovernorClient, + this.payer, + performancePackage, + ); + + // Create 10 threshold tranches with increasing thresholds and cumulative amounts + const tranches = []; + for (let i = 1; i <= 10; i++) { + tranches.push({ + threshold: new BN(i * 100), // 100, 200, 300, ..., 1000 + cumulativeAmount: new BN(i * 100_000_000), // 100M, 200M, ..., 1000M (in smallest unit) + }); + } + const rewardFunction = createThresholdReward(tranches); + + await ppClient + .initializePerformancePackageIx({ + createKey: createKey.publicKey, + mint, + mintGovernor, + mintAuthority, + authority: this.payer.publicKey, + recipient: this.payer.publicKey, + payer: this.payer.publicKey, + oracleReader: { time: {} }, + rewardFunction, + minUnlockTimestamp: new BN(0), + }) + .signers([createKey]) + .rpc(); + + const ppAccount = + await ppClient.fetchPerformancePackage(performancePackage); + + assert.isNotNull(ppAccount); + assert.isDefined(ppAccount.rewardFunction.threshold); + assert.equal(ppAccount.rewardFunction.threshold.tranches.length, 10); + + // Verify first tranche + assert.equal( + ppAccount.rewardFunction.threshold.tranches[0].threshold.toString(), + "100", + ); + assert.equal( + ppAccount.rewardFunction.threshold.tranches[0].cumulativeAmount.toString(), + "100000000", + ); + + // Verify last tranche + assert.equal( + ppAccount.rewardFunction.threshold.tranches[9].threshold.toString(), + "1000", + ); + assert.equal( + ppAccount.rewardFunction.threshold.tranches[9].cumulativeAmount.toString(), + "1000000000", + ); + }); + + it("fails when create_key does not sign", async function () { + const createKey = Keypair.generate(); + const [performancePackage] = getPerformancePackageV2Addr({ + createKey: createKey.publicKey, + }); + + const { mint, mintGovernor, mintAuthority } = + await setupMintGovernorWithAuthority( + this.banksClient, + mintGovernorClient, + this.payer, + performancePackage, + ); + + const rewardFunction = createCliffLinearReward(); + + try { + // Don't include createKey in signers - this should fail + await ppClient + .initializePerformancePackageIx({ + createKey: createKey.publicKey, + mint, + mintGovernor, + mintAuthority, + authority: this.payer.publicKey, + recipient: this.payer.publicKey, + payer: this.payer.publicKey, + oracleReader: { time: {} }, + rewardFunction, + minUnlockTimestamp: new BN(0), + }) + .rpc(); // No signers - should fail + + assert.fail("Should have failed because create_key did not sign"); + } catch (e) { + // The transaction should fail with a signature verification error + assert.include(e.message.toLowerCase(), "signature"); + } + }); + + it("fails when mint_authority.authorized_minter does not match PP", async function () { + const createKey = Keypair.generate(); + + // Create a mint authority for a DIFFERENT authorized minter (not the PP) + const wrongAuthorizedMinter = Keypair.generate(); + const { mint, mintGovernor, mintAuthority } = + await setupMintGovernorWithAuthority( + this.banksClient, + mintGovernorClient, + this.payer, + wrongAuthorizedMinter.publicKey, // Wrong! Should be PP's address + ); + + const rewardFunction = createCliffLinearReward(); + + const callbacks = expectError( + "InvalidMintAuthority", + "Should have failed because mint_authority.authorized_minter doesn't match PP", + ); + + await ppClient + .initializePerformancePackageIx({ + createKey: createKey.publicKey, + mint, + mintGovernor, + mintAuthority, + authority: this.payer.publicKey, + recipient: this.payer.publicKey, + payer: this.payer.publicKey, + oracleReader: { time: {} }, + rewardFunction, + minUnlockTimestamp: new BN(0), + }) + .signers([createKey]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("fails when mint_governor.mint does not match mint", async function () { + const createKey = Keypair.generate(); + const [performancePackage] = getPerformancePackageV2Addr({ + createKey: createKey.publicKey, + }); + + // Set up mint governor with the correct PP as authorized minter + // Note: we intentionally ignore the mint from setupMintGovernorWithAuthority + // and use wrongMint instead to test the failure case + const { mintGovernor, mintAuthority } = + await setupMintGovernorWithAuthority( + this.banksClient, + mintGovernorClient, + this.payer, + performancePackage, + ); + + // Create a different mint to pass in (doesn't match the governor) + const wrongMint = await createMintWithAuthority( + this.banksClient, + this.payer, + this.payer.publicKey, + ); + + const rewardFunction = createCliffLinearReward(); + + const callbacks = expectError( + "InvalidMintGovernor", + "Should have failed because mint_governor.mint doesn't match mint", + ); + + await ppClient + .initializePerformancePackageIx({ + createKey: createKey.publicKey, + mint: wrongMint, // Wrong mint! + mintGovernor, + mintAuthority, + authority: this.payer.publicKey, + recipient: this.payer.publicKey, + payer: this.payer.publicKey, + oracleReader: { time: {} }, + rewardFunction, + minUnlockTimestamp: new BN(0), + }) + .signers([createKey]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("fails with invalid reward function config - unsorted tranches", async function () { + const createKey = Keypair.generate(); + const [performancePackage] = getPerformancePackageV2Addr({ + createKey: createKey.publicKey, + }); + + const { mint, mintGovernor, mintAuthority } = + await setupMintGovernorWithAuthority( + this.banksClient, + mintGovernorClient, + this.payer, + performancePackage, + ); + + // Create threshold reward with unsorted tranches (300 before 200) + const rewardFunction = createThresholdReward([ + { threshold: new BN(100), cumulativeAmount: new BN(100_000_000) }, + { threshold: new BN(300), cumulativeAmount: new BN(300_000_000) }, // Out of order! + { threshold: new BN(200), cumulativeAmount: new BN(200_000_000) }, + ]); + + const callbacks = expectError( + "InvalidTranches", + "Should have failed because tranches are not sorted", + ); + + await ppClient + .initializePerformancePackageIx({ + createKey: createKey.publicKey, + mint, + mintGovernor, + mintAuthority, + authority: this.payer.publicKey, + recipient: this.payer.publicKey, + payer: this.payer.publicKey, + oracleReader: { time: {} }, + rewardFunction, + minUnlockTimestamp: new BN(0), + }) + .signers([createKey]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("fails with invalid reward function config - decreasing cumulative amounts", async function () { + const createKey = Keypair.generate(); + const [performancePackage] = getPerformancePackageV2Addr({ + createKey: createKey.publicKey, + }); + + const { mint, mintGovernor, mintAuthority } = + await setupMintGovernorWithAuthority( + this.banksClient, + mintGovernorClient, + this.payer, + performancePackage, + ); + + // Create threshold reward with decreasing cumulative amounts + const rewardFunction = createThresholdReward([ + { threshold: new BN(100), cumulativeAmount: new BN(300_000_000) }, + { threshold: new BN(200), cumulativeAmount: new BN(200_000_000) }, // Less than previous! + { threshold: new BN(300), cumulativeAmount: new BN(100_000_000) }, + ]); + + const callbacks = expectError( + "InvalidTranches", + "Should have failed because cumulative amounts are not non-decreasing", + ); + + await ppClient + .initializePerformancePackageIx({ + createKey: createKey.publicKey, + mint, + mintGovernor, + mintAuthority, + authority: this.payer.publicKey, + recipient: this.payer.publicKey, + payer: this.payer.publicKey, + oracleReader: { time: {} }, + rewardFunction, + minUnlockTimestamp: new BN(0), + }) + .signers([createKey]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("fails with invalid reward function config - empty tranches", async function () { + const createKey = Keypair.generate(); + const [performancePackage] = getPerformancePackageV2Addr({ + createKey: createKey.publicKey, + }); + + const { mint, mintGovernor, mintAuthority } = + await setupMintGovernorWithAuthority( + this.banksClient, + mintGovernorClient, + this.payer, + performancePackage, + ); + + // Create threshold reward with empty tranches + const rewardFunction = createThresholdReward([]); + + const callbacks = expectError( + "InvalidTranches", + "Should have failed because tranches are empty", + ); + + await ppClient + .initializePerformancePackageIx({ + createKey: createKey.publicKey, + mint, + mintGovernor, + mintAuthority, + authority: this.payer.publicKey, + recipient: this.payer.publicKey, + payer: this.payer.publicKey, + oracleReader: { time: {} }, + rewardFunction, + minUnlockTimestamp: new BN(0), + }) + .signers([createKey]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("fails with invalid CliffLinear config - cliff_value > end_value", async function () { + const createKey = Keypair.generate(); + const [performancePackage] = getPerformancePackageV2Addr({ + createKey: createKey.publicKey, + }); + + const { mint, mintGovernor, mintAuthority } = + await setupMintGovernorWithAuthority( + this.banksClient, + mintGovernorClient, + this.payer, + performancePackage, + ); + + // cliff_value (1000) > end_value (500) - invalid + const rewardFunction = createCliffLinearReward({ + startValue: new BN(0), + cliffValue: new BN(1000), + endValue: new BN(500), // Less than cliff! + cliffAmount: new BN(100_000_000), + totalAmount: new BN(1_000_000_000), + }); + + const callbacks = expectError( + "InvalidVestingSchedule", + "Should have failed because cliff_value > end_value", + ); + + await ppClient + .initializePerformancePackageIx({ + createKey: createKey.publicKey, + mint, + mintGovernor, + mintAuthority, + authority: this.payer.publicKey, + recipient: this.payer.publicKey, + payer: this.payer.publicKey, + oracleReader: { time: {} }, + rewardFunction, + minUnlockTimestamp: new BN(0), + }) + .signers([createKey]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("fails with invalid CliffLinear config - cliff_amount > total_amount", async function () { + const createKey = Keypair.generate(); + const [performancePackage] = getPerformancePackageV2Addr({ + createKey: createKey.publicKey, + }); + + const { mint, mintGovernor, mintAuthority } = + await setupMintGovernorWithAuthority( + this.banksClient, + mintGovernorClient, + this.payer, + performancePackage, + ); + + // cliff_amount (2000) > total_amount (1000) - invalid + const rewardFunction = createCliffLinearReward({ + startValue: new BN(0), + cliffValue: new BN(100), + endValue: new BN(1000), + cliffAmount: new BN(2_000_000_000), // More than total! + totalAmount: new BN(1_000_000_000), + }); + + const callbacks = expectError( + "InvalidVestingSchedule", + "Should have failed because cliff_amount > total_amount", + ); + + await ppClient + .initializePerformancePackageIx({ + createKey: createKey.publicKey, + mint, + mintGovernor, + mintAuthority, + authority: this.payer.publicKey, + recipient: this.payer.publicKey, + payer: this.payer.publicKey, + oracleReader: { time: {} }, + rewardFunction, + minUnlockTimestamp: new BN(0), + }) + .signers([createKey]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("fails with invalid CliffLinear config - start_value > cliff_value", async function () { + const createKey = Keypair.generate(); + const [performancePackage] = getPerformancePackageV2Addr({ + createKey: createKey.publicKey, + }); + + const { mint, mintGovernor, mintAuthority } = + await setupMintGovernorWithAuthority( + this.banksClient, + mintGovernorClient, + this.payer, + performancePackage, + ); + + // start_value (500) > cliff_value (100) - invalid + const rewardFunction = createCliffLinearReward({ + startValue: new BN(500), // Greater than cliff! + cliffValue: new BN(100), + endValue: new BN(1000), + cliffAmount: new BN(100_000_000), + totalAmount: new BN(1_000_000_000), + }); + + const callbacks = expectError( + "InvalidVestingSchedule", + "Should have failed because start_value > cliff_value", + ); + + await ppClient + .initializePerformancePackageIx({ + createKey: createKey.publicKey, + mint, + mintGovernor, + mintAuthority, + authority: this.payer.publicKey, + recipient: this.payer.publicKey, + payer: this.payer.publicKey, + oracleReader: { time: {} }, + rewardFunction, + minUnlockTimestamp: new BN(0), + }) + .signers([createKey]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); +} diff --git a/tests/performancePackageV2/utils.ts b/tests/performancePackageV2/utils.ts index a4517ba24..1fc22d9a5 100644 --- a/tests/performancePackageV2/utils.ts +++ b/tests/performancePackageV2/utils.ts @@ -1,2 +1,243 @@ -// Helper functions for performance_package_v2 tests -// Will be populated as needed during test implementation +import { + Keypair, + PublicKey, + Transaction, + SystemProgram, +} from "@solana/web3.js"; +import * as token from "@solana/spl-token"; +import { BanksClient } from "solana-bankrun"; +import BN from "bn.js"; +import { + MintGovernorClient, + PerformancePackageV2Client, + getMintGovernorAddr, + getMintAuthorityAddr, + getPerformancePackageV2Addr, +} from "@metadaoproject/futarchy/v0.7"; +import type { + OracleReaderV2, + RewardFunctionV2, +} from "@metadaoproject/futarchy/v0.7"; + +/** + * Creates a mint with the specified 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; +} + +/** + * Sets up a mint, mint governor, transfers authority to the governor, and adds a mint authority + * for the specified authorized minter (typically a performance package PDA). + */ +export async function setupMintGovernorWithAuthority( + banksClient: BanksClient, + mintGovernorClient: MintGovernorClient, + payer: Keypair, + authorizedMinter: PublicKey, + maxTotal: BN | null = null, + decimals: number = 6, +): Promise<{ + mint: PublicKey; + mintGovernor: PublicKey; + mintGovernorCreateKey: Keypair; + mintAuthority: PublicKey; +}> { + // Create the mint with payer as authority initially + const mint = await createMintWithAuthority( + banksClient, + payer, + payer.publicKey, + decimals, + ); + + // Initialize the mint governor + const mintGovernorCreateKey = Keypair.generate(); + const [mintGovernor] = getMintGovernorAddr({ + mint, + createKey: mintGovernorCreateKey.publicKey, + }); + + await mintGovernorClient + .initializeMintGovernorIx({ + mint, + createKey: mintGovernorCreateKey.publicKey, + admin: payer.publicKey, + payer: payer.publicKey, + }) + .signers([mintGovernorCreateKey]) + .rpc(); + + // Transfer authority to the governor + await mintGovernorClient + .transferAuthorityToGovernorIx({ + mintGovernor, + mint, + currentAuthority: payer.publicKey, + }) + .rpc(); + + // Add mint authority for the authorized minter (e.g., performance package PDA) + await mintGovernorClient + .addMintAuthorityIx({ + mintGovernor, + admin: payer.publicKey, + authorizedMinter, + maxTotal, + }) + .rpc(); + + const [mintAuthority] = getMintAuthorityAddr({ + mintGovernor, + authorizedMinter, + }); + + return { + mint, + mintGovernor, + mintGovernorCreateKey, + mintAuthority, + }; +} + +/** + * Creates a complete performance package setup including mint, mint governor, and PP account + */ +export async function setupPerformancePackageV2( + banksClient: BanksClient, + mintGovernorClient: MintGovernorClient, + ppClient: PerformancePackageV2Client, + payer: Keypair, + { + authority = payer.publicKey, + recipient = payer.publicKey, + oracleReader = { time: {} } as OracleReaderV2, + rewardFunction, + minUnlockTimestamp = new BN(0), + maxTotal = null, + }: { + authority?: PublicKey; + recipient?: PublicKey; + oracleReader?: OracleReaderV2; + rewardFunction: RewardFunctionV2; + minUnlockTimestamp?: BN; + maxTotal?: BN | null; + }, +): Promise<{ + performancePackage: PublicKey; + createKey: Keypair; + mint: PublicKey; + mintGovernor: PublicKey; + mintAuthority: PublicKey; +}> { + const createKey = Keypair.generate(); + const [performancePackage] = getPerformancePackageV2Addr({ + createKey: createKey.publicKey, + }); + + // Setup mint governor with the PP as authorized minter + const { mint, mintGovernor, mintAuthority } = + await setupMintGovernorWithAuthority( + banksClient, + mintGovernorClient, + payer, + performancePackage, + maxTotal, + ); + + // Initialize the performance package + await ppClient + .initializePerformancePackageIx({ + createKey: createKey.publicKey, + mint, + mintGovernor, + mintAuthority, + authority, + recipient, + payer: payer.publicKey, + oracleReader, + rewardFunction, + minUnlockTimestamp, + }) + .signers([createKey]) + .rpc(); + + return { + performancePackage, + createKey, + mint, + mintGovernor, + mintAuthority, + }; +} + +/** + * Helper to create a CliffLinear reward function + */ +export function createCliffLinearReward({ + startValue = new BN(0), + cliffValue = new BN(100), + endValue = new BN(1000), + cliffAmount = new BN(100_000_000), // 100 tokens with 6 decimals + totalAmount = new BN(1_000_000_000), // 1000 tokens with 6 decimals +}: { + startValue?: BN; + cliffValue?: BN; + endValue?: BN; + cliffAmount?: BN; + totalAmount?: BN; +} = {}): RewardFunctionV2 { + return { + cliffLinear: { + startValue, + cliffValue, + endValue, + cliffAmount, + totalAmount, + }, + } as RewardFunctionV2; +} + +/** + * Helper to create a Threshold reward function + */ +export function createThresholdReward( + tranches: Array<{ threshold: BN; cumulativeAmount: BN }>, +): RewardFunctionV2 { + return { + threshold: { + tranches, + }, + } as RewardFunctionV2; +} diff --git a/vibes/tasks.md b/vibes/tasks.md index 1e8d9ce00..c23d55fb3 100644 --- a/vibes/tasks.md +++ b/vibes/tasks.md @@ -29,32 +29,11 @@ ## Tasks -### Phase 2: initialize_performance_package - -> Reference: `001-performance-package-v2.md` → Instruction 1 - -- [NEXT] 2.2 Add SDK method for initialize_performance_package - - Add `initializePerformancePackageIx()` method to `PerformancePackageV2Client` - - Add `fetchPerformancePackage()` and `deserializePerformancePackage()` methods - - Add type exports for `PerformancePackageAccount`, `OracleReader`, `RewardFunction`, etc. - - Run `./rebuild.sh` - -- [ ] 2.3 Write unit tests for initialize_performance_package - - Create `tests/performancePackageV2/unit/initializePerformancePackage.test.ts` - - Add test utils in `utils.ts` (e.g., `setupMintGovernorWithAuthority`) - - Test: successfully initializes with Time oracle and CliffLinear reward function - - Test: successfully initializes with Time oracle and Threshold reward function - - Test: fails when create_key does not sign - - Test: fails when mint_authority.authorized_minter does not match PP - - Test: fails when mint_governor.mint does not match mint - - Test: fails with invalid reward function config (unsorted tranches, invalid values) - - Import test suite in `main.test.ts` - ### Phase 3: start_unlock > Reference: `001-performance-package-v2.md` → Instruction 2 -- [ ] 3.1 Write start_unlock instruction +- [NEXT] 3.1 Write start_unlock instruction - Create `instructions/start_unlock.rs` - Implement accounts struct with authority/recipient check - Implement `validate()`: check status == Locked, min_unlock_timestamp reached From c76cc08c25a686cc8dc41bac50b39fd3fe6502b1 Mon Sep 17 00:00:00 2001 From: Pileks Date: Thu, 29 Jan 2026 17:19:23 -0800 Subject: [PATCH 12/27] pp unlock ix --- .../src/instructions/mod.rs | 2 + .../src/instructions/start_unlock.rs | 72 +++++++++++++++++++ programs/performance_package_v2/src/lib.rs | 5 ++ .../src/state/performance_package.rs | 13 ++++ sdk/src/v0.7/types/performance_package_v2.ts | 32 +++++++++ vibes/tasks.md | 11 +-- 6 files changed, 125 insertions(+), 10 deletions(-) create mode 100644 programs/performance_package_v2/src/instructions/start_unlock.rs diff --git a/programs/performance_package_v2/src/instructions/mod.rs b/programs/performance_package_v2/src/instructions/mod.rs index ee105203f..59268c658 100644 --- a/programs/performance_package_v2/src/instructions/mod.rs +++ b/programs/performance_package_v2/src/instructions/mod.rs @@ -1,3 +1,5 @@ pub mod initialize_performance_package; +pub mod start_unlock; pub use initialize_performance_package::*; +pub use start_unlock::*; diff --git a/programs/performance_package_v2/src/instructions/start_unlock.rs b/programs/performance_package_v2/src/instructions/start_unlock.rs new file mode 100644 index 000000000..de25280ac --- /dev/null +++ b/programs/performance_package_v2/src/instructions/start_unlock.rs @@ -0,0 +1,72 @@ +use anchor_lang::prelude::*; + +use crate::{ + CommonFields, PackageStatus, PerformancePackage, PerformancePackageError, UnlockStartedEvent, + PERFORMANCE_PACKAGE_SEED, +}; + +#[derive(Accounts)] +pub struct StartUnlock<'info> { + #[account( + mut, + seeds = [PERFORMANCE_PACKAGE_SEED, performance_package.create_key.as_ref()], + bump = performance_package.bump + )] + pub performance_package: Account<'info, PerformancePackage>, + + pub signer: Signer<'info>, +} + +impl StartUnlock<'_> { + pub fn validate(&self) -> Result<()> { + let pp = &self.performance_package; + + // Signer must be authority or recipient + require!( + self.signer.key() == pp.authority || self.signer.key() == pp.recipient, + PerformancePackageError::Unauthorized + ); + + // Must be in Locked status + require!( + pp.status == PackageStatus::Locked, + PerformancePackageError::NotLocked + ); + + // min_unlock_timestamp must have been reached + let clock = Clock::get()?; + require!( + clock.unix_timestamp >= pp.min_unlock_timestamp, + PerformancePackageError::UnlockTimestampNotReached + ); + + Ok(()) + } + + pub fn handle(ctx: Context) -> Result<()> { + let pp = &mut ctx.accounts.performance_package; + + // Record start snapshot (no-op for Time oracle) + pp.oracle_reader.record_start()?; + + // Transition to Unlocking status + pp.status = PackageStatus::Unlocking; + + // Increment sequence number + pp.seq_num += 1; + + let clock = Clock::get()?; + + emit!(UnlockStartedEvent { + common: CommonFields { + slot: clock.slot, + unix_timestamp: clock.unix_timestamp, + performance_package_seq_num: pp.seq_num, + }, + performance_package: pp.key(), + start_time: clock.unix_timestamp, + }); + + Ok(()) + } +} diff --git a/programs/performance_package_v2/src/lib.rs b/programs/performance_package_v2/src/lib.rs index 9b2bf0063..86d2f7db6 100644 --- a/programs/performance_package_v2/src/lib.rs +++ b/programs/performance_package_v2/src/lib.rs @@ -44,4 +44,9 @@ pub mod performance_package_v2 { ) -> Result<()> { InitializePerformancePackage::handle(ctx, args) } + + #[access_control(ctx.accounts.validate())] + pub fn start_unlock(ctx: Context) -> Result<()> { + StartUnlock::handle(ctx) + } } diff --git a/programs/performance_package_v2/src/state/performance_package.rs b/programs/performance_package_v2/src/state/performance_package.rs index 31c064fe7..eb80f9d53 100644 --- a/programs/performance_package_v2/src/state/performance_package.rs +++ b/programs/performance_package_v2/src/state/performance_package.rs @@ -21,6 +21,19 @@ pub enum OracleReader { // FutarchyTwap variant will be added in Phase 9 } +impl OracleReader { + /// Records the start snapshot when unlock begins. + /// For Time oracle, this is a no-op since it just reads current time on demand. + pub fn record_start(&mut self) -> Result<()> { + match self { + OracleReader::Time => { + // No-op for Time oracle - just reads current time on demand + Ok(()) + } + } + } +} + /// A threshold tranche for step-based rewards. #[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, PartialEq, Eq, InitSpace)] pub struct ThresholdTranche { diff --git a/sdk/src/v0.7/types/performance_package_v2.ts b/sdk/src/v0.7/types/performance_package_v2.ts index 2ea311d64..6e1908c67 100644 --- a/sdk/src/v0.7/types/performance_package_v2.ts +++ b/sdk/src/v0.7/types/performance_package_v2.ts @@ -69,6 +69,22 @@ export type PerformancePackageV2 = { }, ]; }, + { + name: "startUnlock"; + accounts: [ + { + name: "performancePackage"; + isMut: true; + isSigner: false; + }, + { + name: "signer"; + isMut: false; + isSigner: true; + }, + ]; + args: []; + }, ]; accounts: [ { @@ -806,6 +822,22 @@ export const IDL: PerformancePackageV2 = { }, ], }, + { + name: "startUnlock", + accounts: [ + { + name: "performancePackage", + isMut: true, + isSigner: false, + }, + { + name: "signer", + isMut: false, + isSigner: true, + }, + ], + args: [], + }, ], accounts: [ { diff --git a/vibes/tasks.md b/vibes/tasks.md index c23d55fb3..afd102085 100644 --- a/vibes/tasks.md +++ b/vibes/tasks.md @@ -33,16 +33,7 @@ > Reference: `001-performance-package-v2.md` → Instruction 2 -- [NEXT] 3.1 Write start_unlock instruction - - Create `instructions/start_unlock.rs` - - Implement accounts struct with authority/recipient check - - Implement `validate()`: check status == Locked, min_unlock_timestamp reached - - Implement `handle()`: call `oracle_reader.record_start()`, set status = Unlocking - - Add `record_start()` method to `OracleReader` (Time variant: no-op) - - Emit `UnlockStartedEvent` - - Wire up in `lib.rs` - -- [ ] 3.2 Add SDK method for start_unlock +- [NEXT] 3.2 Add SDK method for start_unlock - Add `startUnlockIx()` method to `PerformancePackageV2Client` - Run `./rebuild.sh` From f2a8e38e995a6848fa5584dce4d867b2a4563fe4 Mon Sep 17 00:00:00 2001 From: Pileks Date: Thu, 29 Jan 2026 19:10:41 -0800 Subject: [PATCH 13/27] start unlock client/tests --- sdk/src/v0.7/PerformancePackageV2Client.ts | 13 + tests/performancePackageV2/main.test.ts | 2 + .../unit/startUnlock.test.ts | 260 ++++++++++++++++++ vibes/tasks.md | 18 +- 4 files changed, 276 insertions(+), 17 deletions(-) create mode 100644 tests/performancePackageV2/unit/startUnlock.test.ts diff --git a/sdk/src/v0.7/PerformancePackageV2Client.ts b/sdk/src/v0.7/PerformancePackageV2Client.ts index d651674b0..40d71e6f1 100644 --- a/sdk/src/v0.7/PerformancePackageV2Client.ts +++ b/sdk/src/v0.7/PerformancePackageV2Client.ts @@ -142,4 +142,17 @@ export class PerformancePackageV2Client { systemProgram: SystemProgram.programId, }); } + + startUnlockIx({ + performancePackage, + signer = this.provider.publicKey, + }: { + performancePackage: PublicKey; + signer?: PublicKey; + }) { + return this.program.methods.startUnlock().accounts({ + performancePackage, + signer, + }); + } } diff --git a/tests/performancePackageV2/main.test.ts b/tests/performancePackageV2/main.test.ts index 8b466cf88..a4a7b71b0 100644 --- a/tests/performancePackageV2/main.test.ts +++ b/tests/performancePackageV2/main.test.ts @@ -1,4 +1,5 @@ import initializePerformancePackage from "./unit/initializePerformancePackage.test.js"; +import startUnlock from "./unit/startUnlock.test.js"; import { MintGovernorClient, PerformancePackageV2Client, @@ -17,4 +18,5 @@ export default function suite() { }); describe("#initialize_performance_package", initializePerformancePackage); + describe("#start_unlock", startUnlock); } diff --git a/tests/performancePackageV2/unit/startUnlock.test.ts b/tests/performancePackageV2/unit/startUnlock.test.ts new file mode 100644 index 000000000..39aab7d8f --- /dev/null +++ b/tests/performancePackageV2/unit/startUnlock.test.ts @@ -0,0 +1,260 @@ +import { Keypair, PublicKey } from "@solana/web3.js"; +import BN from "bn.js"; +import { assert } from "chai"; +import { + MintGovernorClient, + PerformancePackageV2Client, +} from "@metadaoproject/futarchy/v0.7"; +import { + setupPerformancePackageV2, + createCliffLinearReward, +} from "../utils.js"; +import { expectError } from "../../utils.js"; + +export default function suite() { + let mintGovernorClient: MintGovernorClient; + let ppClient: PerformancePackageV2Client; + + before(async function () { + mintGovernorClient = this.mintGovernor; + ppClient = this.performancePackageV2; + }); + + it("successfully starts when called by authority", async function () { + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + + const { performancePackage } = await setupPerformancePackageV2( + this.banksClient, + mintGovernorClient, + ppClient, + this.payer, + { + authority: authority.publicKey, + recipient: recipient.publicKey, + rewardFunction: createCliffLinearReward(), + minUnlockTimestamp: new BN(0), + }, + ); + + // Verify initial status is Locked + let ppAccount = await ppClient.fetchPerformancePackage(performancePackage); + assert.isDefined(ppAccount.status.locked); + + // Call start_unlock as authority + await ppClient + .startUnlockIx({ + performancePackage, + signer: authority.publicKey, + }) + .signers([authority]) + .rpc(); + + // Verify status is now Unlocking + ppAccount = await ppClient.fetchPerformancePackage(performancePackage); + assert.isDefined(ppAccount.status.unlocking); + assert.equal(ppAccount.seqNum.toString(), "1"); + }); + + it("successfully starts when called by recipient", async function () { + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + + const { performancePackage } = await setupPerformancePackageV2( + this.banksClient, + mintGovernorClient, + ppClient, + this.payer, + { + authority: authority.publicKey, + recipient: recipient.publicKey, + rewardFunction: createCliffLinearReward(), + minUnlockTimestamp: new BN(0), + }, + ); + + // Verify initial status is Locked + let ppAccount = await ppClient.fetchPerformancePackage(performancePackage); + assert.isDefined(ppAccount.status.locked); + + // Call start_unlock as recipient + await ppClient + .startUnlockIx({ + performancePackage, + signer: recipient.publicKey, + }) + .signers([recipient]) + .rpc(); + + // Verify status is now Unlocking + ppAccount = await ppClient.fetchPerformancePackage(performancePackage); + assert.isDefined(ppAccount.status.unlocking); + assert.equal(ppAccount.seqNum.toString(), "1"); + }); + + it("fails when status is not Locked", async function () { + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + + const { performancePackage } = await setupPerformancePackageV2( + this.banksClient, + mintGovernorClient, + ppClient, + this.payer, + { + authority: authority.publicKey, + recipient: recipient.publicKey, + rewardFunction: createCliffLinearReward(), + minUnlockTimestamp: new BN(0), + }, + ); + + // First, start the unlock to transition to Unlocking status + await ppClient + .startUnlockIx({ + performancePackage, + signer: authority.publicKey, + }) + .signers([authority]) + .rpc(); + + // Verify status is now Unlocking + const ppAccount = + await ppClient.fetchPerformancePackage(performancePackage); + assert.isDefined(ppAccount.status.unlocking); + + // Advance by slots to get a new blockhash + await this.advanceBySlots(1n); + + // Try to call start_unlock again - should fail because status is Unlocking, not Locked + const callbacks = expectError( + "NotLocked", + "Should have failed because status is not Locked", + ); + + await ppClient + .startUnlockIx({ + performancePackage, + signer: authority.publicKey, + }) + .signers([authority]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("fails when min_unlock_timestamp not reached", async function () { + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + + // Get the current clock timestamp + const currentClock = await this.banksClient.getClock(); + const currentTimestamp = Number(currentClock.unixTimestamp); + + // Set minUnlockTimestamp to far in the future (1 hour from now) + const minUnlockTimestamp = new BN(currentTimestamp + 3600); + + const { performancePackage } = await setupPerformancePackageV2( + this.banksClient, + mintGovernorClient, + ppClient, + this.payer, + { + authority: authority.publicKey, + recipient: recipient.publicKey, + rewardFunction: createCliffLinearReward(), + minUnlockTimestamp, + }, + ); + + // Try to call start_unlock before min_unlock_timestamp is reached + const callbacks = expectError( + "UnlockTimestampNotReached", + "Should have failed because min_unlock_timestamp not reached", + ); + + await ppClient + .startUnlockIx({ + performancePackage, + signer: authority.publicKey, + }) + .signers([authority]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("successfully starts after min_unlock_timestamp is reached", async function () { + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + + // Get the current clock timestamp + const currentClock = await this.banksClient.getClock(); + const currentTimestamp = Number(currentClock.unixTimestamp); + + // Set minUnlockTimestamp to 10 seconds in the future + const minUnlockTimestamp = new BN(currentTimestamp + 10); + + const { performancePackage } = await setupPerformancePackageV2( + this.banksClient, + mintGovernorClient, + ppClient, + this.payer, + { + authority: authority.publicKey, + recipient: recipient.publicKey, + rewardFunction: createCliffLinearReward(), + minUnlockTimestamp, + }, + ); + + // Advance time to past the min_unlock_timestamp + await this.advanceBySeconds(15); + + // Now it should succeed + await ppClient + .startUnlockIx({ + performancePackage, + signer: authority.publicKey, + }) + .signers([authority]) + .rpc(); + + // Verify status is now Unlocking + const ppAccount = + await ppClient.fetchPerformancePackage(performancePackage); + assert.isDefined(ppAccount.status.unlocking); + }); + + it("fails when signer is neither authority nor recipient", async function () { + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + const unauthorized = Keypair.generate(); + + const { performancePackage } = await setupPerformancePackageV2( + this.banksClient, + mintGovernorClient, + ppClient, + this.payer, + { + authority: authority.publicKey, + recipient: recipient.publicKey, + rewardFunction: createCliffLinearReward(), + minUnlockTimestamp: new BN(0), + }, + ); + + // Try to call start_unlock with an unauthorized signer + const callbacks = expectError( + "Unauthorized", + "Should have failed because signer is neither authority nor recipient", + ); + + await ppClient + .startUnlockIx({ + performancePackage, + signer: unauthorized.publicKey, + }) + .signers([unauthorized]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); +} diff --git a/vibes/tasks.md b/vibes/tasks.md index afd102085..25f3ef096 100644 --- a/vibes/tasks.md +++ b/vibes/tasks.md @@ -29,27 +29,11 @@ ## Tasks -### Phase 3: start_unlock - -> Reference: `001-performance-package-v2.md` → Instruction 2 - -- [NEXT] 3.2 Add SDK method for start_unlock - - Add `startUnlockIx()` method to `PerformancePackageV2Client` - - Run `./rebuild.sh` - -- [ ] 3.3 Write unit tests for start_unlock - - Create `tests/performancePackageV2/unit/startUnlock.test.ts` - - Test: successfully starts when called by authority - - Test: successfully starts when called by recipient - - Test: fails when status is not Locked - - Test: fails when min_unlock_timestamp not reached - - Test: fails when signer is neither authority nor recipient - ### Phase 4: complete_unlock > Reference: `001-performance-package-v2.md` → Instruction 3 -- [ ] 4.1 Write complete_unlock instruction +- [NEXT] 4.1 Write complete_unlock instruction - Create `instructions/complete_unlock.rs` - Implement accounts struct with mint_governor CPI accounts - Implement `validate()`: check status == Unlocking, can_end(), account matches From eabce90f77fd21a88c7ecf00167ee403f5cb010a Mon Sep 17 00:00:00 2001 From: Pileks Date: Thu, 29 Jan 2026 20:17:45 -0800 Subject: [PATCH 14/27] ppv2 - complete unlock ix --- CLAUDE.md | 15 ++ .../src/instructions/complete_unlock.rs | 169 ++++++++++++++++++ .../src/instructions/mod.rs | 2 + programs/performance_package_v2/src/lib.rs | 5 + .../src/state/performance_package.rs | 116 +++++++++++- sdk/src/v0.7/types/performance_package_v2.ts | 102 +++++++++++ vibes/tasks.md | 12 +- 7 files changed, 409 insertions(+), 12 deletions(-) create mode 100644 programs/performance_package_v2/src/instructions/complete_unlock.rs diff --git a/CLAUDE.md b/CLAUDE.md index ca130a5c1..69d91935a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -133,6 +133,21 @@ pub mint_governor: Account<'info, MintGovernor>, pub mint_authority: Account<'info, MintAuthority>, ``` +### Token Account Constraints +For token accounts, prefer `associated_token::*` over `token::*` constraints: +- `associated_token::mint` / `associated_token::authority` - enforces the account is at the canonical ATA address (safer, use for recipient/user-facing accounts) +- `token::mint` / `token::authority` - allows any token account with matching mint/authority (use only when flexibility is intentionally needed, e.g., source accounts where user may fund from non-ATA) + +```rust +// Good - enforces canonical ATA for recipient +#[account(mut, associated_token::mint = mint, associated_token::authority = recipient)] +pub recipient_ata: Account<'info, TokenAccount>, + +// OK - allows flexibility for source accounts +#[account(mut, token::mint = mint, token::authority = funder)] +pub funder_token_account: Account<'info, TokenAccount>, +``` + ### Adding New Instructions 1. Add instruction to Rust program in `programs/[program]/src/instructions/` 2. Update client methods in SDK (`sdk/src/v0.7/`) diff --git a/programs/performance_package_v2/src/instructions/complete_unlock.rs b/programs/performance_package_v2/src/instructions/complete_unlock.rs new file mode 100644 index 000000000..99a59dd2c --- /dev/null +++ b/programs/performance_package_v2/src/instructions/complete_unlock.rs @@ -0,0 +1,169 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token::{Mint, Token, TokenAccount}, +}; +use mint_governor::{ + cpi::{accounts::MintTokens, mint_tokens}, + program::MintGovernor as MintGovernorProgram, + state::{MintAuthority, MintGovernor}, + MintTokensArgs, +}; + +use crate::{ + CommonFields, PackageStatus, PerformancePackage, PerformancePackageError, UnlockCompletedEvent, + PERFORMANCE_PACKAGE_SEED, +}; + +#[derive(Accounts)] +pub struct CompleteUnlock<'info> { + #[account( + mut, + seeds = [PERFORMANCE_PACKAGE_SEED, performance_package.create_key.as_ref()], + bump = performance_package.bump + )] + pub performance_package: Account<'info, PerformancePackage>, + + #[account( + mut, + address = performance_package.mint_governor @ PerformancePackageError::InvalidMintGovernor + )] + pub mint_governor: Account<'info, MintGovernor>, + + #[account( + mut, + address = performance_package.mint_authority @ PerformancePackageError::InvalidMintAuthority + )] + pub mint_authority: Account<'info, MintAuthority>, + + #[account( + mut, + address = performance_package.mint + )] + pub mint: Account<'info, Mint>, + + #[account( + mut, + associated_token::mint = mint, + associated_token::authority = performance_package.recipient + )] + pub recipient_ata: Account<'info, TokenAccount>, + + pub signer: Signer<'info>, + + pub token_program: Program<'info, Token>, + + pub associated_token_program: Program<'info, AssociatedToken>, + + pub mint_governor_program: Program<'info, MintGovernorProgram>, +} + +impl CompleteUnlock<'_> { + pub fn validate(&self) -> Result<()> { + let pp = &self.performance_package; + + // Signer must be authority or recipient + require!( + self.signer.key() == pp.authority || self.signer.key() == pp.recipient, + PerformancePackageError::Unauthorized + ); + + // Must be in Unlocking status + require!( + pp.status == PackageStatus::Unlocking, + PerformancePackageError::NotUnlocking + ); + + // Check if min_duration has passed (for Time oracle, always true) + require!( + pp.oracle_reader.can_end(), + PerformancePackageError::OracleMinDurationNotReached + ); + + Ok(()) + } + + pub fn handle(ctx: Context) -> Result<()> { + let pp = &mut ctx.accounts.performance_package; + + // Record end snapshot (no-op for Time oracle) + pp.oracle_reader.record_end()?; + + // Compute oracle value + let oracle_value = pp.oracle_reader.compute_value()?; + + // Calculate cumulative rewards based on oracle value + let cumulative_rewards = pp.reward_function.calculate(oracle_value)?; + + // Calculate mint amount (only if rewards increased) + let mint_amount = if cumulative_rewards > pp.total_rewards_paid_out { + cumulative_rewards - pp.total_rewards_paid_out + } else { + 0 + }; + + // Mint to recipient if there's anything to mint + if mint_amount > 0 { + let create_key = pp.create_key; + let bump = pp.bump; + + // Build PDA signer seeds for performance_package + let seeds = &[PERFORMANCE_PACKAGE_SEED, create_key.as_ref(), &[bump]]; + let signer_seeds = &[&seeds[..]]; + + let cpi_ctx = CpiContext::new_with_signer( + ctx.accounts.mint_governor_program.to_account_info(), + MintTokens { + mint_governor: ctx.accounts.mint_governor.to_account_info(), + mint_authority: ctx.accounts.mint_authority.to_account_info(), + mint: ctx.accounts.mint.to_account_info(), + destination_ata: ctx.accounts.recipient_ata.to_account_info(), + authorized_minter: ctx.accounts.performance_package.to_account_info(), + token_program: ctx.accounts.token_program.to_account_info(), + }, + signer_seeds, + ); + + mint_tokens( + cpi_ctx, + MintTokensArgs { + amount: mint_amount, + }, + )?; + } + + // Re-borrow pp mutably after CPI + let pp = &mut ctx.accounts.performance_package; + + // Update total_rewards_paid_out + if cumulative_rewards > pp.total_rewards_paid_out { + pp.total_rewards_paid_out = cumulative_rewards; + } + + // Reset oracle state for next cycle + pp.oracle_reader.reset(); + + // Transition back to Locked status + pp.status = PackageStatus::Locked; + + // Increment sequence number + pp.seq_num += 1; + + let clock = Clock::get()?; + + emit!(UnlockCompletedEvent { + common: CommonFields { + slot: clock.slot, + unix_timestamp: clock.unix_timestamp, + performance_package_seq_num: pp.seq_num, + }, + performance_package: pp.key(), + oracle_value, + recipient: pp.recipient, + amount_minted: mint_amount, + total_rewards_paid_out: pp.total_rewards_paid_out, + }); + + Ok(()) + } +} diff --git a/programs/performance_package_v2/src/instructions/mod.rs b/programs/performance_package_v2/src/instructions/mod.rs index 59268c658..d3110d99d 100644 --- a/programs/performance_package_v2/src/instructions/mod.rs +++ b/programs/performance_package_v2/src/instructions/mod.rs @@ -1,5 +1,7 @@ +pub mod complete_unlock; pub mod initialize_performance_package; pub mod start_unlock; +pub use complete_unlock::*; pub use initialize_performance_package::*; pub use start_unlock::*; diff --git a/programs/performance_package_v2/src/lib.rs b/programs/performance_package_v2/src/lib.rs index 86d2f7db6..3703321c7 100644 --- a/programs/performance_package_v2/src/lib.rs +++ b/programs/performance_package_v2/src/lib.rs @@ -49,4 +49,9 @@ pub mod performance_package_v2 { pub fn start_unlock(ctx: Context) -> Result<()> { StartUnlock::handle(ctx) } + + #[access_control(ctx.accounts.validate())] + pub fn complete_unlock(ctx: Context) -> Result<()> { + CompleteUnlock::handle(ctx) + } } diff --git a/programs/performance_package_v2/src/state/performance_package.rs b/programs/performance_package_v2/src/state/performance_package.rs index eb80f9d53..ce57a02a9 100644 --- a/programs/performance_package_v2/src/state/performance_package.rs +++ b/programs/performance_package_v2/src/state/performance_package.rs @@ -1,6 +1,6 @@ use anchor_lang::prelude::*; -use crate::MAX_TRANCHES; +use crate::{PerformancePackageError, MAX_TRANCHES}; /// Lifecycle state for the performance package. #[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, Copy, PartialEq, Eq, InitSpace)] @@ -32,6 +32,120 @@ impl OracleReader { } } } + + /// Records the end snapshot when unlock completes. + /// For Time oracle, this is a no-op since it just reads current time on demand. + pub fn record_end(&mut self) -> Result<()> { + match self { + OracleReader::Time => { + // No-op for Time oracle - just reads current time on demand + Ok(()) + } + } + } + + /// Checks if the minimum duration has passed and unlock can be completed. + /// For Time oracle, always returns true (no min_duration concept). + pub fn can_end(&self) -> bool { + match self { + OracleReader::Time => true, + } + } + + /// Computes the oracle value for reward calculation. + /// For Time oracle, returns the current unix timestamp. + pub fn compute_value(&self) -> Result { + match self { + OracleReader::Time => { + let clock = Clock::get()?; + Ok(clock.unix_timestamp as u128) + } + } + } + + /// Resets the oracle state for the next unlock cycle. + /// For Time oracle, this is a no-op (no state to reset). + pub fn reset(&mut self) { + match self { + OracleReader::Time => { + // No-op for Time oracle - no state to reset + } + } + } +} + +impl RewardFunction { + /// Calculates the cumulative rewards earned for a given oracle value. + /// Returns total tokens deserved so far (not incremental). + pub fn calculate(&self, value: u128) -> Result { + match self { + RewardFunction::CliffLinear { + start_value, + cliff_value, + end_value, + cliff_amount, + total_amount, + } => { + // Before start: 0 rewards + if value < *start_value { + return Ok(0); + } + + // Before cliff: 0 rewards + if value < *cliff_value { + return Ok(0); + } + + // At or after end: full rewards + if value >= *end_value { + return Ok(*total_amount); + } + + // Between cliff and end: cliff_amount + linear interpolation + // linear_portion = (value - cliff_value) / (end_value - cliff_value) * (total_amount - cliff_amount) + + let value_progress = value.checked_sub(*cliff_value).unwrap_or(0); + let value_range = end_value + .checked_sub(*cliff_value) + .ok_or(PerformancePackageError::RewardCalculationOverflow)?; + + // Avoid division by zero + if value_range == 0 { + return Ok(*cliff_amount); + } + + let linear_amount = (*total_amount as u128) + .checked_sub(*cliff_amount as u128) + .ok_or(PerformancePackageError::RewardCalculationOverflow)?; + + // Calculate: cliff_amount + (value_progress * linear_amount / value_range) + let linear_portion = value_progress + .checked_mul(linear_amount) + .ok_or(PerformancePackageError::RewardCalculationOverflow)? + .checked_div(value_range) + .ok_or(PerformancePackageError::RewardCalculationOverflow)?; + + let result = (*cliff_amount as u128) + .checked_add(linear_portion) + .ok_or(PerformancePackageError::RewardCalculationOverflow)?; + + // Safe to convert since total_amount is u64 and result <= total_amount + Ok(result as u64) + } + RewardFunction::Threshold { tranches } => { + // Find the highest threshold that value meets + let mut cumulative = 0u64; + for tranche in tranches.iter() { + if value >= tranche.threshold { + cumulative = tranche.cumulative_amount; + } else { + break; + } + } + Ok(cumulative) + } + } + } } /// A threshold tranche for step-based rewards. diff --git a/sdk/src/v0.7/types/performance_package_v2.ts b/sdk/src/v0.7/types/performance_package_v2.ts index 6e1908c67..ad101a905 100644 --- a/sdk/src/v0.7/types/performance_package_v2.ts +++ b/sdk/src/v0.7/types/performance_package_v2.ts @@ -85,6 +85,57 @@ export type PerformancePackageV2 = { ]; args: []; }, + { + name: "completeUnlock"; + accounts: [ + { + name: "performancePackage"; + isMut: true; + isSigner: false; + }, + { + name: "mintGovernor"; + isMut: true; + isSigner: false; + }, + { + name: "mintAuthority"; + isMut: true; + isSigner: false; + }, + { + name: "mint"; + isMut: true; + isSigner: false; + }, + { + name: "recipientAta"; + isMut: true; + isSigner: false; + }, + { + name: "signer"; + isMut: false; + isSigner: true; + }, + { + name: "tokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "associatedTokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "mintGovernorProgram"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, ]; accounts: [ { @@ -838,6 +889,57 @@ export const IDL: PerformancePackageV2 = { ], args: [], }, + { + name: "completeUnlock", + accounts: [ + { + name: "performancePackage", + isMut: true, + isSigner: false, + }, + { + name: "mintGovernor", + isMut: true, + isSigner: false, + }, + { + name: "mintAuthority", + isMut: true, + isSigner: false, + }, + { + name: "mint", + isMut: true, + isSigner: false, + }, + { + name: "recipientAta", + isMut: true, + isSigner: false, + }, + { + name: "signer", + isMut: false, + isSigner: true, + }, + { + name: "tokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "associatedTokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "mintGovernorProgram", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, ], accounts: [ { diff --git a/vibes/tasks.md b/vibes/tasks.md index 25f3ef096..5232fc5ff 100644 --- a/vibes/tasks.md +++ b/vibes/tasks.md @@ -33,17 +33,7 @@ > Reference: `001-performance-package-v2.md` → Instruction 3 -- [NEXT] 4.1 Write complete_unlock instruction - - Create `instructions/complete_unlock.rs` - - Implement accounts struct with mint_governor CPI accounts - - Implement `validate()`: check status == Unlocking, can_end(), account matches - - Implement `handle()`: record_end, compute_value, calculate rewards, CPI mint, reset, set Locked - - Add `record_end()`, `can_end()`, `compute_value()`, `reset()` to `OracleReader` - - Add `calculate()` method to `RewardFunction` (both variants) - - Emit `UnlockCompletedEvent` - - Wire up in `lib.rs` - -- [ ] 4.2 Add SDK method for complete_unlock +- [NEXT] 4.2 Add SDK method for complete_unlock - Add `completeUnlockIx()` method to `PerformancePackageV2Client` - Run `./rebuild.sh` From e7f1dfb644178a1b31c9141307975cf1228da7d5 Mon Sep 17 00:00:00 2001 From: Pileks Date: Thu, 29 Jan 2026 21:06:58 -0800 Subject: [PATCH 15/27] ppv2 - complete unlock SDK / tests --- CLAUDE.md | 34 + sdk/src/v0.7/PerformancePackageV2Client.ts | 40 +- tests/performancePackageV2/main.test.ts | 2 + .../unit/completeUnlock.test.ts | 1076 +++++++++++++++++ .../unit/startUnlock.test.ts | 9 +- vibes/tasks.md | 24 +- 6 files changed, 1157 insertions(+), 28 deletions(-) create mode 100644 tests/performancePackageV2/unit/completeUnlock.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 69d91935a..54e85eb91 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -160,6 +160,40 @@ Tests use `solana-bankrun` for deterministic testing without external RPC: - `advanceBySlots()` - Simulate time progression - Time constants: `TEN_SECONDS_IN_SLOTS`, `ONE_MINUTE_IN_SLOTS`, `HOUR_IN_SLOTS`, `DAY_IN_SLOTS` +**Getting unique transaction signatures:** When testing error cases that call the same instruction multiple times (e.g., verifying an action fails after state changes), add a `ComputeBudgetProgram.setComputeUnitLimit()` instruction with incrementing values to produce different transaction signatures: + +```typescript +// First call (200_000), second call (200_001), etc. +await client + .someIx({ ... }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), + ]) + .signers([signer]) + .rpc(); +``` + +Do NOT use `advanceBySlots()` for this purpose - it changes the clock which may affect time-dependent tests. + +**Token amounts in tests:** Use easy-to-read round numbers like hundreds or thousands of tokens. Our standard mint decimals is 6, so: +- 100 tokens = `100_000_000` (100 * 10^6) +- 1,000 tokens = `1_000_000_000` (1000 * 10^6) + +This makes test assertions and calculations much easier to verify at a glance. + +**Assertion messages:** Do not include assertion messages for better readability. The assertion itself should be clear enough: + +```typescript +// Good - no message needed +assert.equal(recipientBalance.toString(), "500000000"); +assert.isDefined(ppAccount.status.locked); + +// Avoid - unnecessary message +assert.equal(recipientBalance.toString(), "500000000", "Recipient should have 500 tokens"); +``` + +Exceptions: Keep messages in `expectError()` calls and `assert.fail()` within try-catch blocks, since those are part of error handling patterns and help identify which check failed. + ## SDK Usage ```typescript diff --git a/sdk/src/v0.7/PerformancePackageV2Client.ts b/sdk/src/v0.7/PerformancePackageV2Client.ts index 40d71e6f1..5e7a8703a 100644 --- a/sdk/src/v0.7/PerformancePackageV2Client.ts +++ b/sdk/src/v0.7/PerformancePackageV2Client.ts @@ -1,7 +1,15 @@ import { AnchorProvider, Program } from "@coral-xyz/anchor"; import { AccountInfo, PublicKey, SystemProgram } from "@solana/web3.js"; +import { + TOKEN_PROGRAM_ID, + ASSOCIATED_TOKEN_PROGRAM_ID, + getAssociatedTokenAddressSync, +} from "@solana/spl-token"; import BN from "bn.js"; -import { PERFORMANCE_PACKAGE_V2_PROGRAM_ID } from "./constants.js"; +import { + PERFORMANCE_PACKAGE_V2_PROGRAM_ID, + MINT_GOVERNOR_PROGRAM_ID, +} from "./constants.js"; import { getPerformancePackageV2Addr, getChangeRequestV2Addr, @@ -155,4 +163,34 @@ export class PerformancePackageV2Client { signer, }); } + + completeUnlockIx({ + performancePackage, + mintGovernor, + mintAuthority, + mint, + recipient, + signer = this.provider.publicKey, + }: { + performancePackage: PublicKey; + mintGovernor: PublicKey; + mintAuthority: PublicKey; + mint: PublicKey; + recipient: PublicKey; + signer?: PublicKey; + }) { + const recipientAta = getAssociatedTokenAddressSync(mint, recipient, true); + + return this.program.methods.completeUnlock().accounts({ + performancePackage, + mintGovernor, + mintAuthority, + mint, + recipientAta, + signer, + tokenProgram: TOKEN_PROGRAM_ID, + associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, + mintGovernorProgram: MINT_GOVERNOR_PROGRAM_ID, + }); + } } diff --git a/tests/performancePackageV2/main.test.ts b/tests/performancePackageV2/main.test.ts index a4a7b71b0..e461cdbbe 100644 --- a/tests/performancePackageV2/main.test.ts +++ b/tests/performancePackageV2/main.test.ts @@ -1,5 +1,6 @@ import initializePerformancePackage from "./unit/initializePerformancePackage.test.js"; import startUnlock from "./unit/startUnlock.test.js"; +import completeUnlock from "./unit/completeUnlock.test.js"; import { MintGovernorClient, PerformancePackageV2Client, @@ -19,4 +20,5 @@ export default function suite() { describe("#initialize_performance_package", initializePerformancePackage); describe("#start_unlock", startUnlock); + describe("#complete_unlock", completeUnlock); } diff --git a/tests/performancePackageV2/unit/completeUnlock.test.ts b/tests/performancePackageV2/unit/completeUnlock.test.ts new file mode 100644 index 000000000..b6f91be8c --- /dev/null +++ b/tests/performancePackageV2/unit/completeUnlock.test.ts @@ -0,0 +1,1076 @@ +import { + Keypair, + PublicKey, + SystemProgram, + Transaction, +} from "@solana/web3.js"; +import * as token from "@solana/spl-token"; +import BN from "bn.js"; +import { assert } from "chai"; +import { + MintGovernorClient, + PerformancePackageV2Client, +} from "@metadaoproject/futarchy/v0.7"; +import { + setupPerformancePackageV2, + setupMintGovernorWithAuthority, + createCliffLinearReward, + createThresholdReward, +} from "../utils.js"; +import { expectError } from "../../utils.js"; + +export default function suite() { + let mintGovernorClient: MintGovernorClient; + let ppClient: PerformancePackageV2Client; + + before(async function () { + mintGovernorClient = this.mintGovernor; + ppClient = this.performancePackageV2; + }); + + /** + * Helper to create ATA for recipient + */ + async function createRecipientAta( + context: any, + mint: PublicKey, + recipient: PublicKey, + ): Promise { + const ata = token.getAssociatedTokenAddressSync(mint, recipient, true); + const tx = new Transaction().add( + token.createAssociatedTokenAccountIdempotentInstruction( + context.payer.publicKey, + ata, + recipient, + mint, + ), + ); + tx.recentBlockhash = (await context.banksClient.getLatestBlockhash())[0]; + tx.feePayer = context.payer.publicKey; + tx.sign(context.payer); + await context.banksClient.processTransaction(tx); + return ata; + } + + it("successfully completes unlock and mints tokens (CliffLinear)", async function () { + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + + // Get current timestamp to set up time-based reward function + const currentClock = await this.banksClient.getClock(); + const currentTimestamp = Number(currentClock.unixTimestamp); + + // Setup reward function where cliff is at current time (immediately earns cliff_amount) + // and end is far in future + const rewardFunction = createCliffLinearReward({ + startValue: new BN(0), + cliffValue: new BN(currentTimestamp), // Cliff at current time + endValue: new BN(currentTimestamp + 1000), // End 1000 seconds from now + cliffAmount: new BN(100_000_000), // 100 tokens + totalAmount: new BN(1_000_000_000), // 1000 tokens + }); + + const { performancePackage, mint, mintGovernor, mintAuthority } = + await setupPerformancePackageV2( + this.banksClient, + mintGovernorClient, + ppClient, + this.payer, + { + authority: authority.publicKey, + recipient: recipient.publicKey, + rewardFunction, + minUnlockTimestamp: new BN(0), + }, + ); + + // Create recipient ATA + await createRecipientAta(this, mint, recipient.publicKey); + + // Start unlock + await ppClient + .startUnlockIx({ + performancePackage, + signer: authority.publicKey, + }) + .signers([authority]) + .rpc(); + + // Advance time by 500 seconds (halfway between cliff and end) + await this.advanceBySeconds(500); + + // Complete unlock + await ppClient + .completeUnlockIx({ + performancePackage, + mintGovernor, + mintAuthority, + mint, + recipient: recipient.publicKey, + signer: authority.publicKey, + }) + .signers([authority]) + .rpc(); + + // Verify status is back to Locked + const ppAccount = + await ppClient.fetchPerformancePackage(performancePackage); + assert.isDefined(ppAccount.status.locked); + + // Verify tokens were minted to recipient + // Expected: cliff_amount + linear_portion = 100M + (500/1000 * 900M) = 100M + 450M = 550M + const expectedReward = 550_000_000; + const recipientBalance = await this.getTokenBalance( + mint, + recipient.publicKey, + ); + assert.equal(recipientBalance.toString(), expectedReward.toString()); + assert.equal( + ppAccount.totalRewardsPaidOut.toString(), + expectedReward.toString(), + ); + }); + + it("successfully completes unlock and mints tokens (Threshold)", async function () { + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + + // Get current timestamp to set up time-based thresholds + const currentClock = await this.banksClient.getClock(); + const currentTimestamp = Number(currentClock.unixTimestamp); + + // Setup threshold reward function with time-based thresholds + const rewardFunction = createThresholdReward([ + { + threshold: new BN(currentTimestamp), + cumulativeAmount: new BN(100_000_000), + }, // 100 tokens at current time + { + threshold: new BN(currentTimestamp + 100), + cumulativeAmount: new BN(500_000_000), + }, // 500 tokens at +100s + { + threshold: new BN(currentTimestamp + 200), + cumulativeAmount: new BN(1_000_000_000), + }, // 1000 tokens at +200s + ]); + + const { performancePackage, mint, mintGovernor, mintAuthority } = + await setupPerformancePackageV2( + this.banksClient, + mintGovernorClient, + ppClient, + this.payer, + { + authority: authority.publicKey, + recipient: recipient.publicKey, + rewardFunction, + minUnlockTimestamp: new BN(0), + }, + ); + + // Create recipient ATA + await createRecipientAta(this, mint, recipient.publicKey); + + // Start unlock + await ppClient + .startUnlockIx({ + performancePackage, + signer: authority.publicKey, + }) + .signers([authority]) + .rpc(); + + // Advance time by 150 seconds (should hit second threshold) + await this.advanceBySeconds(150); + + // Complete unlock + await ppClient + .completeUnlockIx({ + performancePackage, + mintGovernor, + mintAuthority, + mint, + recipient: recipient.publicKey, + signer: authority.publicKey, + }) + .signers([authority]) + .rpc(); + + // Verify tokens were minted to recipient (should be 500 tokens = 500_000_000) + const recipientBalance = await this.getTokenBalance( + mint, + recipient.publicKey, + ); + assert.equal(recipientBalance.toString(), "500000000"); + + // Verify status is back to Locked + const ppAccount = + await ppClient.fetchPerformancePackage(performancePackage); + assert.isDefined(ppAccount.status.locked); + }); + + it("mints correct amount to recipient (cumulative - already_paid)", async function () { + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + + // Get current timestamp + const currentClock = await this.banksClient.getClock(); + const currentTimestamp = Number(currentClock.unixTimestamp); + + // Setup threshold reward function + const rewardFunction = createThresholdReward([ + { + threshold: new BN(currentTimestamp), + cumulativeAmount: new BN(100_000_000), + }, + { + threshold: new BN(currentTimestamp + 100), + cumulativeAmount: new BN(500_000_000), + }, + ]); + + const { performancePackage, mint, mintGovernor, mintAuthority } = + await setupPerformancePackageV2( + this.banksClient, + mintGovernorClient, + ppClient, + this.payer, + { + authority: authority.publicKey, + recipient: recipient.publicKey, + rewardFunction, + minUnlockTimestamp: new BN(0), + }, + ); + + // Create recipient ATA + await createRecipientAta(this, mint, recipient.publicKey); + + // First unlock cycle - should mint 100 tokens (first threshold) + await ppClient + .startUnlockIx({ + performancePackage, + signer: authority.publicKey, + }) + .signers([authority]) + .rpc(); + + await ppClient + .completeUnlockIx({ + performancePackage, + mintGovernor, + mintAuthority, + mint, + recipient: recipient.publicKey, + signer: authority.publicKey, + }) + .signers([authority]) + .rpc(); + + let recipientBalance = await this.getTokenBalance( + mint, + recipient.publicKey, + ); + assert.equal(recipientBalance.toString(), "100000000"); + + // Advance time past second threshold + await this.advanceBySeconds(150); + + // Second unlock cycle - should mint only the difference (500 - 100 = 400 tokens) + await ppClient + .startUnlockIx({ + performancePackage, + signer: authority.publicKey, + }) + .signers([authority]) + .rpc(); + + await ppClient + .completeUnlockIx({ + performancePackage, + mintGovernor, + mintAuthority, + mint, + recipient: recipient.publicKey, + signer: authority.publicKey, + }) + .signers([authority]) + .rpc(); + + // Total should be 500 tokens now + recipientBalance = await this.getTokenBalance(mint, recipient.publicKey); + assert.equal(recipientBalance.toString(), "500000000"); + }); + + it("updates total_rewards_paid_out", async function () { + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + + // Get current timestamp + const currentClock = await this.banksClient.getClock(); + const currentTimestamp = Number(currentClock.unixTimestamp); + + const rewardFunction = createThresholdReward([ + { + threshold: new BN(currentTimestamp), + cumulativeAmount: new BN(100_000_000), + }, + ]); + + const { performancePackage, mint, mintGovernor, mintAuthority } = + await setupPerformancePackageV2( + this.banksClient, + mintGovernorClient, + ppClient, + this.payer, + { + authority: authority.publicKey, + recipient: recipient.publicKey, + rewardFunction, + minUnlockTimestamp: new BN(0), + }, + ); + + // Create recipient ATA + await createRecipientAta(this, mint, recipient.publicKey); + + // Verify initial total_rewards_paid_out is 0 + let ppAccount = await ppClient.fetchPerformancePackage(performancePackage); + assert.equal(ppAccount.totalRewardsPaidOut.toString(), "0"); + + // Start and complete unlock + await ppClient + .startUnlockIx({ + performancePackage, + signer: authority.publicKey, + }) + .signers([authority]) + .rpc(); + + await ppClient + .completeUnlockIx({ + performancePackage, + mintGovernor, + mintAuthority, + mint, + recipient: recipient.publicKey, + signer: authority.publicKey, + }) + .signers([authority]) + .rpc(); + + // Verify total_rewards_paid_out is updated + ppAccount = await ppClient.fetchPerformancePackage(performancePackage); + assert.equal(ppAccount.totalRewardsPaidOut.toString(), "100000000"); + }); + + it("resets oracle state (for Time: no state to reset)", async function () { + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + + // Get current timestamp + const currentClock = await this.banksClient.getClock(); + const currentTimestamp = Number(currentClock.unixTimestamp); + + const rewardFunction = createThresholdReward([ + { + threshold: new BN(currentTimestamp), + cumulativeAmount: new BN(100_000_000), + }, + ]); + + const { performancePackage, mint, mintGovernor, mintAuthority } = + await setupPerformancePackageV2( + this.banksClient, + mintGovernorClient, + ppClient, + this.payer, + { + authority: authority.publicKey, + recipient: recipient.publicKey, + rewardFunction, + minUnlockTimestamp: new BN(0), + }, + ); + + // Create recipient ATA + await createRecipientAta(this, mint, recipient.publicKey); + + // Start and complete unlock + await ppClient + .startUnlockIx({ + performancePackage, + signer: authority.publicKey, + }) + .signers([authority]) + .rpc(); + + await ppClient + .completeUnlockIx({ + performancePackage, + mintGovernor, + mintAuthority, + mint, + recipient: recipient.publicKey, + signer: authority.publicKey, + }) + .signers([authority]) + .rpc(); + + // For Time oracle, there's no state to reset - just verify the instruction succeeded + // and the package is back to Locked status + const ppAccount = + await ppClient.fetchPerformancePackage(performancePackage); + assert.isDefined(ppAccount.status.locked); + assert.isDefined(ppAccount.oracleReader.time); + }); + + it("rewards only increase (never decrease)", async function () { + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + + // Get current timestamp + const currentClock = await this.banksClient.getClock(); + const currentTimestamp = Number(currentClock.unixTimestamp); + + // Setup CliffLinear with a time range that spans past and future + // cliff at current time, end 1000 seconds in the future + const rewardFunction = createCliffLinearReward({ + startValue: new BN(0), + cliffValue: new BN(currentTimestamp), // Cliff at current time + endValue: new BN(currentTimestamp + 1000), // End 1000 seconds from now + cliffAmount: new BN(100_000_000), // 100 tokens at cliff + totalAmount: new BN(500_000_000), // Max 500 tokens + }); + + const { performancePackage, mint, mintGovernor, mintAuthority } = + await setupPerformancePackageV2( + this.banksClient, + mintGovernorClient, + ppClient, + this.payer, + { + authority: authority.publicKey, + recipient: recipient.publicKey, + rewardFunction, + minUnlockTimestamp: new BN(0), + }, + ); + + // Create recipient ATA + await createRecipientAta(this, mint, recipient.publicKey); + + // Advance time to 500 seconds after cliff (halfway through linear period) + // This should give us cliff (100) + 50% of linear portion (200) = 300 tokens + await this.advanceBySeconds(500); + + // First unlock - should get 300 tokens + await ppClient + .startUnlockIx({ + performancePackage, + signer: authority.publicKey, + }) + .signers([authority]) + .rpc(); + + await ppClient + .completeUnlockIx({ + performancePackage, + mintGovernor, + mintAuthority, + mint, + recipient: recipient.publicKey, + signer: authority.publicKey, + }) + .signers([authority]) + .rpc(); + + let recipientBalance = await this.getTokenBalance( + mint, + recipient.publicKey, + ); + assert.equal(recipientBalance.toString(), "300000000"); + + let ppAccount = await ppClient.fetchPerformancePackage(performancePackage); + assert.equal(ppAccount.totalRewardsPaidOut.toString(), "300000000"); + + // Now set the clock BACKWARDS to just after cliff (100 seconds after cliff). + // Normally physics doesn't allow travelling backwards in time, + // but here we do it to test the invariant that rewards only increase. + // This would calculate only 100 + 10% of 400 = 140 tokens + // But since we've already paid 300, no new tokens should be minted + const clockAfterFirstUnlock = await this.banksClient.getClock(); + const { Clock } = await import("solana-bankrun"); + this.context.setClock( + new Clock( + clockAfterFirstUnlock.slot, + clockAfterFirstUnlock.epochStartTimestamp, + clockAfterFirstUnlock.epoch, + clockAfterFirstUnlock.leaderScheduleEpoch, + // Set time to 100 seconds after cliff (earlier than the 500 seconds we were at) + BigInt(currentTimestamp + 100), + ), + ); + + // Second unlock - reward function would calculate 140 tokens + // but total_rewards_paid_out is 300, so mint amount should be 0 + await ppClient + .startUnlockIx({ + performancePackage, + signer: authority.publicKey, + }) + .signers([authority]) + .rpc(); + + await ppClient + .completeUnlockIx({ + performancePackage, + mintGovernor, + mintAuthority, + mint, + recipient: recipient.publicKey, + signer: authority.publicKey, + }) + .signers([authority]) + .rpc(); + + // Balance should remain the same - no decrease + recipientBalance = await this.getTokenBalance(mint, recipient.publicKey); + assert.equal(recipientBalance.toString(), "300000000"); + + ppAccount = await ppClient.fetchPerformancePackage(performancePackage); + assert.equal(ppAccount.totalRewardsPaidOut.toString(), "300000000"); + }); + + it("succeeds with zero mint amount when rewards already paid", async function () { + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + + // Get current timestamp + const currentClock = await this.banksClient.getClock(); + const currentTimestamp = Number(currentClock.unixTimestamp); + + // Setup threshold where first threshold is already met + const rewardFunction = createThresholdReward([ + { + threshold: new BN(currentTimestamp), + cumulativeAmount: new BN(100_000_000), + }, + // Second threshold is far in future (won't be reached) + { + threshold: new BN(currentTimestamp + 10000), + cumulativeAmount: new BN(500_000_000), + }, + ]); + + const { performancePackage, mint, mintGovernor, mintAuthority } = + await setupPerformancePackageV2( + this.banksClient, + mintGovernorClient, + ppClient, + this.payer, + { + authority: authority.publicKey, + recipient: recipient.publicKey, + rewardFunction, + minUnlockTimestamp: new BN(0), + }, + ); + + // Create recipient ATA + await createRecipientAta(this, mint, recipient.publicKey); + + // First unlock - should mint 100 tokens + await ppClient + .startUnlockIx({ + performancePackage, + signer: authority.publicKey, + }) + .signers([authority]) + .rpc(); + + await ppClient + .completeUnlockIx({ + performancePackage, + mintGovernor, + mintAuthority, + mint, + recipient: recipient.publicKey, + signer: authority.publicKey, + }) + .signers([authority]) + .rpc(); + + let recipientBalance = await this.getTokenBalance( + mint, + recipient.publicKey, + ); + assert.equal(recipientBalance.toString(), "100000000"); + + // Second unlock - time hasn't advanced enough to hit second threshold + // so no new rewards should be minted (zero mint amount) + await ppClient + .startUnlockIx({ + performancePackage, + signer: authority.publicKey, + }) + .signers([authority]) + .rpc(); + + // Advance just a tiny bit (not enough to hit second threshold) + await this.advanceBySeconds(10); + + // This should succeed even with zero mint amount + await ppClient + .completeUnlockIx({ + performancePackage, + mintGovernor, + mintAuthority, + mint, + recipient: recipient.publicKey, + signer: authority.publicKey, + }) + .signers([authority]) + .rpc(); + + // Balance should remain unchanged + recipientBalance = await this.getTokenBalance(mint, recipient.publicKey); + assert.equal(recipientBalance.toString(), "100000000"); + + // Verify PP is back to Locked status + const ppAccount = + await ppClient.fetchPerformancePackage(performancePackage); + assert.isDefined(ppAccount.status.locked); + }); + + it("can be started again after complete (cycle repeats)", async function () { + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + + // Get current timestamp + const currentClock = await this.banksClient.getClock(); + const currentTimestamp = Number(currentClock.unixTimestamp); + + const rewardFunction = createThresholdReward([ + { + threshold: new BN(currentTimestamp), + cumulativeAmount: new BN(100_000_000), + }, + { + threshold: new BN(currentTimestamp + 100), + cumulativeAmount: new BN(200_000_000), + }, + { + threshold: new BN(currentTimestamp + 200), + cumulativeAmount: new BN(300_000_000), + }, + ]); + + const { performancePackage, mint, mintGovernor, mintAuthority } = + await setupPerformancePackageV2( + this.banksClient, + mintGovernorClient, + ppClient, + this.payer, + { + authority: authority.publicKey, + recipient: recipient.publicKey, + rewardFunction, + minUnlockTimestamp: new BN(0), + }, + ); + + // Create recipient ATA + await createRecipientAta(this, mint, recipient.publicKey); + + // Cycle 1: Unlock and complete + await ppClient + .startUnlockIx({ + performancePackage, + signer: authority.publicKey, + }) + .signers([authority]) + .rpc(); + + await ppClient + .completeUnlockIx({ + performancePackage, + mintGovernor, + mintAuthority, + mint, + recipient: recipient.publicKey, + signer: authority.publicKey, + }) + .signers([authority]) + .rpc(); + + let ppAccount = await ppClient.fetchPerformancePackage(performancePackage); + assert.isDefined(ppAccount.status.locked); + assert.equal(ppAccount.seqNum.toString(), "2"); // seq_num incremented twice (start + complete) + + let recipientBalance = await this.getTokenBalance( + mint, + recipient.publicKey, + ); + assert.equal(recipientBalance.toString(), "100000000"); + + // Advance time + await this.advanceBySeconds(150); + + // Cycle 2: Should be able to start again + await ppClient + .startUnlockIx({ + performancePackage, + signer: authority.publicKey, + }) + .signers([authority]) + .rpc(); + + ppAccount = await ppClient.fetchPerformancePackage(performancePackage); + assert.isDefined(ppAccount.status.unlocking); + + await ppClient + .completeUnlockIx({ + performancePackage, + mintGovernor, + mintAuthority, + mint, + recipient: recipient.publicKey, + signer: authority.publicKey, + }) + .signers([authority]) + .rpc(); + + ppAccount = await ppClient.fetchPerformancePackage(performancePackage); + assert.isDefined(ppAccount.status.locked); + assert.equal(ppAccount.seqNum.toString(), "4"); // seq_num incremented again + + recipientBalance = await this.getTokenBalance(mint, recipient.publicKey); + assert.equal(recipientBalance.toString(), "200000000"); + + // Advance time again + await this.advanceBySeconds(100); + + // Cycle 3: Should work again + await ppClient + .startUnlockIx({ + performancePackage, + signer: authority.publicKey, + }) + .signers([authority]) + .rpc(); + + await ppClient + .completeUnlockIx({ + performancePackage, + mintGovernor, + mintAuthority, + mint, + recipient: recipient.publicKey, + signer: authority.publicKey, + }) + .signers([authority]) + .rpc(); + + recipientBalance = await this.getTokenBalance(mint, recipient.publicKey); + assert.equal(recipientBalance.toString(), "300000000"); + }); + + it("fails when status is not Unlocking", async function () { + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + + // Get current timestamp + const currentClock = await this.banksClient.getClock(); + const currentTimestamp = Number(currentClock.unixTimestamp); + + const rewardFunction = createThresholdReward([ + { + threshold: new BN(currentTimestamp), + cumulativeAmount: new BN(100_000_000), + }, + ]); + + const { performancePackage, mint, mintGovernor, mintAuthority } = + await setupPerformancePackageV2( + this.banksClient, + mintGovernorClient, + ppClient, + this.payer, + { + authority: authority.publicKey, + recipient: recipient.publicKey, + rewardFunction, + minUnlockTimestamp: new BN(0), + }, + ); + + // Create recipient ATA + await createRecipientAta(this, mint, recipient.publicKey); + + // Verify initial status is Locked (not Unlocking) + const ppAccount = + await ppClient.fetchPerformancePackage(performancePackage); + assert.isDefined(ppAccount.status.locked); + + // Try to complete unlock without starting first - should fail + const callbacks = expectError( + "NotUnlocking", + "Should have failed because status is not Unlocking", + ); + + await ppClient + .completeUnlockIx({ + performancePackage, + mintGovernor, + mintAuthority, + mint, + recipient: recipient.publicKey, + signer: authority.publicKey, + }) + .signers([authority]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("fails when signer is neither authority nor recipient", async function () { + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + const unauthorized = Keypair.generate(); + + // Get current timestamp + const currentClock = await this.banksClient.getClock(); + const currentTimestamp = Number(currentClock.unixTimestamp); + + const rewardFunction = createThresholdReward([ + { + threshold: new BN(currentTimestamp), + cumulativeAmount: new BN(100_000_000), + }, + ]); + + const { performancePackage, mint, mintGovernor, mintAuthority } = + await setupPerformancePackageV2( + this.banksClient, + mintGovernorClient, + ppClient, + this.payer, + { + authority: authority.publicKey, + recipient: recipient.publicKey, + rewardFunction, + minUnlockTimestamp: new BN(0), + }, + ); + + // Create recipient ATA + await createRecipientAta(this, mint, recipient.publicKey); + + // Start unlock as authority + await ppClient + .startUnlockIx({ + performancePackage, + signer: authority.publicKey, + }) + .signers([authority]) + .rpc(); + + // Try to complete unlock with an unauthorized signer + const callbacks = expectError( + "Unauthorized", + "Should have failed because signer is neither authority nor recipient", + ); + + await ppClient + .completeUnlockIx({ + performancePackage, + mintGovernor, + mintAuthority, + mint, + recipient: recipient.publicKey, + signer: unauthorized.publicKey, + }) + .signers([unauthorized]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("fails when mint doesn't match", async function () { + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + + // Get current timestamp + const currentClock = await this.banksClient.getClock(); + const currentTimestamp = Number(currentClock.unixTimestamp); + + const rewardFunction = createThresholdReward([ + { + threshold: new BN(currentTimestamp), + cumulativeAmount: new BN(100_000_000), + }, + ]); + + const { performancePackage, mintGovernor, mintAuthority } = + await setupPerformancePackageV2( + this.banksClient, + mintGovernorClient, + ppClient, + this.payer, + { + authority: authority.publicKey, + recipient: recipient.publicKey, + rewardFunction, + minUnlockTimestamp: new BN(0), + }, + ); + + // Create a different mint + const wrongMint = Keypair.generate(); + const createMintTx = new Transaction().add( + SystemProgram.createAccount({ + fromPubkey: this.payer.publicKey, + newAccountPubkey: wrongMint.publicKey, + lamports: await this.banksClient + .getRent() + .then((rent) => Number(rent.minimumBalance(BigInt(82)))), + space: 82, + programId: token.TOKEN_PROGRAM_ID, + }), + token.createInitializeMint2Instruction( + wrongMint.publicKey, + 6, + this.payer.publicKey, + null, + ), + ); + createMintTx.recentBlockhash = ( + await this.banksClient.getLatestBlockhash() + )[0]; + createMintTx.feePayer = this.payer.publicKey; + createMintTx.sign(this.payer, wrongMint); + await this.banksClient.processTransaction(createMintTx); + + // Create recipient ATA for the wrong mint + const wrongRecipientAta = token.getAssociatedTokenAddressSync( + wrongMint.publicKey, + recipient.publicKey, + true, + ); + const createAtaTx = new Transaction().add( + token.createAssociatedTokenAccountIdempotentInstruction( + this.payer.publicKey, + wrongRecipientAta, + recipient.publicKey, + wrongMint.publicKey, + ), + ); + createAtaTx.recentBlockhash = ( + await this.banksClient.getLatestBlockhash() + )[0]; + createAtaTx.feePayer = this.payer.publicKey; + createAtaTx.sign(this.payer); + await this.banksClient.processTransaction(createAtaTx); + + // Start unlock + await ppClient + .startUnlockIx({ + performancePackage, + signer: authority.publicKey, + }) + .signers([authority]) + .rpc(); + + // Try to complete unlock with wrong mint - should fail + const callbacks = expectError( + "ConstraintAddress", + "Should have failed because mint doesn't match", + ); + + await ppClient + .completeUnlockIx({ + performancePackage, + mintGovernor, + mintAuthority, + mint: wrongMint.publicKey, // Wrong mint! + recipient: recipient.publicKey, + signer: authority.publicKey, + }) + .signers([authority]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("fails when mint_governor doesn't match", async function () { + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + + // Get current timestamp + const currentClock = await this.banksClient.getClock(); + const currentTimestamp = Number(currentClock.unixTimestamp); + + const rewardFunction = createThresholdReward([ + { + threshold: new BN(currentTimestamp), + cumulativeAmount: new BN(100_000_000), + }, + ]); + + const { performancePackage, mint, mintAuthority } = + await setupPerformancePackageV2( + this.banksClient, + mintGovernorClient, + ppClient, + this.payer, + { + authority: authority.publicKey, + recipient: recipient.publicKey, + rewardFunction, + minUnlockTimestamp: new BN(0), + }, + ); + + // Create a separate, properly initialized mint governor (for a different mint) + // This ensures we have a valid MintGovernor account that just doesn't match the PP's stored one + const { mintGovernor: wrongMintGovernor } = + await setupMintGovernorWithAuthority( + this.banksClient, + mintGovernorClient, + this.payer, + performancePackage, // Use same authorized_minter so account is set up + null, + 6, + ); + + // Create recipient ATA + await createRecipientAta(this, mint, recipient.publicKey); + + // Start unlock + await ppClient + .startUnlockIx({ + performancePackage, + signer: authority.publicKey, + }) + .signers([authority]) + .rpc(); + + // Try to complete unlock with wrong mint governor - should fail + const callbacks = expectError( + "InvalidMintGovernor", + "Should have failed because mint_governor doesn't match", + ); + + await ppClient + .completeUnlockIx({ + performancePackage, + mintGovernor: wrongMintGovernor, // Wrong - different mint governor! + mintAuthority, + mint, + recipient: recipient.publicKey, + signer: authority.publicKey, + }) + .signers([authority]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); +} diff --git a/tests/performancePackageV2/unit/startUnlock.test.ts b/tests/performancePackageV2/unit/startUnlock.test.ts index 39aab7d8f..577186be2 100644 --- a/tests/performancePackageV2/unit/startUnlock.test.ts +++ b/tests/performancePackageV2/unit/startUnlock.test.ts @@ -1,4 +1,4 @@ -import { Keypair, PublicKey } from "@solana/web3.js"; +import { ComputeBudgetProgram, Keypair, PublicKey } from "@solana/web3.js"; import BN from "bn.js"; import { assert } from "chai"; import { @@ -123,20 +123,21 @@ export default function suite() { await ppClient.fetchPerformancePackage(performancePackage); assert.isDefined(ppAccount.status.unlocking); - // Advance by slots to get a new blockhash - await this.advanceBySlots(1n); - // Try to call start_unlock again - should fail because status is Unlocking, not Locked const callbacks = expectError( "NotLocked", "Should have failed because status is not Locked", ); + // Add a ComputeBudget instruction to get a different tx signature await ppClient .startUnlockIx({ performancePackage, signer: authority.publicKey, }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), + ]) .signers([authority]) .rpc() .then(callbacks[0], callbacks[1]); diff --git a/vibes/tasks.md b/vibes/tasks.md index 5232fc5ff..cb999bfd0 100644 --- a/vibes/tasks.md +++ b/vibes/tasks.md @@ -29,33 +29,11 @@ ## Tasks -### Phase 4: complete_unlock - -> Reference: `001-performance-package-v2.md` → Instruction 3 - -- [NEXT] 4.2 Add SDK method for complete_unlock - - Add `completeUnlockIx()` method to `PerformancePackageV2Client` - - Run `./rebuild.sh` - -- [ ] 4.3 Write unit tests for complete_unlock - - Create `tests/performancePackageV2/unit/completeUnlock.test.ts` - - Test: successfully completes unlock and mints tokens (CliffLinear) - - Test: successfully completes unlock and mints tokens (Threshold) - - Test: mints correct amount to recipient (cumulative - already_paid) - - Test: updates total_rewards_paid_out - - Test: resets oracle state (for Time: no state to reset) - - Test: rewards only increase (never decrease) - - Test: succeeds with zero mint amount when rewards already paid - - Test: can be started again after complete (cycle repeats) - - Test: fails when status is not Unlocking - - Test: fails when signer is neither authority nor recipient - - Test: fails when mint_governor doesn't match - ### Phase 5: change_authority > Reference: `001-performance-package-v2.md` → Instruction 4 -- [ ] 5.1 Write change_authority instruction +- [NEXT] 5.1 Write change_authority instruction - Create `instructions/change_authority.rs` - Implement accounts struct with authority signer check - Implement `validate()` and `handle()` From c3f4fba5251aa46d64acee31db6c362d5e233d40 Mon Sep 17 00:00:00 2001 From: Pileks Date: Thu, 29 Jan 2026 21:25:39 -0800 Subject: [PATCH 16/27] ppv2 - change authority --- programs/performance_package_v2/src/error.rs | 2 + .../src/instructions/change_authority.rs | 53 +++++ .../src/instructions/mod.rs | 2 + programs/performance_package_v2/src/lib.rs | 4 + sdk/src/v0.7/PerformancePackageV2Client.ts | 16 ++ sdk/src/v0.7/types/performance_package_v2.ts | 112 ++++++--- tests/performancePackageV2/main.test.ts | 2 + .../unit/changeAuthority.test.ts | 215 ++++++++++++++++++ vibes/tasks.md | 24 +- 9 files changed, 379 insertions(+), 51 deletions(-) create mode 100644 programs/performance_package_v2/src/instructions/change_authority.rs create mode 100644 tests/performancePackageV2/unit/changeAuthority.test.ts diff --git a/programs/performance_package_v2/src/error.rs b/programs/performance_package_v2/src/error.rs index 4bba830c4..f7cc88cb8 100644 --- a/programs/performance_package_v2/src/error.rs +++ b/programs/performance_package_v2/src/error.rs @@ -7,6 +7,8 @@ pub enum PerformancePackageError { Unauthorized, #[msg("Executor is not the opposite party from proposer")] InvalidExecutor, + #[msg("Signer is not the current authority")] + InvalidAuthority, // Account validation #[msg("Mint governor does not match the provided mint")] diff --git a/programs/performance_package_v2/src/instructions/change_authority.rs b/programs/performance_package_v2/src/instructions/change_authority.rs new file mode 100644 index 000000000..a7265b68c --- /dev/null +++ b/programs/performance_package_v2/src/instructions/change_authority.rs @@ -0,0 +1,53 @@ +use anchor_lang::prelude::*; + +use crate::{ + AuthorityChangedEvent, CommonFields, PerformancePackage, PerformancePackageError, + PERFORMANCE_PACKAGE_SEED, +}; + +#[derive(Accounts)] +pub struct ChangeAuthority<'info> { + #[account( + mut, + seeds = [PERFORMANCE_PACKAGE_SEED, performance_package.create_key.as_ref()], + bump = performance_package.bump, + has_one = authority @ PerformancePackageError::InvalidAuthority + )] + pub performance_package: Account<'info, PerformancePackage>, + + /// Must be the current authority of the performance package + pub authority: Signer<'info>, + + /// The new authority address + /// CHECK: Can be any valid pubkey + pub new_authority: UncheckedAccount<'info>, +} + +impl ChangeAuthority<'_> { + pub fn handle(ctx: Context) -> Result<()> { + let pp = &mut ctx.accounts.performance_package; + + let new_authority = ctx.accounts.new_authority.key(); + + // Update authority + pp.authority = new_authority; + + // Increment sequence number + pp.seq_num += 1; + + let clock = Clock::get()?; + + emit!(AuthorityChangedEvent { + common: CommonFields { + slot: clock.slot, + unix_timestamp: clock.unix_timestamp, + performance_package_seq_num: pp.seq_num, + }, + performance_package: pp.key(), + old_authority: ctx.accounts.authority.key(), + new_authority, + }); + + Ok(()) + } +} diff --git a/programs/performance_package_v2/src/instructions/mod.rs b/programs/performance_package_v2/src/instructions/mod.rs index d3110d99d..0e7633c6d 100644 --- a/programs/performance_package_v2/src/instructions/mod.rs +++ b/programs/performance_package_v2/src/instructions/mod.rs @@ -1,7 +1,9 @@ +pub mod change_authority; pub mod complete_unlock; pub mod initialize_performance_package; pub mod start_unlock; +pub use change_authority::*; pub use complete_unlock::*; pub use initialize_performance_package::*; pub use start_unlock::*; diff --git a/programs/performance_package_v2/src/lib.rs b/programs/performance_package_v2/src/lib.rs index 3703321c7..69dfa5da6 100644 --- a/programs/performance_package_v2/src/lib.rs +++ b/programs/performance_package_v2/src/lib.rs @@ -54,4 +54,8 @@ pub mod performance_package_v2 { pub fn complete_unlock(ctx: Context) -> Result<()> { CompleteUnlock::handle(ctx) } + + pub fn change_authority(ctx: Context) -> Result<()> { + ChangeAuthority::handle(ctx) + } } diff --git a/sdk/src/v0.7/PerformancePackageV2Client.ts b/sdk/src/v0.7/PerformancePackageV2Client.ts index 5e7a8703a..110cf8af2 100644 --- a/sdk/src/v0.7/PerformancePackageV2Client.ts +++ b/sdk/src/v0.7/PerformancePackageV2Client.ts @@ -193,4 +193,20 @@ export class PerformancePackageV2Client { mintGovernorProgram: MINT_GOVERNOR_PROGRAM_ID, }); } + + changeAuthorityIx({ + performancePackage, + authority = this.provider.publicKey, + newAuthority, + }: { + performancePackage: PublicKey; + authority?: PublicKey; + newAuthority: PublicKey; + }) { + return this.program.methods.changeAuthority().accounts({ + performancePackage, + authority, + newAuthority, + }); + } } diff --git a/sdk/src/v0.7/types/performance_package_v2.ts b/sdk/src/v0.7/types/performance_package_v2.ts index ad101a905..b26e09d58 100644 --- a/sdk/src/v0.7/types/performance_package_v2.ts +++ b/sdk/src/v0.7/types/performance_package_v2.ts @@ -136,6 +136,29 @@ export type PerformancePackageV2 = { ]; args: []; }, + { + name: "changeAuthority"; + accounts: [ + { + name: "performancePackage"; + isMut: true; + isSigner: false; + }, + { + name: "authority"; + isMut: false; + isSigner: true; + docs: ["Must be the current authority of the performance package"]; + }, + { + name: "newAuthority"; + isMut: false; + isSigner: false; + docs: ["The new authority address"]; + }, + ]; + args: []; + }, ]; accounts: [ { @@ -726,76 +749,81 @@ export type PerformancePackageV2 = { }, { code: 6002; + name: "InvalidAuthority"; + msg: "Signer is not the current authority"; + }, + { + code: 6003; name: "InvalidMintGovernor"; msg: "Mint governor does not match the provided mint"; }, { - code: 6003; + code: 6004; name: "InvalidMintAuthority"; msg: "Mint authority does not match expected configuration"; }, { - code: 6004; + code: 6005; name: "NotLocked"; msg: "Expected Locked status"; }, { - code: 6005; + code: 6006; name: "NotUnlocking"; msg: "Expected Unlocking status"; }, { - code: 6006; + code: 6007; name: "OracleMissingAccount"; msg: "Expected remaining_accounts not provided"; }, { - code: 6007; + code: 6008; name: "OracleInvalidAccount"; msg: "Account pubkey doesn't match expected"; }, { - code: 6008; + code: 6009; name: "OracleParseError"; msg: "Failed to parse account data"; }, { - code: 6009; + code: 6010; name: "OracleInvalidState"; msg: "Oracle state invalid"; }, { - code: 6010; + code: 6011; name: "OracleMinDurationNotReached"; msg: "Minimum duration hasn't passed yet"; }, { - code: 6011; + code: 6012; name: "UnlockTimestampNotReached"; msg: "Minimum unlock timestamp not yet reached"; }, { - code: 6012; + code: 6013; name: "RewardCalculationOverflow"; msg: "Math overflow in reward function"; }, { - code: 6013; + code: 6014; name: "InvalidTranches"; msg: "Tranches not sorted or empty"; }, { - code: 6014; + code: 6015; name: "InvalidVestingSchedule"; msg: "Invalid vesting schedule configuration"; }, { - code: 6015; + code: 6016; name: "ChangeRequestNotFound"; msg: "Missing proposal for execute"; }, { - code: 6016; + code: 6017; name: "NoChangesProposed"; msg: "All optional change fields are None"; }, @@ -940,6 +968,29 @@ export const IDL: PerformancePackageV2 = { ], args: [], }, + { + name: "changeAuthority", + accounts: [ + { + name: "performancePackage", + isMut: true, + isSigner: false, + }, + { + name: "authority", + isMut: false, + isSigner: true, + docs: ["Must be the current authority of the performance package"], + }, + { + name: "newAuthority", + isMut: false, + isSigner: false, + docs: ["The new authority address"], + }, + ], + args: [], + }, ], accounts: [ { @@ -1530,76 +1581,81 @@ export const IDL: PerformancePackageV2 = { }, { code: 6002, + name: "InvalidAuthority", + msg: "Signer is not the current authority", + }, + { + code: 6003, name: "InvalidMintGovernor", msg: "Mint governor does not match the provided mint", }, { - code: 6003, + code: 6004, name: "InvalidMintAuthority", msg: "Mint authority does not match expected configuration", }, { - code: 6004, + code: 6005, name: "NotLocked", msg: "Expected Locked status", }, { - code: 6005, + code: 6006, name: "NotUnlocking", msg: "Expected Unlocking status", }, { - code: 6006, + code: 6007, name: "OracleMissingAccount", msg: "Expected remaining_accounts not provided", }, { - code: 6007, + code: 6008, name: "OracleInvalidAccount", msg: "Account pubkey doesn't match expected", }, { - code: 6008, + code: 6009, name: "OracleParseError", msg: "Failed to parse account data", }, { - code: 6009, + code: 6010, name: "OracleInvalidState", msg: "Oracle state invalid", }, { - code: 6010, + code: 6011, name: "OracleMinDurationNotReached", msg: "Minimum duration hasn't passed yet", }, { - code: 6011, + code: 6012, name: "UnlockTimestampNotReached", msg: "Minimum unlock timestamp not yet reached", }, { - code: 6012, + code: 6013, name: "RewardCalculationOverflow", msg: "Math overflow in reward function", }, { - code: 6013, + code: 6014, name: "InvalidTranches", msg: "Tranches not sorted or empty", }, { - code: 6014, + code: 6015, name: "InvalidVestingSchedule", msg: "Invalid vesting schedule configuration", }, { - code: 6015, + code: 6016, name: "ChangeRequestNotFound", msg: "Missing proposal for execute", }, { - code: 6016, + code: 6017, name: "NoChangesProposed", msg: "All optional change fields are None", }, diff --git a/tests/performancePackageV2/main.test.ts b/tests/performancePackageV2/main.test.ts index e461cdbbe..3b4baf573 100644 --- a/tests/performancePackageV2/main.test.ts +++ b/tests/performancePackageV2/main.test.ts @@ -1,6 +1,7 @@ import initializePerformancePackage from "./unit/initializePerformancePackage.test.js"; import startUnlock from "./unit/startUnlock.test.js"; import completeUnlock from "./unit/completeUnlock.test.js"; +import changeAuthority from "./unit/changeAuthority.test.js"; import { MintGovernorClient, PerformancePackageV2Client, @@ -21,4 +22,5 @@ export default function suite() { describe("#initialize_performance_package", initializePerformancePackage); describe("#start_unlock", startUnlock); describe("#complete_unlock", completeUnlock); + describe("#change_authority", changeAuthority); } diff --git a/tests/performancePackageV2/unit/changeAuthority.test.ts b/tests/performancePackageV2/unit/changeAuthority.test.ts new file mode 100644 index 000000000..10b8b9344 --- /dev/null +++ b/tests/performancePackageV2/unit/changeAuthority.test.ts @@ -0,0 +1,215 @@ +import { ComputeBudgetProgram, Keypair } from "@solana/web3.js"; +import BN from "bn.js"; +import { assert } from "chai"; +import { + MintGovernorClient, + PerformancePackageV2Client, +} from "@metadaoproject/futarchy/v0.7"; +import { + setupPerformancePackageV2, + createCliffLinearReward, +} from "../utils.js"; +import { expectError } from "../../utils.js"; + +export default function suite() { + let mintGovernorClient: MintGovernorClient; + let ppClient: PerformancePackageV2Client; + + before(async function () { + mintGovernorClient = this.mintGovernor; + ppClient = this.performancePackageV2; + }); + + it("successfully changes authority", async function () { + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + const newAuthority = Keypair.generate(); + + const { performancePackage } = await setupPerformancePackageV2( + this.banksClient, + mintGovernorClient, + ppClient, + this.payer, + { + authority: authority.publicKey, + recipient: recipient.publicKey, + rewardFunction: createCliffLinearReward(), + minUnlockTimestamp: new BN(0), + }, + ); + + // Verify initial authority + let ppAccount = await ppClient.fetchPerformancePackage(performancePackage); + assert.equal( + ppAccount.authority.toBase58(), + authority.publicKey.toBase58(), + ); + assert.equal(ppAccount.seqNum.toString(), "0"); + + // Change authority + await ppClient + .changeAuthorityIx({ + performancePackage, + authority: authority.publicKey, + newAuthority: newAuthority.publicKey, + }) + .signers([authority]) + .rpc(); + + // Verify authority changed + ppAccount = await ppClient.fetchPerformancePackage(performancePackage); + assert.equal( + ppAccount.authority.toBase58(), + newAuthority.publicKey.toBase58(), + ); + assert.equal(ppAccount.seqNum.toString(), "1"); + }); + + it("new authority can perform authority actions", async function () { + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + const newAuthority = Keypair.generate(); + const newerAuthority = Keypair.generate(); + + const { performancePackage } = await setupPerformancePackageV2( + this.banksClient, + mintGovernorClient, + ppClient, + this.payer, + { + authority: authority.publicKey, + recipient: recipient.publicKey, + rewardFunction: createCliffLinearReward(), + minUnlockTimestamp: new BN(0), + }, + ); + + // Change authority to newAuthority + await ppClient + .changeAuthorityIx({ + performancePackage, + authority: authority.publicKey, + newAuthority: newAuthority.publicKey, + }) + .signers([authority]) + .rpc(); + + // Verify newAuthority is set + let ppAccount = await ppClient.fetchPerformancePackage(performancePackage); + assert.equal( + ppAccount.authority.toBase58(), + newAuthority.publicKey.toBase58(), + ); + + // Now newAuthority should be able to change authority again + await ppClient + .changeAuthorityIx({ + performancePackage, + authority: newAuthority.publicKey, + newAuthority: newerAuthority.publicKey, + }) + .signers([newAuthority]) + .rpc(); + + // Verify newerAuthority is now set + ppAccount = await ppClient.fetchPerformancePackage(performancePackage); + assert.equal( + ppAccount.authority.toBase58(), + newerAuthority.publicKey.toBase58(), + ); + assert.equal(ppAccount.seqNum.toString(), "2"); + }); + + it("old authority cannot perform authority actions after change", async function () { + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + const newAuthority = Keypair.generate(); + const anotherAuthority = Keypair.generate(); + + const { performancePackage } = await setupPerformancePackageV2( + this.banksClient, + mintGovernorClient, + ppClient, + this.payer, + { + authority: authority.publicKey, + recipient: recipient.publicKey, + rewardFunction: createCliffLinearReward(), + minUnlockTimestamp: new BN(0), + }, + ); + + // Change authority to newAuthority + await ppClient + .changeAuthorityIx({ + performancePackage, + authority: authority.publicKey, + newAuthority: newAuthority.publicKey, + }) + .signers([authority]) + .rpc(); + + // Verify newAuthority is set + const ppAccount = + await ppClient.fetchPerformancePackage(performancePackage); + assert.equal( + ppAccount.authority.toBase58(), + newAuthority.publicKey.toBase58(), + ); + + // Old authority should no longer be able to change authority + const callbacks = expectError( + "InvalidAuthority", + "Should have failed because old authority is no longer the authority", + ); + + await ppClient + .changeAuthorityIx({ + performancePackage, + authority: authority.publicKey, + newAuthority: anotherAuthority.publicKey, + }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), + ]) + .signers([authority]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("fails when signer is not current authority", async function () { + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + const unauthorized = Keypair.generate(); + const newAuthority = Keypair.generate(); + + const { performancePackage } = await setupPerformancePackageV2( + this.banksClient, + mintGovernorClient, + ppClient, + this.payer, + { + authority: authority.publicKey, + recipient: recipient.publicKey, + rewardFunction: createCliffLinearReward(), + minUnlockTimestamp: new BN(0), + }, + ); + + // Try to change authority with an unauthorized signer + const callbacks = expectError( + "InvalidAuthority", + "Should have failed because signer is not the current authority", + ); + + await ppClient + .changeAuthorityIx({ + performancePackage, + authority: unauthorized.publicKey, + newAuthority: newAuthority.publicKey, + }) + .signers([unauthorized]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); +} diff --git a/vibes/tasks.md b/vibes/tasks.md index cb999bfd0..d1feb96de 100644 --- a/vibes/tasks.md +++ b/vibes/tasks.md @@ -29,33 +29,11 @@ ## Tasks -### Phase 5: change_authority - -> Reference: `001-performance-package-v2.md` → Instruction 4 - -- [NEXT] 5.1 Write change_authority instruction - - Create `instructions/change_authority.rs` - - Implement accounts struct with authority signer check - - Implement `validate()` and `handle()` - - Emit `AuthorityChangedEvent` - - Wire up in `lib.rs` - -- [ ] 5.2 Add SDK method for change_authority - - Add `changeAuthorityIx()` method to `PerformancePackageV2Client` - - Run `./rebuild.sh` - -- [ ] 5.3 Write unit tests for change_authority - - Create `tests/performancePackageV2/unit/changeAuthority.test.ts` - - Test: successfully changes authority - - Test: new authority can perform authority actions - - Test: old authority cannot perform authority actions after change - - Test: fails when signer is not current authority - ### Phase 6: propose_change > Reference: `001-performance-package-v2.md` → Instruction 5 -- [ ] 6.1 Write propose_change instruction +- [NEXT] 6.1 Write propose_change instruction - Create `instructions/propose_change.rs` - Implement accounts struct with ChangeRequest PDA init - Implement `ProposeChangeArgs` with optional fields and pda_nonce From 3573dd925172a948bb526550eb329f3c6ad0937a Mon Sep 17 00:00:00 2001 From: Pileks Date: Thu, 29 Jan 2026 21:52:54 -0800 Subject: [PATCH 17/27] ppv2 - propose change ix --- .../initialize_performance_package.rs | 50 +----- .../src/instructions/mod.rs | 2 + .../src/instructions/propose_change.rs | 122 +++++++++++++++ programs/performance_package_v2/src/lib.rs | 5 + .../src/state/performance_package.rs | 57 +++++++ sdk/src/v0.7/types/performance_package_v2.ts | 144 ++++++++++++++++++ vibes/tasks.md | 11 +- 7 files changed, 333 insertions(+), 58 deletions(-) create mode 100644 programs/performance_package_v2/src/instructions/propose_change.rs diff --git a/programs/performance_package_v2/src/instructions/initialize_performance_package.rs b/programs/performance_package_v2/src/instructions/initialize_performance_package.rs index b418447e9..21544dc98 100644 --- a/programs/performance_package_v2/src/instructions/initialize_performance_package.rs +++ b/programs/performance_package_v2/src/instructions/initialize_performance_package.rs @@ -54,7 +54,8 @@ pub struct InitializePerformancePackage<'info> { impl InitializePerformancePackage<'_> { pub fn validate(&self, args: &InitializePerformancePackageArgs) -> Result<()> { - validate_reward_function(&args.reward_function)?; + args.oracle_reader.validate()?; + args.reward_function.validate()?; Ok(()) } @@ -98,50 +99,3 @@ impl InitializePerformancePackage<'_> { Ok(()) } } - -/// Validates the reward function configuration. -fn validate_reward_function(reward_function: &RewardFunction) -> Result<()> { - match reward_function { - RewardFunction::CliffLinear { - start_value, - cliff_value, - end_value, - cliff_amount, - total_amount, - } => { - // start_value <= cliff_value <= end_value - require!( - start_value <= cliff_value && cliff_value <= end_value, - PerformancePackageError::InvalidVestingSchedule - ); - // cliff_amount <= total_amount - require!( - cliff_amount <= total_amount, - PerformancePackageError::InvalidVestingSchedule - ); - } - RewardFunction::Threshold { tranches } => { - // Must have at least one tranche - require!( - !tranches.is_empty(), - PerformancePackageError::InvalidTranches - ); - - // Tranches must be sorted by threshold ascending - // and cumulative_amount must be non-decreasing - for window in tranches.windows(2) { - let prev = &window[0]; - let curr = &window[1]; - require!( - prev.threshold < curr.threshold, - PerformancePackageError::InvalidTranches - ); - require!( - prev.cumulative_amount <= curr.cumulative_amount, - PerformancePackageError::InvalidTranches - ); - } - } - } - Ok(()) -} diff --git a/programs/performance_package_v2/src/instructions/mod.rs b/programs/performance_package_v2/src/instructions/mod.rs index 0e7633c6d..c660a34c6 100644 --- a/programs/performance_package_v2/src/instructions/mod.rs +++ b/programs/performance_package_v2/src/instructions/mod.rs @@ -1,9 +1,11 @@ pub mod change_authority; pub mod complete_unlock; pub mod initialize_performance_package; +pub mod propose_change; pub mod start_unlock; pub use change_authority::*; pub use complete_unlock::*; pub use initialize_performance_package::*; +pub use propose_change::*; pub use start_unlock::*; diff --git a/programs/performance_package_v2/src/instructions/propose_change.rs b/programs/performance_package_v2/src/instructions/propose_change.rs new file mode 100644 index 000000000..f6158eef5 --- /dev/null +++ b/programs/performance_package_v2/src/instructions/propose_change.rs @@ -0,0 +1,122 @@ +use anchor_lang::prelude::*; + +use crate::{ + ChangeProposedEvent, ChangeRequest, CommonFields, OracleReader, PerformancePackage, + PerformancePackageError, ProposerType, RewardFunction, CHANGE_REQUEST_SEED, +}; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct ProposeChangeArgs { + pub pda_nonce: u32, + pub new_recipient: Option, + pub new_oracle_reader: Option, + pub new_reward_function: Option, +} + +#[derive(Accounts)] +#[instruction(args: ProposeChangeArgs)] +pub struct ProposeChange<'info> { + #[account(mut)] + pub performance_package: Account<'info, PerformancePackage>, + + #[account( + init, + payer = payer, + space = 8 + ChangeRequest::INIT_SPACE, + seeds = [ + CHANGE_REQUEST_SEED, + performance_package.key().as_ref(), + proposer.key().as_ref(), + args.pda_nonce.to_le_bytes().as_ref() + ], + bump + )] + pub change_request: Account<'info, ChangeRequest>, + + pub proposer: Signer<'info>, + + #[account(mut)] + pub payer: Signer<'info>, + + pub system_program: Program<'info, System>, +} + +impl ProposeChange<'_> { + pub fn validate(&self, args: &ProposeChangeArgs) -> Result<()> { + let pp = &self.performance_package; + + // Signer must be authority or recipient + require!( + self.proposer.key() == pp.authority || self.proposer.key() == pp.recipient, + PerformancePackageError::Unauthorized + ); + + // At least one change must be proposed + require!( + args.new_recipient.is_some() + || args.new_oracle_reader.is_some() + || args.new_reward_function.is_some(), + PerformancePackageError::NoChangesProposed + ); + + // Validate oracle config if provided + if let Some(ref oracle) = args.new_oracle_reader { + oracle.validate()?; + } + + // Validate reward function if provided + if let Some(ref reward_fn) = args.new_reward_function { + reward_fn.validate()?; + } + + Ok(()) + } + + pub fn handle(ctx: Context, args: ProposeChangeArgs) -> Result<()> { + let pp = &mut ctx.accounts.performance_package; + let proposer_key = ctx.accounts.proposer.key(); + + // Determine proposer type + // Authority could theoretically change during change proposal, + // so we need this ProposerType to know who needs to sign the change request. + let proposer_type = if proposer_key == pp.authority { + ProposerType::Authority + } else { + ProposerType::Recipient + }; + + let clock = Clock::get()?; + + // Initialize the change request + ctx.accounts.change_request.set_inner(ChangeRequest { + performance_package: pp.key(), + proposer_type, + proposed_at: clock.unix_timestamp, + pda_nonce: args.pda_nonce, + bump: ctx.bumps.change_request, + new_recipient: args.new_recipient, + new_oracle_reader: args.new_oracle_reader.clone(), + new_reward_function: args.new_reward_function.clone(), + }); + + // Increment seq_num for event tracking + pp.seq_num += 1; + + emit!(ChangeProposedEvent { + common: CommonFields { + slot: clock.slot, + unix_timestamp: clock.unix_timestamp, + performance_package_seq_num: pp.seq_num, + }, + performance_package: pp.key(), + change_request: ctx.accounts.change_request.key(), + proposer_type, + pda_nonce: args.pda_nonce, + new_recipient: args.new_recipient, + new_oracle_reader: args.new_oracle_reader, + new_reward_function: args.new_reward_function, + }); + + Ok(()) + } +} diff --git a/programs/performance_package_v2/src/lib.rs b/programs/performance_package_v2/src/lib.rs index 69dfa5da6..b0d9e2c2f 100644 --- a/programs/performance_package_v2/src/lib.rs +++ b/programs/performance_package_v2/src/lib.rs @@ -58,4 +58,9 @@ pub mod performance_package_v2 { pub fn change_authority(ctx: Context) -> Result<()> { ChangeAuthority::handle(ctx) } + + #[access_control(ctx.accounts.validate(&args))] + pub fn propose_change(ctx: Context, args: ProposeChangeArgs) -> Result<()> { + ProposeChange::handle(ctx, args) + } } diff --git a/programs/performance_package_v2/src/state/performance_package.rs b/programs/performance_package_v2/src/state/performance_package.rs index ce57a02a9..528642c09 100644 --- a/programs/performance_package_v2/src/state/performance_package.rs +++ b/programs/performance_package_v2/src/state/performance_package.rs @@ -22,6 +22,16 @@ pub enum OracleReader { } impl OracleReader { + /// Validates the oracle reader configuration. + pub fn validate(&self) -> Result<()> { + match self { + OracleReader::Time => { + // Time oracle has no configuration to validate + Ok(()) + } + } + } + /// Records the start snapshot when unlock begins. /// For Time oracle, this is a no-op since it just reads current time on demand. pub fn record_start(&mut self) -> Result<()> { @@ -75,6 +85,53 @@ impl OracleReader { } impl RewardFunction { + /// Validates the reward function configuration. + pub fn validate(&self) -> Result<()> { + match self { + RewardFunction::CliffLinear { + start_value, + cliff_value, + end_value, + cliff_amount, + total_amount, + } => { + // start_value <= cliff_value <= end_value + require!( + start_value <= cliff_value && cliff_value <= end_value, + PerformancePackageError::InvalidVestingSchedule + ); + // cliff_amount <= total_amount + require!( + cliff_amount <= total_amount, + PerformancePackageError::InvalidVestingSchedule + ); + } + RewardFunction::Threshold { tranches } => { + // Must have at least one tranche + require!( + !tranches.is_empty(), + PerformancePackageError::InvalidTranches + ); + + // Tranches must be sorted by threshold ascending + // and cumulative_amount must be non-decreasing + for window in tranches.windows(2) { + let prev = &window[0]; + let curr = &window[1]; + require!( + prev.threshold < curr.threshold, + PerformancePackageError::InvalidTranches + ); + require!( + prev.cumulative_amount <= curr.cumulative_amount, + PerformancePackageError::InvalidTranches + ); + } + } + } + Ok(()) + } + /// Calculates the cumulative rewards earned for a given oracle value. /// Returns total tokens deserved so far (not incremental). pub fn calculate(&self, value: u128) -> Result { diff --git a/sdk/src/v0.7/types/performance_package_v2.ts b/sdk/src/v0.7/types/performance_package_v2.ts index b26e09d58..2008b0830 100644 --- a/sdk/src/v0.7/types/performance_package_v2.ts +++ b/sdk/src/v0.7/types/performance_package_v2.ts @@ -159,6 +159,44 @@ export type PerformancePackageV2 = { ]; args: []; }, + { + name: "proposeChange"; + accounts: [ + { + name: "performancePackage"; + isMut: true; + isSigner: false; + }, + { + name: "changeRequest"; + isMut: true; + isSigner: false; + }, + { + name: "proposer"; + isMut: false; + isSigner: true; + }, + { + name: "payer"; + isMut: true; + isSigner: true; + }, + { + name: "systemProgram"; + isMut: false; + isSigner: false; + }, + ]; + args: [ + { + name: "args"; + type: { + defined: "ProposeChangeArgs"; + }; + }, + ]; + }, ]; accounts: [ { @@ -356,6 +394,40 @@ export type PerformancePackageV2 = { ]; }; }, + { + name: "ProposeChangeArgs"; + type: { + kind: "struct"; + fields: [ + { + name: "pdaNonce"; + type: "u32"; + }, + { + name: "newRecipient"; + type: { + option: "publicKey"; + }; + }, + { + name: "newOracleReader"; + type: { + option: { + defined: "OracleReader"; + }; + }; + }, + { + name: "newRewardFunction"; + type: { + option: { + defined: "RewardFunction"; + }; + }; + }, + ]; + }; + }, { name: "ThresholdTranche"; docs: ["A threshold tranche for step-based rewards."]; @@ -991,6 +1063,44 @@ export const IDL: PerformancePackageV2 = { ], args: [], }, + { + name: "proposeChange", + accounts: [ + { + name: "performancePackage", + isMut: true, + isSigner: false, + }, + { + name: "changeRequest", + isMut: true, + isSigner: false, + }, + { + name: "proposer", + isMut: false, + isSigner: true, + }, + { + name: "payer", + isMut: true, + isSigner: true, + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: "args", + type: { + defined: "ProposeChangeArgs", + }, + }, + ], + }, ], accounts: [ { @@ -1188,6 +1298,40 @@ export const IDL: PerformancePackageV2 = { ], }, }, + { + name: "ProposeChangeArgs", + type: { + kind: "struct", + fields: [ + { + name: "pdaNonce", + type: "u32", + }, + { + name: "newRecipient", + type: { + option: "publicKey", + }, + }, + { + name: "newOracleReader", + type: { + option: { + defined: "OracleReader", + }, + }, + }, + { + name: "newRewardFunction", + type: { + option: { + defined: "RewardFunction", + }, + }, + }, + ], + }, + }, { name: "ThresholdTranche", docs: ["A threshold tranche for step-based rewards."], diff --git a/vibes/tasks.md b/vibes/tasks.md index d1feb96de..30c826919 100644 --- a/vibes/tasks.md +++ b/vibes/tasks.md @@ -33,16 +33,7 @@ > Reference: `001-performance-package-v2.md` → Instruction 5 -- [NEXT] 6.1 Write propose_change instruction - - Create `instructions/propose_change.rs` - - Implement accounts struct with ChangeRequest PDA init - - Implement `ProposeChangeArgs` with optional fields and pda_nonce - - Implement `validate()`: at least one Some field, validate configs - - Implement `handle()`: create ChangeRequest - - Emit `ChangeProposedEvent` - - Wire up in `lib.rs` - -- [ ] 6.2 Add SDK method for propose_change +- [NEXT] 6.2 Add SDK method for propose_change - Add `proposeChangeIx()` method to `PerformancePackageV2Client` - Add `fetchChangeRequest()` method - Run `./rebuild.sh` From 83c0a375dee6bf273465941b3c702562fca18f0d Mon Sep 17 00:00:00 2001 From: Pileks Date: Thu, 29 Jan 2026 22:14:00 -0800 Subject: [PATCH 18/27] ppv2 - propose change SDK/tests --- sdk/src/v0.7/PerformancePackageV2Client.ts | 39 ++ tests/performancePackageV2/main.test.ts | 2 + .../unit/proposeChange.test.ts | 475 ++++++++++++++++++ vibes/tasks.md | 23 +- 4 files changed, 517 insertions(+), 22 deletions(-) create mode 100644 tests/performancePackageV2/unit/proposeChange.test.ts diff --git a/sdk/src/v0.7/PerformancePackageV2Client.ts b/sdk/src/v0.7/PerformancePackageV2Client.ts index 110cf8af2..74657ed2a 100644 --- a/sdk/src/v0.7/PerformancePackageV2Client.ts +++ b/sdk/src/v0.7/PerformancePackageV2Client.ts @@ -209,4 +209,43 @@ export class PerformancePackageV2Client { newAuthority, }); } + + proposeChangeIx({ + performancePackage, + proposer = this.provider.publicKey, + payer = this.provider.publicKey, + pdaNonce, + newRecipient = null, + newOracleReader = null, + newRewardFunction = null, + }: { + performancePackage: PublicKey; + proposer?: PublicKey; + payer?: PublicKey; + pdaNonce: number; + newRecipient?: PublicKey | null; + newOracleReader?: OracleReaderV2 | null; + newRewardFunction?: RewardFunctionV2 | null; + }) { + const [changeRequest] = this.getChangeRequestAddr( + performancePackage, + proposer, + pdaNonce, + ); + + return this.program.methods + .proposeChange({ + pdaNonce, + newRecipient, + newOracleReader, + newRewardFunction, + }) + .accounts({ + performancePackage, + changeRequest, + proposer, + payer, + systemProgram: SystemProgram.programId, + }); + } } diff --git a/tests/performancePackageV2/main.test.ts b/tests/performancePackageV2/main.test.ts index 3b4baf573..412d18201 100644 --- a/tests/performancePackageV2/main.test.ts +++ b/tests/performancePackageV2/main.test.ts @@ -2,6 +2,7 @@ import initializePerformancePackage from "./unit/initializePerformancePackage.te import startUnlock from "./unit/startUnlock.test.js"; import completeUnlock from "./unit/completeUnlock.test.js"; import changeAuthority from "./unit/changeAuthority.test.js"; +import proposeChange from "./unit/proposeChange.test.js"; import { MintGovernorClient, PerformancePackageV2Client, @@ -23,4 +24,5 @@ export default function suite() { describe("#start_unlock", startUnlock); describe("#complete_unlock", completeUnlock); describe("#change_authority", changeAuthority); + describe("#propose_change", proposeChange); } diff --git a/tests/performancePackageV2/unit/proposeChange.test.ts b/tests/performancePackageV2/unit/proposeChange.test.ts new file mode 100644 index 000000000..9f178a521 --- /dev/null +++ b/tests/performancePackageV2/unit/proposeChange.test.ts @@ -0,0 +1,475 @@ +import { ComputeBudgetProgram, Keypair } from "@solana/web3.js"; +import BN from "bn.js"; +import { assert } from "chai"; +import { + MintGovernorClient, + PerformancePackageV2Client, +} from "@metadaoproject/futarchy/v0.7"; +import { + setupPerformancePackageV2, + createCliffLinearReward, + createThresholdReward, +} from "../utils.js"; +import { expectError } from "../../utils.js"; + +export default function suite() { + let mintGovernorClient: MintGovernorClient; + let ppClient: PerformancePackageV2Client; + + before(async function () { + mintGovernorClient = this.mintGovernor; + ppClient = this.performancePackageV2; + }); + + it("successfully proposes change when called by authority", async function () { + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + const newRecipient = Keypair.generate(); + + const { performancePackage } = await setupPerformancePackageV2( + this.banksClient, + mintGovernorClient, + ppClient, + this.payer, + { + authority: authority.publicKey, + recipient: recipient.publicKey, + rewardFunction: createCliffLinearReward(), + minUnlockTimestamp: new BN(0), + }, + ); + + const pdaNonce = 1; + const [changeRequest] = ppClient.getChangeRequestAddr( + performancePackage, + authority.publicKey, + pdaNonce, + ); + + await ppClient + .proposeChangeIx({ + performancePackage, + proposer: authority.publicKey, + payer: this.payer.publicKey, + pdaNonce, + newRecipient: newRecipient.publicKey, + }) + .signers([authority]) + .rpc(); + + // Verify change request was created + const changeRequestAccount = + await ppClient.fetchChangeRequest(changeRequest); + assert.isNotNull(changeRequestAccount); + assert.equal( + changeRequestAccount.performancePackage.toBase58(), + performancePackage.toBase58(), + ); + assert.isDefined(changeRequestAccount.proposerType.authority); + assert.equal(changeRequestAccount.pdaNonce, pdaNonce); + assert.equal( + changeRequestAccount.newRecipient.toBase58(), + newRecipient.publicKey.toBase58(), + ); + assert.isNull(changeRequestAccount.newOracleReader); + assert.isNull(changeRequestAccount.newRewardFunction); + + // Verify seq_num was incremented + const ppAccount = + await ppClient.fetchPerformancePackage(performancePackage); + assert.equal(ppAccount.seqNum.toString(), "1"); + }); + + it("successfully proposes change when called by recipient", async function () { + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + const newRecipient = Keypair.generate(); + + const { performancePackage } = await setupPerformancePackageV2( + this.banksClient, + mintGovernorClient, + ppClient, + this.payer, + { + authority: authority.publicKey, + recipient: recipient.publicKey, + rewardFunction: createCliffLinearReward(), + minUnlockTimestamp: new BN(0), + }, + ); + + const pdaNonce = 1; + const [changeRequest] = ppClient.getChangeRequestAddr( + performancePackage, + recipient.publicKey, + pdaNonce, + ); + + await ppClient + .proposeChangeIx({ + performancePackage, + proposer: recipient.publicKey, + payer: this.payer.publicKey, + pdaNonce, + newRecipient: newRecipient.publicKey, + }) + .signers([recipient]) + .rpc(); + + // Verify change request was created with recipient as proposer + const changeRequestAccount = + await ppClient.fetchChangeRequest(changeRequest); + assert.isNotNull(changeRequestAccount); + assert.isDefined(changeRequestAccount.proposerType.recipient); + }); + + it("successfully proposes recipient change", async function () { + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + const newRecipient = Keypair.generate(); + + const { performancePackage } = await setupPerformancePackageV2( + this.banksClient, + mintGovernorClient, + ppClient, + this.payer, + { + authority: authority.publicKey, + recipient: recipient.publicKey, + rewardFunction: createCliffLinearReward(), + minUnlockTimestamp: new BN(0), + }, + ); + + const pdaNonce = 1; + const [changeRequest] = ppClient.getChangeRequestAddr( + performancePackage, + authority.publicKey, + pdaNonce, + ); + + await ppClient + .proposeChangeIx({ + performancePackage, + proposer: authority.publicKey, + payer: this.payer.publicKey, + pdaNonce, + newRecipient: newRecipient.publicKey, + }) + .signers([authority]) + .rpc(); + + const changeRequestAccount = + await ppClient.fetchChangeRequest(changeRequest); + assert.equal( + changeRequestAccount.newRecipient.toBase58(), + newRecipient.publicKey.toBase58(), + ); + assert.isNull(changeRequestAccount.newOracleReader); + assert.isNull(changeRequestAccount.newRewardFunction); + }); + + it("successfully proposes oracle change", async function () { + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + + const { performancePackage } = await setupPerformancePackageV2( + this.banksClient, + mintGovernorClient, + ppClient, + this.payer, + { + authority: authority.publicKey, + recipient: recipient.publicKey, + rewardFunction: createCliffLinearReward(), + minUnlockTimestamp: new BN(0), + }, + ); + + const pdaNonce = 1; + const [changeRequest] = ppClient.getChangeRequestAddr( + performancePackage, + authority.publicKey, + pdaNonce, + ); + + const newOracleReader = { time: {} }; + + await ppClient + .proposeChangeIx({ + performancePackage, + proposer: authority.publicKey, + payer: this.payer.publicKey, + pdaNonce, + newOracleReader, + }) + .signers([authority]) + .rpc(); + + const changeRequestAccount = + await ppClient.fetchChangeRequest(changeRequest); + assert.isNull(changeRequestAccount.newRecipient); + assert.isNotNull(changeRequestAccount.newOracleReader); + assert.isDefined(changeRequestAccount.newOracleReader.time); + assert.isNull(changeRequestAccount.newRewardFunction); + }); + + it("successfully proposes reward function change", async function () { + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + + const { performancePackage } = await setupPerformancePackageV2( + this.banksClient, + mintGovernorClient, + ppClient, + this.payer, + { + authority: authority.publicKey, + recipient: recipient.publicKey, + rewardFunction: createCliffLinearReward(), + minUnlockTimestamp: new BN(0), + }, + ); + + const pdaNonce = 1; + const [changeRequest] = ppClient.getChangeRequestAddr( + performancePackage, + authority.publicKey, + pdaNonce, + ); + + const newRewardFunction = createThresholdReward([ + { threshold: new BN(100), cumulativeAmount: new BN(100_000_000) }, + { threshold: new BN(200), cumulativeAmount: new BN(200_000_000) }, + ]); + + await ppClient + .proposeChangeIx({ + performancePackage, + proposer: authority.publicKey, + payer: this.payer.publicKey, + pdaNonce, + newRewardFunction, + }) + .signers([authority]) + .rpc(); + + const changeRequestAccount = + await ppClient.fetchChangeRequest(changeRequest); + assert.isNull(changeRequestAccount.newRecipient); + assert.isNull(changeRequestAccount.newOracleReader); + assert.isNotNull(changeRequestAccount.newRewardFunction); + assert.isDefined(changeRequestAccount.newRewardFunction.threshold); + }); + + it("successfully proposes multiple changes at once", async function () { + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + const newRecipient = Keypair.generate(); + + const { performancePackage } = await setupPerformancePackageV2( + this.banksClient, + mintGovernorClient, + ppClient, + this.payer, + { + authority: authority.publicKey, + recipient: recipient.publicKey, + rewardFunction: createCliffLinearReward(), + minUnlockTimestamp: new BN(0), + }, + ); + + const pdaNonce = 1; + const [changeRequest] = ppClient.getChangeRequestAddr( + performancePackage, + authority.publicKey, + pdaNonce, + ); + + const newOracleReader = { time: {} }; + const newRewardFunction = createThresholdReward([ + { threshold: new BN(100), cumulativeAmount: new BN(100_000_000) }, + ]); + + await ppClient + .proposeChangeIx({ + performancePackage, + proposer: authority.publicKey, + payer: this.payer.publicKey, + pdaNonce, + newRecipient: newRecipient.publicKey, + newOracleReader, + newRewardFunction, + }) + .signers([authority]) + .rpc(); + + const changeRequestAccount = + await ppClient.fetchChangeRequest(changeRequest); + assert.equal( + changeRequestAccount.newRecipient.toBase58(), + newRecipient.publicKey.toBase58(), + ); + assert.isNotNull(changeRequestAccount.newOracleReader); + assert.isDefined(changeRequestAccount.newOracleReader.time); + assert.isNotNull(changeRequestAccount.newRewardFunction); + assert.isDefined(changeRequestAccount.newRewardFunction.threshold); + }); + + it("allows multiple concurrent proposals with different nonces", async function () { + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + const newRecipient1 = Keypair.generate(); + const newRecipient2 = Keypair.generate(); + + const { performancePackage } = await setupPerformancePackageV2( + this.banksClient, + mintGovernorClient, + ppClient, + this.payer, + { + authority: authority.publicKey, + recipient: recipient.publicKey, + rewardFunction: createCliffLinearReward(), + minUnlockTimestamp: new BN(0), + }, + ); + + // Create first proposal with nonce 1 + const pdaNonce1 = 1; + const [changeRequest1] = ppClient.getChangeRequestAddr( + performancePackage, + authority.publicKey, + pdaNonce1, + ); + + await ppClient + .proposeChangeIx({ + performancePackage, + proposer: authority.publicKey, + payer: this.payer.publicKey, + pdaNonce: pdaNonce1, + newRecipient: newRecipient1.publicKey, + }) + .signers([authority]) + .rpc(); + + // Create second proposal with nonce 2 + const pdaNonce2 = 2; + const [changeRequest2] = ppClient.getChangeRequestAddr( + performancePackage, + authority.publicKey, + pdaNonce2, + ); + + await ppClient + .proposeChangeIx({ + performancePackage, + proposer: authority.publicKey, + payer: this.payer.publicKey, + pdaNonce: pdaNonce2, + newRecipient: newRecipient2.publicKey, + }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), + ]) + .signers([authority]) + .rpc(); + + // Verify both change requests exist independently + const changeRequestAccount1 = + await ppClient.fetchChangeRequest(changeRequest1); + const changeRequestAccount2 = + await ppClient.fetchChangeRequest(changeRequest2); + + assert.isNotNull(changeRequestAccount1); + assert.isNotNull(changeRequestAccount2); + assert.equal(changeRequestAccount1.pdaNonce, pdaNonce1); + assert.equal(changeRequestAccount2.pdaNonce, pdaNonce2); + assert.equal( + changeRequestAccount1.newRecipient.toBase58(), + newRecipient1.publicKey.toBase58(), + ); + assert.equal( + changeRequestAccount2.newRecipient.toBase58(), + newRecipient2.publicKey.toBase58(), + ); + }); + + it("fails when all optional fields are None", async function () { + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + + const { performancePackage } = await setupPerformancePackageV2( + this.banksClient, + mintGovernorClient, + ppClient, + this.payer, + { + authority: authority.publicKey, + recipient: recipient.publicKey, + rewardFunction: createCliffLinearReward(), + minUnlockTimestamp: new BN(0), + }, + ); + + const pdaNonce = 1; + + const callbacks = expectError( + "NoChangesProposed", + "Should have failed because no changes were proposed", + ); + + await ppClient + .proposeChangeIx({ + performancePackage, + proposer: authority.publicKey, + payer: this.payer.publicKey, + pdaNonce, + // All optional fields are null (default) + }) + .signers([authority]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("fails when signer is neither authority nor recipient", async function () { + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + const unauthorized = Keypair.generate(); + const newRecipient = Keypair.generate(); + + const { performancePackage } = await setupPerformancePackageV2( + this.banksClient, + mintGovernorClient, + ppClient, + this.payer, + { + authority: authority.publicKey, + recipient: recipient.publicKey, + rewardFunction: createCliffLinearReward(), + minUnlockTimestamp: new BN(0), + }, + ); + + const pdaNonce = 1; + + const callbacks = expectError( + "Unauthorized", + "Should have failed because signer is neither authority nor recipient", + ); + + await ppClient + .proposeChangeIx({ + performancePackage, + proposer: unauthorized.publicKey, + payer: this.payer.publicKey, + pdaNonce, + newRecipient: newRecipient.publicKey, + }) + .signers([unauthorized]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); +} diff --git a/vibes/tasks.md b/vibes/tasks.md index 30c826919..3a585664d 100644 --- a/vibes/tasks.md +++ b/vibes/tasks.md @@ -29,32 +29,11 @@ ## Tasks -### Phase 6: propose_change - -> Reference: `001-performance-package-v2.md` → Instruction 5 - -- [NEXT] 6.2 Add SDK method for propose_change - - Add `proposeChangeIx()` method to `PerformancePackageV2Client` - - Add `fetchChangeRequest()` method - - Run `./rebuild.sh` - -- [ ] 6.3 Write unit tests for propose_change - - Create `tests/performancePackageV2/unit/proposeChange.test.ts` - - Test: successfully proposes change when called by authority - - Test: successfully proposes change when called by recipient - - Test: successfully proposes recipient change - - Test: successfully proposes oracle change - - Test: successfully proposes reward function change - - Test: successfully proposes multiple changes at once - - Test: allows multiple concurrent proposals with different nonces - - Test: fails when all optional fields are None - - Test: fails when signer is neither authority nor recipient - ### Phase 7: execute_change > Reference: `001-performance-package-v2.md` → Instruction 6 -- [ ] 7.1 Write execute_change instruction +- [NEXT] 7.1 Write execute_change instruction - Create `instructions/execute_change.rs` - Implement accounts struct with ChangeRequest validation - Implement `validate()`: opposite party check, Locked status for config changes From 55fabb0f044fbb41f55d61566a3beebf13868a90 Mon Sep 17 00:00:00 2001 From: Pileks Date: Thu, 29 Jan 2026 22:30:40 -0800 Subject: [PATCH 19/27] ppv2 - execute change ix --- CLAUDE.md | 21 ++++ .../src/instructions/execute_change.rs | 112 ++++++++++++++++++ .../src/instructions/mod.rs | 2 + programs/performance_package_v2/src/lib.rs | 5 + sdk/src/v0.7/types/performance_package_v2.ts | 52 ++++++++ vibes/tasks.md | 10 +- 6 files changed, 193 insertions(+), 9 deletions(-) create mode 100644 programs/performance_package_v2/src/instructions/execute_change.rs diff --git a/CLAUDE.md b/CLAUDE.md index 54e85eb91..1c4af27b0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -148,6 +148,27 @@ pub recipient_ata: Account<'info, TokenAccount>, pub funder_token_account: Account<'info, TokenAccount>, ``` +### Require Macros +When writing validation checks, prefer specific require macros over generic `require!`: +1. `require_keys_eq!` - when comparing two `Pubkey` values +2. `require_eq!` - when comparing two values of the same type (requires `Display` trait) +3. `require_neq!` - when asserting two values are not equal (requires `Display` trait) +4. `require_gt!` / `require_gte!` - for greater than / greater than or equal comparisons +5. `require!` - for boolean conditions, including enum comparisons where the type doesn't implement `Display` + +```rust +// Good - specific macros provide better error messages +require_keys_eq!(signer.key(), account.authority, MyError::Unauthorized); +require_eq!(account.count, 0, MyError::InvalidCount); // integers implement Display +require_gte!(args.amount, 1, MyError::InvalidAmount); + +// OK - enums typically don't implement Display, so use require! +require!(account.status == Status::Active, MyError::InvalidStatus); + +// Avoid - generic require when a specific macro exists +require!(signer.key() == account.authority, MyError::Unauthorized); +``` + ### Adding New Instructions 1. Add instruction to Rust program in `programs/[program]/src/instructions/` 2. Update client methods in SDK (`sdk/src/v0.7/`) diff --git a/programs/performance_package_v2/src/instructions/execute_change.rs b/programs/performance_package_v2/src/instructions/execute_change.rs new file mode 100644 index 000000000..44b5b5944 --- /dev/null +++ b/programs/performance_package_v2/src/instructions/execute_change.rs @@ -0,0 +1,112 @@ +use anchor_lang::prelude::*; + +use crate::{ + ChangeExecutedEvent, ChangeRequest, CommonFields, PackageStatus, PerformancePackage, + PerformancePackageError, ProposerType, +}; + +#[derive(Accounts)] +pub struct ExecuteChange<'info> { + #[account(mut)] + pub performance_package: Account<'info, PerformancePackage>, + + #[account( + mut, + has_one = performance_package @ PerformancePackageError::ChangeRequestNotFound, + close = rent_destination + )] + pub change_request: Account<'info, ChangeRequest>, + + pub executor: Signer<'info>, + + /// CHECK: Receives closed account rent + #[account(mut)] + pub rent_destination: UncheckedAccount<'info>, +} + +impl ExecuteChange<'_> { + pub fn validate(&self) -> Result<()> { + let pp = &self.performance_package; + let cr = &self.change_request; + let executor = self.executor.key(); + + // Executor must be the opposite party from the proposer + match cr.proposer_type { + ProposerType::Authority => { + require_keys_eq!( + executor, + pp.recipient, + PerformancePackageError::InvalidExecutor + ); + } + ProposerType::Recipient => { + require_keys_eq!( + executor, + pp.authority, + PerformancePackageError::InvalidExecutor + ); + } + } + + // Config changes (oracle/reward function) can only happen when Locked + if cr.new_oracle_reader.is_some() || cr.new_reward_function.is_some() { + require!( + pp.status == PackageStatus::Locked, + PerformancePackageError::NotLocked + ); + } + + // Validate oracle reader if provided + if let Some(ref oracle_reader) = cr.new_oracle_reader { + oracle_reader.validate()?; + } + + // Validate reward function if provided + if let Some(ref reward_function) = cr.new_reward_function { + reward_function.validate()?; + } + + Ok(()) + } + + pub fn handle(ctx: Context) -> Result<()> { + let pp = &mut ctx.accounts.performance_package; + let cr = &ctx.accounts.change_request; + let executor = ctx.accounts.executor.key(); + + // Apply all Some fields from change_request + if let Some(new_recipient) = cr.new_recipient { + pp.recipient = new_recipient; + } + + if let Some(ref new_oracle_reader) = cr.new_oracle_reader { + pp.oracle_reader = new_oracle_reader.clone(); + } + + if let Some(ref new_reward_function) = cr.new_reward_function { + pp.reward_function = new_reward_function.clone(); + } + + // Increment seq_num for event tracking + pp.seq_num += 1; + + let clock = Clock::get()?; + + emit!(ChangeExecutedEvent { + common: CommonFields { + slot: clock.slot, + unix_timestamp: clock.unix_timestamp, + performance_package_seq_num: pp.seq_num, + }, + performance_package: pp.key(), + executed_by: executor, + new_recipient: cr.new_recipient, + new_oracle_reader: cr.new_oracle_reader.clone(), + new_reward_function: cr.new_reward_function.clone(), + }); + + // The change_request account is closed automatically via the `close = rent_destination` constraint + + Ok(()) + } +} diff --git a/programs/performance_package_v2/src/instructions/mod.rs b/programs/performance_package_v2/src/instructions/mod.rs index c660a34c6..9f9e03511 100644 --- a/programs/performance_package_v2/src/instructions/mod.rs +++ b/programs/performance_package_v2/src/instructions/mod.rs @@ -1,11 +1,13 @@ pub mod change_authority; pub mod complete_unlock; +pub mod execute_change; pub mod initialize_performance_package; pub mod propose_change; pub mod start_unlock; pub use change_authority::*; pub use complete_unlock::*; +pub use execute_change::*; pub use initialize_performance_package::*; pub use propose_change::*; pub use start_unlock::*; diff --git a/programs/performance_package_v2/src/lib.rs b/programs/performance_package_v2/src/lib.rs index b0d9e2c2f..828f483dc 100644 --- a/programs/performance_package_v2/src/lib.rs +++ b/programs/performance_package_v2/src/lib.rs @@ -63,4 +63,9 @@ pub mod performance_package_v2 { pub fn propose_change(ctx: Context, args: ProposeChangeArgs) -> Result<()> { ProposeChange::handle(ctx, args) } + + #[access_control(ctx.accounts.validate())] + pub fn execute_change(ctx: Context) -> Result<()> { + ExecuteChange::handle(ctx) + } } diff --git a/sdk/src/v0.7/types/performance_package_v2.ts b/sdk/src/v0.7/types/performance_package_v2.ts index 2008b0830..3737c48c4 100644 --- a/sdk/src/v0.7/types/performance_package_v2.ts +++ b/sdk/src/v0.7/types/performance_package_v2.ts @@ -197,6 +197,32 @@ export type PerformancePackageV2 = { }, ]; }, + { + name: "executeChange"; + accounts: [ + { + name: "performancePackage"; + isMut: true; + isSigner: false; + }, + { + name: "changeRequest"; + isMut: true; + isSigner: false; + }, + { + name: "executor"; + isMut: false; + isSigner: true; + }, + { + name: "rentDestination"; + isMut: true; + isSigner: false; + }, + ]; + args: []; + }, ]; accounts: [ { @@ -1101,6 +1127,32 @@ export const IDL: PerformancePackageV2 = { }, ], }, + { + name: "executeChange", + accounts: [ + { + name: "performancePackage", + isMut: true, + isSigner: false, + }, + { + name: "changeRequest", + isMut: true, + isSigner: false, + }, + { + name: "executor", + isMut: false, + isSigner: true, + }, + { + name: "rentDestination", + isMut: true, + isSigner: false, + }, + ], + args: [], + }, ], accounts: [ { diff --git a/vibes/tasks.md b/vibes/tasks.md index 3a585664d..9acd6e741 100644 --- a/vibes/tasks.md +++ b/vibes/tasks.md @@ -33,15 +33,7 @@ > Reference: `001-performance-package-v2.md` → Instruction 6 -- [NEXT] 7.1 Write execute_change instruction - - Create `instructions/execute_change.rs` - - Implement accounts struct with ChangeRequest validation - - Implement `validate()`: opposite party check, Locked status for config changes - - Implement `handle()`: apply changes, close ChangeRequest - - Emit `ChangeExecutedEvent` - - Wire up in `lib.rs` - -- [ ] 7.2 Add SDK method for execute_change +- [NEXT] 7.2 Add SDK method for execute_change - Add `executeChangeIx()` method to `PerformancePackageV2Client` - Run `./rebuild.sh` From 2b402f5e2d64aedca9e774fbcdb5d7005d853c03 Mon Sep 17 00:00:00 2001 From: Pileks Date: Thu, 29 Jan 2026 22:34:23 -0800 Subject: [PATCH 20/27] minor refactor --- .../performance_package_v2/src/instructions/start_unlock.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/programs/performance_package_v2/src/instructions/start_unlock.rs b/programs/performance_package_v2/src/instructions/start_unlock.rs index de25280ac..9858a1700 100644 --- a/programs/performance_package_v2/src/instructions/start_unlock.rs +++ b/programs/performance_package_v2/src/instructions/start_unlock.rs @@ -35,8 +35,9 @@ impl StartUnlock<'_> { // min_unlock_timestamp must have been reached let clock = Clock::get()?; - require!( - clock.unix_timestamp >= pp.min_unlock_timestamp, + require_gte!( + clock.unix_timestamp, + pp.min_unlock_timestamp, PerformancePackageError::UnlockTimestampNotReached ); From 58e1fd546296bb2a12e3d411f76eebc549333afa Mon Sep 17 00:00:00 2001 From: Pileks Date: Thu, 29 Jan 2026 22:51:54 -0800 Subject: [PATCH 21/27] ppv2 - execute change SDK & tests --- sdk/src/v0.7/PerformancePackageV2Client.ts | 19 + tests/performancePackageV2/main.test.ts | 2 + .../unit/executeChange.test.ts | 665 ++++++++++++++++++ vibes/tasks.md | 23 +- 4 files changed, 687 insertions(+), 22 deletions(-) create mode 100644 tests/performancePackageV2/unit/executeChange.test.ts diff --git a/sdk/src/v0.7/PerformancePackageV2Client.ts b/sdk/src/v0.7/PerformancePackageV2Client.ts index 74657ed2a..55dc2fac0 100644 --- a/sdk/src/v0.7/PerformancePackageV2Client.ts +++ b/sdk/src/v0.7/PerformancePackageV2Client.ts @@ -248,4 +248,23 @@ export class PerformancePackageV2Client { systemProgram: SystemProgram.programId, }); } + + executeChangeIx({ + performancePackage, + changeRequest, + executor = this.provider.publicKey, + rentDestination = this.provider.publicKey, + }: { + performancePackage: PublicKey; + changeRequest: PublicKey; + executor?: PublicKey; + rentDestination?: PublicKey; + }) { + return this.program.methods.executeChange().accounts({ + performancePackage, + changeRequest, + executor, + rentDestination, + }); + } } diff --git a/tests/performancePackageV2/main.test.ts b/tests/performancePackageV2/main.test.ts index 412d18201..012e053c6 100644 --- a/tests/performancePackageV2/main.test.ts +++ b/tests/performancePackageV2/main.test.ts @@ -3,6 +3,7 @@ import startUnlock from "./unit/startUnlock.test.js"; import completeUnlock from "./unit/completeUnlock.test.js"; import changeAuthority from "./unit/changeAuthority.test.js"; import proposeChange from "./unit/proposeChange.test.js"; +import executeChange from "./unit/executeChange.test.js"; import { MintGovernorClient, PerformancePackageV2Client, @@ -25,4 +26,5 @@ export default function suite() { describe("#complete_unlock", completeUnlock); describe("#change_authority", changeAuthority); describe("#propose_change", proposeChange); + describe("#execute_change", executeChange); } diff --git a/tests/performancePackageV2/unit/executeChange.test.ts b/tests/performancePackageV2/unit/executeChange.test.ts new file mode 100644 index 000000000..cb22d85d9 --- /dev/null +++ b/tests/performancePackageV2/unit/executeChange.test.ts @@ -0,0 +1,665 @@ +import { ComputeBudgetProgram, Keypair } from "@solana/web3.js"; +import BN from "bn.js"; +import { assert } from "chai"; +import { + MintGovernorClient, + PerformancePackageV2Client, +} from "@metadaoproject/futarchy/v0.7"; +import { + setupPerformancePackageV2, + createCliffLinearReward, + createThresholdReward, +} from "../utils.js"; +import { expectError } from "../../utils.js"; + +export default function suite() { + let mintGovernorClient: MintGovernorClient; + let ppClient: PerformancePackageV2Client; + + before(async function () { + mintGovernorClient = this.mintGovernor; + ppClient = this.performancePackageV2; + }); + + it("successfully executes (authority proposed, recipient signs)", async function () { + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + const newRecipient = Keypair.generate(); + + const { performancePackage } = await setupPerformancePackageV2( + this.banksClient, + mintGovernorClient, + ppClient, + this.payer, + { + authority: authority.publicKey, + recipient: recipient.publicKey, + rewardFunction: createCliffLinearReward(), + minUnlockTimestamp: new BN(0), + }, + ); + + // Authority proposes a change + const pdaNonce = 1; + const [changeRequest] = ppClient.getChangeRequestAddr( + performancePackage, + authority.publicKey, + pdaNonce, + ); + + await ppClient + .proposeChangeIx({ + performancePackage, + proposer: authority.publicKey, + payer: this.payer.publicKey, + pdaNonce, + newRecipient: newRecipient.publicKey, + }) + .signers([authority]) + .rpc(); + + // Recipient executes the change + await ppClient + .executeChangeIx({ + performancePackage, + changeRequest, + executor: recipient.publicKey, + rentDestination: this.payer.publicKey, + }) + .signers([recipient]) + .rpc(); + + // Verify change was applied + const ppAccount = + await ppClient.fetchPerformancePackage(performancePackage); + assert.equal( + ppAccount.recipient.toBase58(), + newRecipient.publicKey.toBase58(), + ); + }); + + it("successfully executes (recipient proposed, authority signs)", async function () { + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + const newRecipient = Keypair.generate(); + + const { performancePackage } = await setupPerformancePackageV2( + this.banksClient, + mintGovernorClient, + ppClient, + this.payer, + { + authority: authority.publicKey, + recipient: recipient.publicKey, + rewardFunction: createCliffLinearReward(), + minUnlockTimestamp: new BN(0), + }, + ); + + // Recipient proposes a change + const pdaNonce = 1; + const [changeRequest] = ppClient.getChangeRequestAddr( + performancePackage, + recipient.publicKey, + pdaNonce, + ); + + await ppClient + .proposeChangeIx({ + performancePackage, + proposer: recipient.publicKey, + payer: this.payer.publicKey, + pdaNonce, + newRecipient: newRecipient.publicKey, + }) + .signers([recipient]) + .rpc(); + + // Authority executes the change + await ppClient + .executeChangeIx({ + performancePackage, + changeRequest, + executor: authority.publicKey, + rentDestination: this.payer.publicKey, + }) + .signers([authority]) + .rpc(); + + // Verify change was applied + const ppAccount = + await ppClient.fetchPerformancePackage(performancePackage); + assert.equal( + ppAccount.recipient.toBase58(), + newRecipient.publicKey.toBase58(), + ); + }); + + it("successfully executes recipient change", async function () { + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + const newRecipient = Keypair.generate(); + + const { performancePackage } = await setupPerformancePackageV2( + this.banksClient, + mintGovernorClient, + ppClient, + this.payer, + { + authority: authority.publicKey, + recipient: recipient.publicKey, + rewardFunction: createCliffLinearReward(), + minUnlockTimestamp: new BN(0), + }, + ); + + // Verify initial recipient + let ppAccount = await ppClient.fetchPerformancePackage(performancePackage); + assert.equal( + ppAccount.recipient.toBase58(), + recipient.publicKey.toBase58(), + ); + + // Authority proposes recipient change + const pdaNonce = 1; + const [changeRequest] = ppClient.getChangeRequestAddr( + performancePackage, + authority.publicKey, + pdaNonce, + ); + + await ppClient + .proposeChangeIx({ + performancePackage, + proposer: authority.publicKey, + payer: this.payer.publicKey, + pdaNonce, + newRecipient: newRecipient.publicKey, + }) + .signers([authority]) + .rpc(); + + // Recipient executes the change + await ppClient + .executeChangeIx({ + performancePackage, + changeRequest, + executor: recipient.publicKey, + rentDestination: this.payer.publicKey, + }) + .signers([recipient]) + .rpc(); + + // Verify recipient was changed + ppAccount = await ppClient.fetchPerformancePackage(performancePackage); + assert.equal( + ppAccount.recipient.toBase58(), + newRecipient.publicKey.toBase58(), + ); + }); + + it("successfully executes oracle change", async function () { + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + + const { performancePackage } = await setupPerformancePackageV2( + this.banksClient, + mintGovernorClient, + ppClient, + this.payer, + { + authority: authority.publicKey, + recipient: recipient.publicKey, + rewardFunction: createCliffLinearReward(), + minUnlockTimestamp: new BN(0), + }, + ); + + // Verify initial oracle is Time + let ppAccount = await ppClient.fetchPerformancePackage(performancePackage); + assert.isDefined(ppAccount.oracleReader.time); + + // Authority proposes oracle change (time -> time, but verifies the mechanism works) + const pdaNonce = 1; + const [changeRequest] = ppClient.getChangeRequestAddr( + performancePackage, + authority.publicKey, + pdaNonce, + ); + + const newOracleReader = { time: {} }; + + await ppClient + .proposeChangeIx({ + performancePackage, + proposer: authority.publicKey, + payer: this.payer.publicKey, + pdaNonce, + newOracleReader, + }) + .signers([authority]) + .rpc(); + + // Recipient executes the change + await ppClient + .executeChangeIx({ + performancePackage, + changeRequest, + executor: recipient.publicKey, + rentDestination: this.payer.publicKey, + }) + .signers([recipient]) + .rpc(); + + // Verify oracle is still Time (the change was executed successfully) + ppAccount = await ppClient.fetchPerformancePackage(performancePackage); + assert.isDefined(ppAccount.oracleReader.time); + }); + + it("successfully executes reward function change", async function () { + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + + const { performancePackage } = await setupPerformancePackageV2( + this.banksClient, + mintGovernorClient, + ppClient, + this.payer, + { + authority: authority.publicKey, + recipient: recipient.publicKey, + rewardFunction: createCliffLinearReward(), + minUnlockTimestamp: new BN(0), + }, + ); + + // Verify initial reward function is CliffLinear + let ppAccount = await ppClient.fetchPerformancePackage(performancePackage); + assert.isDefined(ppAccount.rewardFunction.cliffLinear); + + // Authority proposes reward function change to Threshold + const pdaNonce = 1; + const [changeRequest] = ppClient.getChangeRequestAddr( + performancePackage, + authority.publicKey, + pdaNonce, + ); + + const newRewardFunction = createThresholdReward([ + { threshold: new BN(100), cumulativeAmount: new BN(100_000_000) }, + { threshold: new BN(200), cumulativeAmount: new BN(200_000_000) }, + ]); + + await ppClient + .proposeChangeIx({ + performancePackage, + proposer: authority.publicKey, + payer: this.payer.publicKey, + pdaNonce, + newRewardFunction, + }) + .signers([authority]) + .rpc(); + + // Recipient executes the change + await ppClient + .executeChangeIx({ + performancePackage, + changeRequest, + executor: recipient.publicKey, + rentDestination: this.payer.publicKey, + }) + .signers([recipient]) + .rpc(); + + // Verify reward function was changed to Threshold + ppAccount = await ppClient.fetchPerformancePackage(performancePackage); + assert.isDefined(ppAccount.rewardFunction.threshold); + }); + + it("successfully executes multiple changes at once", async function () { + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + const newRecipient = Keypair.generate(); + + const { performancePackage } = await setupPerformancePackageV2( + this.banksClient, + mintGovernorClient, + ppClient, + this.payer, + { + authority: authority.publicKey, + recipient: recipient.publicKey, + rewardFunction: createCliffLinearReward(), + minUnlockTimestamp: new BN(0), + }, + ); + + // Verify initial state + let ppAccount = await ppClient.fetchPerformancePackage(performancePackage); + assert.equal( + ppAccount.recipient.toBase58(), + recipient.publicKey.toBase58(), + ); + assert.isDefined(ppAccount.oracleReader.time); + assert.isDefined(ppAccount.rewardFunction.cliffLinear); + + // Authority proposes multiple changes at once + const pdaNonce = 1; + const [changeRequest] = ppClient.getChangeRequestAddr( + performancePackage, + authority.publicKey, + pdaNonce, + ); + + const newOracleReader = { time: {} }; + const newRewardFunction = createThresholdReward([ + { threshold: new BN(100), cumulativeAmount: new BN(100_000_000) }, + ]); + + await ppClient + .proposeChangeIx({ + performancePackage, + proposer: authority.publicKey, + payer: this.payer.publicKey, + pdaNonce, + newRecipient: newRecipient.publicKey, + newOracleReader, + newRewardFunction, + }) + .signers([authority]) + .rpc(); + + // Recipient executes the change + await ppClient + .executeChangeIx({ + performancePackage, + changeRequest, + executor: recipient.publicKey, + rentDestination: this.payer.publicKey, + }) + .signers([recipient]) + .rpc(); + + // Verify all changes were applied + ppAccount = await ppClient.fetchPerformancePackage(performancePackage); + assert.equal( + ppAccount.recipient.toBase58(), + newRecipient.publicKey.toBase58(), + ); + assert.isDefined(ppAccount.oracleReader.time); + assert.isDefined(ppAccount.rewardFunction.threshold); + }); + + it("closes change_request account and returns rent", async function () { + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + const newRecipient = Keypair.generate(); + const rentDestination = Keypair.generate(); + + const { performancePackage } = await setupPerformancePackageV2( + this.banksClient, + mintGovernorClient, + ppClient, + this.payer, + { + authority: authority.publicKey, + recipient: recipient.publicKey, + rewardFunction: createCliffLinearReward(), + minUnlockTimestamp: new BN(0), + }, + ); + + // Authority proposes a change + const pdaNonce = 1; + const [changeRequest] = ppClient.getChangeRequestAddr( + performancePackage, + authority.publicKey, + pdaNonce, + ); + + await ppClient + .proposeChangeIx({ + performancePackage, + proposer: authority.publicKey, + payer: this.payer.publicKey, + pdaNonce, + newRecipient: newRecipient.publicKey, + }) + .signers([authority]) + .rpc(); + + // Verify change request exists + let changeRequestAccount = await ppClient.fetchChangeRequest(changeRequest); + assert.isNotNull(changeRequestAccount); + + // Get rent destination balance before + const rentDestBalanceBefore = await this.banksClient.getBalance( + rentDestination.publicKey, + ); + + // Recipient executes the change + await ppClient + .executeChangeIx({ + performancePackage, + changeRequest, + executor: recipient.publicKey, + rentDestination: rentDestination.publicKey, + }) + .signers([recipient]) + .rpc(); + + // Verify change request account was closed + const closedAccount = await this.banksClient.getAccount(changeRequest); + assert.isNull(closedAccount); + + // Verify rent was returned + const rentDestBalanceAfter = await this.banksClient.getBalance( + rentDestination.publicKey, + ); + assert.isTrue(rentDestBalanceAfter > rentDestBalanceBefore); + }); + + it("fails when same party tries to propose and execute", async function () { + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + const newRecipient = Keypair.generate(); + + const { performancePackage } = await setupPerformancePackageV2( + this.banksClient, + mintGovernorClient, + ppClient, + this.payer, + { + authority: authority.publicKey, + recipient: recipient.publicKey, + rewardFunction: createCliffLinearReward(), + minUnlockTimestamp: new BN(0), + }, + ); + + // Authority proposes a change + const pdaNonce = 1; + const [changeRequest] = ppClient.getChangeRequestAddr( + performancePackage, + authority.publicKey, + pdaNonce, + ); + + await ppClient + .proposeChangeIx({ + performancePackage, + proposer: authority.publicKey, + payer: this.payer.publicKey, + pdaNonce, + newRecipient: newRecipient.publicKey, + }) + .signers([authority]) + .rpc(); + + // Authority tries to execute their own proposal - should fail + const callbacks = expectError( + "InvalidExecutor", + "Should have failed because same party cannot propose and execute", + ); + + await ppClient + .executeChangeIx({ + performancePackage, + changeRequest, + executor: authority.publicKey, + rentDestination: this.payer.publicKey, + }) + .signers([authority]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("fails when oracle change attempted while Unlocking", async function () { + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + + const { performancePackage } = await setupPerformancePackageV2( + this.banksClient, + mintGovernorClient, + ppClient, + this.payer, + { + authority: authority.publicKey, + recipient: recipient.publicKey, + rewardFunction: createCliffLinearReward(), + minUnlockTimestamp: new BN(0), + }, + ); + + // Authority proposes an oracle change + const pdaNonce = 1; + const [changeRequest] = ppClient.getChangeRequestAddr( + performancePackage, + authority.publicKey, + pdaNonce, + ); + + const newOracleReader = { time: {} }; + + await ppClient + .proposeChangeIx({ + performancePackage, + proposer: authority.publicKey, + payer: this.payer.publicKey, + pdaNonce, + newOracleReader, + }) + .signers([authority]) + .rpc(); + + // Start unlock to transition to Unlocking status + await ppClient + .startUnlockIx({ + performancePackage, + signer: authority.publicKey, + }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), + ]) + .signers([authority]) + .rpc(); + + // Verify status is Unlocking + const ppAccount = + await ppClient.fetchPerformancePackage(performancePackage); + assert.isDefined(ppAccount.status.unlocking); + + // Try to execute oracle change while Unlocking - should fail + const callbacks = expectError( + "NotLocked", + "Should have failed because oracle change cannot be executed while Unlocking", + ); + + await ppClient + .executeChangeIx({ + performancePackage, + changeRequest, + executor: recipient.publicKey, + rentDestination: this.payer.publicKey, + }) + .signers([recipient]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("fails when reward function change attempted while Unlocking", async function () { + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + + const { performancePackage } = await setupPerformancePackageV2( + this.banksClient, + mintGovernorClient, + ppClient, + this.payer, + { + authority: authority.publicKey, + recipient: recipient.publicKey, + rewardFunction: createCliffLinearReward(), + minUnlockTimestamp: new BN(0), + }, + ); + + // Authority proposes a reward function change + const pdaNonce = 1; + const [changeRequest] = ppClient.getChangeRequestAddr( + performancePackage, + authority.publicKey, + pdaNonce, + ); + + const newRewardFunction = createThresholdReward([ + { threshold: new BN(100), cumulativeAmount: new BN(100_000_000) }, + ]); + + await ppClient + .proposeChangeIx({ + performancePackage, + proposer: authority.publicKey, + payer: this.payer.publicKey, + pdaNonce, + newRewardFunction, + }) + .signers([authority]) + .rpc(); + + // Start unlock to transition to Unlocking status + await ppClient + .startUnlockIx({ + performancePackage, + signer: authority.publicKey, + }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), + ]) + .signers([authority]) + .rpc(); + + // Verify status is Unlocking + const ppAccount = + await ppClient.fetchPerformancePackage(performancePackage); + assert.isDefined(ppAccount.status.unlocking); + + // Try to execute reward function change while Unlocking - should fail + const callbacks = expectError( + "NotLocked", + "Should have failed because reward function change cannot be executed while Unlocking", + ); + + await ppClient + .executeChangeIx({ + performancePackage, + changeRequest, + executor: recipient.publicKey, + rentDestination: this.payer.publicKey, + }) + .signers([recipient]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); +} diff --git a/vibes/tasks.md b/vibes/tasks.md index 9acd6e741..203a4431f 100644 --- a/vibes/tasks.md +++ b/vibes/tasks.md @@ -29,32 +29,11 @@ ## Tasks -### Phase 7: execute_change - -> Reference: `001-performance-package-v2.md` → Instruction 6 - -- [NEXT] 7.2 Add SDK method for execute_change - - Add `executeChangeIx()` method to `PerformancePackageV2Client` - - Run `./rebuild.sh` - -- [ ] 7.3 Write unit tests for execute_change - - Create `tests/performancePackageV2/unit/executeChange.test.ts` - - Test: successfully executes (authority proposed, recipient signs) - - Test: successfully executes (recipient proposed, authority signs) - - Test: successfully executes recipient change - - Test: successfully executes oracle change - - Test: successfully executes reward function change - - Test: successfully executes multiple changes at once - - Test: closes change_request account and returns rent - - Test: fails when same party tries to propose and execute - - Test: fails when oracle change attempted while Unlocking - - Test: fails when reward function change attempted while Unlocking - ### Phase 8: close_performance_package > Reference: `001-performance-package-v2.md` → Instruction 7 -- [ ] 8.1 Write close_performance_package instruction +- [NEXT] 8.1 Write close_performance_package instruction - Create `instructions/close_performance_package.rs` - Implement accounts struct with admin check - Implement `validate()`: admin == METADAO_ADMIN, status == Locked From 6cb58e7aeb406aa1f2225df717c2fdbf25805e58 Mon Sep 17 00:00:00 2001 From: Pileks Date: Fri, 30 Jan 2026 11:02:55 -0800 Subject: [PATCH 22/27] ppv2 - close performance package ix, sdk, tests --- .../performance_package_v2/src/constants.rs | 7 -- programs/performance_package_v2/src/error.rs | 2 + .../instructions/close_performance_package.rs | 68 +++++++++++ .../src/instructions/mod.rs | 2 + programs/performance_package_v2/src/lib.rs | 5 + sdk/src/v0.7/PerformancePackageV2Client.ts | 16 +++ sdk/src/v0.7/types/performance_package_v2.ts | 108 +++++++++++++----- tests/performancePackageV2/main.test.ts | 2 + .../unit/closePerformancePackage.test.ts | 60 ++++++++++ vibes/tasks.md | 24 +--- 10 files changed, 236 insertions(+), 58 deletions(-) create mode 100644 programs/performance_package_v2/src/instructions/close_performance_package.rs create mode 100644 tests/performancePackageV2/unit/closePerformancePackage.test.ts diff --git a/programs/performance_package_v2/src/constants.rs b/programs/performance_package_v2/src/constants.rs index 730d0ccef..1146d282e 100644 --- a/programs/performance_package_v2/src/constants.rs +++ b/programs/performance_package_v2/src/constants.rs @@ -5,10 +5,3 @@ pub const CHANGE_REQUEST_SEED: &[u8] = b"change_request"; #[constant] pub const MAX_TRANCHES: usize = 10; - -pub mod admin { - use anchor_lang::prelude::declare_id; - - // MetaDAO operational multisig - declare_id!("6awyHMshBGVjJ3ozdSJdyyDE1CTAXUwrpNMaRGMsb4sf"); -} diff --git a/programs/performance_package_v2/src/error.rs b/programs/performance_package_v2/src/error.rs index f7cc88cb8..f74454347 100644 --- a/programs/performance_package_v2/src/error.rs +++ b/programs/performance_package_v2/src/error.rs @@ -9,6 +9,8 @@ pub enum PerformancePackageError { InvalidExecutor, #[msg("Signer is not the current authority")] InvalidAuthority, + #[msg("Signer is not the admin")] + InvalidAdmin, // Account validation #[msg("Mint governor does not match the provided mint")] diff --git a/programs/performance_package_v2/src/instructions/close_performance_package.rs b/programs/performance_package_v2/src/instructions/close_performance_package.rs new file mode 100644 index 000000000..98e645f05 --- /dev/null +++ b/programs/performance_package_v2/src/instructions/close_performance_package.rs @@ -0,0 +1,68 @@ +use anchor_lang::prelude::*; + +use crate::{ + CommonFields, PackageStatus, PerformancePackage, PerformancePackageClosedEvent, + PerformancePackageError, PERFORMANCE_PACKAGE_SEED, +}; + +pub mod admin { + use anchor_lang::prelude::declare_id; + + // MetaDAO operational multisig + declare_id!("6awyHMshBGVjJ3ozdSJdyyDE1CTAXUwrpNMaRGMsb4sf"); +} + +#[derive(Accounts)] +pub struct ClosePerformancePackage<'info> { + #[account( + mut, + seeds = [PERFORMANCE_PACKAGE_SEED, performance_package.create_key.as_ref()], + bump = performance_package.bump, + close = rent_destination + )] + pub performance_package: Account<'info, PerformancePackage>, + + pub admin: Signer<'info>, + + /// CHECK: Receives closed account rent + #[account(mut)] + pub rent_destination: UncheckedAccount<'info>, +} + +impl ClosePerformancePackage<'_> { + pub fn validate(&self) -> Result<()> { + #[cfg(feature = "production")] + require_keys_eq!( + self.admin.key(), + admin::ID, + PerformancePackageError::InvalidAdmin + ); + + // Status must be Locked (cannot close while unlocking) + require!( + self.performance_package.status == PackageStatus::Locked, + PerformancePackageError::NotLocked + ); + + Ok(()) + } + + pub fn handle(ctx: Context) -> Result<()> { + let pp = &ctx.accounts.performance_package; + let clock = Clock::get()?; + + emit!(PerformancePackageClosedEvent { + common: CommonFields { + slot: clock.slot, + unix_timestamp: clock.unix_timestamp, + performance_package_seq_num: pp.seq_num, + }, + performance_package: pp.key(), + total_rewards_paid_out: pp.total_rewards_paid_out, + }); + + // The performance_package account is closed automatically via the `close = rent_destination` constraint + + Ok(()) + } +} diff --git a/programs/performance_package_v2/src/instructions/mod.rs b/programs/performance_package_v2/src/instructions/mod.rs index 9f9e03511..29b6752da 100644 --- a/programs/performance_package_v2/src/instructions/mod.rs +++ b/programs/performance_package_v2/src/instructions/mod.rs @@ -1,4 +1,5 @@ pub mod change_authority; +pub mod close_performance_package; pub mod complete_unlock; pub mod execute_change; pub mod initialize_performance_package; @@ -6,6 +7,7 @@ pub mod propose_change; pub mod start_unlock; pub use change_authority::*; +pub use close_performance_package::*; pub use complete_unlock::*; pub use execute_change::*; pub use initialize_performance_package::*; diff --git a/programs/performance_package_v2/src/lib.rs b/programs/performance_package_v2/src/lib.rs index 828f483dc..9d12ee302 100644 --- a/programs/performance_package_v2/src/lib.rs +++ b/programs/performance_package_v2/src/lib.rs @@ -68,4 +68,9 @@ pub mod performance_package_v2 { pub fn execute_change(ctx: Context) -> Result<()> { ExecuteChange::handle(ctx) } + + #[access_control(ctx.accounts.validate())] + pub fn close_performance_package(ctx: Context) -> Result<()> { + ClosePerformancePackage::handle(ctx) + } } diff --git a/sdk/src/v0.7/PerformancePackageV2Client.ts b/sdk/src/v0.7/PerformancePackageV2Client.ts index 55dc2fac0..1b41e5dcc 100644 --- a/sdk/src/v0.7/PerformancePackageV2Client.ts +++ b/sdk/src/v0.7/PerformancePackageV2Client.ts @@ -267,4 +267,20 @@ export class PerformancePackageV2Client { rentDestination, }); } + + closePerformancePackageIx({ + performancePackage, + admin = this.provider.publicKey, + rentDestination = this.provider.publicKey, + }: { + performancePackage: PublicKey; + admin?: PublicKey; + rentDestination?: PublicKey; + }) { + return this.program.methods.closePerformancePackage().accounts({ + performancePackage, + admin, + rentDestination, + }); + } } diff --git a/sdk/src/v0.7/types/performance_package_v2.ts b/sdk/src/v0.7/types/performance_package_v2.ts index 3737c48c4..41f6912be 100644 --- a/sdk/src/v0.7/types/performance_package_v2.ts +++ b/sdk/src/v0.7/types/performance_package_v2.ts @@ -223,6 +223,27 @@ export type PerformancePackageV2 = { ]; args: []; }, + { + name: "closePerformancePackage"; + accounts: [ + { + name: "performancePackage"; + isMut: true; + isSigner: false; + }, + { + name: "admin"; + isMut: false; + isSigner: true; + }, + { + name: "rentDestination"; + isMut: true; + isSigner: false; + }, + ]; + args: []; + }, ]; accounts: [ { @@ -852,76 +873,81 @@ export type PerformancePackageV2 = { }, { code: 6003; + name: "InvalidAdmin"; + msg: "Signer is not the admin"; + }, + { + code: 6004; name: "InvalidMintGovernor"; msg: "Mint governor does not match the provided mint"; }, { - code: 6004; + code: 6005; name: "InvalidMintAuthority"; msg: "Mint authority does not match expected configuration"; }, { - code: 6005; + code: 6006; name: "NotLocked"; msg: "Expected Locked status"; }, { - code: 6006; + code: 6007; name: "NotUnlocking"; msg: "Expected Unlocking status"; }, { - code: 6007; + code: 6008; name: "OracleMissingAccount"; msg: "Expected remaining_accounts not provided"; }, { - code: 6008; + code: 6009; name: "OracleInvalidAccount"; msg: "Account pubkey doesn't match expected"; }, { - code: 6009; + code: 6010; name: "OracleParseError"; msg: "Failed to parse account data"; }, { - code: 6010; + code: 6011; name: "OracleInvalidState"; msg: "Oracle state invalid"; }, { - code: 6011; + code: 6012; name: "OracleMinDurationNotReached"; msg: "Minimum duration hasn't passed yet"; }, { - code: 6012; + code: 6013; name: "UnlockTimestampNotReached"; msg: "Minimum unlock timestamp not yet reached"; }, { - code: 6013; + code: 6014; name: "RewardCalculationOverflow"; msg: "Math overflow in reward function"; }, { - code: 6014; + code: 6015; name: "InvalidTranches"; msg: "Tranches not sorted or empty"; }, { - code: 6015; + code: 6016; name: "InvalidVestingSchedule"; msg: "Invalid vesting schedule configuration"; }, { - code: 6016; + code: 6017; name: "ChangeRequestNotFound"; msg: "Missing proposal for execute"; }, { - code: 6017; + code: 6018; name: "NoChangesProposed"; msg: "All optional change fields are None"; }, @@ -1153,6 +1179,27 @@ export const IDL: PerformancePackageV2 = { ], args: [], }, + { + name: "closePerformancePackage", + accounts: [ + { + name: "performancePackage", + isMut: true, + isSigner: false, + }, + { + name: "admin", + isMut: false, + isSigner: true, + }, + { + name: "rentDestination", + isMut: true, + isSigner: false, + }, + ], + args: [], + }, ], accounts: [ { @@ -1782,76 +1829,81 @@ export const IDL: PerformancePackageV2 = { }, { code: 6003, + name: "InvalidAdmin", + msg: "Signer is not the admin", + }, + { + code: 6004, name: "InvalidMintGovernor", msg: "Mint governor does not match the provided mint", }, { - code: 6004, + code: 6005, name: "InvalidMintAuthority", msg: "Mint authority does not match expected configuration", }, { - code: 6005, + code: 6006, name: "NotLocked", msg: "Expected Locked status", }, { - code: 6006, + code: 6007, name: "NotUnlocking", msg: "Expected Unlocking status", }, { - code: 6007, + code: 6008, name: "OracleMissingAccount", msg: "Expected remaining_accounts not provided", }, { - code: 6008, + code: 6009, name: "OracleInvalidAccount", msg: "Account pubkey doesn't match expected", }, { - code: 6009, + code: 6010, name: "OracleParseError", msg: "Failed to parse account data", }, { - code: 6010, + code: 6011, name: "OracleInvalidState", msg: "Oracle state invalid", }, { - code: 6011, + code: 6012, name: "OracleMinDurationNotReached", msg: "Minimum duration hasn't passed yet", }, { - code: 6012, + code: 6013, name: "UnlockTimestampNotReached", msg: "Minimum unlock timestamp not yet reached", }, { - code: 6013, + code: 6014, name: "RewardCalculationOverflow", msg: "Math overflow in reward function", }, { - code: 6014, + code: 6015, name: "InvalidTranches", msg: "Tranches not sorted or empty", }, { - code: 6015, + code: 6016, name: "InvalidVestingSchedule", msg: "Invalid vesting schedule configuration", }, { - code: 6016, + code: 6017, name: "ChangeRequestNotFound", msg: "Missing proposal for execute", }, { - code: 6017, + code: 6018, name: "NoChangesProposed", msg: "All optional change fields are None", }, diff --git a/tests/performancePackageV2/main.test.ts b/tests/performancePackageV2/main.test.ts index 012e053c6..a8b7cf515 100644 --- a/tests/performancePackageV2/main.test.ts +++ b/tests/performancePackageV2/main.test.ts @@ -4,6 +4,7 @@ import completeUnlock from "./unit/completeUnlock.test.js"; import changeAuthority from "./unit/changeAuthority.test.js"; import proposeChange from "./unit/proposeChange.test.js"; import executeChange from "./unit/executeChange.test.js"; +import closePerformancePackage from "./unit/closePerformancePackage.test.js"; import { MintGovernorClient, PerformancePackageV2Client, @@ -27,4 +28,5 @@ export default function suite() { describe("#change_authority", changeAuthority); describe("#propose_change", proposeChange); describe("#execute_change", executeChange); + describe("#close_performance_package", closePerformancePackage); } diff --git a/tests/performancePackageV2/unit/closePerformancePackage.test.ts b/tests/performancePackageV2/unit/closePerformancePackage.test.ts new file mode 100644 index 000000000..d27ae1d08 --- /dev/null +++ b/tests/performancePackageV2/unit/closePerformancePackage.test.ts @@ -0,0 +1,60 @@ +import { Keypair } from "@solana/web3.js"; +import BN from "bn.js"; +import { assert } from "chai"; +import { + MintGovernorClient, + PerformancePackageV2Client, +} from "@metadaoproject/futarchy/v0.7"; +import { + setupPerformancePackageV2, + createCliffLinearReward, +} from "../utils.js"; + +export default function suite() { + let mintGovernorClient: MintGovernorClient; + let ppClient: PerformancePackageV2Client; + + before(async function () { + mintGovernorClient = this.mintGovernor; + ppClient = this.performancePackageV2; + }); + + it("successfully closes", async function () { + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + + const { performancePackage } = await setupPerformancePackageV2( + this.banksClient, + mintGovernorClient, + ppClient, + this.payer, + { + authority: authority.publicKey, + recipient: recipient.publicKey, + rewardFunction: createCliffLinearReward(), + minUnlockTimestamp: new BN(0), + }, + ); + + // Verify PP exists before closing + const ppAccount = + await ppClient.fetchPerformancePackage(performancePackage); + assert.isDefined(ppAccount); + assert.isDefined(ppAccount.status.locked); + + const rentDestination = Keypair.generate().publicKey; + + // Close the PP + await ppClient + .closePerformancePackageIx({ + performancePackage, + admin: this.payer.publicKey, + rentDestination, + }) + .rpc(); + + // Verify PP no longer exists + const rawAccount = await this.banksClient.getAccount(performancePackage); + assert.isNull(rawAccount); + }); +} diff --git a/vibes/tasks.md b/vibes/tasks.md index 203a4431f..ef54da30e 100644 --- a/vibes/tasks.md +++ b/vibes/tasks.md @@ -29,33 +29,11 @@ ## Tasks -### Phase 8: close_performance_package - -> Reference: `001-performance-package-v2.md` → Instruction 7 - -- [NEXT] 8.1 Write close_performance_package instruction - - Create `instructions/close_performance_package.rs` - - Implement accounts struct with admin check - - Implement `validate()`: admin == METADAO_ADMIN, status == Locked - - Implement `handle()`: close account - - Emit `PerformancePackageClosedEvent` - - Wire up in `lib.rs` - -- [ ] 8.2 Add SDK method for close_performance_package - - Add `closePerformancePackageIx()` method to `PerformancePackageV2Client` - - Run `./rebuild.sh` - -- [ ] 8.3 Write unit tests for close_performance_package - - Create `tests/performancePackageV2/unit/closePerformancePackage.test.ts` - - Test: successfully closes PP when called by admin - - Test: fails when caller is not admin - - Test: fails when status is Unlocking - ### Phase 9: FutarchyTwap Oracle Support > Reference: `001-performance-package-v2.md` → OracleReader variants -- [ ] 9.1 Add FutarchyTwap variant to OracleReader +- [NEXT] 9.1 Add FutarchyTwap variant to OracleReader - Add `FutarchyTwap` variant with fields: amm, min_duration, start_value/time, end_value/time - Implement `record_start()` for FutarchyTwap (read accumulator from AMM remaining_account) - Implement `record_end()` for FutarchyTwap From 3d23fb4e3bd9a9d680ee5254199ab770e26fea66 Mon Sep 17 00:00:00 2001 From: Pileks Date: Fri, 30 Jan 2026 13:24:49 -0800 Subject: [PATCH 23/27] ppv2 - futarchy twap aggregator support --- Cargo.lock | 1 + programs/performance_package_v2/Cargo.toml | 1 + .../src/instructions/complete_unlock.rs | 4 +- .../src/instructions/start_unlock.rs | 4 +- .../src/state/performance_package.rs | 159 +++++++++- sdk/src/v0.7/PerformancePackageV2Client.ts | 24 +- sdk/src/v0.7/types/performance_package_v2.ts | 70 +++++ .../unit/completeUnlock.test.ts | 274 ++++++++++++++++++ .../unit/initializePerformancePackage.test.ts | 130 +++++++++ .../unit/startUnlock.test.ts | 150 ++++++++++ tests/performancePackageV2/utils.ts | 134 +++++++++ vibes/tasks.md | 31 +- 12 files changed, 943 insertions(+), 39 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4618de50f..8a51feafc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1494,6 +1494,7 @@ version = "0.7.0" dependencies = [ "anchor-lang", "anchor-spl", + "futarchy", "mint_governor", "solana-security-txt", ] diff --git a/programs/performance_package_v2/Cargo.toml b/programs/performance_package_v2/Cargo.toml index faee19864..5fbd69bd8 100644 --- a/programs/performance_package_v2/Cargo.toml +++ b/programs/performance_package_v2/Cargo.toml @@ -21,3 +21,4 @@ anchor-lang = { version = "0.29.0", features = ["init-if-needed", "event-cpi"] } anchor-spl = "0.29.0" solana-security-txt = "1.1.1" mint_governor = { path = "../mint_governor", features = ["cpi"] } +futarchy = { path = "../futarchy", features = ["cpi"] } diff --git a/programs/performance_package_v2/src/instructions/complete_unlock.rs b/programs/performance_package_v2/src/instructions/complete_unlock.rs index 99a59dd2c..41ca42aec 100644 --- a/programs/performance_package_v2/src/instructions/complete_unlock.rs +++ b/programs/performance_package_v2/src/instructions/complete_unlock.rs @@ -86,8 +86,8 @@ impl CompleteUnlock<'_> { pub fn handle(ctx: Context) -> Result<()> { let pp = &mut ctx.accounts.performance_package; - // Record end snapshot (no-op for Time oracle) - pp.oracle_reader.record_end()?; + // Record end snapshot (no-op for Time oracle, reads AMM for FutarchyTwap) + pp.oracle_reader.record_end(ctx.remaining_accounts)?; // Compute oracle value let oracle_value = pp.oracle_reader.compute_value()?; diff --git a/programs/performance_package_v2/src/instructions/start_unlock.rs b/programs/performance_package_v2/src/instructions/start_unlock.rs index 9858a1700..288e86f06 100644 --- a/programs/performance_package_v2/src/instructions/start_unlock.rs +++ b/programs/performance_package_v2/src/instructions/start_unlock.rs @@ -47,8 +47,8 @@ impl StartUnlock<'_> { pub fn handle(ctx: Context) -> Result<()> { let pp = &mut ctx.accounts.performance_package; - // Record start snapshot (no-op for Time oracle) - pp.oracle_reader.record_start()?; + // Record start snapshot (no-op for Time oracle, reads AMM for FutarchyTwap) + pp.oracle_reader.record_start(ctx.remaining_accounts)?; // Transition to Unlocking status pp.status = PackageStatus::Unlocking; diff --git a/programs/performance_package_v2/src/state/performance_package.rs b/programs/performance_package_v2/src/state/performance_package.rs index 528642c09..9cf3ca1d8 100644 --- a/programs/performance_package_v2/src/state/performance_package.rs +++ b/programs/performance_package_v2/src/state/performance_package.rs @@ -1,4 +1,5 @@ use anchor_lang::prelude::*; +use futarchy::state::{Dao, PoolState}; use crate::{PerformancePackageError, MAX_TRANCHES}; @@ -18,7 +19,68 @@ pub enum OracleReader { /// Reads current timestamp from Clock::get() /// No state needed - just reads current time on demand Time, - // FutarchyTwap variant will be added in Phase 9 + /// Reads accumulator from Futarchy AMM, computes TWAP + /// Two snapshots: start (on start_unlock) and end (on complete_unlock) + /// TWAP = (end_value - start_value) / (end_time - start_time) + FutarchyTwap { + /// The Futarchy DAO account to read (contains embedded AMM) + amm: Pubkey, + /// Minimum seconds between start and end + min_duration: u32, + /// Start snapshot (recorded on start_unlock) + start_value: u128, + start_time: i64, + /// End snapshot (recorded on complete_unlock) + end_value: u128, + end_time: i64, + }, +} + +/// Reads the effective aggregator value from a Futarchy DAO account. +/// Extrapolates the aggregator to the current timestamp using the last observation. +fn read_futarchy_aggregator( + remaining_accounts: &[AccountInfo], + expected_amm: &Pubkey, +) -> Result<(u128, i64)> { + // Get the DAO account from remaining_accounts + let dao_account = remaining_accounts + .first() + .ok_or(PerformancePackageError::OracleMissingAccount)?; + + // Verify the account key matches the configured amm + require_keys_eq!( + dao_account.key(), + *expected_amm, + PerformancePackageError::OracleInvalidAccount + ); + + // Deserialize the Dao account + let dao_data = dao_account.try_borrow_data()?; + let dao = Dao::try_deserialize(&mut &dao_data[..]) + .map_err(|_| PerformancePackageError::OracleParseError)?; + + // Read the oracle data from the spot pool + let (aggregator, last_updated_timestamp, last_observation) = match &dao.amm.state { + PoolState::Spot { spot } => ( + spot.oracle.aggregator, + spot.oracle.last_updated_timestamp, + spot.oracle.last_observation, + ), + PoolState::Futarchy { spot, .. } => ( + spot.oracle.aggregator, + spot.oracle.last_updated_timestamp, + spot.oracle.last_observation, + ), + }; + + // Compute effective aggregator at current time by extrapolating + // from the last update using the last observation value + let clock = Clock::get()?; + let time_since_update = clock.unix_timestamp.saturating_sub(last_updated_timestamp) as u128; + let effective_aggregator = + aggregator.wrapping_add(last_observation.saturating_mul(time_since_update)); + + Ok((effective_aggregator, clock.unix_timestamp)) } impl OracleReader { @@ -29,57 +91,148 @@ impl OracleReader { // Time oracle has no configuration to validate Ok(()) } + OracleReader::FutarchyTwap { min_duration, .. } => { + // min_duration must be > 0 to avoid division by zero in TWAP calculation + require!( + *min_duration > 0, + PerformancePackageError::InvalidVestingSchedule + ); + Ok(()) + } } } /// Records the start snapshot when unlock begins. /// For Time oracle, this is a no-op since it just reads current time on demand. - pub fn record_start(&mut self) -> Result<()> { + /// For FutarchyTwap, reads the accumulator from the Dao's spot pool oracle. + pub fn record_start(&mut self, remaining_accounts: &[AccountInfo]) -> Result<()> { match self { OracleReader::Time => { // No-op for Time oracle - just reads current time on demand Ok(()) } + OracleReader::FutarchyTwap { + amm, + start_value, + start_time, + .. + } => { + let (effective_aggregator, timestamp) = + read_futarchy_aggregator(remaining_accounts, amm)?; + *start_value = effective_aggregator; + *start_time = timestamp; + Ok(()) + } } } /// Records the end snapshot when unlock completes. /// For Time oracle, this is a no-op since it just reads current time on demand. - pub fn record_end(&mut self) -> Result<()> { + /// For FutarchyTwap, reads the accumulator from the Dao's spot pool oracle. + pub fn record_end(&mut self, remaining_accounts: &[AccountInfo]) -> Result<()> { match self { OracleReader::Time => { // No-op for Time oracle - just reads current time on demand Ok(()) } + OracleReader::FutarchyTwap { + amm, + end_value, + end_time, + .. + } => { + let (effective_aggregator, timestamp) = + read_futarchy_aggregator(remaining_accounts, amm)?; + *end_value = effective_aggregator; + *end_time = timestamp; + + Ok(()) + } } } /// Checks if the minimum duration has passed and unlock can be completed. /// For Time oracle, always returns true (no min_duration concept). + /// For FutarchyTwap, checks if min_duration seconds have passed since start_time. pub fn can_end(&self) -> bool { match self { OracleReader::Time => true, + OracleReader::FutarchyTwap { + min_duration, + start_time, + .. + } => { + let clock = Clock::get(); + match clock { + Ok(clock) => { + let elapsed = clock.unix_timestamp.saturating_sub(*start_time); + elapsed >= *min_duration as i64 + } + Err(_) => false, + } + } } } /// Computes the oracle value for reward calculation. /// For Time oracle, returns the current unix timestamp. + /// For FutarchyTwap, returns the TWAP = (end_value - start_value) / (end_time - start_time). pub fn compute_value(&self) -> Result { match self { OracleReader::Time => { let clock = Clock::get()?; + Ok(clock.unix_timestamp as u128) } + OracleReader::FutarchyTwap { + start_value, + start_time, + end_value, + end_time, + .. + } => { + // Calculate time delta + let time_delta = end_time + .checked_sub(*start_time) + .ok_or(PerformancePackageError::OracleInvalidState)?; + + // Ensure time_delta > 0 to avoid division by zero + require!(time_delta > 0, PerformancePackageError::OracleInvalidState); + + // Calculate TWAP: (end_value - start_value) / time_delta + // Note: end_value should always be >= start_value since aggregator is cumulative + // If end_value < start_value, the aggregator wrapped (extremely rare) + let value_delta = end_value.wrapping_sub(*start_value); + + let twap = value_delta + .checked_div(time_delta as u128) + .ok_or(PerformancePackageError::OracleInvalidState)?; + + Ok(twap) + } } } /// Resets the oracle state for the next unlock cycle. /// For Time oracle, this is a no-op (no state to reset). + /// For FutarchyTwap, clears the start and end snapshots. pub fn reset(&mut self) { match self { OracleReader::Time => { // No-op for Time oracle - no state to reset } + OracleReader::FutarchyTwap { + start_value, + start_time, + end_value, + end_time, + .. + } => { + *start_value = 0; + *start_time = 0; + *end_value = 0; + *end_time = 0; + } } } } diff --git a/sdk/src/v0.7/PerformancePackageV2Client.ts b/sdk/src/v0.7/PerformancePackageV2Client.ts index 1b41e5dcc..00ebab774 100644 --- a/sdk/src/v0.7/PerformancePackageV2Client.ts +++ b/sdk/src/v0.7/PerformancePackageV2Client.ts @@ -154,14 +154,24 @@ export class PerformancePackageV2Client { startUnlockIx({ performancePackage, signer = this.provider.publicKey, + dao, }: { performancePackage: PublicKey; signer?: PublicKey; + dao?: PublicKey; }) { - return this.program.methods.startUnlock().accounts({ + const builder = this.program.methods.startUnlock().accounts({ performancePackage, signer, }); + + if (dao) { + return builder.remainingAccounts([ + { pubkey: dao, isSigner: false, isWritable: false }, + ]); + } + + return builder; } completeUnlockIx({ @@ -171,6 +181,7 @@ export class PerformancePackageV2Client { mint, recipient, signer = this.provider.publicKey, + dao, }: { performancePackage: PublicKey; mintGovernor: PublicKey; @@ -178,10 +189,11 @@ export class PerformancePackageV2Client { mint: PublicKey; recipient: PublicKey; signer?: PublicKey; + dao?: PublicKey; }) { const recipientAta = getAssociatedTokenAddressSync(mint, recipient, true); - return this.program.methods.completeUnlock().accounts({ + const builder = this.program.methods.completeUnlock().accounts({ performancePackage, mintGovernor, mintAuthority, @@ -192,6 +204,14 @@ export class PerformancePackageV2Client { associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, mintGovernorProgram: MINT_GOVERNOR_PROGRAM_ID, }); + + if (dao) { + return builder.remainingAccounts([ + { pubkey: dao, isSigner: false, isWritable: false }, + ]); + } + + return builder; } changeAuthorityIx({ diff --git a/sdk/src/v0.7/types/performance_package_v2.ts b/sdk/src/v0.7/types/performance_package_v2.ts index 41f6912be..9d7fac816 100644 --- a/sdk/src/v0.7/types/performance_package_v2.ts +++ b/sdk/src/v0.7/types/performance_package_v2.ts @@ -536,6 +536,41 @@ export type PerformancePackageV2 = { { name: "Time"; }, + { + name: "FutarchyTwap"; + fields: [ + { + name: "amm"; + docs: [ + "The Futarchy DAO account to read (contains embedded AMM)", + ]; + type: "publicKey"; + }, + { + name: "minDuration"; + docs: ["Minimum seconds between start and end"]; + type: "u32"; + }, + { + name: "startValue"; + docs: ["Start snapshot (recorded on start_unlock)"]; + type: "u128"; + }, + { + name: "startTime"; + type: "i64"; + }, + { + name: "endValue"; + docs: ["End snapshot (recorded on complete_unlock)"]; + type: "u128"; + }, + { + name: "endTime"; + type: "i64"; + }, + ]; + }, ]; }; }, @@ -1492,6 +1527,41 @@ export const IDL: PerformancePackageV2 = { { name: "Time", }, + { + name: "FutarchyTwap", + fields: [ + { + name: "amm", + docs: [ + "The Futarchy DAO account to read (contains embedded AMM)", + ], + type: "publicKey", + }, + { + name: "minDuration", + docs: ["Minimum seconds between start and end"], + type: "u32", + }, + { + name: "startValue", + docs: ["Start snapshot (recorded on start_unlock)"], + type: "u128", + }, + { + name: "startTime", + type: "i64", + }, + { + name: "endValue", + docs: ["End snapshot (recorded on complete_unlock)"], + type: "u128", + }, + { + name: "endTime", + type: "i64", + }, + ], + }, ], }, }, diff --git a/tests/performancePackageV2/unit/completeUnlock.test.ts b/tests/performancePackageV2/unit/completeUnlock.test.ts index b6f91be8c..f790a035e 100644 --- a/tests/performancePackageV2/unit/completeUnlock.test.ts +++ b/tests/performancePackageV2/unit/completeUnlock.test.ts @@ -16,8 +16,11 @@ import { setupMintGovernorWithAuthority, createCliffLinearReward, createThresholdReward, + createFutarchyTwapOracle, + setupDaoForTwapTests, } from "../utils.js"; import { expectError } from "../../utils.js"; +import { getPerformancePackageV2Addr } from "@metadaoproject/futarchy/v0.7"; export default function suite() { let mintGovernorClient: MintGovernorClient; @@ -777,6 +780,277 @@ export default function suite() { assert.equal(recipientBalance.toString(), "300000000"); }); + it("records end snapshot for FutarchyTwap", async function () { + // Setup a DAO for TWAP oracle + const dao = await setupDaoForTwapTests(this); + + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + + const createKey = Keypair.generate(); + const [performancePackage] = getPerformancePackageV2Addr({ + createKey: createKey.publicKey, + }); + + const { mint, mintGovernor, mintAuthority } = + await setupMintGovernorWithAuthority( + this.banksClient, + mintGovernorClient, + this.payer, + performancePackage, + ); + + const minDuration = 10; // Short duration for testing + const oracleReader = createFutarchyTwapOracle({ amm: dao, minDuration }); + // Use threshold reward with TWAP thresholds + const rewardFunction = createThresholdReward([ + { threshold: new BN(100), cumulativeAmount: new BN(100_000_000) }, + { threshold: new BN(500), cumulativeAmount: new BN(500_000_000) }, + ]); + + await ppClient + .initializePerformancePackageIx({ + createKey: createKey.publicKey, + mint, + mintGovernor, + mintAuthority, + authority: authority.publicKey, + recipient: recipient.publicKey, + payer: this.payer.publicKey, + oracleReader, + rewardFunction, + minUnlockTimestamp: new BN(0), + }) + .signers([createKey]) + .rpc(); + + // Create recipient ATA + await createRecipientAta(this, mint, recipient.publicKey); + + // Advance time so the effective aggregator will be non-zero + await this.advanceBySeconds(10); + + // Start unlock + await ppClient + .startUnlockIx({ + performancePackage, + signer: authority.publicKey, + dao, + }) + .signers([authority]) + .rpc(); + + // Verify start snapshot was recorded + let ppAccount = await ppClient.fetchPerformancePackage(performancePackage); + const startValue = + ppAccount.oracleReader.futarchyTwap.startValue.toString(); + const startTime = ppAccount.oracleReader.futarchyTwap.startTime.toString(); + assert.notEqual(startValue, "0"); + assert.notEqual(startTime, "0"); + assert.equal(ppAccount.oracleReader.futarchyTwap.endValue.toString(), "0"); + assert.equal(ppAccount.oracleReader.futarchyTwap.endTime.toString(), "0"); + + // Advance time past min_duration + await this.advanceBySeconds(minDuration + 5); + + // Complete unlock + await ppClient + .completeUnlockIx({ + performancePackage, + mintGovernor, + mintAuthority, + mint, + recipient: recipient.publicKey, + signer: authority.publicKey, + dao, + }) + .signers([authority]) + .rpc(); + + // Verify end snapshot was recorded and then reset + ppAccount = await ppClient.fetchPerformancePackage(performancePackage); + assert.isDefined(ppAccount.status.locked); + // After complete, oracle state is reset + assert.equal( + ppAccount.oracleReader.futarchyTwap.startValue.toString(), + "0", + ); + assert.equal(ppAccount.oracleReader.futarchyTwap.startTime.toString(), "0"); + assert.equal(ppAccount.oracleReader.futarchyTwap.endValue.toString(), "0"); + assert.equal(ppAccount.oracleReader.futarchyTwap.endTime.toString(), "0"); + }); + + it("correctly computes TWAP for FutarchyTwap", async function () { + // Setup a DAO for TWAP oracle + const dao = await setupDaoForTwapTests(this); + + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + + const createKey = Keypair.generate(); + const [performancePackage] = getPerformancePackageV2Addr({ + createKey: createKey.publicKey, + }); + + const { mint, mintGovernor, mintAuthority } = + await setupMintGovernorWithAuthority( + this.banksClient, + mintGovernorClient, + this.payer, + performancePackage, + ); + + const minDuration = 10; // Short duration for testing + const oracleReader = createFutarchyTwapOracle({ amm: dao, minDuration }); + // Use threshold reward - any TWAP above 0 earns 100 tokens + const rewardFunction = createThresholdReward([ + { threshold: new BN(1), cumulativeAmount: new BN(100_000_000) }, // Any TWAP > 0 + ]); + + await ppClient + .initializePerformancePackageIx({ + createKey: createKey.publicKey, + mint, + mintGovernor, + mintAuthority, + authority: authority.publicKey, + recipient: recipient.publicKey, + payer: this.payer.publicKey, + oracleReader, + rewardFunction, + minUnlockTimestamp: new BN(0), + }) + .signers([createKey]) + .rpc(); + + // Create recipient ATA + await createRecipientAta(this, mint, recipient.publicKey); + + // Advance time so the effective aggregator will be non-zero + await this.advanceBySeconds(10); + + // Start unlock + await ppClient + .startUnlockIx({ + performancePackage, + signer: authority.publicKey, + dao, + }) + .signers([authority]) + .rpc(); + + // Advance time past min_duration + await this.advanceBySeconds(minDuration + 5); + + // Complete unlock - TWAP should be computed + await ppClient + .completeUnlockIx({ + performancePackage, + mintGovernor, + mintAuthority, + mint, + recipient: recipient.publicKey, + signer: authority.publicKey, + dao, + }) + .signers([authority]) + .rpc(); + + // Verify the PP is back to locked and rewards were computed + const ppAccount = + await ppClient.fetchPerformancePackage(performancePackage); + assert.isDefined(ppAccount.status.locked); + + // The DAO has a twapInitialObservation of 1000 (from setupDaoForTwapTests) + // So the TWAP should be around 1000, which is > 1, so we should get 100 tokens + const recipientBalance = await this.getTokenBalance( + mint, + recipient.publicKey, + ); + assert.equal(recipientBalance.toString(), "100000000"); + assert.equal(ppAccount.totalRewardsPaidOut.toString(), "100000000"); + }); + + it("fails when min_duration not reached for FutarchyTwap", async function () { + // Setup a DAO for TWAP oracle + const dao = await setupDaoForTwapTests(this); + + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + + const createKey = Keypair.generate(); + const [performancePackage] = getPerformancePackageV2Addr({ + createKey: createKey.publicKey, + }); + + const { mint, mintGovernor, mintAuthority } = + await setupMintGovernorWithAuthority( + this.banksClient, + mintGovernorClient, + this.payer, + performancePackage, + ); + + // Set a longer min_duration that we won't wait for + const minDuration = 3600; // 1 hour + const oracleReader = createFutarchyTwapOracle({ amm: dao, minDuration }); + const rewardFunction = createThresholdReward([ + { threshold: new BN(1), cumulativeAmount: new BN(100_000_000) }, + ]); + + await ppClient + .initializePerformancePackageIx({ + createKey: createKey.publicKey, + mint, + mintGovernor, + mintAuthority, + authority: authority.publicKey, + recipient: recipient.publicKey, + payer: this.payer.publicKey, + oracleReader, + rewardFunction, + minUnlockTimestamp: new BN(0), + }) + .signers([createKey]) + .rpc(); + + // Create recipient ATA + await createRecipientAta(this, mint, recipient.publicKey); + + // Start unlock + await ppClient + .startUnlockIx({ + performancePackage, + signer: authority.publicKey, + dao, + }) + .signers([authority]) + .rpc(); + + // Advance time, but NOT past min_duration (only 10 seconds instead of 3600) + await this.advanceBySeconds(10); + + // Try to complete unlock - should fail because min_duration not reached + const callbacks = expectError( + "OracleMinDurationNotReached", + "Should have failed because min_duration not reached", + ); + + await ppClient + .completeUnlockIx({ + performancePackage, + mintGovernor, + mintAuthority, + mint, + recipient: recipient.publicKey, + signer: authority.publicKey, + dao, + }) + .signers([authority]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + it("fails when status is not Unlocking", async function () { const authority = Keypair.generate(); const recipient = Keypair.generate(); diff --git a/tests/performancePackageV2/unit/initializePerformancePackage.test.ts b/tests/performancePackageV2/unit/initializePerformancePackage.test.ts index e850f15b4..9307f13ee 100644 --- a/tests/performancePackageV2/unit/initializePerformancePackage.test.ts +++ b/tests/performancePackageV2/unit/initializePerformancePackage.test.ts @@ -11,6 +11,8 @@ import { createCliffLinearReward, createThresholdReward, createMintWithAuthority, + createFutarchyTwapOracle, + setupDaoForTwapTests, } from "../utils.js"; import { expectError } from "../../utils.js"; @@ -226,6 +228,134 @@ export default function suite() { ); }); + it("successfully initializes with FutarchyTwap oracle and CliffLinear reward function", async function () { + // Setup a DAO for TWAP oracle + const dao = await setupDaoForTwapTests(this); + + const createKey = Keypair.generate(); + const [performancePackage] = getPerformancePackageV2Addr({ + createKey: createKey.publicKey, + }); + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + + const { mint, mintGovernor, mintAuthority } = + await setupMintGovernorWithAuthority( + this.banksClient, + mintGovernorClient, + this.payer, + performancePackage, + ); + + const minDuration = 60; // 60 seconds + const oracleReader = createFutarchyTwapOracle({ amm: dao, minDuration }); + const rewardFunction = createCliffLinearReward({ + startValue: new BN(0), + cliffValue: new BN(100), // TWAP value threshold + endValue: new BN(1000), + cliffAmount: new BN(100_000_000), // 100 tokens + totalAmount: new BN(1_000_000_000), // 1000 tokens + }); + + await ppClient + .initializePerformancePackageIx({ + createKey: createKey.publicKey, + mint, + mintGovernor, + mintAuthority, + authority: authority.publicKey, + recipient: recipient.publicKey, + payer: this.payer.publicKey, + oracleReader, + rewardFunction, + minUnlockTimestamp: new BN(0), + }) + .signers([createKey]) + .rpc(); + + const ppAccount = + await ppClient.fetchPerformancePackage(performancePackage); + + assert.isNotNull(ppAccount); + assert.isDefined(ppAccount.oracleReader.futarchyTwap); + assert.isDefined(ppAccount.rewardFunction.cliffLinear); + assert.isDefined(ppAccount.status.locked); + + // Verify FutarchyTwap properties + const futarchyTwap = ppAccount.oracleReader.futarchyTwap; + assert.equal(futarchyTwap.amm.toBase58(), dao.toBase58()); + assert.equal(futarchyTwap.minDuration, minDuration); + assert.equal(futarchyTwap.startValue.toString(), "0"); + assert.equal(futarchyTwap.startTime.toString(), "0"); + assert.equal(futarchyTwap.endValue.toString(), "0"); + assert.equal(futarchyTwap.endTime.toString(), "0"); + }); + + it("successfully initializes with FutarchyTwap oracle and Threshold reward function", async function () { + // Setup a DAO for TWAP oracle + const dao = await setupDaoForTwapTests(this); + + const createKey = Keypair.generate(); + const [performancePackage] = getPerformancePackageV2Addr({ + createKey: createKey.publicKey, + }); + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + + const { mint, mintGovernor, mintAuthority } = + await setupMintGovernorWithAuthority( + this.banksClient, + mintGovernorClient, + this.payer, + performancePackage, + ); + + const minDuration = 120; // 120 seconds + const oracleReader = createFutarchyTwapOracle({ amm: dao, minDuration }); + const rewardFunction = createThresholdReward([ + { threshold: new BN(100), cumulativeAmount: new BN(100_000_000) }, // TWAP >= 100 + { threshold: new BN(500), cumulativeAmount: new BN(500_000_000) }, // TWAP >= 500 + { threshold: new BN(1000), cumulativeAmount: new BN(1_000_000_000) }, // TWAP >= 1000 + ]); + + await ppClient + .initializePerformancePackageIx({ + createKey: createKey.publicKey, + mint, + mintGovernor, + mintAuthority, + authority: authority.publicKey, + recipient: recipient.publicKey, + payer: this.payer.publicKey, + oracleReader, + rewardFunction, + minUnlockTimestamp: new BN(0), + }) + .signers([createKey]) + .rpc(); + + const ppAccount = + await ppClient.fetchPerformancePackage(performancePackage); + + assert.isNotNull(ppAccount); + assert.isDefined(ppAccount.oracleReader.futarchyTwap); + assert.isDefined(ppAccount.rewardFunction.threshold); + assert.isDefined(ppAccount.status.locked); + + // Verify FutarchyTwap properties + const futarchyTwap = ppAccount.oracleReader.futarchyTwap; + assert.equal(futarchyTwap.amm.toBase58(), dao.toBase58()); + assert.equal(futarchyTwap.minDuration, minDuration); + + // Verify Threshold tranches + const tranches = ppAccount.rewardFunction.threshold.tranches; + assert.equal(tranches.length, 3); + assert.equal(tranches[0].threshold.toString(), "100"); + assert.equal(tranches[0].cumulativeAmount.toString(), "100000000"); + assert.equal(tranches[1].threshold.toString(), "500"); + assert.equal(tranches[2].threshold.toString(), "1000"); + }); + it("fails when create_key does not sign", async function () { const createKey = Keypair.generate(); const [performancePackage] = getPerformancePackageV2Addr({ diff --git a/tests/performancePackageV2/unit/startUnlock.test.ts b/tests/performancePackageV2/unit/startUnlock.test.ts index 577186be2..fe9334559 100644 --- a/tests/performancePackageV2/unit/startUnlock.test.ts +++ b/tests/performancePackageV2/unit/startUnlock.test.ts @@ -8,8 +8,12 @@ import { import { setupPerformancePackageV2, createCliffLinearReward, + createFutarchyTwapOracle, + setupMintGovernorWithAuthority, + setupDaoForTwapTests, } from "../utils.js"; import { expectError } from "../../utils.js"; +import { getPerformancePackageV2Addr } from "@metadaoproject/futarchy/v0.7"; export default function suite() { let mintGovernorClient: MintGovernorClient; @@ -225,6 +229,152 @@ export default function suite() { assert.isDefined(ppAccount.status.unlocking); }); + it("records start snapshot for FutarchyTwap", async function () { + // Setup a DAO for TWAP oracle + const dao = await setupDaoForTwapTests(this); + + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + + const createKey = Keypair.generate(); + const [performancePackage] = getPerformancePackageV2Addr({ + createKey: createKey.publicKey, + }); + + const { mint, mintGovernor, mintAuthority } = + await setupMintGovernorWithAuthority( + this.banksClient, + mintGovernorClient, + this.payer, + performancePackage, + ); + + const minDuration = 60; + const oracleReader = createFutarchyTwapOracle({ amm: dao, minDuration }); + const rewardFunction = createCliffLinearReward(); + + await ppClient + .initializePerformancePackageIx({ + createKey: createKey.publicKey, + mint, + mintGovernor, + mintAuthority, + authority: authority.publicKey, + recipient: recipient.publicKey, + payer: this.payer.publicKey, + oracleReader, + rewardFunction, + minUnlockTimestamp: new BN(0), + }) + .signers([createKey]) + .rpc(); + + // Verify initial state + let ppAccount = await ppClient.fetchPerformancePackage(performancePackage); + assert.isDefined(ppAccount.oracleReader.futarchyTwap); + assert.equal( + ppAccount.oracleReader.futarchyTwap.startValue.toString(), + "0", + ); + assert.equal(ppAccount.oracleReader.futarchyTwap.startTime.toString(), "0"); + + // Advance time so the effective aggregator will be non-zero + // The effective_aggregator = aggregator + last_observation * time_since_update + // After DAO init, aggregator is 0 but last_observation = twapInitialObservation + // We need time_since_update > 0 for effective_aggregator to be non-zero + await this.advanceBySeconds(10); + + // Get current time before starting unlock + const currentClock = await this.banksClient.getClock(); + const currentTimestamp = Number(currentClock.unixTimestamp); + + // Call start_unlock with DAO as remaining account + await ppClient + .startUnlockIx({ + performancePackage, + signer: authority.publicKey, + dao, + }) + .signers([authority]) + .rpc(); + + // Verify start snapshot was recorded + ppAccount = await ppClient.fetchPerformancePackage(performancePackage); + assert.isDefined(ppAccount.status.unlocking); + assert.isDefined(ppAccount.oracleReader.futarchyTwap); + + const futarchyTwap = ppAccount.oracleReader.futarchyTwap; + // start_value should be non-zero (the aggregator value from the DAO) + assert.notEqual(futarchyTwap.startValue.toString(), "0"); + // start_time should be close to current timestamp + const startTime = Number(futarchyTwap.startTime.toString()); + assert.isAtLeast(startTime, currentTimestamp); + // end values should still be 0 (not recorded yet) + assert.equal(futarchyTwap.endValue.toString(), "0"); + assert.equal(futarchyTwap.endTime.toString(), "0"); + }); + + it("fails when AMM account doesn't match for FutarchyTwap", async function () { + // Setup a DAO for TWAP oracle + const dao = await setupDaoForTwapTests(this); + + // Setup a different DAO to pass as wrong account + const wrongDao = await setupDaoForTwapTests(this); + + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + + const createKey = Keypair.generate(); + const [performancePackage] = getPerformancePackageV2Addr({ + createKey: createKey.publicKey, + }); + + const { mint, mintGovernor, mintAuthority } = + await setupMintGovernorWithAuthority( + this.banksClient, + mintGovernorClient, + this.payer, + performancePackage, + ); + + const minDuration = 60; + // Create oracle with the first DAO + const oracleReader = createFutarchyTwapOracle({ amm: dao, minDuration }); + const rewardFunction = createCliffLinearReward(); + + await ppClient + .initializePerformancePackageIx({ + createKey: createKey.publicKey, + mint, + mintGovernor, + mintAuthority, + authority: authority.publicKey, + recipient: recipient.publicKey, + payer: this.payer.publicKey, + oracleReader, + rewardFunction, + minUnlockTimestamp: new BN(0), + }) + .signers([createKey]) + .rpc(); + + // Try to start unlock with wrong DAO - should fail + const callbacks = expectError( + "OracleInvalidAccount", + "Should have failed because AMM account doesn't match", + ); + + await ppClient + .startUnlockIx({ + performancePackage, + signer: authority.publicKey, + dao: wrongDao, // Wrong DAO! + }) + .signers([authority]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + it("fails when signer is neither authority nor recipient", async function () { const authority = Keypair.generate(); const recipient = Keypair.generate(); diff --git a/tests/performancePackageV2/utils.ts b/tests/performancePackageV2/utils.ts index 1fc22d9a5..4bdb4fc73 100644 --- a/tests/performancePackageV2/utils.ts +++ b/tests/performancePackageV2/utils.ts @@ -3,6 +3,7 @@ import { PublicKey, Transaction, SystemProgram, + ComputeBudgetProgram, } from "@solana/web3.js"; import * as token from "@solana/spl-token"; import { BanksClient } from "solana-bankrun"; @@ -13,6 +14,8 @@ import { getMintGovernorAddr, getMintAuthorityAddr, getPerformancePackageV2Addr, + PriceMath, + getDaoAddr, } from "@metadaoproject/futarchy/v0.7"; import type { OracleReaderV2, @@ -241,3 +244,134 @@ export function createThresholdReward( }, } as RewardFunctionV2; } + +/** + * Helper to create a FutarchyTwap oracle reader + */ +export function createFutarchyTwapOracle({ + amm, + minDuration = 60, // Default 60 seconds +}: { + amm: PublicKey; + minDuration?: number; +}): OracleReaderV2 { + return { + futarchyTwap: { + amm, + minDuration, + startValue: new BN(0), + startTime: new BN(0), + endValue: new BN(0), + endTime: new BN(0), + }, + } as OracleReaderV2; +} + +const THOUSAND_BUCK_PRICE = PriceMath.getAmmPrice(1000, 6, 6); + +/** + * Sets up a basic DAO for testing FutarchyTwap oracle + */ +export async function setupDaoForTwapTests(context: any): Promise { + // Create base and quote mints + const baseMint = await createMintWithAuthority( + context.banksClient, + context.payer, + context.payer.publicKey, + ); + const quoteMint = await createMintWithAuthority( + context.banksClient, + context.payer, + context.payer.publicKey, + ); + + // Create token accounts and mint tokens to payer + const payerBaseAta = token.getAssociatedTokenAddressSync( + baseMint, + context.payer.publicKey, + true, + ); + const payerQuoteAta = token.getAssociatedTokenAddressSync( + quoteMint, + context.payer.publicKey, + true, + ); + + const createAtaTx = new Transaction().add( + token.createAssociatedTokenAccountIdempotentInstruction( + context.payer.publicKey, + payerBaseAta, + context.payer.publicKey, + baseMint, + ), + token.createAssociatedTokenAccountIdempotentInstruction( + context.payer.publicKey, + payerQuoteAta, + context.payer.publicKey, + quoteMint, + ), + ); + createAtaTx.recentBlockhash = ( + await context.banksClient.getLatestBlockhash() + )[0]; + createAtaTx.feePayer = context.payer.publicKey; + createAtaTx.sign(context.payer); + await context.banksClient.processTransaction(createAtaTx); + + // Mint tokens to payer + const mintBaseTx = new Transaction().add( + token.createMintToInstruction( + baseMint, + payerBaseAta, + context.payer.publicKey, + 100_000_000_000, // 100k tokens + ), + token.createMintToInstruction( + quoteMint, + payerQuoteAta, + context.payer.publicKey, + 100_000_000_000, // 100k tokens + ), + ); + mintBaseTx.recentBlockhash = ( + await context.banksClient.getLatestBlockhash() + )[0]; + mintBaseTx.feePayer = context.payer.publicKey; + mintBaseTx.sign(context.payer); + await context.banksClient.processTransaction(mintBaseTx); + + // Initialize DAO + const nonce = new BN(Math.floor(Math.random() * 1000000)); + + await context.futarchy + .initializeDaoIx({ + baseMint, + quoteMint, + params: { + secondsPerProposal: 60 * 60 * 24 * 3, + twapStartDelaySeconds: 60 * 60 * 24, + twapInitialObservation: THOUSAND_BUCK_PRICE, + twapMaxObservationChangePerUpdate: THOUSAND_BUCK_PRICE.divn(100), + minQuoteFutarchicLiquidity: new BN(10_000), + minBaseFutarchicLiquidity: new BN(10_000), + passThresholdBps: 300, + nonce, + initialSpendingLimit: null, + baseToStake: new BN(0), + teamSponsoredPassThresholdBps: 300, + teamAddress: context.payer.publicKey, + }, + provideLiquidity: true, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + const [dao] = getDaoAddr({ + nonce, + daoCreator: context.payer.publicKey, + }); + + return dao; +} diff --git a/vibes/tasks.md b/vibes/tasks.md index ef54da30e..efeb48f32 100644 --- a/vibes/tasks.md +++ b/vibes/tasks.md @@ -29,33 +29,4 @@ ## Tasks -### Phase 9: FutarchyTwap Oracle Support - -> Reference: `001-performance-package-v2.md` → OracleReader variants - -- [NEXT] 9.1 Add FutarchyTwap variant to OracleReader - - Add `FutarchyTwap` variant with fields: amm, min_duration, start_value/time, end_value/time - - Implement `record_start()` for FutarchyTwap (read accumulator from AMM remaining_account) - - Implement `record_end()` for FutarchyTwap - - Implement `can_end()` for FutarchyTwap (check min_duration) - - Implement `compute_value()` for FutarchyTwap (TWAP calculation) - - Implement `reset()` for FutarchyTwap - -- [ ] 9.2 Update instructions for FutarchyTwap - - Update `start_unlock` to handle remaining_accounts for FutarchyTwap - - Update `complete_unlock` to handle remaining_accounts for FutarchyTwap - - Add validation that AMM account matches oracle_reader.amm - -- [ ] 9.3 Update SDK for FutarchyTwap - - Update `startUnlockIx()` to accept optional AMM account - - Update `completeUnlockIx()` to accept optional AMM account - - Run `./rebuild.sh` - -- [ ] 9.4 Write unit tests for FutarchyTwap - - Update `initializePerformancePackage.test.ts`: add test for FutarchyTwap + CliffLinear - - Update `initializePerformancePackage.test.ts`: add test for FutarchyTwap + Threshold - - Update `startUnlock.test.ts`: add test for recording start snapshot - - Update `startUnlock.test.ts`: add test for wrong AMM account failure - - Update `completeUnlock.test.ts`: add test for recording end snapshot - - Update `completeUnlock.test.ts`: add test for TWAP computation - - Update `completeUnlock.test.ts`: add test for min_duration not reached failure +All tasks completed! 🎉 From 3904d2145ea2c828674a777bb054736d49a0e7eb Mon Sep 17 00:00:00 2001 From: Pileks Date: Fri, 30 Jan 2026 20:14:54 -0800 Subject: [PATCH 24/27] minor refactor/addition of tests --- programs/performance_package_v2/src/error.rs | 2 +- .../src/state/change_request.rs | 1 - .../src/state/performance_package.rs | 89 ++++++++----------- sdk/src/v0.7/types/performance_package_v2.ts | 30 ++++--- .../unit/executeChange.test.ts | 33 +++++-- 5 files changed, 81 insertions(+), 74 deletions(-) diff --git a/programs/performance_package_v2/src/error.rs b/programs/performance_package_v2/src/error.rs index f74454347..e6d170ca7 100644 --- a/programs/performance_package_v2/src/error.rs +++ b/programs/performance_package_v2/src/error.rs @@ -45,7 +45,7 @@ pub enum PerformancePackageError { RewardCalculationOverflow, // Configuration - #[msg("Tranches not sorted or empty")] + #[msg("Tranches should be sorted and non-empty")] InvalidTranches, #[msg("Invalid vesting schedule configuration")] InvalidVestingSchedule, diff --git a/programs/performance_package_v2/src/state/change_request.rs b/programs/performance_package_v2/src/state/change_request.rs index 838e35c3e..c39a399be 100644 --- a/programs/performance_package_v2/src/state/change_request.rs +++ b/programs/performance_package_v2/src/state/change_request.rs @@ -24,7 +24,6 @@ pub struct ChangeRequest { pub pda_nonce: u32, pub bump: u8, - // === Optional Changes (at least one must be Some) === /// New recipient address (if changing) pub new_recipient: Option, /// New oracle configuration (if changing) diff --git a/programs/performance_package_v2/src/state/performance_package.rs b/programs/performance_package_v2/src/state/performance_package.rs index 9cf3ca1d8..aa12dcf50 100644 --- a/programs/performance_package_v2/src/state/performance_package.rs +++ b/programs/performance_package_v2/src/state/performance_package.rs @@ -87,14 +87,14 @@ impl OracleReader { /// Validates the oracle reader configuration. pub fn validate(&self) -> Result<()> { match self { - OracleReader::Time => { + &OracleReader::Time => { // Time oracle has no configuration to validate Ok(()) } - OracleReader::FutarchyTwap { min_duration, .. } => { + &OracleReader::FutarchyTwap { min_duration, .. } => { // min_duration must be > 0 to avoid division by zero in TWAP calculation require!( - *min_duration > 0, + min_duration > 0, PerformancePackageError::InvalidVestingSchedule ); Ok(()) @@ -191,22 +191,18 @@ impl OracleReader { end_time, .. } => { - // Calculate time delta - let time_delta = end_time - .checked_sub(*start_time) - .ok_or(PerformancePackageError::OracleInvalidState)?; + let time_delta = end_time - start_time; // Ensure time_delta > 0 to avoid division by zero require!(time_delta > 0, PerformancePackageError::OracleInvalidState); // Calculate TWAP: (end_value - start_value) / time_delta // Note: end_value should always be >= start_value since aggregator is cumulative - // If end_value < start_value, the aggregator wrapped (extremely rare) + // If end_value < start_value, the aggregator wrapped - rare but possible + // wrapping_sub ensures we get the correct difference despite wrapping let value_delta = end_value.wrapping_sub(*start_value); - let twap = value_delta - .checked_div(time_delta as u128) - .ok_or(PerformancePackageError::OracleInvalidState)?; + let twap = value_delta / time_delta as u128; Ok(twap) } @@ -218,9 +214,7 @@ impl OracleReader { /// For FutarchyTwap, clears the start and end snapshots. pub fn reset(&mut self) { match self { - OracleReader::Time => { - // No-op for Time oracle - no state to reset - } + OracleReader::Time => {} OracleReader::FutarchyTwap { start_value, start_time, @@ -253,6 +247,7 @@ impl RewardFunction { start_value <= cliff_value && cliff_value <= end_value, PerformancePackageError::InvalidVestingSchedule ); + // cliff_amount <= total_amount require!( cliff_amount <= total_amount, @@ -286,10 +281,10 @@ impl RewardFunction { } /// Calculates the cumulative rewards earned for a given oracle value. - /// Returns total tokens deserved so far (not incremental). + /// Returns total tokens deserved so far (cumulative, not incremental). pub fn calculate(&self, value: u128) -> Result { match self { - RewardFunction::CliffLinear { + &RewardFunction::CliffLinear { start_value, cliff_value, end_value, @@ -297,50 +292,41 @@ impl RewardFunction { total_amount, } => { // Before start: 0 rewards - if value < *start_value { + if value < start_value { return Ok(0); } // Before cliff: 0 rewards - if value < *cliff_value { + if value < cliff_value { return Ok(0); } // At or after end: full rewards - if value >= *end_value { - return Ok(*total_amount); + if value >= end_value { + return Ok(total_amount); } - // Between cliff and end: cliff_amount + linear interpolation - // linear_portion = (value - cliff_value) / (end_value - cliff_value) * (total_amount - cliff_amount) + // Between cliff and end: cliff_amount + linear_portion + // linear_portion = (value - cliff_value) * (total_amount - cliff_amount) / (end_value - cliff_value) - let value_progress = value.checked_sub(*cliff_value).unwrap_or(0); - let value_range = end_value - .checked_sub(*cliff_value) - .ok_or(PerformancePackageError::RewardCalculationOverflow)?; + // Value progress is zero if value is less than cliff_value + let value_progress = value.saturating_sub(cliff_value); + let value_range = end_value - cliff_value; - // Avoid division by zero + // If there's no linear range, it's only a cliff if value_range == 0 { - return Ok(*cliff_amount); + return Ok(cliff_amount); } - let linear_amount = (*total_amount as u128) - .checked_sub(*cliff_amount as u128) - .ok_or(PerformancePackageError::RewardCalculationOverflow)?; + let linear_amount = (total_amount - cliff_amount) as u128; // Calculate: cliff_amount + (value_progress * linear_amount / value_range) - let linear_portion = value_progress - .checked_mul(linear_amount) - .ok_or(PerformancePackageError::RewardCalculationOverflow)? - .checked_div(value_range) - .ok_or(PerformancePackageError::RewardCalculationOverflow)?; - - let result = (*cliff_amount as u128) - .checked_add(linear_portion) - .ok_or(PerformancePackageError::RewardCalculationOverflow)?; - - // Safe to convert since total_amount is u64 and result <= total_amount - Ok(result as u64) + let linear_portion = u64::try_from(value_progress * linear_amount / value_range) + .map_err(|_| PerformancePackageError::RewardCalculationOverflow)?; + + let result = cliff_amount + linear_portion; + + Ok(result) } RewardFunction::Threshold { tranches } => { // Find the highest threshold that value meets @@ -363,7 +349,7 @@ impl RewardFunction { pub struct ThresholdTranche { /// Oracle value threshold pub threshold: u128, - /// Total tokens at this level (cumulative, not incremental) + /// Total tokens at this tranche (cumulative, not incremental) pub cumulative_amount: u64, } @@ -397,7 +383,6 @@ pub enum RewardFunction { #[account] #[derive(InitSpace, Debug)] pub struct PerformancePackage { - // === Core References === /// Token mint controlled by mint_governor pub mint: Pubkey, /// MintGovernor account @@ -405,32 +390,28 @@ pub struct PerformancePackage { /// MintAuthority PDA for this PP pub mint_authority: Pubkey, - // === Authorities === - /// DAO multisig vault - can modify PP + /// Usually the DAO multisig vault - can modify PP pub authority: Pubkey, - /// Team multisig - receives minted tokens + /// Usually the team multisig - receives minted tokens pub recipient: Pubkey, - // === Inline Configuration === /// Stores start/end snapshots for oracle calculations pub oracle_reader: OracleReader, /// How to calculate rewards pub reward_function: RewardFunction, - // === Lifecycle === - /// Locked or Unlocking + /// Locked or Unlocking state pub status: PackageStatus, /// Can't start unlock before this time pub min_unlock_timestamp: i64, - // === Payout Tracking === - /// Cumulative tokens minted to recipient + /// Cumulative tokens minted to the recipient pub total_rewards_paid_out: u64, /// Event sequence number pub seq_num: u64, - // === PDA === /// Used for PDA derivation pub create_key: Pubkey, + /// PDA bump pub bump: u8, } diff --git a/sdk/src/v0.7/types/performance_package_v2.ts b/sdk/src/v0.7/types/performance_package_v2.ts index 9d7fac816..9f9bc7fd2 100644 --- a/sdk/src/v0.7/types/performance_package_v2.ts +++ b/sdk/src/v0.7/types/performance_package_v2.ts @@ -338,12 +338,12 @@ export type PerformancePackageV2 = { }, { name: "authority"; - docs: ["DAO multisig vault - can modify PP"]; + docs: ["Usually the DAO multisig vault - can modify PP"]; type: "publicKey"; }, { name: "recipient"; - docs: ["Team multisig - receives minted tokens"]; + docs: ["Usually the team multisig - receives minted tokens"]; type: "publicKey"; }, { @@ -362,7 +362,7 @@ export type PerformancePackageV2 = { }, { name: "status"; - docs: ["Locked or Unlocking"]; + docs: ["Locked or Unlocking state"]; type: { defined: "PackageStatus"; }; @@ -374,7 +374,7 @@ export type PerformancePackageV2 = { }, { name: "totalRewardsPaidOut"; - docs: ["Cumulative tokens minted to recipient"]; + docs: ["Cumulative tokens minted to the recipient"]; type: "u64"; }, { @@ -389,6 +389,7 @@ export type PerformancePackageV2 = { }, { name: "bump"; + docs: ["PDA bump"]; type: "u8"; }, ]; @@ -488,7 +489,9 @@ export type PerformancePackageV2 = { }, { name: "cumulativeAmount"; - docs: ["Total tokens at this level (cumulative, not incremental)"]; + docs: [ + "Total tokens at this tranche (cumulative, not incremental)", + ]; type: "u64"; }, ]; @@ -969,7 +972,7 @@ export type PerformancePackageV2 = { { code: 6015; name: "InvalidTranches"; - msg: "Tranches not sorted or empty"; + msg: "Tranches should be sorted and non-empty"; }, { code: 6016; @@ -1329,12 +1332,12 @@ export const IDL: PerformancePackageV2 = { }, { name: "authority", - docs: ["DAO multisig vault - can modify PP"], + docs: ["Usually the DAO multisig vault - can modify PP"], type: "publicKey", }, { name: "recipient", - docs: ["Team multisig - receives minted tokens"], + docs: ["Usually the team multisig - receives minted tokens"], type: "publicKey", }, { @@ -1353,7 +1356,7 @@ export const IDL: PerformancePackageV2 = { }, { name: "status", - docs: ["Locked or Unlocking"], + docs: ["Locked or Unlocking state"], type: { defined: "PackageStatus", }, @@ -1365,7 +1368,7 @@ export const IDL: PerformancePackageV2 = { }, { name: "totalRewardsPaidOut", - docs: ["Cumulative tokens minted to recipient"], + docs: ["Cumulative tokens minted to the recipient"], type: "u64", }, { @@ -1380,6 +1383,7 @@ export const IDL: PerformancePackageV2 = { }, { name: "bump", + docs: ["PDA bump"], type: "u8", }, ], @@ -1479,7 +1483,9 @@ export const IDL: PerformancePackageV2 = { }, { name: "cumulativeAmount", - docs: ["Total tokens at this level (cumulative, not incremental)"], + docs: [ + "Total tokens at this tranche (cumulative, not incremental)", + ], type: "u64", }, ], @@ -1960,7 +1966,7 @@ export const IDL: PerformancePackageV2 = { { code: 6015, name: "InvalidTranches", - msg: "Tranches not sorted or empty", + msg: "Tranches should be sorted and non-empty", }, { code: 6016, diff --git a/tests/performancePackageV2/unit/executeChange.test.ts b/tests/performancePackageV2/unit/executeChange.test.ts index cb22d85d9..faaabb7dd 100644 --- a/tests/performancePackageV2/unit/executeChange.test.ts +++ b/tests/performancePackageV2/unit/executeChange.test.ts @@ -9,6 +9,7 @@ import { setupPerformancePackageV2, createCliffLinearReward, createThresholdReward, + createFutarchyTwapOracle, } from "../utils.js"; import { expectError } from "../../utils.js"; @@ -202,6 +203,10 @@ export default function suite() { const authority = Keypair.generate(); const recipient = Keypair.generate(); + // Initialize with FutarchyTwap oracle + const fakeAmm = Keypair.generate().publicKey; + const initialOracleReader = createFutarchyTwapOracle({ amm: fakeAmm }); + const { performancePackage } = await setupPerformancePackageV2( this.banksClient, mintGovernorClient, @@ -210,16 +215,21 @@ export default function suite() { { authority: authority.publicKey, recipient: recipient.publicKey, + oracleReader: initialOracleReader, rewardFunction: createCliffLinearReward(), minUnlockTimestamp: new BN(0), }, ); - // Verify initial oracle is Time + // Verify initial oracle is FutarchyTwap let ppAccount = await ppClient.fetchPerformancePackage(performancePackage); - assert.isDefined(ppAccount.oracleReader.time); + assert.isDefined(ppAccount.oracleReader.futarchyTwap); + assert.equal( + ppAccount.oracleReader.futarchyTwap.amm.toBase58(), + fakeAmm.toBase58(), + ); - // Authority proposes oracle change (time -> time, but verifies the mechanism works) + // Authority proposes oracle change (FutarchyTwap -> Time) const pdaNonce = 1; const [changeRequest] = ppClient.getChangeRequestAddr( performancePackage, @@ -251,7 +261,7 @@ export default function suite() { .signers([recipient]) .rpc(); - // Verify oracle is still Time (the change was executed successfully) + // Verify oracle was changed to Time ppAccount = await ppClient.fetchPerformancePackage(performancePackage); assert.isDefined(ppAccount.oracleReader.time); }); @@ -352,7 +362,9 @@ export default function suite() { pdaNonce, ); - const newOracleReader = { time: {} }; + // We don't need a real AMM for this test, just a pubkey + const fakeAmm = Keypair.generate().publicKey; + const newOracleReader = createFutarchyTwapOracle({ amm: fakeAmm }); const newRewardFunction = createThresholdReward([ { threshold: new BN(100), cumulativeAmount: new BN(100_000_000) }, ]); @@ -387,7 +399,16 @@ export default function suite() { ppAccount.recipient.toBase58(), newRecipient.publicKey.toBase58(), ); - assert.isDefined(ppAccount.oracleReader.time); + assert.isDefined(ppAccount.oracleReader.futarchyTwap); + assert.equal( + ppAccount.oracleReader.futarchyTwap.amm.toBase58(), + fakeAmm.toBase58(), + ); + assert.equal(ppAccount.oracleReader.futarchyTwap.minDuration, 60); + assert.equal(ppAccount.oracleReader.futarchyTwap.startValue.toNumber(), 0); + assert.equal(ppAccount.oracleReader.futarchyTwap.startTime.toNumber(), 0); + assert.equal(ppAccount.oracleReader.futarchyTwap.endValue.toNumber(), 0); + assert.equal(ppAccount.oracleReader.futarchyTwap.endTime.toNumber(), 0); assert.isDefined(ppAccount.rewardFunction.threshold); }); From 3cb4d49cbc8b0e483d9f21f8afcc2e0f527415ec Mon Sep 17 00:00:00 2001 From: Pileks Date: Fri, 30 Jan 2026 21:51:35 -0800 Subject: [PATCH 25/27] ppv2 - twap-specific test for no decrease in rewards --- .../unit/completeUnlock.test.ts | 178 +++++ vibes/000-mint-governor.md | 466 ------------ vibes/001-performance-package-v2.md | 691 ------------------ vibes/task-template.md | 54 -- vibes/tasks.md | 32 - 5 files changed, 178 insertions(+), 1243 deletions(-) delete mode 100644 vibes/000-mint-governor.md delete mode 100644 vibes/001-performance-package-v2.md delete mode 100644 vibes/task-template.md delete mode 100644 vibes/tasks.md diff --git a/tests/performancePackageV2/unit/completeUnlock.test.ts b/tests/performancePackageV2/unit/completeUnlock.test.ts index f790a035e..a8edca147 100644 --- a/tests/performancePackageV2/unit/completeUnlock.test.ts +++ b/tests/performancePackageV2/unit/completeUnlock.test.ts @@ -546,6 +546,184 @@ export default function suite() { assert.equal(ppAccount.totalRewardsPaidOut.toString(), "300000000"); }); + it("rewards only increase (never decrease) with FutarchyTwap and CliffLinear", async function () { + // Setup DAO for TWAP oracle + const dao = await setupDaoForTwapTests(this); + + const authority = Keypair.generate(); + const recipient = Keypair.generate(); + + const createKey = Keypair.generate(); + const [performancePackage] = getPerformancePackageV2Addr({ + createKey: createKey.publicKey, + }); + + const { mint, mintGovernor, mintAuthority } = + await setupMintGovernorWithAuthority( + this.banksClient, + mintGovernorClient, + this.payer, + performancePackage, + ); + + // FutarchyTwap oracle with short min_duration + const minDuration = 10; + const oracleReader = createFutarchyTwapOracle({ amm: dao, minDuration }); + + // CliffLinear reward function based on TWAP thresholds + // The DAO's twapInitialObservation is ~1000 (from setupDaoForTwapTests) + // TWAP of 1000 is 50% into the 500-1500 range → earns ~300 tokens + const rewardFunction = createCliffLinearReward({ + startValue: new BN(0), + cliffValue: new BN(500), // Cliff at TWAP = 500 + endValue: new BN(1500), // End at TWAP = 1500 + cliffAmount: new BN(100_000_000), // 100 tokens at cliff + totalAmount: new BN(500_000_000), // 500 tokens at end + }); + + await ppClient + .initializePerformancePackageIx({ + createKey: createKey.publicKey, + mint, + mintGovernor, + mintAuthority, + authority: authority.publicKey, + recipient: recipient.publicKey, + payer: this.payer.publicKey, + oracleReader, + rewardFunction, + minUnlockTimestamp: new BN(0), + }) + .signers([createKey]) + .rpc(); + + await createRecipientAta(this, mint, recipient.publicKey); + + // Advance time so effective aggregator will be non-zero + await this.advanceBySeconds(10); + + // === FIRST UNLOCK CYCLE === + await ppClient + .startUnlockIx({ + performancePackage, + signer: authority.publicKey, + dao, + }) + .signers([authority]) + .rpc(); + + // Record the start snapshot for later manipulation + let ppAccount = await ppClient.fetchPerformancePackage(performancePackage); + + await this.advanceBySeconds(minDuration + 5); + + await ppClient + .completeUnlockIx({ + performancePackage, + mintGovernor, + mintAuthority, + mint, + recipient: recipient.publicKey, + signer: authority.publicKey, + dao, + }) + .signers([authority]) + .rpc(); + + // Verify first cycle rewards (should earn based on TWAP ~1000) + let recipientBalance = await this.getTokenBalance( + mint, + recipient.publicKey, + ); + const firstCycleReward = Number(recipientBalance); + assert.isTrue(firstCycleReward > 0); + + ppAccount = await ppClient.fetchPerformancePackage(performancePackage); + assert.equal( + ppAccount.totalRewardsPaidOut.toString(), + recipientBalance.toString(), + ); + + // === SECOND UNLOCK CYCLE WITH MANIPULATED LOWER TWAP === + await this.advanceBySeconds(10); + + await ppClient + .startUnlockIx({ + performancePackage, + signer: authority.publicKey, + dao, + }) + .signers([authority]) + .rpc(); + + // Get the start snapshot value for second cycle + ppAccount = await ppClient.fetchPerformancePackage(performancePackage); + const secondCycleStartValue = + ppAccount.oracleReader.futarchyTwap.startValue; + const secondCycleStartTime = ppAccount.oracleReader.futarchyTwap.startTime; + + await this.advanceBySeconds(minDuration + 5); + + // === MANIPULATE DAO AGGREGATOR TO PRODUCE LOW TWAP === + // TWAP = (end_aggregator - start_aggregator) / (end_time - start_time) + // We want TWAP < 500 (below cliff) so calculated reward = 0 + // But we've already paid firstCycleReward, so no decrease should happen + // + // Note: The aggregator always INCREASES - it's cumulative. But the RATE + // of increase can vary. If price is lower during cycle 2, the aggregator + // grows more slowly, producing a lower TWAP. This is a valid real-world + // scenario (price dropped). + + const AGGREGATOR_OFFSET = 9; // 8 (discriminator) + 1 (enum discriminant) + const AGGREGATOR_SIZE = 16; // u128 + + const daoAccount = await this.banksClient.getAccount(dao); + + // Calculate a low aggregator: startValue + (low_twap * time_delta) + // For TWAP = 100 (well below cliff of 500): + const currentClock = await this.banksClient.getClock(); + const timeDelta = + Number(currentClock.unixTimestamp) - Number(secondCycleStartTime); + const targetTwap = new BN(100); // Very low TWAP, below cliff + const newAggregator = secondCycleStartValue.add(targetTwap.muln(timeDelta)); + + // Directly modify the aggregator at offset 9 + const aggregatorBuffer = newAggregator.toArrayLike( + Buffer, + "le", + AGGREGATOR_SIZE, + ); + daoAccount.data.set(aggregatorBuffer, AGGREGATOR_OFFSET); + + // Update the account in the test context + this.context.setAccount(dao, daoAccount); + + // Complete unlock - TWAP will be ~100, which is below cliff (500) + // So calculated reward = 0, but we've already paid firstCycleReward + await ppClient + .completeUnlockIx({ + performancePackage, + mintGovernor, + mintAuthority, + mint, + recipient: recipient.publicKey, + signer: authority.publicKey, + dao, + }) + .signers([authority]) + .rpc(); + + // === VERIFY REWARDS DID NOT DECREASE === + recipientBalance = await this.getTokenBalance(mint, recipient.publicKey); + assert.equal(recipientBalance.toString(), firstCycleReward.toString()); + + ppAccount = await ppClient.fetchPerformancePackage(performancePackage); + assert.equal( + ppAccount.totalRewardsPaidOut.toString(), + firstCycleReward.toString(), + ); + }); + it("succeeds with zero mint amount when rewards already paid", async function () { const authority = Keypair.generate(); const recipient = Keypair.generate(); diff --git a/vibes/000-mint-governor.md b/vibes/000-mint-governor.md deleted file mode 100644 index be9cfea28..000000000 --- a/vibes/000-mint-governor.md +++ /dev/null @@ -1,466 +0,0 @@ -# Mint Governor Program - -## Overview - -The Mint Governor program allows a DAO to transfer the mint authority of its token to a program-controlled PDA. This enables granular delegation of minting rights to multiple addresses with configurable limits, while maintaining a single admin that controls all delegations. - -**Key Features:** -- Transfer mint authority to a program-controlled PDA -- Admin can grant/revoke mint rights to other addresses -- Optional total mint limits for delegated minters -- Admin can reclaim full mint authority if needed -- Tracking of total minted amounts per authorized minter - -**MetaDAO-specific details** - -- DAO’s Squads Multisig Vault would be the admin for the Mint Governor (or DAO, but this is less likely) -- Mint Authority rights can then be given to: - - The Squads Multisig Vault - - Price-based performance package - - ??? - ---- - -## Account Structure - -### MintGovernor (PDA) -The main account that holds configuration for a governed mint. Seeds: `["mint_governor", mint, create_key]` - -The `create_key` is required to prevent frontrunning attacks. Without it, an attacker could race to create a MintGovernor for any mint and set themselves as admin before the legitimate party. The `create_key` must sign during initialization, ensuring only the intended party can create that specific PDA. - -Note: Multiple MintGovernors can exist for the same mint (with different create_keys), but only one can hold the actual mint authority at a time. - -```rust -pub struct MintGovernor { - pub mint: Pubkey, // The token mint being governed - pub admin: Pubkey, // Admin who can grant/revoke mint rights - pub create_key: Pubkey, // Key used in PDA derivation (anti-frontrun) - pub seq_num: u64, // Sequence number for event ordering (starts at 0) - pub bump: u8, // PDA bump -} -``` - -### MintAuthority -Represents an address that has been granted minting rights. Seeds: `["mint_authority", mint_governor, authorized_minter]` - -```rust -pub struct MintAuthority { - pub mint_governor: Pubkey, // Reference to the governor - pub authorized_minter: Pubkey, // Address that can mint - pub max_total: Option, // Max total tokens this minter can ever mint (None = unlimited) - pub total_minted: u64, // Running total of tokens minted by this authority - pub bump: u8, -} -``` - ---- - -## Instructions - -### 1. `initialize_mint_governor` -Creates a MintGovernor account for a mint. Does NOT transfer authority yet. - -**Accounts:** -- `mint` - The token mint -- `mint_governor` - PDA to create (seeds: `["mint_governor", mint, create_key]`) -- `create_key` - Key used in PDA derivation, prevents frontrunning (signer) -- `admin` - Will become the admin -- `payer` - Pays for account creation -- `system_program` - -**Args:** None - -**Emits:** `MintGovernorInitializedEvent` - ---- - -### 2. `transfer_authority_to_governor` -Transfers the mint authority from current authority to the MintGovernor PDA. - -**Accounts:** -- `mint` - The token mint (mut) -- `mint_governor` - The governor PDA (mut, for seq_num increment) -- `current_authority` - Current mint authority (signer) -- `token_program` - -**Args:** None - -**Checks:** -- `mint_governor.mint == mint.key()` - Ensures the governor is for this specific mint - -**Emits:** `MintAuthorityTransferredEvent` - ---- - -### 3. `add_mint_authority` -Admin grants minting rights to an address. - -**Accounts:** -- `mint_governor` - The governor (mut, for seq_num increment) -- `mint_authority` - PDA to create for the authorized minter -- `admin` - Must be governor's admin (signer) -- `authorized_minter` - Address receiving mint rights -- `payer` - Pays for account creation -- `system_program` - -**Args:** -- `max_total: Option` - Optional lifetime limit - -**Emits:** `MintAuthorityAddedEvent` - ---- - -### 4. `update_mint_authority` -Admin updates the limits for an existing authorized minter. - -**Accounts:** -- `mint_governor` - The governor (mut, for seq_num increment) -- `mint_authority` - Existing authority account (mut) -- `admin` - Must be governor's admin (signer) - -**Args:** -- `max_total: Option` - New lifetime limit - -**Notes:** -- Setting `max_total` to a value less than or equal to `total_minted` acts as a "soft revoke" - the minter can no longer mint but their account and history remain intact. - -**Emits:** `MintAuthorityUpdatedEvent` - ---- - -### 5. `remove_mint_authority` -Admin revokes minting rights from an address, closing the account. - -**Accounts:** -- `mint_governor` - The governor (mut, for seq_num increment) -- `mint_authority` - Authority account to close (mut) -- `admin` - Must be governor's admin (signer) -- `rent_destination` - Receives closed account rent - -**Args:** None - -**Emits:** `MintAuthorityRemovedEvent` - ---- - -### 6. `mint_tokens` -An authorized minter mints tokens to a destination. - -**Accounts:** -- `mint_governor` - The governor (mut, for seq_num increment) -- `mint_authority` - Minter's authority record (mut, for updating total_minted) -- `mint` - The token mint (mut) -- `destination` - Token account to mint to (mut) -- `authorized_minter` - Must match mint_authority.authorized_minter (signer) -- `token_program` - -**Args:** -- `amount: u64` - Amount to mint - -**Checks:** -- `total_minted + amount <= max_total` (if set) - -**Emits:** `TokensMintedEvent` - ---- - -### 7. `update_mint_governor_admin` -Admin transfers admin rights to a new address. - -**Accounts:** -- `mint_governor` - The governor (mut, for admin update and seq_num increment) -- `admin` - Current admin (signer) -- `new_admin` - New admin address - -**Args:** None - -**Emits:** `MintGovernorAdminUpdatedEvent` - ---- - -### 8. `reclaim_authority` -Admin reclaims the mint authority back from the program to any address. - -**Accounts:** -- `mint_governor` - The governor (mut, for seq_num increment) -- `mint` - The token mint (mut) -- `admin` - Must be governor's admin (signer) -- `new_authority` - Address to receive mint authority -- `token_program` - -**Args:** None - -**Notes:** -- Existing MintAuthority accounts become non-functional after this call (they can no longer mint since the governor no longer holds authority). These accounts are intentionally left in place to preserve historical records and can be closed via `remove_mint_authority` if desired. - -**Emits:** `MintAuthorityReclaimedEvent` - ---- - -## Events - -All events include `CommonFields` for consistent metadata: - -```rust -#[derive(AnchorSerialize, AnchorDeserialize)] -pub struct CommonFields { - pub slot: u64, - pub unix_timestamp: i64, - pub mint_governor_seq_num: u64, -} - -impl CommonFields { - pub fn new(clock: &Clock, mint_governor_seq_num: u64) -> Self { - Self { - slot: clock.slot, - unix_timestamp: clock.unix_timestamp, - mint_governor_seq_num, - } - } -} -``` - -### MintGovernorInitializedEvent -Emitted by: `initialize_mint_governor` - -```rust -#[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, -} -``` - -### MintAuthorityTransferredEvent -Emitted by: `transfer_authority_to_governor` - -```rust -#[event] -pub struct MintAuthorityTransferredEvent { - pub common: CommonFields, - pub mint_governor: Pubkey, - pub mint: Pubkey, - pub previous_authority: Pubkey, -} -``` - -### MintAuthorityAddedEvent -Emitted by: `add_mint_authority` - -```rust -#[event] -pub struct MintAuthorityAddedEvent { - pub common: CommonFields, - pub mint_governor: Pubkey, - pub mint_authority: Pubkey, - pub authorized_minter: Pubkey, - pub max_total: Option, -} -``` - -### MintAuthorityUpdatedEvent -Emitted by: `update_mint_authority` - -```rust -#[event] -pub struct MintAuthorityUpdatedEvent { - pub common: CommonFields, - pub mint_governor: Pubkey, - pub mint_authority: Pubkey, - pub authorized_minter: Pubkey, - pub previous_max_total: Option, - pub new_max_total: Option, -} -``` - -### MintAuthorityRemovedEvent -Emitted by: `remove_mint_authority` - -```rust -#[event] -pub struct MintAuthorityRemovedEvent { - pub common: CommonFields, - pub mint_governor: Pubkey, - pub authorized_minter: Pubkey, - pub total_minted: u64, // Final total before revocation -} -``` - -### TokensMintedEvent -Emitted by: `mint_tokens` - -```rust -#[event] -pub struct TokensMintedEvent { - pub common: CommonFields, - pub mint_governor: Pubkey, - pub mint: Pubkey, - pub authorized_minter: Pubkey, - pub destination: Pubkey, - pub amount: u64, - pub post_total_minted: u64, // Updated total for this minter - pub post_mint_supply: u64, // Updated total supply of the mint -} -``` - -### MintGovernorAdminUpdatedEvent -Emitted by: `update_mint_governor_admin` - -```rust -#[event] -pub struct MintGovernorAdminUpdatedEvent { - pub common: CommonFields, - pub mint_governor: Pubkey, - pub previous_admin: Pubkey, - pub new_admin: Pubkey, -} -``` - -### MintAuthorityReclaimedEvent -Emitted by: `reclaim_authority` - -```rust -#[event] -pub struct MintAuthorityReclaimedEvent { - pub common: CommonFields, - pub mint_governor: Pubkey, - pub mint: Pubkey, - pub new_authority: Pubkey, -} -``` - ---- - -## Testing - -Tests are organized as unit tests per instruction, following the pattern established in `tests/bidWall/`. - -### Test Structure - -``` -tests/mintGovernor/ -├── main.test.ts # Imports and describes all unit test suites -├── utils.ts # Shared helper functions -└── unit/ - ├── initializeMintGovernor.test.ts - ├── transferAuthorityToGovernor.test.ts - ├── addMintAuthority.test.ts - ├── updateMintAuthority.test.ts - ├── removeMintAuthority.test.ts - ├── mintTokens.test.ts - ├── updateMintGovernorAdmin.test.ts - └── reclaimAuthority.test.ts -``` - -### Unit Tests by Instruction - -#### `initialize_mint_governor` - -| Test Case | Description | -|-----------|-------------| -| successfully initializes a mint governor | Creates MintGovernor PDA with correct fields (mint, admin, create_key, seq_num=0, bump) | -| fails when create_key does not sign | Rejects if the create_key account is not a signer | - -#### `transfer_authority_to_governor` - -| Test Case | Description | -|-----------|-------------| -| successfully transfers mint authority to governor | Transfers authority from current_authority to MintGovernor PDA | -| fails when current_authority is not the actual mint authority | Rejects if signer doesn't own mint authority | -| fails when mint_governor.mint does not match mint | Rejects if wrong governor PDA is provided | -| fails when governor does not hold authority after previous reclaim | Ensures transfer works correctly on re-transfer scenario | - -#### `add_mint_authority` - -| Test Case | Description | -|-----------|-------------| -| successfully adds mint authority with max_total | Creates MintAuthority PDA with limit set | -| successfully adds mint authority without max_total (unlimited) | Creates MintAuthority PDA with None limit | -| fails when admin is not the governor's admin | Rejects unauthorized admin | -| fails when mint_authority already exists | Rejects duplicate creation | - -#### `update_mint_authority` - -| Test Case | Description | -|-----------|-------------| -| successfully updates max_total to a new value | Updates limit from one value to another | -| successfully updates max_total to None (unlimited) | Removes limit | -| successfully updates max_total to value <= total_minted (soft revoke) | Sets limit that prevents further minting | -| fails when admin is not the governor's admin | Rejects unauthorized admin | -| fails when mint_authority does not exist | Rejects update on non-existent authority | - -#### `remove_mint_authority` - -| Test Case | Description | -|-----------|-------------| -| successfully removes mint authority | Closes MintAuthority account and returns rent | -| successfully removes mint authority that has minted tokens | Confirms historical minting doesn't block removal | -| fails when admin is not the governor's admin | Rejects unauthorized admin | -| fails when mint_authority does not exist | Rejects removal of non-existent authority | - -#### `mint_tokens` - -| Test Case | Description | -|-----------|-------------| -| successfully mints tokens within limit | Mints amount that stays under max_total | -| successfully mints tokens with unlimited authority | Mints with None max_total | -| successfully mints tokens up to exact limit | Mints exactly remaining quota | -| successfully mints multiple times accumulating total_minted | Verifies total_minted tracks correctly across calls | -| fails when amount exceeds remaining quota | Rejects mint that would exceed max_total | -| fails when authorized_minter is not the signer | Rejects unauthorized minter | -| fails when governor does not hold mint authority | Rejects if authority was reclaimed | -| fails when mint_authority.mint_governor does not match | Rejects mismatched authority/governor | - -#### `update_mint_governor_admin` - -| Test Case | Description | -|-----------|-------------| -| successfully updates admin | Transfers admin rights to new address | -| new admin can perform admin actions | Verifies new admin can add/remove authorities | -| old admin cannot perform admin actions after transfer | Verifies old admin is rejected | -| fails when admin is not the current admin | Rejects unauthorized admin change | - -#### `reclaim_authority` - -| Test Case | Description | -|-----------|-------------| -| successfully reclaims authority to new address | Transfers mint authority from PDA to new_authority | -| successfully reclaims authority back to admin | Admin can reclaim to themselves | -| existing mint authorities cannot mint after reclaim | Verifies MintAuthority accounts become non-functional | -| mint authorities can still be removed after reclaim | Verifies cleanup still works | -| fails when admin is not the governor's admin | Rejects unauthorized reclaim | -| fails when governor does not currently hold mint authority | Rejects if authority already transferred away | - ---- - -## Potential Improvements - -### Combine `add_mint_authority` and `update_mint_authority` into `set_mint_authority` - -The `add_mint_authority` and `update_mint_authority` instructions could be combined into a single `set_mint_authority` instruction using Anchor's `init_if_needed` constraint. This would: - -- Simplify the API from two instructions to one -- Make the instruction idempotent (calling with the same params produces consistent results) -- Reduce code duplication - -**Implementation approach:** -```rust -#[account( - init_if_needed, - 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>, -``` - -The handler would detect if the account was freshly initialized (e.g., by checking if `bump == 0` before setting fields) and: -- On init: set all fields (`mint_governor`, `authorized_minter`, `max_total`, `total_minted = 0`, `bump`) -- On update: only update `max_total` - -Different events could be emitted based on whether it was an init or update, or a single `MintAuthoritySetEvent` could include a boolean flag indicating if it was newly created. diff --git a/vibes/001-performance-package-v2.md b/vibes/001-performance-package-v2.md deleted file mode 100644 index 5c5bbeb3f..000000000 --- a/vibes/001-performance-package-v2.md +++ /dev/null @@ -1,691 +0,0 @@ -# Performance Package v2 Program - -## Overview - -Performance Package v2 (PP v2) is a token minting program that rewards teams based on achieved milestones. It is the spiritual successor to `price_based_performance_package` (v1), with key architectural differences: - -**Key Features:** -- Modular oracle system via inline enum `OracleReader` variants -- Configurable reward calculations via inline `RewardFunction` variants -- Two-phase lifecycle: Locked → Unlocking → Locked (repeats) -- Two-party approval for critical changes (authority + recipient must agree) -- Integration with `mint_governor` for token minting - -**Comparison with v1:** - -| Aspect | v1 | v2 | -|--------|----|----| -| Token source | Pre-funded vault | Minted via `mint_governor` | -| Reward logic | Fixed price tranches | Modular OracleReader + RewardFunction | -| Oracle types | Single Switchboard oracle | Multiple oracle types supported | -| Coexistence | Existing deployments remain | New launches use v2 | - ---- - -## Constants - -### MetaDAO Operational Multisig - -The admin address for privileged operations (e.g., `close_performance_package`). Uses the same pattern as v1's `burn_performance_package`: - -```rust -pub mod admin { - use anchor_lang::prelude::declare_id; - - // MetaDAO operational multisig - declare_id!("6awyHMshBGVjJ3ozdSJdyyDE1CTAXUwrpNMaRGMsb4sf"); -} -``` - ---- - -## Account Structure - -### PerformancePackage (PDA) -The main account representing a performance package. Acts as the `authorized_minter` in mint_governor. Seeds: `["performance_package", create_key]` - -```rust -pub struct PerformancePackage { - // === Core References === - pub mint: Pubkey, // Token mint controlled by mint_governor - pub mint_governor: Pubkey, // MintGovernor account - pub mint_authority: Pubkey, // MintAuthority PDA for this PP - - // === Authorities === - pub authority: Pubkey, // DAO multisig vault - can modify PP - pub recipient: Pubkey, // Team multisig - receives minted tokens - - // === Inline Configuration === - pub oracle_reader: OracleReader, // Stores start/end snapshots - pub reward_function: RewardFunction, // How to calculate rewards - - // === Lifecycle === - pub status: PackageStatus, // Locked or Unlocking - pub min_unlock_timestamp: i64, // Can't start before this time - - // === Payout Tracking === - pub total_rewards_paid_out: u64, // Cumulative tokens minted to recipient - pub seq_num: u64, // Event sequence number - - // === PDA === - pub create_key: Pubkey, // Used for PDA derivation - pub bump: u8, -} -``` - -**Payout Logic:** `tokens_to_mint = reward_function.calculate(value) - total_rewards_paid_out` - -### PackageStatus (Enum) -Lifecycle state for the performance package. - -```rust -pub enum PackageStatus { - Locked, // Ready to start (or waiting for min_unlock_timestamp) - Unlocking, // Unlock in progress, waiting for min_duration -} -``` - -### OracleReader (Inline Enum) -An inline enum that knows how to read from an external oracle account. Extracts a `value: u128` and records snapshots for TWAP calculations. - -```rust -pub enum OracleReader { - /// Reads current timestamp from Clock::get() - /// No state needed - just reads current time on demand - Time, - - /// Reads accumulator from Futarchy AMM, computes TWAP - /// Two snapshots: start (on start_unlock) and end (on complete_unlock) - /// TWAP = (end_value - start_value) / (end_time - start_time) - FutarchyTwap { - amm: Pubkey, // The Futarchy AMM account to read - min_duration: u32, // Minimum seconds between start and end - // Start snapshot (recorded on start_unlock) - start_value: u128, - start_time: i64, - // End snapshot (recorded on complete_unlock) - end_value: u128, - end_time: i64, - }, -} -``` - -**External Oracle Accounts:** - -| OracleReader Variant | External Account(s) Needed | What It Reads | -|---------------------|---------------------------|---------------| -| `Time` | None (`Clock::get()`) | `unix_timestamp` | -| `FutarchyTwap` | Futarchy AMM | Price accumulator | - -**Note:** All oracle sources are read via `remaining_accounts`. The `OracleReader` variant determines how many accounts to consume and how to interpret them. - -### RewardFunction (Inline Enum) -An inline enum that calculates cumulative rewards from oracle values. Returns total tokens deserved so far (not incremental). - -```rust -pub enum RewardFunction { - /// Cliff + Linear: cliff_amount at cliff_value, then linear accrual to total_amount at end_value - /// Works with any oracle value (e.g., time, price, or other metrics) - /// For no-cliff behavior, set cliff_value = start_value and cliff_amount = 0 - CliffLinear { - start_value: u128, - cliff_value: u128, - end_value: u128, - cliff_amount: u64, - total_amount: u64, // Includes cliff - }, - - /// Threshold-based tranches (similar to v1) - /// Each tranche: if value >= threshold, cumulative reward = amount - Threshold { - tranches: Vec, // Must be sorted by threshold ascending - }, -} - -pub struct ThresholdTranche { - pub threshold: u128, // Oracle value threshold - pub cumulative_amount: u64, // Total tokens at this level (not incremental) -} -``` - -### ChangeRequest (PDA) -Temporary account for two-party approval flow. Seeds: `["change_request", performance_package, proposer, pda_nonce.to_le_bytes()]` - -```rust -pub struct ChangeRequest { - pub performance_package: Pubkey, - pub proposer_type: ProposerType, // Who proposed - pub proposed_at: i64, // When proposed - pub pda_nonce: u32, // For unique PDA derivation - pub bump: u8, - - // === Optional Changes (at least one must be Some) === - pub new_recipient: Option, - pub new_oracle_reader: Option, - pub new_reward_function: Option, -} - -pub enum ProposerType { - Authority, - Recipient, -} -``` - ---- - -## Instructions - -### 1. `initialize_performance_package` -Creates a PerformancePackage account linked to a mint_governor. - -**Accounts:** -- `performance_package` - PDA to create (seeds: `["performance_package", create_key]`) -- `mint` - The token mint -- `mint_governor` - The MintGovernor for this mint -- `mint_authority` - The MintAuthority PDA for this PP (must exist) -- `create_key` - Key used in PDA derivation (signer) -- `authority` - DAO multisig that will control the PP -- `recipient` - Team multisig that receives minted tokens -- `payer` - Pays for account creation -- `system_program` - -**Args:** -- `oracle_reader: OracleReader` - Oracle configuration -- `reward_function: RewardFunction` - Reward calculation configuration -- `min_unlock_timestamp: i64` - Earliest time unlock can be started - -**Checks:** -- `mint_governor.mint == mint.key()` - Governor controls correct mint -- `mint_authority.mint_governor == mint_governor.key()` - Authority belongs to governor -- `mint_authority.authorized_minter == performance_package.key()` - PP is the authorized minter -- Validates reward_function configuration (e.g., tranches sorted, vesting values ordered correctly) - -**Emits:** `PerformancePackageCreatedEvent` - ---- - -### 2. `start_unlock` -Begins the unlock period (starts oracle recording). - -**Accounts:** -- `performance_package` - The PP (mut) -- `signer` - Must be authority or recipient (signer) - -**Remaining Accounts:** -- `Time` → none -- `FutarchyTwap` → Futarchy AMM account - -**Args:** None - -**Checks:** -- `signer == pp.authority || signer == pp.recipient` -- `pp.status == Locked` -- `Clock::get().unix_timestamp >= pp.min_unlock_timestamp` - -**Logic:** -1. Call `pp.oracle_reader.record_start(remaining_accounts)` -2. Set `pp.status = Unlocking` - -**Emits:** `UnlockStartedEvent` - ---- - -### 3. `complete_unlock` -Completes the unlock period, calculates rewards, mints tokens, and resets for next cycle. - -**Accounts:** -- `performance_package` - The PP (mut, signer via PDA for CPI) -- `mint_governor` - Referenced by `pp.mint_governor` -- `mint_authority` - Referenced by `pp.mint_authority` -- `mint` - Referenced by `pp.mint` (mut) -- `recipient_ata` - Token account for `pp.recipient` (mut) -- `signer` - Must be authority or recipient (signer) -- `token_program` - -**Remaining Accounts:** -- `Time` → none -- `FutarchyTwap` → Futarchy AMM account - -**Args:** None - -**Checks:** -- `signer == pp.authority || signer == pp.recipient` -- `pp.status == Unlocking` -- `pp.oracle_reader.can_end(Clock::get().unix_timestamp)` - min_duration passed -- `pp.mint_governor == mint_governor.key()` -- `pp.mint_authority == mint_authority.key()` -- `pp.mint == mint.key()` - -**Logic:** -1. Call `pp.oracle_reader.record_end(remaining_accounts)` -2. Compute `value = pp.oracle_reader.compute_value()` -3. Compute `cumulative_rewards = pp.reward_function.calculate(value)` -4. If `cumulative_rewards > pp.total_rewards_paid_out`: (rewards only increase) - - `mint_amount = cumulative_rewards - pp.total_rewards_paid_out` - - CPI to `mint_governor::mint_tokens(mint_amount, recipient_ata)` - - `pp.total_rewards_paid_out = cumulative_rewards` -5. Call `pp.oracle_reader.reset()` - prepare for next cycle -6. Set `pp.status = Locked` - -**Emits:** `UnlockCompletedEvent` - ---- - -### 4. `change_authority` -Transfers authority to a new address. - -**Accounts:** -- `performance_package` - The PP (mut) -- `authority` - Must be PP's current authority (signer) -- `new_authority` - The new authority address - -**Args:** None - -**Checks:** -- `authority == pp.authority` - -**Logic:** -1. `pp.authority = new_authority.key()` - -**Notes:** -- Single-signer instruction - current authority can unilaterally transfer authority -- No approval from recipient required - -**Emits:** `AuthorityChangedEvent` - ---- - -### 5. `propose_change` -Proposes a change that requires two-party approval. - -**Accounts:** -- `performance_package` - The PP (mut, for seq_num) -- `change_request` - PDA to create (seeds: `["change_request", pp, proposer, pda_nonce.to_le_bytes()]`) -- `proposer` - Must be authority or recipient (signer) -- `payer` - Pays for account creation -- `system_program` - -**Args:** -- `pda_nonce: u32` - Unique nonce for PDA derivation (allows multiple concurrent proposals) -- `new_recipient: Option` - New recipient address (if changing) -- `new_oracle_reader: Option` - New oracle configuration (if changing) -- `new_reward_function: Option` - New reward function (if changing) - -**Checks:** -- `proposer == pp.authority || proposer == pp.recipient` -- At least one of `new_recipient`, `new_oracle_reader`, or `new_reward_function` must be `Some` -- If `new_oracle_reader.is_some()`: validates the oracle configuration -- If `new_reward_function.is_some()`: validates the reward function configuration - -**Logic:** -1. Determine `proposer_type` based on whether proposer is authority or recipient -2. Create ChangeRequest with optional fields, proposer_type, current timestamp, and pda_nonce - -**Emits:** `ChangeProposedEvent` - ---- - -### 6. `execute_change` -Executes a proposed change (opposite party must sign). - -**Accounts:** -- `performance_package` - The PP (mut) -- `change_request` - The ChangeRequest account (mut, will be closed) -- `executor` - Must be opposite party from proposer (signer) -- `rent_destination` - Receives closed account rent - -**Args:** None - -**Checks:** -- `change_request.performance_package == pp.key()` -- If `proposer_type == Authority`, then `executor == pp.recipient` -- If `proposer_type == Recipient`, then `executor == pp.authority` -- If `new_oracle_reader.is_some() || new_reward_function.is_some()`: `pp.status == Locked` (can only update when not unlocking) - -**Logic:** -Apply all `Some` fields from `change_request`: -- If `new_recipient.is_some()`: `pp.recipient = new_recipient.unwrap()` -- If `new_oracle_reader.is_some()`: `pp.oracle_reader = new_oracle_reader.unwrap()` -- If `new_reward_function.is_some()`: `pp.reward_function = new_reward_function.unwrap()` - -Close `change_request` account after execution. - -**Emits:** `ChangeExecutedEvent` - ---- - -### 7. `close_performance_package` -Closes the PP (admin-only operation). - -**Accounts:** -- `performance_package` - The PP (mut, will be closed) -- `admin` - MetaDAO operational multisig (signer) -- `rent_destination` - Receives closed account rent - -**Args:** None - -**Checks:** -- `admin == METADAO_ADMIN` (hardcoded operational multisig address) -- `pp.status == Locked` - Cannot close while unlocking - -**Notes:** -- This is a destructive operation - any unpaid rewards are forfeited -- Consider doing a final `complete_unlock` before closing to claim any pending rewards -- Similar to v1's `burn_performance_package` instruction - -**Emits:** `PerformancePackageClosedEvent` - ---- - -## Events - -All events include common fields for consistent metadata: - -```rust -pub struct CommonFields { - pub slot: u64, - pub unix_timestamp: i64, - pub performance_package_seq_num: u64, -} -``` - -### PerformancePackageCreatedEvent -Emitted by: `initialize_performance_package` - -```rust -#[event] -pub struct PerformancePackageCreatedEvent { - pub common: CommonFields, - pub performance_package: Pubkey, - pub mint: Pubkey, - pub mint_governor: Pubkey, - pub authority: Pubkey, - pub recipient: Pubkey, - pub create_key: Pubkey, - pub pda_bump: u8, -} -``` - -### UnlockStartedEvent -Emitted by: `start_unlock` - -```rust -#[event] -pub struct UnlockStartedEvent { - pub common: CommonFields, - pub performance_package: Pubkey, - pub start_time: i64, -} -``` - -### UnlockCompletedEvent -Emitted by: `complete_unlock` - -```rust -#[event] -pub struct UnlockCompletedEvent { - pub common: CommonFields, - pub performance_package: Pubkey, - pub oracle_value: u128, - pub recipient: Pubkey, - pub amount_minted: u64, - pub total_rewards_paid_out: u64, // Cumulative after this unlock -} -``` - -### AuthorityChangedEvent -Emitted by: `change_authority` - -```rust -#[event] -pub struct AuthorityChangedEvent { - pub common: CommonFields, - pub performance_package: Pubkey, - pub old_authority: Pubkey, - pub new_authority: Pubkey, -} -``` - -### ChangeProposedEvent -Emitted by: `propose_change` - -```rust -#[event] -pub struct ChangeProposedEvent { - pub common: CommonFields, - pub performance_package: Pubkey, - pub change_request: Pubkey, - pub proposer_type: ProposerType, - pub pda_nonce: u32, - pub new_recipient: Option, - pub new_oracle_reader: Option, - pub new_reward_function: Option, -} -``` - -### ChangeExecutedEvent -Emitted by: `execute_change` - -```rust -#[event] -pub struct ChangeExecutedEvent { - pub common: CommonFields, - pub performance_package: Pubkey, - pub executed_by: Pubkey, - pub new_recipient: Option, - pub new_oracle_reader: Option, - pub new_reward_function: Option, -} -``` - -### PerformancePackageClosedEvent -Emitted by: `close_performance_package` - -```rust -#[event] -pub struct PerformancePackageClosedEvent { - pub common: CommonFields, - pub performance_package: Pubkey, - pub total_rewards_paid_out: u64, // Final cumulative amount paid -} -``` - ---- - -## Testing - -Tests are organized as unit tests per instruction, following the pattern established in `tests/bidWall/` and `tests/mintGovernor/`. - -### Test Structure - -``` -tests/performancePackageV2/ -├── main.test.ts # Imports and describes all unit test suites -├── utils.ts # Shared helper functions -└── unit/ - ├── initializePerformancePackage.test.ts - ├── startUnlock.test.ts - ├── completeUnlock.test.ts - ├── changeAuthority.test.ts - ├── proposeChange.test.ts - ├── executeChange.test.ts - └── closePerformancePackage.test.ts -``` - -### Unit Tests by Instruction - -#### `initialize_performance_package` - -| Test Case | Description | -|-----------|-------------| -| successfully initializes with Time oracle and CliffLinear reward function | Creates PP with Time + CliffLinear | -| successfully initializes with FutarchyTwap oracle and CliffLinear reward function | Creates PP with FutarchyTwap + CliffLinear | -| successfully initializes with FutarchyTwap oracle and Threshold reward function | Creates PP with FutarchyTwap + Threshold | -| fails when create_key does not sign | Rejects if the create_key account is not a signer | -| fails when mint_authority.authorized_minter does not match PP | Rejects if MintAuthority wasn't set up for this PP | -| fails when mint_governor.mint does not match mint | Rejects if wrong governor is provided | -| fails with invalid reward function config | Rejects unsorted tranches, invalid timestamps, etc. | - -#### `start_unlock` - -| Test Case | Description | -|-----------|-------------| -| successfully starts when called by authority | Transitions Locked → Unlocking | -| successfully starts when called by recipient | Transitions Locked → Unlocking | -| records start snapshot for FutarchyTwap | Verifies start_value and start_time are set | -| fails when status is not Locked | Rejects if already Unlocking | -| fails when min_unlock_timestamp not reached | Rejects if current time < min_unlock_timestamp | -| fails when signer is neither authority nor recipient | Rejects unauthorized caller | -| fails when AMM account doesn't match for FutarchyTwap | Rejects wrong remaining account | - -#### `complete_unlock` - -| Test Case | Description | -|-----------|-------------| -| successfully completes unlock and mints tokens | Transitions Unlocking → Locked, mints tokens | -| records end snapshot for FutarchyTwap | Verifies end_value and end_time are set | -| correctly computes TWAP for FutarchyTwap | Verifies (end_value - start_value) / (end_time - start_time) | -| mints correct amount to recipient | Mints cumulative_rewards - total_rewards_paid_out | -| updates total_rewards_paid_out | Verifies tracking equals cumulative rewards after mint | -| resets oracle state | Verifies start/end values reset to 0 | -| rewards only increase (never decrease) | Verifies lower oracle value doesn't reduce rewards | -| succeeds with zero mint amount | No-op mint when rewards already paid | -| can be started again after complete | Verifies cycle can repeat | -| fails when status is not Unlocking | Rejects if Locked | -| fails when min_duration not reached | Rejects if oracle's min_duration hasn't passed | -| fails when signer is neither authority nor recipient | Rejects unauthorized caller | -| fails when mint_governor doesn't match | Rejects wrong governor | - -#### `change_authority` - -| Test Case | Description | -|-----------|-------------| -| successfully changes authority | Updates pp.authority to new address | -| new authority can perform authority actions | Verifies new authority can call start_unlock, etc. | -| old authority cannot perform authority actions after change | Verifies old authority is rejected | -| fails when signer is not current authority | Rejects unauthorized caller | - -#### `propose_change` - -| Test Case | Description | -|-----------|-------------| -| successfully proposes change when called by authority | Creates ChangeRequest with ProposerType::Authority | -| successfully proposes change when called by recipient | Creates ChangeRequest with ProposerType::Recipient | -| successfully proposes oracle change | Creates ChangeRequest with new_oracle_reader | -| successfully proposes reward function change | Creates ChangeRequest with new_reward_function | -| successfully proposes multiple changes at once | Creates ChangeRequest with multiple Some fields | -| allows multiple concurrent proposals with different nonces | Verifies pda_nonce uniqueness | -| fails when all optional fields are None | Rejects proposal with no changes | -| fails when signer is neither authority nor recipient | Rejects unauthorized caller | -| fails with invalid oracle config | Rejects invalid oracle_reader | -| fails with invalid reward function config | Rejects invalid reward_function | - -#### `execute_change` - -| Test Case | Description | -|-----------|-------------| -| successfully executes change (authority proposed, recipient signs) | Applies proposed changes to PP | -| successfully executes change (recipient proposed, authority signs) | Applies proposed changes to PP | -| successfully executes oracle change | Updates pp.oracle_reader | -| successfully executes reward function change | Updates pp.reward_function | -| successfully executes multiple changes at once | Applies all Some fields | -| closes change_request account | Verifies account closed and rent returned | -| fails when same party tries to propose and execute | Rejects self-approval | -| fails when change_request doesn't exist | Rejects missing proposal | -| fails when oracle change attempted while Unlocking | Rejects if pp.status != Locked | -| fails when reward function change attempted while Unlocking | Rejects if pp.status != Locked | - -#### `close_performance_package` - -| Test Case | Description | -|-----------|-------------| -| successfully closes PP when called by admin | Closes PP account | -| fails when caller is not admin | Rejects unauthorized caller | -| fails when status is Unlocking | Rejects if unlock in progress | - ---- - -## Integration with mint_governor - -PP v2 integrates with `mint_governor` as an authorized minter: - -``` -Setup: -1. Create MintGovernor for token mint (if not exists) -2. Admin calls mint_governor::add_mint_authority for PP's PDA - - authorized_minter = PP PDA - - max_total = optional cap for this PP -3. Create PP with references to mint_governor and mint_authority - -Minting: -1. PP::complete_unlock calculates tokens to mint -2. CPI to mint_governor::mint_tokens - - PP PDA signs as authorized_minter - - Tokens minted to recipient's ATA -3. mint_governor updates MintAuthority.total_minted -4. PP updates its own tracking -``` - -**Caps:** -- `MintAuthority.max_total`: Package-level cap (optional, set by admin) -- `RewardFunction` max: Per-function cap (embedded in function parameters) - ---- - -## Error Conditions - -```rust -pub enum PerformancePackageError { - // Authorization - Unauthorized, // Signer is neither authority nor recipient - InvalidExecutor, // Executor is not the opposite party from proposer - - // State - NotLocked, // Expected Locked status - NotUnlocking, // Expected Unlocking status - - // Oracle - OracleMissingAccount, // Expected remaining_accounts not provided - OracleInvalidAccount, // Account pubkey doesn't match expected - OracleParseError, // Failed to parse account data - OracleInvalidState, // Oracle state invalid (e.g., time_delta == 0) - OracleMinDurationNotReached, // min_duration hasn't passed yet - - // Time - UnlockTimestampNotReached, // min_unlock_timestamp not yet reached - - // Rewards - RewardCalculationOverflow, // Math overflow in reward function - - // Configuration - InvalidTranches, // Tranches not sorted or empty - InvalidVestingSchedule, // Cliff value after end value, start > cliff, etc. - - // Change Requests - ChangeRequestNotFound, // Missing proposal for execute - NoChangesProposed, // All optional change fields are None -} -``` - ---- - -## Potential Improvements - -### Add ChangeRequest rejection instruction - -Allow the non-proposing party to reject (close) a ChangeRequest, refunding SOL to proposer. This provides an explicit "no" signal rather than leaving proposals hanging indefinitely. - -### Support for multiple oracle sources - -Some reward scenarios might benefit from combining multiple oracle values (e.g., price AND time conditions). This could be implemented as a composite oracle reader that consumes multiple accounts from `remaining_accounts`: - -```rust -OracleReader::Composite { - oracle_count: u8, // Number of oracle accounts to read from remaining_accounts - combiner: CombineFunction, // Min, Max, Average, etc. -} -``` - -### Multiple PPs for complex schedules - -For scenarios requiring multiple independent reward schedules (e.g., price milestones AND time vesting), create separate Performance Packages. Each PP operates independently with its own oracle and reward function. diff --git a/vibes/task-template.md b/vibes/task-template.md deleted file mode 100644 index 85574d7c5..000000000 --- a/vibes/task-template.md +++ /dev/null @@ -1,54 +0,0 @@ -# [Feature Name] Implementation Tasks - -## Instructions for Claude - -**READ THIS FIRST:** - -1. Look at this file and find the task marked with `[NEXT]` -2. Read the referenced section in `vibes/[implementation-plan].md` for full context -3. Do ONLY that task - nothing else -4. After completing the task, verify with `[verification command]` -5. If successful, remove the completed task from this file -6. Mark the next task with `[NEXT]` -7. Stop and wait for the user - -**DO NOT:** -- Do multiple tasks at once -- Skip ahead -- Forget to verify - -**Reference:** Full implementation plan is in `vibes/[implementation-plan].md` - ---- - -## Tasks - -### Phase N: [Phase Name] - -> Reference: `[implementation-plan].md` → "Phase N: [Phase Name]" - -- [NEXT] N.1 [Task title] - - [Detail about what to do] - - [Another detail] - - [File to create/modify] - -- [ ] N.2 [Task title] - - Reference: Section N.2 - - [Detail about what to do] - - [Accounts/Args/Other specifics] - -- [ ] N.3 [Task title] - - Reference: Section N.3 - - [Detail 1] - - [Detail 2] - - [Detail 3] - -### Phase M: [Next Phase Name] - -> Reference: `[implementation-plan].md` → "Phase M: [Next Phase Name]" - -- [ ] M.1 [Task title] - - [Details...] - -- [ ] M.2 [Task title] - - [Details...] diff --git a/vibes/tasks.md b/vibes/tasks.md deleted file mode 100644 index efeb48f32..000000000 --- a/vibes/tasks.md +++ /dev/null @@ -1,32 +0,0 @@ -# Performance Package v2 Implementation Tasks - -## Instructions for Claude - -**READ THIS FIRST:** - -1. Look at this file and find the task marked with `[NEXT]` -2. Read the referenced section in `vibes/001-performance-package-v2.md` for full context -3. Do ONLY that task - nothing else -4. After completing the task, if necessary, verify: - - First run `./rebuild.sh` (rebuilds SDK, runs typecheck and lint) - - Then run `anchor test --skip-build` to execute tests - - Run only the performancePackageV2 tests by specifying `describe.only("performance_package_v2" ...)` inside `tests/main.test.ts` and then remove the `.only` when you're done - - Since you will be fixing tests individually, confirm they work by using `it.only` -5. Once done, remove the completed task from this file entirely -6. Mark the next task with `[NEXT]` -7. Stop and wait for the user - -**DO NOT:** -- Do multiple tasks at once -- Skip ahead -- Forget to verify - -**Reference:** Full spec is in `vibes/001-performance-package-v2.md` - -**Note:** We implement Time oracle first. FutarchyTwap is added in Phase 9 after the core flow works. - ---- - -## Tasks - -All tasks completed! 🎉 From f0a6874960321141f5ba9f162570ad090d76467f Mon Sep 17 00:00:00 2001 From: Pileks Date: Mon, 2 Feb 2026 16:01:12 -0800 Subject: [PATCH 26/27] rename client-exported structs --- sdk/src/v0.7/PerformancePackageV2Client.ts | 18 +++++++++--------- sdk/src/v0.7/types/index.ts | 12 ++++++------ tests/performancePackageV2/utils.ts | 22 +++++++++++----------- 3 files changed, 26 insertions(+), 26 deletions(-) diff --git a/sdk/src/v0.7/PerformancePackageV2Client.ts b/sdk/src/v0.7/PerformancePackageV2Client.ts index 00ebab774..0e807c702 100644 --- a/sdk/src/v0.7/PerformancePackageV2Client.ts +++ b/sdk/src/v0.7/PerformancePackageV2Client.ts @@ -20,9 +20,9 @@ import { } from "./types/performance_package_v2.js"; import type { PerformancePackageV2Account, - ChangeRequestV2Account, - OracleReaderV2, - RewardFunctionV2, + PerformancePackageV2ChangeRequestAccount, + PerformancePackageV2OracleReader, + PerformancePackageV2RewardFunction, } from "./types/index.js"; export type CreatePerformancePackageV2ClientParams = { @@ -94,13 +94,13 @@ export class PerformancePackageV2Client { async fetchChangeRequest( changeRequest: PublicKey, - ): Promise { + ): Promise { return this.program.account.changeRequest.fetchNullable(changeRequest); } async deserializeChangeRequest( accountInfo: AccountInfo, - ): Promise { + ): Promise { return this.program.coder.accounts.decode( "changeRequest", accountInfo.data, @@ -126,8 +126,8 @@ export class PerformancePackageV2Client { authority: PublicKey; recipient: PublicKey; payer?: PublicKey; - oracleReader: OracleReaderV2; - rewardFunction: RewardFunctionV2; + oracleReader: PerformancePackageV2OracleReader; + rewardFunction: PerformancePackageV2RewardFunction; minUnlockTimestamp: BN; }) { const [performancePackage] = this.getPerformancePackageAddr(createKey); @@ -244,8 +244,8 @@ export class PerformancePackageV2Client { payer?: PublicKey; pdaNonce: number; newRecipient?: PublicKey | null; - newOracleReader?: OracleReaderV2 | null; - newRewardFunction?: RewardFunctionV2 | null; + newOracleReader?: PerformancePackageV2OracleReader | null; + newRewardFunction?: PerformancePackageV2RewardFunction | null; }) { const [changeRequest] = this.getChangeRequestAddr( performancePackage, diff --git a/sdk/src/v0.7/types/index.ts b/sdk/src/v0.7/types/index.ts index b7f0e92b9..7a17781f8 100644 --- a/sdk/src/v0.7/types/index.ts +++ b/sdk/src/v0.7/types/index.ts @@ -78,17 +78,17 @@ export type MintAuthorityAccount = export type PerformancePackageV2Account = IdlAccounts["performancePackage"]; -export type ChangeRequestV2Account = +export type PerformancePackageV2ChangeRequestAccount = IdlAccounts["changeRequest"]; -export type OracleReaderV2 = +export type PerformancePackageV2OracleReader = IdlTypes["OracleReader"]; -export type RewardFunctionV2 = +export type PerformancePackageV2RewardFunction = IdlTypes["RewardFunction"]; -export type PackageStatusV2 = +export type PerformancePackageV2PackageStatus = IdlTypes["PackageStatus"]; -export type ProposerTypeV2 = +export type PerformancePackageV2ProposerType = IdlTypes["ProposerType"]; -export type ThresholdTrancheV2 = +export type PerformancePackageV2ThresholdTranche = IdlTypes["ThresholdTranche"]; export type BidWallInitializedEvent = diff --git a/tests/performancePackageV2/utils.ts b/tests/performancePackageV2/utils.ts index 4bdb4fc73..56912b67c 100644 --- a/tests/performancePackageV2/utils.ts +++ b/tests/performancePackageV2/utils.ts @@ -18,8 +18,8 @@ import { getDaoAddr, } from "@metadaoproject/futarchy/v0.7"; import type { - OracleReaderV2, - RewardFunctionV2, + PerformancePackageV2OracleReader, + PerformancePackageV2RewardFunction, } from "@metadaoproject/futarchy/v0.7"; /** @@ -145,15 +145,15 @@ export async function setupPerformancePackageV2( { authority = payer.publicKey, recipient = payer.publicKey, - oracleReader = { time: {} } as OracleReaderV2, + oracleReader = { time: {} } as PerformancePackageV2OracleReader, rewardFunction, minUnlockTimestamp = new BN(0), maxTotal = null, }: { authority?: PublicKey; recipient?: PublicKey; - oracleReader?: OracleReaderV2; - rewardFunction: RewardFunctionV2; + oracleReader?: PerformancePackageV2OracleReader; + rewardFunction: PerformancePackageV2RewardFunction; minUnlockTimestamp?: BN; maxTotal?: BN | null; }, @@ -220,7 +220,7 @@ export function createCliffLinearReward({ endValue?: BN; cliffAmount?: BN; totalAmount?: BN; -} = {}): RewardFunctionV2 { +} = {}): PerformancePackageV2RewardFunction { return { cliffLinear: { startValue, @@ -229,7 +229,7 @@ export function createCliffLinearReward({ cliffAmount, totalAmount, }, - } as RewardFunctionV2; + } as PerformancePackageV2RewardFunction; } /** @@ -237,12 +237,12 @@ export function createCliffLinearReward({ */ export function createThresholdReward( tranches: Array<{ threshold: BN; cumulativeAmount: BN }>, -): RewardFunctionV2 { +): PerformancePackageV2RewardFunction { return { threshold: { tranches, }, - } as RewardFunctionV2; + } as PerformancePackageV2RewardFunction; } /** @@ -254,7 +254,7 @@ export function createFutarchyTwapOracle({ }: { amm: PublicKey; minDuration?: number; -}): OracleReaderV2 { +}): PerformancePackageV2OracleReader { return { futarchyTwap: { amm, @@ -264,7 +264,7 @@ export function createFutarchyTwapOracle({ endValue: new BN(0), endTime: new BN(0), }, - } as OracleReaderV2; + } as PerformancePackageV2OracleReader; } const THOUSAND_BUCK_PRICE = PriceMath.getAmmPrice(1000, 6, 6); From 29246c3aa2b06c98df2afce908c4b7f4920f4bb7 Mon Sep 17 00:00:00 2001 From: Pileks Date: Sun, 8 Feb 2026 16:16:02 -0800 Subject: [PATCH 27/27] fix .only preventing all tests from running --- tests/futarchy/unit/adminCancelProposal.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/futarchy/unit/adminCancelProposal.test.ts b/tests/futarchy/unit/adminCancelProposal.test.ts index 847556595..2b0febe20 100644 --- a/tests/futarchy/unit/adminCancelProposal.test.ts +++ b/tests/futarchy/unit/adminCancelProposal.test.ts @@ -127,7 +127,7 @@ export default function suite() { .rpc(); }); - it.only("should cancel a pending proposal", async function () { + it("should cancel a pending proposal", async function () { let storedProposal = await this.futarchy.getProposal(proposal); assert.exists(storedProposal.state.pending);