diff --git a/Anchor.toml b/Anchor.toml index 3d9eda01a..545001795 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..8a51feafc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1488,6 +1488,17 @@ 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", + "futarchy", + "mint_governor", + "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..5fbd69bd8 --- /dev/null +++ b/programs/performance_package_v2/Cargo.toml @@ -0,0 +1,24 @@ +[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" +mint_governor = { path = "../mint_governor", features = ["cpi"] } +futarchy = { path = "../futarchy", features = ["cpi"] } diff --git a/programs/performance_package_v2/src/constants.rs b/programs/performance_package_v2/src/constants.rs new file mode 100644 index 000000000..1146d282e --- /dev/null +++ b/programs/performance_package_v2/src/constants.rs @@ -0,0 +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/error.rs b/programs/performance_package_v2/src/error.rs new file mode 100644 index 000000000..e6d170ca7 --- /dev/null +++ b/programs/performance_package_v2/src/error.rs @@ -0,0 +1,58 @@ +use anchor_lang::prelude::*; + +#[error_code] +pub enum PerformancePackageError { + // Authorization + #[msg("Signer is neither authority nor recipient")] + Unauthorized, + #[msg("Executor is not the opposite party from proposer")] + 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")] + InvalidMintGovernor, + #[msg("Mint authority does not match expected configuration")] + InvalidMintAuthority, + + // 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 should be sorted and non-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 new file mode 100644 index 000000000..b727482a4 --- /dev/null +++ b/programs/performance_package_v2/src/events.rs @@ -0,0 +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/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/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/complete_unlock.rs b/programs/performance_package_v2/src/instructions/complete_unlock.rs new file mode 100644 index 000000000..41ca42aec --- /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, reads AMM for FutarchyTwap) + pp.oracle_reader.record_end(ctx.remaining_accounts)?; + + // 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/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/initialize_performance_package.rs b/programs/performance_package_v2/src/instructions/initialize_performance_package.rs new file mode 100644 index 000000000..21544dc98 --- /dev/null +++ b/programs/performance_package_v2/src/instructions/initialize_performance_package.rs @@ -0,0 +1,101 @@ +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<()> { + args.oracle_reader.validate()?; + args.reward_function.validate()?; + 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(()) + } +} 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..29b6752da --- /dev/null +++ b/programs/performance_package_v2/src/instructions/mod.rs @@ -0,0 +1,15 @@ +pub mod change_authority; +pub mod close_performance_package; +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 close_performance_package::*; +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/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/instructions/start_unlock.rs b/programs/performance_package_v2/src/instructions/start_unlock.rs new file mode 100644 index 000000000..288e86f06 --- /dev/null +++ b/programs/performance_package_v2/src/instructions/start_unlock.rs @@ -0,0 +1,73 @@ +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_gte!( + 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, reads AMM for FutarchyTwap) + pp.oracle_reader.record_start(ctx.remaining_accounts)?; + + // 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 new file mode 100644 index 000000000..9d12ee302 --- /dev/null +++ b/programs/performance_package_v2/src/lib.rs @@ -0,0 +1,76 @@ +//! 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::*; + + #[access_control(ctx.accounts.validate(&args))] + pub fn initialize_performance_package( + ctx: Context, + args: InitializePerformancePackageArgs, + ) -> Result<()> { + InitializePerformancePackage::handle(ctx, args) + } + + #[access_control(ctx.accounts.validate())] + 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) + } + + 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) + } + + #[access_control(ctx.accounts.validate())] + 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/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..c39a399be --- /dev/null +++ b/programs/performance_package_v2/src/state/change_request.rs @@ -0,0 +1,33 @@ +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, + + /// 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 new file mode 100644 index 000000000..0c6612b86 --- /dev/null +++ b/programs/performance_package_v2/src/state/mod.rs @@ -0,0 +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..aa12dcf50 --- /dev/null +++ b/programs/performance_package_v2/src/state/performance_package.rs @@ -0,0 +1,417 @@ +use anchor_lang::prelude::*; +use futarchy::state::{Dao, PoolState}; + +use crate::{PerformancePackageError, 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, + /// 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 { + /// Validates the oracle reader configuration. + pub fn validate(&self) -> Result<()> { + match self { + &OracleReader::Time => { + // 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. + /// 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. + /// 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, + .. + } => { + 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 - 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 / time_delta as u128; + + 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 => {} + OracleReader::FutarchyTwap { + start_value, + start_time, + end_value, + end_time, + .. + } => { + *start_value = 0; + *start_time = 0; + *end_value = 0; + *end_time = 0; + } + } + } +} + +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 (cumulative, 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_portion + // linear_portion = (value - cliff_value) * (total_amount - cliff_amount) / (end_value - cliff_value) + + // 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; + + // If there's no linear range, it's only a cliff + if value_range == 0 { + return Ok(cliff_amount); + } + + let linear_amount = (total_amount - cliff_amount) as u128; + + // Calculate: cliff_amount + (value_progress * linear_amount / value_range) + 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 + 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. +#[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, PartialEq, Eq, InitSpace)] +pub struct ThresholdTranche { + /// Oracle value threshold + pub threshold: u128, + /// Total tokens at this tranche (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 { + /// Token mint controlled by mint_governor + pub mint: Pubkey, + /// MintGovernor account + pub mint_governor: Pubkey, + /// MintAuthority PDA for this PP + pub mint_authority: Pubkey, + + /// Usually the DAO multisig vault - can modify PP + pub authority: Pubkey, + /// Usually the team multisig - receives minted tokens + pub recipient: Pubkey, + + /// Stores start/end snapshots for oracle calculations + pub oracle_reader: OracleReader, + /// How to calculate rewards + pub reward_function: RewardFunction, + + /// Locked or Unlocking state + pub status: PackageStatus, + /// Can't start unlock before this time + pub min_unlock_timestamp: i64, + + /// Cumulative tokens minted to the recipient + pub total_rewards_paid_out: u64, + /// Event sequence number + pub seq_num: u64, + + /// Used for PDA derivation + pub create_key: Pubkey, + /// PDA bump + pub bump: u8, +} 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/sdk/src/v0.7/PerformancePackageV2Client.ts b/sdk/src/v0.7/PerformancePackageV2Client.ts new file mode 100644 index 000000000..0e807c702 --- /dev/null +++ b/sdk/src/v0.7/PerformancePackageV2Client.ts @@ -0,0 +1,306 @@ +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, + MINT_GOVERNOR_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, + PerformancePackageV2ChangeRequestAccount, + PerformancePackageV2OracleReader, + PerformancePackageV2RewardFunction, +} 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, + ); + } + + 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: PerformancePackageV2OracleReader; + rewardFunction: PerformancePackageV2RewardFunction; + 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, + }); + } + + startUnlockIx({ + performancePackage, + signer = this.provider.publicKey, + dao, + }: { + performancePackage: PublicKey; + signer?: PublicKey; + dao?: PublicKey; + }) { + const builder = this.program.methods.startUnlock().accounts({ + performancePackage, + signer, + }); + + if (dao) { + return builder.remainingAccounts([ + { pubkey: dao, isSigner: false, isWritable: false }, + ]); + } + + return builder; + } + + completeUnlockIx({ + performancePackage, + mintGovernor, + mintAuthority, + mint, + recipient, + signer = this.provider.publicKey, + dao, + }: { + performancePackage: PublicKey; + mintGovernor: PublicKey; + mintAuthority: PublicKey; + mint: PublicKey; + recipient: PublicKey; + signer?: PublicKey; + dao?: PublicKey; + }) { + const recipientAta = getAssociatedTokenAddressSync(mint, recipient, true); + + const builder = 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, + }); + + if (dao) { + return builder.remainingAccounts([ + { pubkey: dao, isSigner: false, isWritable: false }, + ]); + } + + return builder; + } + + changeAuthorityIx({ + performancePackage, + authority = this.provider.publicKey, + newAuthority, + }: { + performancePackage: PublicKey; + authority?: PublicKey; + newAuthority: PublicKey; + }) { + return this.program.methods.changeAuthority().accounts({ + performancePackage, + authority, + 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?: PerformancePackageV2OracleReader | null; + newRewardFunction?: PerformancePackageV2RewardFunction | 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, + }); + } + + 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, + }); + } + + 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/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..7a17781f8 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 PerformancePackageV2ChangeRequestAccount = + IdlAccounts["changeRequest"]; +export type PerformancePackageV2OracleReader = + IdlTypes["OracleReader"]; +export type PerformancePackageV2RewardFunction = + IdlTypes["RewardFunction"]; +export type PerformancePackageV2PackageStatus = + IdlTypes["PackageStatus"]; +export type PerformancePackageV2ProposerType = + IdlTypes["ProposerType"]; +export type PerformancePackageV2ThresholdTranche = + 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 new file mode 100644 index 000000000..9f9bc7fd2 --- /dev/null +++ b/sdk/src/v0.7/types/performance_package_v2.ts @@ -0,0 +1,1987 @@ +export type PerformancePackageV2 = { + version: "0.7.0"; + name: "performance_package_v2"; + constants: [ + { + name: "MAX_TRANCHES"; + type: { + defined: "usize"; + }; + value: "10"; + }, + ]; + 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"; + }; + }, + ]; + }, + { + name: "startUnlock"; + accounts: [ + { + name: "performancePackage"; + isMut: true; + isSigner: false; + }, + { + name: "signer"; + isMut: false; + isSigner: true; + }, + ]; + 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: []; + }, + { + 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: []; + }, + { + 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"; + }; + }, + ]; + }, + { + 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: []; + }, + { + name: "closePerformancePackage"; + accounts: [ + { + name: "performancePackage"; + isMut: true; + isSigner: false; + }, + { + name: "admin"; + isMut: false; + isSigner: true; + }, + { + name: "rentDestination"; + isMut: true; + isSigner: false; + }, + ]; + args: []; + }, + ]; + 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: ["Usually the DAO multisig vault - can modify PP"]; + type: "publicKey"; + }, + { + name: "recipient"; + docs: ["Usually the 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 state"]; + type: { + defined: "PackageStatus"; + }; + }, + { + name: "minUnlockTimestamp"; + docs: ["Can't start unlock before this time"]; + type: "i64"; + }, + { + name: "totalRewardsPaidOut"; + docs: ["Cumulative tokens minted to the recipient"]; + type: "u64"; + }, + { + name: "seqNum"; + docs: ["Event sequence number"]; + type: "u64"; + }, + { + name: "createKey"; + docs: ["Used for PDA derivation"]; + type: "publicKey"; + }, + { + name: "bump"; + docs: ["PDA bump"]; + type: "u8"; + }, + ]; + }; + }, + ]; + types: [ + { + name: "CommonFields"; + docs: ["Common fields included in all events for consistent metadata."]; + type: { + kind: "struct"; + fields: [ + { + name: "slot"; + type: "u64"; + }, + { + name: "unixTimestamp"; + type: "i64"; + }, + { + name: "performancePackageSeqNum"; + type: "u64"; + }, + ]; + }; + }, + { + name: "InitializePerformancePackageArgs"; + type: { + kind: "struct"; + fields: [ + { + name: "oracleReader"; + type: { + defined: "OracleReader"; + }; + }, + { + name: "rewardFunction"; + type: { + defined: "RewardFunction"; + }; + }, + { + name: "minUnlockTimestamp"; + type: "i64"; + }, + ]; + }; + }, + { + 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."]; + type: { + kind: "struct"; + fields: [ + { + name: "threshold"; + docs: ["Oracle value threshold"]; + type: "u128"; + }, + { + name: "cumulativeAmount"; + docs: [ + "Total tokens at this tranche (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: "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"; + }, + ]; + }, + ]; + }; + }, + { + 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"; + }; + }; + }, + ]; + }, + ]; + }; + }, + ]; + 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: "Unauthorized"; + msg: "Signer is neither authority nor recipient"; + }, + { + code: 6001; + name: "InvalidExecutor"; + msg: "Executor is not the opposite party from proposer"; + }, + { + code: 6002; + name: "InvalidAuthority"; + msg: "Signer is not the current authority"; + }, + { + code: 6003; + name: "InvalidAdmin"; + msg: "Signer is not the admin"; + }, + { + code: 6004; + name: "InvalidMintGovernor"; + msg: "Mint governor does not match the provided mint"; + }, + { + code: 6005; + name: "InvalidMintAuthority"; + msg: "Mint authority does not match expected configuration"; + }, + { + code: 6006; + name: "NotLocked"; + msg: "Expected Locked status"; + }, + { + code: 6007; + name: "NotUnlocking"; + msg: "Expected Unlocking status"; + }, + { + code: 6008; + name: "OracleMissingAccount"; + msg: "Expected remaining_accounts not provided"; + }, + { + code: 6009; + name: "OracleInvalidAccount"; + msg: "Account pubkey doesn't match expected"; + }, + { + code: 6010; + name: "OracleParseError"; + msg: "Failed to parse account data"; + }, + { + code: 6011; + name: "OracleInvalidState"; + msg: "Oracle state invalid"; + }, + { + code: 6012; + name: "OracleMinDurationNotReached"; + msg: "Minimum duration hasn't passed yet"; + }, + { + code: 6013; + name: "UnlockTimestampNotReached"; + msg: "Minimum unlock timestamp not yet reached"; + }, + { + code: 6014; + name: "RewardCalculationOverflow"; + msg: "Math overflow in reward function"; + }, + { + code: 6015; + name: "InvalidTranches"; + msg: "Tranches should be sorted and non-empty"; + }, + { + code: 6016; + name: "InvalidVestingSchedule"; + msg: "Invalid vesting schedule configuration"; + }, + { + code: 6017; + name: "ChangeRequestNotFound"; + msg: "Missing proposal for execute"; + }, + { + code: 6018; + name: "NoChangesProposed"; + msg: "All optional change fields are None"; + }, + ]; +}; + +export const IDL: PerformancePackageV2 = { + version: "0.7.0", + name: "performance_package_v2", + constants: [ + { + name: "MAX_TRANCHES", + type: { + defined: "usize", + }, + value: "10", + }, + ], + 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", + }, + }, + ], + }, + { + name: "startUnlock", + accounts: [ + { + name: "performancePackage", + isMut: true, + isSigner: false, + }, + { + name: "signer", + isMut: false, + isSigner: true, + }, + ], + 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: [], + }, + { + 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: [], + }, + { + 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", + }, + }, + ], + }, + { + 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: [], + }, + { + name: "closePerformancePackage", + accounts: [ + { + name: "performancePackage", + isMut: true, + isSigner: false, + }, + { + name: "admin", + isMut: false, + isSigner: true, + }, + { + name: "rentDestination", + isMut: true, + isSigner: false, + }, + ], + args: [], + }, + ], + 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: ["Usually the DAO multisig vault - can modify PP"], + type: "publicKey", + }, + { + name: "recipient", + docs: ["Usually the 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 state"], + type: { + defined: "PackageStatus", + }, + }, + { + name: "minUnlockTimestamp", + docs: ["Can't start unlock before this time"], + type: "i64", + }, + { + name: "totalRewardsPaidOut", + docs: ["Cumulative tokens minted to the recipient"], + type: "u64", + }, + { + name: "seqNum", + docs: ["Event sequence number"], + type: "u64", + }, + { + name: "createKey", + docs: ["Used for PDA derivation"], + type: "publicKey", + }, + { + name: "bump", + docs: ["PDA bump"], + type: "u8", + }, + ], + }, + }, + ], + types: [ + { + name: "CommonFields", + docs: ["Common fields included in all events for consistent metadata."], + type: { + kind: "struct", + fields: [ + { + name: "slot", + type: "u64", + }, + { + name: "unixTimestamp", + type: "i64", + }, + { + name: "performancePackageSeqNum", + type: "u64", + }, + ], + }, + }, + { + name: "InitializePerformancePackageArgs", + type: { + kind: "struct", + fields: [ + { + name: "oracleReader", + type: { + defined: "OracleReader", + }, + }, + { + name: "rewardFunction", + type: { + defined: "RewardFunction", + }, + }, + { + name: "minUnlockTimestamp", + type: "i64", + }, + ], + }, + }, + { + 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."], + type: { + kind: "struct", + fields: [ + { + name: "threshold", + docs: ["Oracle value threshold"], + type: "u128", + }, + { + name: "cumulativeAmount", + docs: [ + "Total tokens at this tranche (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: "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", + }, + ], + }, + ], + }, + }, + { + 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", + }, + }, + }, + ], + }, + ], + }, + }, + ], + 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: "Unauthorized", + msg: "Signer is neither authority nor recipient", + }, + { + code: 6001, + name: "InvalidExecutor", + msg: "Executor is not the opposite party from proposer", + }, + { + code: 6002, + name: "InvalidAuthority", + msg: "Signer is not the current authority", + }, + { + code: 6003, + name: "InvalidAdmin", + msg: "Signer is not the admin", + }, + { + code: 6004, + name: "InvalidMintGovernor", + msg: "Mint governor does not match the provided mint", + }, + { + code: 6005, + name: "InvalidMintAuthority", + msg: "Mint authority does not match expected configuration", + }, + { + code: 6006, + name: "NotLocked", + msg: "Expected Locked status", + }, + { + code: 6007, + name: "NotUnlocking", + msg: "Expected Unlocking status", + }, + { + code: 6008, + name: "OracleMissingAccount", + msg: "Expected remaining_accounts not provided", + }, + { + code: 6009, + name: "OracleInvalidAccount", + msg: "Account pubkey doesn't match expected", + }, + { + code: 6010, + name: "OracleParseError", + msg: "Failed to parse account data", + }, + { + code: 6011, + name: "OracleInvalidState", + msg: "Oracle state invalid", + }, + { + code: 6012, + name: "OracleMinDurationNotReached", + msg: "Minimum duration hasn't passed yet", + }, + { + code: 6013, + name: "UnlockTimestampNotReached", + msg: "Minimum unlock timestamp not yet reached", + }, + { + code: 6014, + name: "RewardCalculationOverflow", + msg: "Math overflow in reward function", + }, + { + code: 6015, + name: "InvalidTranches", + msg: "Tranches should be sorted and non-empty", + }, + { + code: 6016, + name: "InvalidVestingSchedule", + msg: "Invalid vesting schedule configuration", + }, + { + code: 6017, + name: "ChangeRequestNotFound", + msg: "Missing proposal for execute", + }, + { + code: 6018, + 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/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); 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..a8b7cf515 --- /dev/null +++ b/tests/performancePackageV2/main.test.ts @@ -0,0 +1,32 @@ +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 proposeChange from "./unit/proposeChange.test.js"; +import executeChange from "./unit/executeChange.test.js"; +import closePerformancePackage from "./unit/closePerformancePackage.test.js"; +import { + MintGovernorClient, + PerformancePackageV2Client, +} 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, + }); + this.performancePackageV2 = PerformancePackageV2Client.createClient({ + provider: provider as any, + }); + }); + + describe("#initialize_performance_package", initializePerformancePackage); + describe("#start_unlock", startUnlock); + describe("#complete_unlock", completeUnlock); + describe("#change_authority", changeAuthority); + describe("#propose_change", proposeChange); + describe("#execute_change", executeChange); + describe("#close_performance_package", closePerformancePackage); +} 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/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/tests/performancePackageV2/unit/completeUnlock.test.ts b/tests/performancePackageV2/unit/completeUnlock.test.ts new file mode 100644 index 000000000..a8edca147 --- /dev/null +++ b/tests/performancePackageV2/unit/completeUnlock.test.ts @@ -0,0 +1,1528 @@ +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, + createFutarchyTwapOracle, + setupDaoForTwapTests, +} from "../utils.js"; +import { expectError } from "../../utils.js"; +import { getPerformancePackageV2Addr } from "@metadaoproject/futarchy/v0.7"; + +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("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(); + + // 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("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(); + + // 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/executeChange.test.ts b/tests/performancePackageV2/unit/executeChange.test.ts new file mode 100644 index 000000000..faaabb7dd --- /dev/null +++ b/tests/performancePackageV2/unit/executeChange.test.ts @@ -0,0 +1,686 @@ +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, + createFutarchyTwapOracle, +} 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(); + + // Initialize with FutarchyTwap oracle + const fakeAmm = Keypair.generate().publicKey; + const initialOracleReader = createFutarchyTwapOracle({ amm: fakeAmm }); + + const { performancePackage } = await setupPerformancePackageV2( + this.banksClient, + mintGovernorClient, + ppClient, + this.payer, + { + authority: authority.publicKey, + recipient: recipient.publicKey, + oracleReader: initialOracleReader, + rewardFunction: createCliffLinearReward(), + minUnlockTimestamp: new BN(0), + }, + ); + + // Verify initial oracle is FutarchyTwap + let ppAccount = await ppClient.fetchPerformancePackage(performancePackage); + assert.isDefined(ppAccount.oracleReader.futarchyTwap); + assert.equal( + ppAccount.oracleReader.futarchyTwap.amm.toBase58(), + fakeAmm.toBase58(), + ); + + // Authority proposes oracle change (FutarchyTwap -> Time) + 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 was changed to Time + 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, + ); + + // 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) }, + ]); + + 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.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); + }); + + 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/tests/performancePackageV2/unit/initializePerformancePackage.test.ts b/tests/performancePackageV2/unit/initializePerformancePackage.test.ts new file mode 100644 index 000000000..9307f13ee --- /dev/null +++ b/tests/performancePackageV2/unit/initializePerformancePackage.test.ts @@ -0,0 +1,751 @@ +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, + createFutarchyTwapOracle, + setupDaoForTwapTests, +} 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("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({ + 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/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/tests/performancePackageV2/unit/startUnlock.test.ts b/tests/performancePackageV2/unit/startUnlock.test.ts new file mode 100644 index 000000000..fe9334559 --- /dev/null +++ b/tests/performancePackageV2/unit/startUnlock.test.ts @@ -0,0 +1,411 @@ +import { ComputeBudgetProgram, 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, + 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; + 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); + + // 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]); + }); + + 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("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(); + 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/tests/performancePackageV2/utils.ts b/tests/performancePackageV2/utils.ts new file mode 100644 index 000000000..56912b67c --- /dev/null +++ b/tests/performancePackageV2/utils.ts @@ -0,0 +1,377 @@ +import { + Keypair, + PublicKey, + Transaction, + SystemProgram, + ComputeBudgetProgram, +} 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, + PriceMath, + getDaoAddr, +} from "@metadaoproject/futarchy/v0.7"; +import type { + PerformancePackageV2OracleReader, + PerformancePackageV2RewardFunction, +} 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 PerformancePackageV2OracleReader, + rewardFunction, + minUnlockTimestamp = new BN(0), + maxTotal = null, + }: { + authority?: PublicKey; + recipient?: PublicKey; + oracleReader?: PerformancePackageV2OracleReader; + rewardFunction: PerformancePackageV2RewardFunction; + 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; +} = {}): PerformancePackageV2RewardFunction { + return { + cliffLinear: { + startValue, + cliffValue, + endValue, + cliffAmount, + totalAmount, + }, + } as PerformancePackageV2RewardFunction; +} + +/** + * Helper to create a Threshold reward function + */ +export function createThresholdReward( + tranches: Array<{ threshold: BN; cumulativeAmount: BN }>, +): PerformancePackageV2RewardFunction { + return { + threshold: { + tranches, + }, + } as PerformancePackageV2RewardFunction; +} + +/** + * Helper to create a FutarchyTwap oracle reader + */ +export function createFutarchyTwapOracle({ + amm, + minDuration = 60, // Default 60 seconds +}: { + amm: PublicKey; + minDuration?: number; +}): PerformancePackageV2OracleReader { + return { + futarchyTwap: { + amm, + minDuration, + startValue: new BN(0), + startTime: new BN(0), + endValue: new BN(0), + endTime: new BN(0), + }, + } as PerformancePackageV2OracleReader; +} + +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; +}