diff --git a/Cargo.lock b/Cargo.lock index 83881e267..a33d935ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1562,10 +1562,11 @@ dependencies = [ [[package]] name = "price_based_performance_package" -version = "0.6.0" +version = "0.6.1" dependencies = [ "anchor-lang", "anchor-spl", + "futarchy", "solana-security-txt", ] diff --git a/programs/price_based_performance_package/Cargo.toml b/programs/price_based_performance_package/Cargo.toml index e2a29a48d..abae9909b 100644 --- a/programs/price_based_performance_package/Cargo.toml +++ b/programs/price_based_performance_package/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "price_based_performance_package" -version = "0.6.0" +version = "0.6.1" description = "Created with Anchor" edition = "2021" @@ -19,4 +19,5 @@ production = [] [dependencies] anchor-lang = { version = "=0.29.0", features = ["init-if-needed", "event-cpi"] } anchor-spl = "=0.29.0" +futarchy = { path = "../futarchy", features = ["cpi"] } solana-security-txt = "=1.1.1" diff --git a/programs/price_based_performance_package/src/constants.rs b/programs/price_based_performance_package/src/constants.rs index 7ad6deab6..f837e7165 100644 --- a/programs/price_based_performance_package/src/constants.rs +++ b/programs/price_based_performance_package/src/constants.rs @@ -2,3 +2,7 @@ use anchor_lang::prelude::*; #[constant] pub const MAX_TRANCHES: usize = 10; + +/// Scale of oracle prices: quote atoms per base atom, times 1e12 +#[constant] +pub const PRICE_SCALE: u128 = 1_000_000_000_000; diff --git a/programs/price_based_performance_package/src/error.rs b/programs/price_based_performance_package/src/error.rs index da0e0bd26..39a093b3f 100644 --- a/programs/price_based_performance_package/src/error.rs +++ b/programs/price_based_performance_package/src/error.rs @@ -34,4 +34,26 @@ pub enum PriceBasedPerformancePackageError { TotalTokenAmountOverflow, #[msg("Recipient and performance package authority must be different keys")] RecipientAuthorityMustDiffer, + #[msg("Withdrawal limits must have non-zero caps, a future end, and a window of at least one second")] + InvalidWithdrawalLimits, + #[msg("Amount exceeds the withdrawable balance")] + InsufficientWithdrawableBalance, + #[msg("Token cap for the current window exceeded")] + TokenWindowLimitExceeded, + #[msg("Quote cap for the current window exceeded")] + QuoteWindowLimitExceeded, + #[msg("Oracle price observation is missing or zero")] + InvalidPriceObservation, + #[msg("Token withdrawals are disabled by the withdrawal mode")] + WithdrawTokensDisabled, + #[msg("Sell withdrawals are disabled by the withdrawal mode")] + WithdrawViaSellDisabled, + #[msg("Performance package has not been resized to the current layout")] + AccountNotMigrated, + #[msg("Quote mint must differ from the package's token mint")] + InvalidQuoteMint, + #[msg("The package's quote account and the quote destination must be passed together")] + QuoteSweepAccountsIncomplete, + #[msg("Oracle Dao's base mint must be the package's token mint")] + OracleMintMismatch, } diff --git a/programs/price_based_performance_package/src/events.rs b/programs/price_based_performance_package/src/events.rs index e9288d0de..759e0fdd9 100644 --- a/programs/price_based_performance_package/src/events.rs +++ b/programs/price_based_performance_package/src/events.rs @@ -1,4 +1,4 @@ -use crate::ChangeType; +use crate::{ChangeType, WindowUsage}; use anchor_lang::prelude::*; #[derive(AnchorSerialize, AnchorDeserialize)] @@ -43,6 +43,39 @@ pub struct UnlockCompleted { pub twap_price: u128, } +/// Present on a withdrawal that ran under active limits +#[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, Copy)] +pub struct CappedWithdrawal { + /// The price the withdrawal was valued at: the higher of the spot pool's observation and its reserve price + pub price: u128, + /// `amount` valued at that price, in quote atoms + pub quote_value: u64, + /// Window usage after this withdrawal + pub usage: WindowUsage, +} + +#[event] +pub struct TokensWithdrawn { + pub common: CommonFields, + pub performance_package: Pubkey, + pub recipient: Pubkey, + pub amount: u64, + /// `None` when no limits were active + pub capped: Option, +} + +#[event] +pub struct TokensSold { + pub common: CommonFields, + pub performance_package: Pubkey, + pub recipient: Pubkey, + pub amount: u64, + pub quote_received: u64, + pub min_quote_out: u64, + /// Window usage after this sale; `None` when no limits were active + pub capped: Option, +} + #[event] pub struct ChangeProposed { pub common: CommonFields, diff --git a/programs/price_based_performance_package/src/instructions/burn_performance_package.rs b/programs/price_based_performance_package/src/instructions/burn_performance_package.rs index b2c0f467d..3999b9406 100644 --- a/programs/price_based_performance_package/src/instructions/burn_performance_package.rs +++ b/programs/price_based_performance_package/src/instructions/burn_performance_package.rs @@ -1,5 +1,8 @@ use anchor_lang::prelude::*; -use anchor_spl::token::{self, Burn, Mint, Token, TokenAccount}; +use anchor_spl::{ + associated_token::AssociatedToken, + token::{self, Burn, CloseAccount, Mint, Token, TokenAccount}, +}; use super::*; @@ -15,11 +18,13 @@ pub struct BurnPerformancePackage<'info> { #[account( mut, close = spill_account, + has_one = recipient, has_one = token_mint, has_one = performance_package_token_vault )] pub performance_package: Box>, + /// Emptied by the payout and the burn, then closed to the spill account #[account( mut, associated_token::mint = token_mint, @@ -27,6 +32,18 @@ pub struct BurnPerformancePackage<'info> { )] pub performance_package_token_vault: Box>, + /// CHECK: Pinned to the package's recipient by `has_one` + pub recipient: UncheckedAccount<'info>, + + /// The recipient's ATA that receives the unlocked balance - created if needed + #[account( + init_if_needed, + payer = admin, + associated_token::mint = token_mint, + associated_token::authority = recipient + )] + pub recipient_token_account: Box>, + #[account(mut)] pub admin: Signer<'info>, @@ -35,13 +52,32 @@ pub struct BurnPerformancePackage<'info> { pub spill_account: UncheckedAccount<'info>, #[account(mut, address = performance_package.token_mint)] - pub token_mint: Account<'info, Mint>, + pub token_mint: Box>, + + /// The mint of the package's quote ATA; any mint other than the package's token mint + pub quote_mint: Option>>, + + /// The package's quote ATA, swept into `quote_destination` and closed when passed + #[account( + mut, + associated_token::mint = quote_mint, + associated_token::authority = performance_package + )] + pub package_quote_account: Option>>, + /// Where the quote balance goes, chosen by the admin + #[account(mut, token::mint = quote_mint)] + pub quote_destination: Option>>, + + pub system_program: Program<'info, System>, pub token_program: Program<'info, Token>, + pub associated_token_program: Program<'info, AssociatedToken>, } impl BurnPerformancePackage<'_> { pub fn validate(&self) -> Result<()> { + PerformancePackage::assert_migrated(&self.performance_package.to_account_info())?; + #[cfg(feature = "production")] require_keys_eq!( self.admin.key(), @@ -49,11 +85,47 @@ impl BurnPerformancePackage<'_> { PriceBasedPerformancePackageError::InvalidAdmin ); + // Ensure the quote mint is not the package's token mint. + if let Some(quote_mint) = &self.quote_mint { + require_keys_neq!( + quote_mint.key(), + self.token_mint.key(), + PriceBasedPerformancePackageError::InvalidQuoteMint + ); + } + + // Ensure the quote account and its destination are passed together. + require_eq!( + self.package_quote_account.is_some(), + self.quote_destination.is_some(), + PriceBasedPerformancePackageError::QuoteSweepAccountsIncomplete + ); + Ok(()) } pub fn handle(ctx: Context) -> Result<()> { - let performance_package = &ctx.accounts.performance_package; + let Self { + performance_package, + performance_package_token_vault, + recipient: _, + recipient_token_account, + admin: _, + spill_account, + token_mint, + quote_mint: _, + package_quote_account, + quote_destination, + system_program: _, + token_program, + associated_token_program: _, + } = ctx.accounts; + + let vault_amount = performance_package_token_vault.amount; + let withdrawable = performance_package.withdrawable(vault_amount)?; + let locked = vault_amount + .checked_sub(withdrawable) + .ok_or(PriceBasedPerformancePackageError::InvariantViolated)?; let seeds = &[ b"performance_package", @@ -62,25 +134,78 @@ impl BurnPerformancePackage<'_> { ]; let signer = &[&seeds[..]]; - // Burn any remaining tokens in the performance package token vault - if ctx.accounts.performance_package_token_vault.amount > 0 { + // Hand the recipient what is already unlocked before burning the rest + if withdrawable > 0 { + token::transfer( + CpiContext::new_with_signer( + token_program.to_account_info(), + token::Transfer { + from: performance_package_token_vault.to_account_info(), + to: recipient_token_account.to_account_info(), + authority: performance_package.to_account_info(), + }, + signer, + ), + withdrawable, + )?; + } + + if locked > 0 { token::burn( CpiContext::new_with_signer( - ctx.accounts.token_program.to_account_info(), + token_program.to_account_info(), Burn { - mint: ctx.accounts.token_mint.to_account_info(), - from: ctx - .accounts - .performance_package_token_vault - .to_account_info(), + mint: token_mint.to_account_info(), + from: performance_package_token_vault.to_account_info(), authority: performance_package.to_account_info(), }, signer, ), - ctx.accounts.performance_package_token_vault.amount, + locked, )?; } + // The vault is empty now, so its rent goes to the spill account + token::close_account(CpiContext::new_with_signer( + token_program.to_account_info(), + CloseAccount { + account: performance_package_token_vault.to_account_info(), + destination: spill_account.to_account_info(), + authority: performance_package.to_account_info(), + }, + signer, + ))?; + + // Move whatever sits in the quote account to the admin's destination, then close it + if let (Some(package_quote_account), Some(quote_destination)) = + (package_quote_account, quote_destination) + { + if package_quote_account.amount > 0 { + token::transfer( + CpiContext::new_with_signer( + token_program.to_account_info(), + token::Transfer { + from: package_quote_account.to_account_info(), + to: quote_destination.to_account_info(), + authority: performance_package.to_account_info(), + }, + signer, + ), + package_quote_account.amount, + )?; + } + + token::close_account(CpiContext::new_with_signer( + token_program.to_account_info(), + CloseAccount { + account: package_quote_account.to_account_info(), + destination: spill_account.to_account_info(), + authority: performance_package.to_account_info(), + }, + signer, + ))?; + } + // Performance package account gets closed using close constraint Ok(()) diff --git a/programs/price_based_performance_package/src/instructions/change_performance_package_authority.rs b/programs/price_based_performance_package/src/instructions/change_performance_package_authority.rs index 02c620b30..a4cfa9e69 100644 --- a/programs/price_based_performance_package/src/instructions/change_performance_package_authority.rs +++ b/programs/price_based_performance_package/src/instructions/change_performance_package_authority.rs @@ -21,6 +21,8 @@ pub struct ChangePerformancePackageAuthority<'info> { impl<'info> ChangePerformancePackageAuthority<'info> { pub fn validate(&self, params: &ChangePerformancePackageAuthorityParams) -> Result<()> { + PerformancePackage::assert_migrated(&self.performance_package.to_account_info())?; + require_keys_neq!( params.new_performance_package_authority, self.performance_package.recipient, diff --git a/programs/price_based_performance_package/src/instructions/complete_unlock.rs b/programs/price_based_performance_package/src/instructions/complete_unlock.rs index a39cad925..ed5176891 100644 --- a/programs/price_based_performance_package/src/instructions/complete_unlock.rs +++ b/programs/price_based_performance_package/src/instructions/complete_unlock.rs @@ -1,53 +1,22 @@ use anchor_lang::prelude::*; -use anchor_spl::{ - associated_token::AssociatedToken, - token::{self, Mint, Token, TokenAccount}, -}; use super::*; #[derive(Accounts)] #[event_cpi] pub struct CompleteUnlock<'info> { - #[account(mut, has_one = token_mint, has_one = performance_package_token_vault)] - pub performance_package: Box>, + #[account(mut)] + pub performance_package: Account<'info, PerformancePackage>, /// CHECK: We will read the aggregator value from this account #[account(address = performance_package.oracle_config.oracle_account)] pub oracle_account: UncheckedAccount<'info>, - - /// The token account where locked tokens are stored - #[account(mut)] - pub performance_package_token_vault: Box>, - - /// The token mint - validated via has_one constraint on locker - pub token_mint: Account<'info, Mint>, - - /// The recipient's ATA where tokens will be sent - created if needed - #[account( - init_if_needed, - payer = payer, - associated_token::mint = token_mint, - associated_token::authority = token_recipient - )] - pub recipient_token_account: Box>, - - /// CHECK: validated to match locker.token_recipient - #[account(address = performance_package.recipient @ PriceBasedPerformancePackageError::UnauthorizedChangeRequest)] - pub token_recipient: UncheckedAccount<'info>, - - /// Payer for creating the ATA if needed - #[account(mut)] - pub payer: Signer<'info>, - - pub system_program: Program<'info, System>, - - pub token_program: Program<'info, Token>, - pub associated_token_program: Program<'info, AssociatedToken>, } impl CompleteUnlock<'_> { pub fn validate(&self) -> Result<()> { + PerformancePackage::assert_migrated(&self.performance_package.to_account_info())?; + if !matches!( self.performance_package.state, PerformancePackageState::Unlocking { .. } @@ -66,14 +35,6 @@ impl CompleteUnlock<'_> { let Self { performance_package, oracle_account, - performance_package_token_vault, - token_mint: _, - recipient_token_account, - token_recipient: _, - payer: _, - system_program: _, - token_program, - associated_token_program: _, event_authority: _, program: _, } = ctx.accounts; @@ -145,30 +106,8 @@ impl CompleteUnlock<'_> { } } - // Only transfer if there are tokens to unlock - if tokens_to_unlock > 0 { - // Transfer tokens to recipient using PDA signature - let seeds = &[ - b"performance_package", - performance_package.create_key.as_ref(), - &[performance_package.pda_bump], - ]; - let signer = &[&seeds[..]]; - - let transfer_ctx = CpiContext::new_with_signer( - token_program.to_account_info(), - token::Transfer { - from: performance_package_token_vault.to_account_info(), - to: recipient_token_account.to_account_info(), - authority: performance_package.to_account_info(), - }, - signer, - ); - - token::transfer(transfer_ctx, tokens_to_unlock)?; - - performance_package.already_unlocked_amount += tokens_to_unlock; - } + // Unlocked tokens stay in the vault until the recipient withdraws them + performance_package.already_unlocked_amount += tokens_to_unlock; require_gte!( performance_package.total_token_amount, diff --git a/programs/price_based_performance_package/src/instructions/execute_change.rs b/programs/price_based_performance_package/src/instructions/execute_change.rs index 258a65de8..e6f398a68 100644 --- a/programs/price_based_performance_package/src/instructions/execute_change.rs +++ b/programs/price_based_performance_package/src/instructions/execute_change.rs @@ -24,6 +24,8 @@ pub struct ExecuteChange<'info> { impl<'info> ExecuteChange<'info> { pub fn validate(&self) -> Result<()> { + PerformancePackage::assert_migrated(&self.performance_package.to_account_info())?; + if self.change_request.proposer_type == ProposerType::Recipient { // If recipient proposed, locker authority must execute require_keys_eq!( @@ -70,6 +72,8 @@ impl<'info> ExecuteChange<'info> { return Err(PriceBasedPerformancePackageError::InvalidPerformancePackageState.into()); } + let clock = Clock::get()?; + // Apply the change based on type match &change_request.change_type { ChangeType::Oracle { new_oracle_config } => { @@ -78,11 +82,17 @@ impl<'info> ExecuteChange<'info> { ChangeType::Recipient { new_recipient } => { performance_package.recipient = *new_recipient; } + ChangeType::UnlockTerms { + min_unlock_timestamp, + limits, + } => { + performance_package.min_unlock_timestamp = *min_unlock_timestamp; + performance_package.replace_withdrawal_policy(*limits, clock.unix_timestamp); + } } performance_package.seq_num += 1; // Emit event - let clock = Clock::get()?; emit_cpi!(ChangeExecuted { common: CommonFields::new(&clock, performance_package.seq_num), performance_package: performance_package.key(), diff --git a/programs/price_based_performance_package/src/instructions/initialize_performance_package.rs b/programs/price_based_performance_package/src/instructions/initialize_performance_package.rs index c57e36713..c61fb9183 100644 --- a/programs/price_based_performance_package/src/instructions/initialize_performance_package.rs +++ b/programs/price_based_performance_package/src/instructions/initialize_performance_package.rs @@ -15,7 +15,6 @@ pub struct InitializePerformancePackageParams { } #[derive(Accounts)] -#[instruction(params: InitializePerformancePackageParams)] #[event_cpi] pub struct InitializePerformancePackage<'info> { #[account( @@ -67,6 +66,15 @@ impl InitializePerformancePackage<'_> { } pub fn handle(ctx: Context, params: InitializePerformancePackageParams) -> Result<()> { + Self::handle_inner(ctx, params, None) + } + + /// Shared by both initialisers; `limits` anchor a withdrawal policy at the creation clock. + pub fn handle_inner( + ctx: Context, + base: InitializePerformancePackageParams, + limits: Option, + ) -> Result<()> { let Self { performance_package, create_key, @@ -89,7 +97,7 @@ impl InitializePerformancePackage<'_> { twap_length_seconds, grantee, performance_package_authority, - } = params; + } = base; require_neq!(tranches.len(), 0); @@ -163,6 +171,8 @@ impl InitializePerformancePackage<'_> { already_unlocked_amount: 0, performance_package_token_vault: performance_package_token_vault.key(), seq_num: 0, + withdrawal_policy: limits + .map(|limits| WithdrawalPolicy::new(limits.into_limits(clock.unix_timestamp))), }); emit_cpi!(PerformancePackageInitialized { diff --git a/programs/price_based_performance_package/src/instructions/initialize_performance_package_with_limits.rs b/programs/price_based_performance_package/src/instructions/initialize_performance_package_with_limits.rs new file mode 100644 index 000000000..22696d829 --- /dev/null +++ b/programs/price_based_performance_package/src/instructions/initialize_performance_package_with_limits.rs @@ -0,0 +1,31 @@ +use anchor_lang::prelude::*; + +use super::*; + +#[derive(Debug, Clone, AnchorSerialize, AnchorDeserialize, PartialEq, Eq)] +pub struct InitializePerformancePackageWithLimitsParams { + pub base: InitializePerformancePackageParams, + pub limits: Option, +} + +impl InitializePerformancePackage<'_> { + pub fn validate_with_limits( + &self, + params: &InitializePerformancePackageWithLimitsParams, + ) -> Result<()> { + self.validate(¶ms.base)?; + + if let Some(limits) = ¶ms.limits { + limits.validate(Clock::get()?.unix_timestamp)?; + } + + Ok(()) + } + + pub fn handle_with_limits( + ctx: Context, + params: InitializePerformancePackageWithLimitsParams, + ) -> Result<()> { + Self::handle_inner(ctx, params.base, params.limits) + } +} diff --git a/programs/price_based_performance_package/src/instructions/mod.rs b/programs/price_based_performance_package/src/instructions/mod.rs index b85ac90e1..3b334a5af 100644 --- a/programs/price_based_performance_package/src/instructions/mod.rs +++ b/programs/price_based_performance_package/src/instructions/mod.rs @@ -5,13 +5,21 @@ pub mod change_performance_package_authority; pub mod complete_unlock; pub mod execute_change; pub mod initialize_performance_package; +pub mod initialize_performance_package_with_limits; pub mod propose_change; +pub mod resize_performance_package; pub mod start_unlock; +pub mod withdraw_tokens; +pub mod withdraw_via_sell; pub use burn_performance_package::*; pub use change_performance_package_authority::*; pub use complete_unlock::*; pub use execute_change::*; pub use initialize_performance_package::*; +pub use initialize_performance_package_with_limits::*; pub use propose_change::*; +pub use resize_performance_package::*; pub use start_unlock::*; +pub use withdraw_tokens::*; +pub use withdraw_via_sell::*; diff --git a/programs/price_based_performance_package/src/instructions/propose_change.rs b/programs/price_based_performance_package/src/instructions/propose_change.rs index 2430dad21..5cef64849 100644 --- a/programs/price_based_performance_package/src/instructions/propose_change.rs +++ b/programs/price_based_performance_package/src/instructions/propose_change.rs @@ -37,6 +37,8 @@ pub struct ProposeChange<'info> { impl<'info> ProposeChange<'info> { pub fn validate(&self, params: &ProposeChangeParams) -> Result<()> { + PerformancePackage::assert_migrated(&self.performance_package.to_account_info())?; + if self.proposer.key() != self.performance_package.recipient && self.proposer.key() != self.performance_package.performance_package_authority { @@ -54,6 +56,15 @@ impl<'info> ProposeChange<'info> { return Err(PriceBasedPerformancePackageError::InvalidPerformancePackageState.into()); } + // Ensure proposed limits are valid; the cliff itself may be any time, past or future. + if let ChangeType::UnlockTerms { + limits: Some(limits), + .. + } = ¶ms.change_type + { + limits.validate(Clock::get()?.unix_timestamp)?; + } + Ok(()) } diff --git a/programs/price_based_performance_package/src/instructions/resize_performance_package.rs b/programs/price_based_performance_package/src/instructions/resize_performance_package.rs new file mode 100644 index 000000000..67f796536 --- /dev/null +++ b/programs/price_based_performance_package/src/instructions/resize_performance_package.rs @@ -0,0 +1,90 @@ +use anchor_lang::error::ErrorCode; +use anchor_lang::{system_program, Discriminator}; + +use super::*; + +#[derive(Accounts)] +pub struct ResizePerformancePackage<'info> { + /// CHECK: owner and discriminator are checked in `validate` + #[account(mut)] + pub performance_package: UncheckedAccount<'info>, + #[account(mut)] + pub payer: Signer<'info>, + pub system_program: Program<'info, System>, +} + +impl ResizePerformancePackage<'_> { + pub fn validate(&self) -> Result<()> { + require_keys_eq!( + *self.performance_package.owner, + crate::ID, + ErrorCode::AccountOwnedByWrongProgram + ); + + let data = self.performance_package.try_borrow_data()?; + require_gte!(data.len(), 8, ErrorCode::AccountDiscriminatorNotFound); + require!( + data[..8] == PerformancePackage::discriminator(), + ErrorCode::AccountDiscriminatorMismatch + ); + + Ok(()) + } + + pub fn handle(ctx: Context) -> Result<()> { + let performance_package = &ctx.accounts.performance_package; + + // Already migrated; idempotent so the migration script can be re-run. + if performance_package.data_len() == PerformancePackage::SIZE { + return Ok(()); + } + + require_eq!( + performance_package.data_len(), + PerformancePackage::OLD_SIZE, + ErrorCode::AccountDidNotDeserialize + ); + + let old = OldPerformancePackage::deserialize( + &mut &performance_package.try_borrow_data()?[8..], + )?; + + let new = PerformancePackage { + tranches: old.tranches, + total_token_amount: old.total_token_amount, + already_unlocked_amount: old.already_unlocked_amount, + min_unlock_timestamp: old.min_unlock_timestamp, + oracle_config: old.oracle_config, + twap_length_seconds: old.twap_length_seconds, + recipient: old.recipient, + state: old.state, + create_key: old.create_key, + pda_bump: old.pda_bump, + performance_package_authority: old.performance_package_authority, + token_mint: old.token_mint, + seq_num: old.seq_num, + performance_package_token_vault: old.performance_package_token_vault, + withdrawal_policy: None, + }; + + performance_package.realloc(PerformancePackage::SIZE, true)?; + + let lamports_needed = Rent::get()?.minimum_balance(PerformancePackage::SIZE); + if lamports_needed > performance_package.lamports() { + system_program::transfer( + CpiContext::new( + ctx.accounts.system_program.to_account_info(), + system_program::Transfer { + from: ctx.accounts.payer.to_account_info(), + to: performance_package.to_account_info(), + }, + ), + lamports_needed - performance_package.lamports(), + )?; + } + + new.serialize(&mut &mut performance_package.try_borrow_mut_data()?[8..])?; + + Ok(()) + } +} diff --git a/programs/price_based_performance_package/src/instructions/start_unlock.rs b/programs/price_based_performance_package/src/instructions/start_unlock.rs index 023d6b9b7..b3f8a4b8e 100644 --- a/programs/price_based_performance_package/src/instructions/start_unlock.rs +++ b/programs/price_based_performance_package/src/instructions/start_unlock.rs @@ -18,6 +18,8 @@ pub struct StartUnlock<'info> { impl StartUnlock<'_> { pub fn validate(&self) -> Result<()> { + PerformancePackage::assert_migrated(&self.performance_package.to_account_info())?; + require_eq!( self.performance_package.state, PerformancePackageState::Locked, diff --git a/programs/price_based_performance_package/src/instructions/withdraw_tokens.rs b/programs/price_based_performance_package/src/instructions/withdraw_tokens.rs new file mode 100644 index 000000000..a9acf2f70 --- /dev/null +++ b/programs/price_based_performance_package/src/instructions/withdraw_tokens.rs @@ -0,0 +1,152 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token::{self, Mint, Token, TokenAccount}, +}; + +use super::*; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct WithdrawTokensParams { + pub amount: u64, +} + +#[derive(Accounts)] +#[event_cpi] +pub struct WithdrawTokens<'info> { + #[account( + mut, + has_one = recipient, + has_one = token_mint, + has_one = performance_package_token_vault + )] + pub performance_package: Box>, + + /// CHECK: Read as a futarchy `Dao` only while a withdrawal policy is active + #[account(address = performance_package.oracle_config.oracle_account)] + pub oracle_account: UncheckedAccount<'info>, + + /// The token account where locked tokens are stored + #[account(mut)] + pub performance_package_token_vault: Box>, + + pub token_mint: Box>, + + /// The recipient's ATA where tokens will be sent - created if needed + #[account( + init_if_needed, + payer = payer, + associated_token::mint = token_mint, + associated_token::authority = recipient + )] + pub recipient_token_account: Box>, + + /// Only the recipient can withdraw + pub recipient: Signer<'info>, + + /// Payer for creating the ATA if needed + #[account(mut)] + pub payer: Signer<'info>, + + pub system_program: Program<'info, System>, + pub token_program: Program<'info, Token>, + pub associated_token_program: Program<'info, AssociatedToken>, +} + +impl WithdrawTokens<'_> { + pub fn validate(&self, params: &WithdrawTokensParams) -> Result<()> { + PerformancePackage::assert_migrated(&self.performance_package.to_account_info())?; + + require_gt!(params.amount, 0); + + Ok(()) + } + + pub fn handle(ctx: Context, params: WithdrawTokensParams) -> Result<()> { + let Self { + performance_package, + oracle_account, + performance_package_token_vault, + token_mint, + recipient_token_account, + recipient, + payer: _, + system_program: _, + token_program, + associated_token_program: _, + event_authority: _, + program: _, + } = ctx.accounts; + + let clock = Clock::get()?; + let now = clock.unix_timestamp; + let WithdrawTokensParams { amount } = params; + + let withdrawable = + performance_package.withdrawable(performance_package_token_vault.amount)?; + require_gte!( + withdrawable, + amount, + PriceBasedPerformancePackageError::InsufficientWithdrawableBalance + ); + + let capped_withdrawal = match performance_package.active_policy(now) { + Some(policy) => { + require!( + policy.limits.withdrawal_mode.allows_tokens(), + PriceBasedPerformancePackageError::WithdrawTokensDisabled + ); + + policy.roll_if_new_window(now); + policy.assert_tokens_fit(amount)?; + + let dao = read_dao(oracle_account, &token_mint.key())?; + let price = valuation_price(&dao)?; + let quote_value = quote_value_at_price(amount, price)?; + policy.assert_quote_fits(quote_value)?; + + policy.record_withdrawal(amount, quote_value); + + Some(CappedWithdrawal { + price, + quote_value, + usage: policy.usage, + }) + } + // No active policy means no limits were active, so no capped withdrawal + None => None, + }; + + let seeds = &[ + b"performance_package", + performance_package.create_key.as_ref(), + &[performance_package.pda_bump], + ]; + let signer = &[&seeds[..]]; + + token::transfer( + CpiContext::new_with_signer( + token_program.to_account_info(), + token::Transfer { + from: performance_package_token_vault.to_account_info(), + to: recipient_token_account.to_account_info(), + authority: performance_package.to_account_info(), + }, + signer, + ), + amount, + )?; + + performance_package.seq_num += 1; + + emit_cpi!(TokensWithdrawn { + common: CommonFields::new(&clock, performance_package.seq_num), + performance_package: performance_package.key(), + recipient: recipient.key(), + amount, + capped: capped_withdrawal, + }); + + Ok(()) + } +} diff --git a/programs/price_based_performance_package/src/instructions/withdraw_via_sell.rs b/programs/price_based_performance_package/src/instructions/withdraw_via_sell.rs new file mode 100644 index 000000000..c4114f3c7 --- /dev/null +++ b/programs/price_based_performance_package/src/instructions/withdraw_via_sell.rs @@ -0,0 +1,222 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token::{self, Mint, Token, TokenAccount}, +}; +use futarchy::{program::Futarchy, Dao, SpotSwapParams, SwapType}; + +use super::*; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct WithdrawViaSellParams { + pub amount: u64, + pub min_quote_out: u64, +} + +#[derive(Accounts)] +#[event_cpi] +pub struct WithdrawViaSell<'info> { + #[account( + mut, + has_one = recipient, + has_one = token_mint, + has_one = performance_package_token_vault + )] + pub performance_package: Box>, + + /// The futarchy Dao whose spot pool buys the tokens + #[account( + mut, + address = performance_package.oracle_config.oracle_account, + constraint = dao.base_mint == token_mint.key() @ PriceBasedPerformancePackageError::OracleMintMismatch + )] + pub dao: Box>, + + /// The token account where locked tokens are stored; the sale is paid out of it + #[account(mut)] + pub performance_package_token_vault: Box>, + + pub token_mint: Box>, + + #[account(address = dao.quote_mint)] + pub quote_mint: Box>, + + #[account(mut, address = dao.amm.amm_base_vault)] + pub amm_base_vault: Box>, + + #[account(mut, address = dao.amm.amm_quote_vault)] + pub amm_quote_vault: Box>, + + /// The package's quote ATA that receives the proceeds before they are forwarded + #[account( + init_if_needed, + payer = payer, + associated_token::mint = quote_mint, + associated_token::authority = performance_package + )] + pub package_quote_account: Box>, + + /// The recipient's quote ATA where the proceeds are sent + #[account( + init_if_needed, + payer = payer, + associated_token::mint = quote_mint, + associated_token::authority = recipient + )] + pub recipient_quote_account: Box>, + + /// Only the recipient can withdraw + pub recipient: Signer<'info>, + + /// Payer for creating the ATAs if needed + #[account(mut)] + pub payer: Signer<'info>, + + pub futarchy_program: Program<'info, Futarchy>, + + /// CHECK: Futarchy's event authority, pinned by its seeds + #[account(seeds = [b"__event_authority"], bump, seeds::program = futarchy_program)] + pub futarchy_event_authority: UncheckedAccount<'info>, + + pub system_program: Program<'info, System>, + pub token_program: Program<'info, Token>, + pub associated_token_program: Program<'info, AssociatedToken>, +} + +impl WithdrawViaSell<'_> { + pub fn validate(&self, params: &WithdrawViaSellParams) -> Result<()> { + PerformancePackage::assert_migrated(&self.performance_package.to_account_info())?; + + require_gt!(params.amount, 0); + + Ok(()) + } + + pub fn handle(ctx: Context, params: WithdrawViaSellParams) -> Result<()> { + let Self { + performance_package, + dao, + performance_package_token_vault, + token_mint: _, + quote_mint: _, + amm_base_vault, + amm_quote_vault, + package_quote_account, + recipient_quote_account, + recipient, + payer: _, + futarchy_program, + futarchy_event_authority, + system_program: _, + token_program, + associated_token_program: _, + event_authority: _, + program: _, + } = ctx.accounts; + + let clock = Clock::get()?; + let now = clock.unix_timestamp; + let WithdrawViaSellParams { + amount, + min_quote_out, + } = params; + + let withdrawable = + performance_package.withdrawable(performance_package_token_vault.amount)?; + require_gte!( + withdrawable, + amount, + PriceBasedPerformancePackageError::InsufficientWithdrawableBalance + ); + + // The token cap is checked before the sale so that a failure moves nothing + if let Some(policy) = performance_package.active_policy(now) { + require!( + policy.limits.withdrawal_mode.allows_sell(), + PriceBasedPerformancePackageError::WithdrawViaSellDisabled + ); + + policy.roll_if_new_window(now); + policy.assert_tokens_fit(amount)?; + } + + let quote_before = package_quote_account.amount; + + let create_key = performance_package.create_key; + let seeds = &[ + b"performance_package", + create_key.as_ref(), + &[performance_package.pda_bump], + ]; + let signer = &[&seeds[..]]; + + futarchy::cpi::spot_swap( + CpiContext::new_with_signer( + futarchy_program.to_account_info(), + futarchy::cpi::accounts::SpotSwap { + dao: dao.to_account_info(), + user_base_account: performance_package_token_vault.to_account_info(), + user_quote_account: package_quote_account.to_account_info(), + amm_base_vault: amm_base_vault.to_account_info(), + amm_quote_vault: amm_quote_vault.to_account_info(), + user: performance_package.to_account_info(), + token_program: token_program.to_account_info(), + event_authority: futarchy_event_authority.to_account_info(), + program: futarchy_program.to_account_info(), + }, + signer, + ), + SpotSwapParams { + input_amount: amount, + swap_type: SwapType::Sell, + min_output_amount: min_quote_out, + }, + )?; + + package_quote_account.reload()?; + let quote_received = package_quote_account + .amount + .checked_sub(quote_before) + .ok_or(PriceBasedPerformancePackageError::InvariantViolated)?; + + // The quote cap is checked against what the pool actually paid; a failure fails the transaction, sale included + let capped = match performance_package.active_policy(now) { + Some(policy) => { + // Fail the transaction if the quote cap is exceeded + policy.assert_quote_fits(quote_received)?; + policy.record_withdrawal(amount, quote_received); + + Some(policy.usage) + } + None => None, + }; + + // Forward exactly what the pool paid + token::transfer( + CpiContext::new_with_signer( + token_program.to_account_info(), + token::Transfer { + from: package_quote_account.to_account_info(), + to: recipient_quote_account.to_account_info(), + authority: performance_package.to_account_info(), + }, + signer, + ), + quote_received, + )?; + + performance_package.seq_num += 1; + + emit_cpi!(TokensSold { + common: CommonFields::new(&clock, performance_package.seq_num), + performance_package: performance_package.key(), + recipient: recipient.key(), + amount, + quote_received, + min_quote_out, + capped, + }); + + Ok(()) + } +} diff --git a/programs/price_based_performance_package/src/lib.rs b/programs/price_based_performance_package/src/lib.rs index bc4c938d6..331bf111a 100644 --- a/programs/price_based_performance_package/src/lib.rs +++ b/programs/price_based_performance_package/src/lib.rs @@ -28,7 +28,7 @@ security_txt! { project_url: "https://metadao.fi", contacts: "telegram:metaproph3t,telegram:kollan_house", source_code: "https://github.com/metaDAOproject/programs", - source_release: "v0.6.0", + source_release: "v0.6.1", 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)" } @@ -47,6 +47,14 @@ pub mod price_based_performance_package { InitializePerformancePackage::handle(ctx, params) } + #[access_control(ctx.accounts.validate_with_limits(¶ms))] + pub fn initialize_performance_package_with_limits( + ctx: Context, + params: InitializePerformancePackageWithLimitsParams, + ) -> Result<()> { + InitializePerformancePackage::handle_with_limits(ctx, params) + } + #[access_control(ctx.accounts.validate())] pub fn start_unlock(ctx: Context) -> Result<()> { StartUnlock::handle(ctx) @@ -79,4 +87,25 @@ pub mod price_based_performance_package { pub fn burn_performance_package(ctx: Context) -> Result<()> { BurnPerformancePackage::handle(ctx) } + + #[access_control(ctx.accounts.validate())] + pub fn resize_performance_package(ctx: Context) -> Result<()> { + ResizePerformancePackage::handle(ctx) + } + + #[access_control(ctx.accounts.validate(¶ms))] + pub fn withdraw_tokens( + ctx: Context, + params: WithdrawTokensParams, + ) -> Result<()> { + WithdrawTokens::handle(ctx, params) + } + + #[access_control(ctx.accounts.validate(¶ms))] + pub fn withdraw_via_sell( + ctx: Context, + params: WithdrawViaSellParams, + ) -> Result<()> { + WithdrawViaSell::handle(ctx, params) + } } diff --git a/programs/price_based_performance_package/src/state/mod.rs b/programs/price_based_performance_package/src/state/mod.rs index 89f992c89..098caeed5 100644 --- a/programs/price_based_performance_package/src/state/mod.rs +++ b/programs/price_based_performance_package/src/state/mod.rs @@ -1,3 +1,5 @@ +pub mod oracle; pub mod performance_package; +pub use oracle::*; pub use performance_package::*; diff --git a/programs/price_based_performance_package/src/state/oracle.rs b/programs/price_based_performance_package/src/state/oracle.rs new file mode 100644 index 000000000..f0a6621aa --- /dev/null +++ b/programs/price_based_performance_package/src/state/oracle.rs @@ -0,0 +1,60 @@ +use anchor_lang::error::ErrorCode; +use anchor_lang::prelude::*; +use futarchy::{Dao, Pool, PoolState}; + +use crate::{PriceBasedPerformancePackageError, PRICE_SCALE}; + +/// Read the oracle account as the futarchy `Dao` whose spot pool prices withdrawals of `token_mint`. +pub fn read_dao(oracle: &AccountInfo, token_mint: &Pubkey) -> Result { + if oracle.owner != &Dao::owner() { + return Err(Error::from(ErrorCode::AccountOwnedByWrongProgram) + .with_pubkeys((*oracle.owner, Dao::owner()))); + } + + let data = oracle.try_borrow_data()?; + let dao = Dao::try_deserialize(&mut &data[..])?; + require_keys_eq!( + dao.base_mint, + *token_mint, + PriceBasedPerformancePackageError::OracleMintMismatch + ); + + Ok(dao) +} + +/// The higher of the spot pool's damped observation and its reserve price. +pub fn valuation_price(dao: &Dao) -> Result { + let pool: &Pool = match &dao.amm.state { + PoolState::Spot { spot } | PoolState::Futarchy { spot, .. } => spot, + }; + + let observation = pool.oracle.last_observation; + require_gt!( + observation, + 0, + PriceBasedPerformancePackageError::InvalidPriceObservation + ); + + let reserve_price = if pool.base_reserves == 0 { + 0 + } else { + (pool.quote_reserves as u128 * PRICE_SCALE) / pool.base_reserves as u128 + }; + + Ok(observation.max(reserve_price)) +} + +/// Value `amount` base atoms at `price`, in quote atoms, rounding up. +pub fn quote_value_at_price(amount: u64, price: u128) -> Result { + let scaled = (amount as u128) + .checked_mul(price) + .ok_or(PriceBasedPerformancePackageError::QuoteWindowLimitExceeded)?; + + let value = scaled + .checked_add(PRICE_SCALE - 1) + .ok_or(PriceBasedPerformancePackageError::QuoteWindowLimitExceeded)? + / PRICE_SCALE; + + u64::try_from(value) + .map_err(|_| PriceBasedPerformancePackageError::QuoteWindowLimitExceeded.into()) +} diff --git a/programs/price_based_performance_package/src/state/performance_package.rs b/programs/price_based_performance_package/src/state/performance_package.rs index f591f2e69..7d8d2e9dc 100644 --- a/programs/price_based_performance_package/src/state/performance_package.rs +++ b/programs/price_based_performance_package/src/state/performance_package.rs @@ -1,11 +1,15 @@ use anchor_lang::prelude::*; -use crate::MAX_TRANCHES; +use crate::{PriceBasedPerformancePackageError, MAX_TRANCHES}; -/// Starting at `byte_offset` in `oracle_account`, this program expects to read: +/// Starting at `byte_offset` in `oracle_account`, the unlock instructions read: /// - 16 bytes for the aggregator, stored as a little endian u128 -/// - 8 bytes for the slot that the aggregator was last updated, stored as a -/// little endian u64 +/// - 8 bytes for the timestamp that the aggregator was last updated, stored as +/// a little endian i64 +/// +/// While withdrawal limits are active, `oracle_account` must also be a futarchy +/// `Dao`: the withdraw instructions value withdrawals from its spot pool, at the +/// higher of the pool's damped observation and its reserve price. /// /// The aggregator should be a weighted sum of prices, where the weight is the /// number of seconds between prices. Here's an example: @@ -80,6 +84,253 @@ pub struct PerformancePackage { pub seq_num: u64, /// The vault that stores the tokens pub performance_package_token_vault: Pubkey, + /// Appended in 0.6.1; `None` means uncapped, and so do expired limits + pub withdrawal_policy: Option, +} + +impl PerformancePackage { + /// Account size since 0.6.1 (582 bytes) + pub const SIZE: usize = 8 + Self::INIT_SPACE; + /// Account size before 0.6.1 (520 bytes) + pub const OLD_SIZE: usize = 8 + OldPerformancePackage::INIT_SPACE; + + /// Ensure the package has been resized to the current layout. + pub fn assert_migrated(info: &AccountInfo) -> Result<()> { + require_eq!( + info.data_len(), + Self::SIZE, + PriceBasedPerformancePackageError::AccountNotMigrated + ); + Ok(()) + } + + /// Everything in the vault that is not still locked, "donations" included. + pub fn withdrawable(&self, vault_amount: u64) -> Result { + let locked = self + .total_token_amount + .checked_sub(self.already_unlocked_amount) + .ok_or(PriceBasedPerformancePackageError::InvariantViolated)?; + let withdrawable = vault_amount + .checked_sub(locked) + .ok_or(PriceBasedPerformancePackageError::InvariantViolated)?; + Ok(withdrawable) + } + + /// The policy whose limits are still in force at `now`, if any. + pub fn active_policy(&mut self, now: i64) -> Option<&mut WithdrawalPolicy> { + self.withdrawal_policy + .as_mut() + .filter(|policy| now < policy.limits.end_timestamp) + } + + /// Replace the withdrawal policy as a whole. `None` removes it. + pub fn replace_withdrawal_policy(&mut self, limits: Option, now: i64) { + let current = self.withdrawal_policy; + + self.withdrawal_policy = limits.map(|new_limits| match current { + // If the window size is the same, keep the same window and usage + Some(current) if current.limits.window_seconds == new_limits.window_seconds => { + WithdrawalPolicy { + limits: new_limits.into_limits(current.limits.start_timestamp), + usage: current.usage, + } + } + // If the window size is different, start a new window NOW and keep the current usage + Some(current) => WithdrawalPolicy { + limits: new_limits.into_limits(now), + usage: WindowUsage { + window_index: 0, + ..current.usage + }, + }, + None => WithdrawalPolicy::new(new_limits.into_limits(now)), + }); + } +} + +/// The 0.6.0 layout, decoded by the resize before an account is migrated +#[derive(AnchorSerialize, AnchorDeserialize, InitSpace)] +pub struct OldPerformancePackage { + #[max_len(MAX_TRANCHES)] + pub tranches: Vec, + pub total_token_amount: u64, + pub already_unlocked_amount: u64, + pub min_unlock_timestamp: i64, + pub oracle_config: OracleConfig, + pub twap_length_seconds: u32, + pub recipient: Pubkey, + pub state: PerformancePackageState, + pub create_key: Pubkey, + pub pda_bump: u8, + pub performance_package_authority: Pubkey, + pub token_mint: Pubkey, + pub seq_num: u64, + pub performance_package_token_vault: Pubkey, +} + +/// The agreed limits together with the usage they are enforced against +#[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, Copy, PartialEq, Eq, InitSpace)] +pub struct WithdrawalPolicy { + pub limits: WithdrawalLimits, + pub usage: WindowUsage, +} + +impl WithdrawalPolicy { + /// Fresh limits with nothing withdrawn yet + pub fn new(limits: WithdrawalLimits) -> Self { + Self { + limits, + usage: WindowUsage::default(), + } + } + + /// Reset the counters if `now` falls in a later window than the last withdrawal. + pub fn roll_if_new_window(&mut self, now: i64) { + let window_index = + (now - self.limits.start_timestamp) / self.limits.window_seconds as i64; + + if window_index != self.usage.window_index { + self.usage = WindowUsage { + window_index, + tokens_used: 0, + quote_used: 0, + }; + } + } + + /// Ensure `amount` more base tokens fit under the window's token cap. + pub fn assert_tokens_fit(&self, amount: u64) -> Result<()> { + let tokens_used = self + .usage + .tokens_used + .checked_add(amount) + .ok_or(PriceBasedPerformancePackageError::TokenWindowLimitExceeded)?; + + require_gte!( + self.limits.max_tokens_per_window, + tokens_used, + PriceBasedPerformancePackageError::TokenWindowLimitExceeded + ); + + Ok(()) + } + + /// Ensure `quote_value` more quote atoms fit under the window's quote cap. + pub fn assert_quote_fits(&self, quote_value: u64) -> Result<()> { + let quote_used = self + .usage + .quote_used + .checked_add(quote_value) + .ok_or(PriceBasedPerformancePackageError::QuoteWindowLimitExceeded)?; + + require_gte!( + self.limits.max_quote_per_window, + quote_used, + PriceBasedPerformancePackageError::QuoteWindowLimitExceeded + ); + + Ok(()) + } + + /// Count a withdrawal that passed both cap checks against the window. + pub fn record_withdrawal(&mut self, amount: u64, quote_value: u64) { + self.usage.tokens_used += amount; + self.usage.quote_used += quote_value; + } +} + +/// Usage in the window the last withdrawal fell in +#[derive( + AnchorSerialize, AnchorDeserialize, Debug, Clone, Copy, PartialEq, Eq, InitSpace, Default, +)] +pub struct WindowUsage { + /// `(now - limits.start_timestamp) / limits.window_seconds` at the last withdrawal + pub window_index: i64, + pub tokens_used: u64, + pub quote_used: u64, +} + +#[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, Copy, PartialEq, Eq, InitSpace)] +pub struct WithdrawalLimits { + /// Anchor for window boundaries; set by the program when limits take effect or `window_seconds` changes + pub start_timestamp: i64, + /// Caps apply while `now < end_timestamp` + pub end_timestamp: i64, + /// Duration of the window in seconds + pub window_seconds: u32, + /// Max base tokens withdrawn per window + pub max_tokens_per_window: u64, + /// Max quote value withdrawn per window, in quote atoms + pub max_quote_per_window: u64, + /// Which withdrawal routes the recipient may use while the caps are active + pub withdrawal_mode: WithdrawalMode, +} + +#[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, Copy, PartialEq, Eq, InitSpace)] +pub enum WithdrawalMode { + Tokens, + Sell, + Both, +} + +impl WithdrawalMode { + pub fn allows_tokens(&self) -> bool { + matches!(self, WithdrawalMode::Tokens | WithdrawalMode::Both) + } + + pub fn allows_sell(&self) -> bool { + matches!(self, WithdrawalMode::Sell | WithdrawalMode::Both) + } +} + +/// What the two parties agree on; the program supplies `start_timestamp` +#[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, Copy, PartialEq, Eq, InitSpace)] +pub struct LimitsParams { + pub end_timestamp: i64, + pub window_seconds: u32, + pub max_tokens_per_window: u64, + pub max_quote_per_window: u64, + pub withdrawal_mode: WithdrawalMode, +} + +impl LimitsParams { + /// Ensure the caps are non-zero, the end is ahead of `now`, and the window is at least one second. + pub fn validate(&self, now: i64) -> Result<()> { + require_gt!( + self.max_tokens_per_window, + 0, + PriceBasedPerformancePackageError::InvalidWithdrawalLimits + ); + require_gt!( + self.max_quote_per_window, + 0, + PriceBasedPerformancePackageError::InvalidWithdrawalLimits + ); + require_gt!( + self.end_timestamp, + now, + PriceBasedPerformancePackageError::InvalidWithdrawalLimits + ); + require_gte!( + self.window_seconds, + 1, + PriceBasedPerformancePackageError::InvalidWithdrawalLimits + ); + + Ok(()) + } + + /// Anchor the window boundaries at `start_timestamp`. + pub fn into_limits(self, start_timestamp: i64) -> WithdrawalLimits { + WithdrawalLimits { + start_timestamp, + end_timestamp: self.end_timestamp, + window_seconds: self.window_seconds, + max_tokens_per_window: self.max_tokens_per_window, + max_quote_per_window: self.max_quote_per_window, + withdrawal_mode: self.withdrawal_mode, + } + } } #[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, Copy, PartialEq, Eq, InitSpace)] @@ -116,6 +367,11 @@ pub enum ChangeType { Oracle { new_oracle_config: OracleConfig }, /// Change the token recipient Recipient { new_recipient: Pubkey }, + /// Change the unlock cliff and the withdrawal limits together; `None` limits means uncapped + UnlockTerms { + min_unlock_timestamp: i64, + limits: Option, + }, } #[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, PartialEq, Eq, InitSpace)] diff --git a/scripts/utils/daoActions.ts b/scripts/utils/daoActions.ts index b3ce9b16d..86e4c434e 100644 --- a/scripts/utils/daoActions.ts +++ b/scripts/utils/daoActions.ts @@ -35,6 +35,10 @@ import { FutarchyClient, UpdateDaoParams, } from "@metadaoproject/programs/futarchy/v0.6"; +import { + LimitsParams, + PriceBasedPerformancePackageClient, +} from "@metadaoproject/programs/price_based_performance_package"; import { buildAdminApprovalTransactions } from "./adminApproval.js"; import { getSquadsPdasFromDao, probeSquadsVaultTransaction } from "./squads.js"; @@ -494,6 +498,78 @@ export const removeSpendingLimit = }; }; +// Proposes new unlock terms for a performance package whose authority is the +// DAO's vault: a new cliff, and per-window withdrawal limits or none. The vault +// is the proposer and pays the change request's rent, so it needs a little +// SOL. The package's recipient executes the change with executeChange.ts. +export const proposePerformancePackageUnlockTerms = + ({ + performancePackage, + minUnlockTimestamp, + limits, + pdaNonce, + }: { + performancePackage: PublicKey; + minUnlockTimestamp: BN; + limits: LimitsParams | null; + pdaNonce: number; + }): DaoActionBuilder => + async ({ provider, daoMultisigVault }) => { + const priceBasedPerformancePackage = + PriceBasedPerformancePackageClient.createClient({ provider }); + + const performancePackageAccount = + await priceBasedPerformancePackage.getPerformancePackage( + performancePackage, + ); + if ( + !performancePackageAccount.performancePackageAuthority.equals( + daoMultisigVault, + ) + ) { + throw new Error( + `Performance package ${performancePackage.toBase58()} has authority ${performancePackageAccount.performancePackageAuthority.toBase58()}, not the DAO's vault`, + ); + } + + // The proposal fails at execution if the change request already exists + const changeRequest = priceBasedPerformancePackage.getChangeRequestAddress( + performancePackage, + daoMultisigVault, + pdaNonce, + ); + if ((await provider.connection.getAccountInfo(changeRequest)) !== null) { + throw new Error( + `Change request ${changeRequest.toBase58()} already exists - use another pdaNonce`, + ); + } + + console.log("Performance package:", performancePackage.toBase58()); + console.log("Recipient:", performancePackageAccount.recipient.toBase58()); + console.log( + "Current min unlock timestamp:", + performancePackageAccount.minUnlockTimestamp.toString(), + ); + console.log("Change request:", changeRequest.toBase58()); + + return { + instructions: [ + await priceBasedPerformancePackage + .proposeChangeIx({ + params: { + changeType: { unlockTerms: { minUnlockTimestamp, limits } }, + pdaNonce, + }, + performancePackage, + proposer: daoMultisigVault, + }) + // The vault pays the rent; otherwise the payer defaults to the wallet + .accounts({ payer: daoMultisigVault }) + .instruction(), + ], + }; + }; + /** * Runs the action builders against the DAO's squads accounts. Returns the * instructions the DAO's vault should execute, `setupTransaction` - a diff --git a/scripts/v0.7/burnPerformancePackage.ts b/scripts/v0.7/burnPerformancePackage.ts index f813d991b..0fc1d56f7 100644 --- a/scripts/v0.7/burnPerformancePackage.ts +++ b/scripts/v0.7/burnPerformancePackage.ts @@ -4,11 +4,15 @@ import { PRICE_BASED_PERFORMANCE_PACKAGE_PROGRAM_ID, PriceBasedPerformancePackageClient, METADAO_MULTISIG_VAULT, + QuoteSweep, } from "@metadaoproject/programs"; import { PublicKey, TransactionMessage } from "@solana/web3.js"; // Set the performance package address before running the script const performancePackage = new PublicKey(""); +// Set when the package's quote ATA exists (it does once the recipient has +// sold): its balance is swept into `quoteDestination` and the ATA is closed +const quoteSweep: QuoteSweep | undefined = undefined; const provider = anchor.AnchorProvider.env(); @@ -27,36 +31,33 @@ const metadaoSquadsMultisig = new PublicKey( ); const metadaoSquadsMultisigVault = METADAO_MULTISIG_VAULT; -// This should only be run once per DAO/AMM -// It's meant to be a one-off operation that reduces liquidity to a target K (the inital pool's liquidity) and collect it as "fees" -// We're using this because we didn't track LP fee collection in the pool state, nor did we exclude those fees from liquidity +// Retires a performance package: the recipient is paid what is already unlocked, +// the locked remainder is burned, and the package with its token accounts is closed export const burnPerformancePackage = async () => { const performancePackageAccount = await priceBasedPerformancePackage.getPerformancePackage( performancePackage, ); - // We call the collect fees instruction from Metadao DAO's multisig account - // It's the only one that can call the collect fees instruction + // Only Metadao DAO's multisig vault may burn a package const metaDaoSquadsMultisigAccount = await multisig.accounts.Multisig.fromAccountAddress( anchor.getProvider().connection, metadaoSquadsMultisig, ); - // Prepare transaction message - const burnPerformancePackageIx = - await priceBasedPerformancePackage.program.methods - .burnPerformancePackage() - .accounts({ - performancePackage, - performancePackageTokenVault: - performancePackageAccount.performancePackageTokenVault, - tokenMint: performancePackageAccount.tokenMint, - admin: metadaoSquadsMultisigVault, - spillAccount: payer.publicKey, - }) - .instruction(); + // Prepare transaction message. The recipient's token account is created if + // it is missing, paid by the vault; rent from the closed accounts goes to the payer. + const burnPerformancePackageIx = await priceBasedPerformancePackage + .burnPerformancePackageIx({ + performancePackage, + tokenMint: performancePackageAccount.tokenMint, + recipient: performancePackageAccount.recipient, + admin: metadaoSquadsMultisigVault, + spillAccount: payer.publicKey, + quoteSweep, + }) + .instruction(); const transactionMessage = new TransactionMessage({ instructions: [burnPerformancePackageIx], diff --git a/scripts/v0.7/executeChange.ts b/scripts/v0.7/executeChange.ts new file mode 100644 index 000000000..32224ac27 --- /dev/null +++ b/scripts/v0.7/executeChange.ts @@ -0,0 +1,49 @@ +import * as anchor from "@coral-xyz/anchor"; +import BN from "bn.js"; +import { PriceBasedPerformancePackageClient } from "@metadaoproject/programs/price_based_performance_package"; +import { PublicKey } from "@solana/web3.js"; + +// Set the change request to execute before running the script +const CHANGE_REQUEST = new PublicKey(""); + +const provider = anchor.AnchorProvider.env(); + +// The wallet must be the package's recipient for a change the authority +// proposed, and the authority for a change the recipient proposed +const executor = provider.wallet["payer"]; + +const priceBasedPerformancePackage = + PriceBasedPerformancePackageClient.createClient({ provider }); + +// Prints BN fields as decimal strings instead of hex +const decimalBns = (_key: string, value: unknown) => + BN.isBN(value) ? value.toString() : value; + +const executeChange = async () => { + const changeRequest = + await priceBasedPerformancePackage.getChangeRequest(CHANGE_REQUEST); + + console.log("Change request:", CHANGE_REQUEST.toBase58()); + console.log( + "Performance package:", + changeRequest.performancePackage.toBase58(), + ); + console.log( + "Proposed at:", + new Date(changeRequest.proposedAt.toNumber() * 1_000).toISOString(), + ); + console.log("Change:", JSON.stringify(changeRequest.changeType, decimalBns)); + console.log("Executor:", executor.publicKey.toBase58()); + + const signature = await priceBasedPerformancePackage + .executeChangeIx({ + performancePackage: changeRequest.performancePackage, + changeRequest: CHANGE_REQUEST, + executor: executor.publicKey, + }) + .rpc(); + + console.log("Execute change transaction sent:", signature); +}; + +executeChange().catch(console.error); diff --git a/scripts/v0.7/resizePerformancePackages.ts b/scripts/v0.7/resizePerformancePackages.ts new file mode 100644 index 000000000..7132ba761 --- /dev/null +++ b/scripts/v0.7/resizePerformancePackages.ts @@ -0,0 +1,157 @@ +import { + ComputeBudgetProgram, + Keypair, + TransactionInstruction, + VersionedTransaction, + TransactionMessage, +} from "@solana/web3.js"; +import * as anchor from "@coral-xyz/anchor"; +import { PriceBasedPerformancePackageClient } from "@metadaoproject/programs/price_based_performance_package"; +import dotenv from "dotenv"; +import bs58 from "bs58"; + +dotenv.config(); + +const provider = anchor.AnchorProvider.env(); +const payer = provider.wallet["payer"]; + +function getDiscriminator(accountName: string): Buffer { + return Buffer.from( + anchor.BorshAccountsCoder.accountDiscriminator(accountName), + ); +} + +async function main() { + const priceBasedPerformancePackage = + PriceBasedPerformancePackageClient.createClient({ provider }); + + const performancePackageDiscriminator = + getDiscriminator("PerformancePackage"); + + const batchSize = 20; + + console.log( + `PerformancePackage discriminator (hex): ${performancePackageDiscriminator.toString("hex")}`, + ); + console.log( + `Program ID: ${priceBasedPerformancePackage.programId.toBase58()}\n`, + ); + + const performancePackageAccounts = + await provider.connection.getProgramAccounts( + priceBasedPerformancePackage.programId, + { + filters: [ + { + memcmp: { + offset: 0, + bytes: bs58.encode(performancePackageDiscriminator), + }, + }, + ], + }, + ); + + console.log( + `Found ${performancePackageAccounts.length} performance packages`, + ); + for (let i = 0; i < performancePackageAccounts.length; i += batchSize) { + const batch = performancePackageAccounts.slice( + i, + Math.min(i + batchSize, performancePackageAccounts.length), + ); + console.log( + `Processing batch ${i / batchSize + 1} with ${batch.length} performance packages`, + ); + + const ixs = await Promise.all( + batch.map(async ({ pubkey }) => { + return await priceBasedPerformancePackage + .resizePerformancePackageIx({ + performancePackage: pubkey, + payer: payer.publicKey, + }) + .instruction(); + }), + ); + + await sendAndConfirmTransaction( + ixs, + `Resize performance packages batch ${i / batchSize + 1}`, + ); + } + + // Verify all accounts load through SDK and report withdrawal policy status + console.log("\nConfirming performance packages can be loaded through SDK..."); + const performancePackages = + await priceBasedPerformancePackage.program.account.performancePackage.all(); + console.log(`Confirmed ${performancePackages.length} performance packages\n`); + + for (const { + publicKey, + account: performancePackage, + } of performancePackages) { + console.log(`Performance package: ${publicKey.toBase58()}`); + console.log(` Recipient: ${performancePackage.recipient.toBase58()}`); + console.log(` Token mint: ${performancePackage.tokenMint.toBase58()}`); + console.log( + ` Withdrawal policy: ${performancePackage.withdrawalPolicy === null ? "none" : "set"}`, + ); + } +} + +main().catch((error) => { + console.error("Fatal error:", error); + process.exit(1); +}); + +async function sendAndConfirmTransaction( + ixs: TransactionInstruction[], + label: string, + signers: Keypair[] = [], +) { + const { blockhash } = await provider.connection.getLatestBlockhash(); + + // Simulate without compute budget to get units consumed + const messageV0 = new TransactionMessage({ + instructions: ixs, + payerKey: payer.publicKey, + recentBlockhash: blockhash, + }).compileToV0Message(); + const simulationTx = new VersionedTransaction(messageV0); + simulationTx.sign([payer, ...signers]); + + const simulationResult = + await provider.connection.simulateTransaction(simulationTx); + + const computeBudgetIx = ComputeBudgetProgram.setComputeUnitLimit({ + units: Math.ceil(simulationResult.value.unitsConsumed! * 1.15), + }); + + // Rebuild transaction with compute budget instruction prepended + const finalMessageV0 = new TransactionMessage({ + instructions: [computeBudgetIx, ...ixs], + payerKey: payer.publicKey, + recentBlockhash: blockhash, + }).compileToV0Message(); + const tx = new VersionedTransaction(finalMessageV0); + tx.sign([payer, ...signers]); + + const txHash = await provider.connection.sendRawTransaction(tx.serialize()); + console.log(`${label} transaction sent:`, txHash); + + await provider.connection.confirmTransaction(txHash, "confirmed"); + const txStatus = await provider.connection.getTransaction(txHash, { + maxSupportedTransactionVersion: 0, + commitment: "confirmed", + }); + if (txStatus?.meta?.err) { + throw new Error( + `Transaction failed: ${txHash}\nError: ${JSON.stringify( + txStatus?.meta?.err, + )}\n\n${txStatus?.meta?.logMessages?.join("\n")}`, + ); + } + console.log(`${label} transaction confirmed`); + return txHash; +} diff --git a/scripts/v0.7/rip-cars/proposeUnlockTerms.ts b/scripts/v0.7/rip-cars/proposeUnlockTerms.ts new file mode 100644 index 000000000..8f49f206e --- /dev/null +++ b/scripts/v0.7/rip-cars/proposeUnlockTerms.ts @@ -0,0 +1,106 @@ +import * as anchor from "@coral-xyz/anchor"; +import BN from "bn.js"; +import { PublicKey } from "@solana/web3.js"; +import { FutarchyClient } from "@metadaoproject/programs/futarchy/v0.6"; +import { + PriceBasedPerformancePackageClient, + WithdrawalMode, +} from "@metadaoproject/programs/price_based_performance_package"; +import { + buildDaoActionTransactions, + proposePerformancePackageUnlockTerms, + signAndSendDaoActionTransactions, +} from "../../utils/daoActions.js"; + +// Enqueues the Rip Cars unlock terms change through the admin approval system: +// the cliff on the performance package is removed and, until the original +// cliff date, withdrawals are capped per 30-day window. Once the ops multisig +// approves + executes the enqueue, approve + execute the DAO proposal with +// executeMultisigProposalApproval.ts; the recipient then applies the change +// with executeChange.ts. + +/////////////// +// Constants // +/////////////// + +const PERFORMANCE_PACKAGE = new PublicKey( + "92NY2WWWNAfnMr8qyGXWXKbauhnR7d6ei9j9BGHVwh7r", +); + +// Removes the cliff; any time at or before the change executes would do +const MIN_UNLOCK_TIMESTAMP = new BN(0); + +// The original cliff date, 2028-01-16; withdrawals are uncapped from then on +const END_TIMESTAMP = new BN(Date.UTC(2028, 0, 16) / 1_000); +const WINDOW_SECONDS = 30 * 24 * 60 * 60; +// 645,000 CARS, 5% of the 12,900,000 in the package +const MAX_TOKENS_PER_WINDOW = new BN(645_000).mul(new BN(10 ** 6)); +// 100,000 USDC +const MAX_QUOTE_PER_WINDOW = new BN(100_000).mul(new BN(10 ** 6)); + +// Distinguishes this change request from others the vault proposes +const PDA_NONCE = 1; + +//////////////// +// Operations // +//////////////// + +const WITHDRAWAL_MODES: Record = { + tokens: { tokens: {} }, + sell: { sell: {} }, + both: { both: {} }, +}; + +const withdrawalMode = WITHDRAWAL_MODES.both; + +const provider = anchor.AnchorProvider.env(); + +// Payer MUST be a member of the MetaDAO operational multisig with permission +// to propose transactions +const payer = provider.wallet["payer"]; + +const futarchy = FutarchyClient.createClient({ provider }); +const priceBasedPerformancePackage = + PriceBasedPerformancePackageClient.createClient({ provider }); + +async function main() { + // The package reads its price from the Rip Cars DAO + const { oracleConfig } = + await priceBasedPerformancePackage.getPerformancePackage( + PERFORMANCE_PACKAGE, + ); + const dao = oracleConfig.oracleAccount; + + console.log("DAO:", dao.toBase58()); + console.log( + "Withdrawal mode: both token withdrawals and sells into FutarchyAMM", + ); + + const transactions = await buildDaoActionTransactions({ + provider, + futarchy, + dao, + payer: payer.publicKey, + actions: [ + proposePerformancePackageUnlockTerms({ + performancePackage: PERFORMANCE_PACKAGE, + minUnlockTimestamp: MIN_UNLOCK_TIMESTAMP, + limits: { + endTimestamp: END_TIMESTAMP, + windowSeconds: WINDOW_SECONDS, + maxTokensPerWindow: MAX_TOKENS_PER_WINDOW, + maxQuotePerWindow: MAX_QUOTE_PER_WINDOW, + withdrawalMode, + }, + pdaNonce: PDA_NONCE, + }), + ], + }); + + await signAndSendDaoActionTransactions({ provider, payer, transactions }); +} + +main().catch((error) => { + console.error("Error enqueueing DAO actions:", error); + process.exit(1); +}); diff --git a/sdk/README.md b/sdk/README.md index 231d84760..08da22f6d 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -37,7 +37,7 @@ Each versioned subpath exports: - PDA derivation helpers (`getDaoAddr`, `getProposalAddr`, `getLaunchAddr`, ...). - Generated Anchor types (accounts, args, IDL). -The package root also exports shared utilities: program IDs and constants (`MAINNET_USDC`, `SQUADS_PROGRAM_ID`, ...), top-level PDA helpers (`getEventAuthorityAddr`, `getMetadataAddr`), and price math (`AmmMath`). +The package root also exports shared utilities: program IDs and constants (`MAINNET_USDC`, `SQUADS_PROGRAM_ID`, ...), top-level PDA helpers (`getEventAuthorityAddr`, `getMetadataAddr`), and price math (`AmmMath`). Optional helper modules live on their own subpath under a program version and are not re-exported by any barrel, e.g. `@metadaoproject/programs/price_based_performance_package/v0.6/withdrawalLimits`. ## Usage diff --git a/sdk/src/price_based_performance_package/v0.6/PriceBasedPerformancePackageClient.ts b/sdk/src/price_based_performance_package/v0.6/PriceBasedPerformancePackageClient.ts index 389150a27..5874994e6 100644 --- a/sdk/src/price_based_performance_package/v0.6/PriceBasedPerformancePackageClient.ts +++ b/sdk/src/price_based_performance_package/v0.6/PriceBasedPerformancePackageClient.ts @@ -1,5 +1,6 @@ import { AnchorProvider, Program } from "@coral-xyz/anchor"; import { + ComputeBudgetProgram, PublicKey, Transaction, TransactionInstruction, @@ -14,18 +15,32 @@ import { PriceBasedPerformancePackage, IDL as PriceBasedPerformancePackageIDL, } from "./types/price_based_performance_package.js"; -import { PRICE_BASED_PERFORMANCE_PACKAGE_PROGRAM_ID } from "../../constants.js"; +import { + FUTARCHY_V0_6_PROGRAM_ID, + PRICE_BASED_PERFORMANCE_PACKAGE_PROGRAM_ID, +} from "../../constants.js"; import BN from "bn.js"; // import { OracleConfig } from "./types/index.js"; import { getChangeRequestAddr, getPerformancePackageAddr } from "./pda.js"; import { getEventAuthorityAddr } from "../../pda.js"; -import { InitializePerformancePackageParams } from "./types/index.js"; +import { + InitializePerformancePackageParams, + InitializePerformancePackageWithLimitsParams, + PerformancePackage, + ProposeChangeParams, +} from "./types/index.js"; export type CreatePriceBasedPerformancePackageClientParams = { provider: AnchorProvider; priceBasedTokenLockProgramId?: PublicKey; }; +/** Burning sweeps the package's ATA for `quoteMint` into `quoteDestination` and closes it */ +export type QuoteSweep = { + quoteMint: PublicKey; + quoteDestination: PublicKey; +}; + export class PriceBasedPerformancePackageClient { public readonly provider: AnchorProvider; public readonly program: Program; @@ -66,31 +81,54 @@ export class PriceBasedPerformancePackageClient { grantor: PublicKey; grantorTokenAccount?: PublicKey; }) { - const performancePackage = getPerformancePackageAddr({ - createKey: params.createKey, - })[0]; - - const grantorTokenAccount = - params.grantorTokenAccount ?? - getAssociatedTokenAddressSync(params.tokenMint, params.grantor, true); - return this.program.methods .initializePerformancePackage(params.params) - .accounts({ + .accounts(this.initializePerformancePackageAccounts(params)); + } + + public initializePerformancePackageWithLimitsIx(params: { + params: InitializePerformancePackageWithLimitsParams; + createKey: PublicKey; + tokenMint: PublicKey; + grantor: PublicKey; + grantorTokenAccount?: PublicKey; + }) { + return this.program.methods + .initializePerformancePackageWithLimits(params.params) + .accounts(this.initializePerformancePackageAccounts(params)); + } + + // Both initialisers share one accounts struct. + private initializePerformancePackageAccounts({ + createKey, + tokenMint, + grantor, + grantorTokenAccount, + }: { + createKey: PublicKey; + tokenMint: PublicKey; + grantor: PublicKey; + grantorTokenAccount?: PublicKey; + }) { + const performancePackage = getPerformancePackageAddr({ createKey })[0]; + + return { + performancePackage, + createKey, + tokenMint, + grantorTokenAccount: + grantorTokenAccount ?? + getAssociatedTokenAddressSync(tokenMint, grantor, true), + performancePackageTokenVault: getAssociatedTokenAddressSync( + tokenMint, performancePackage, - createKey: params.createKey, - tokenMint: params.tokenMint, - grantorTokenAccount, - performancePackageTokenVault: getAssociatedTokenAddressSync( - params.tokenMint, - performancePackage, - true, - ), - grantor: params.grantor, - systemProgram: SystemProgram.programId, - tokenProgram: TOKEN_PROGRAM_ID, - associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, - }); + true, + ), + grantor, + systemProgram: SystemProgram.programId, + tokenProgram: TOKEN_PROGRAM_ID, + associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, + }; } public startUnlockIx(params: { @@ -108,35 +146,111 @@ export class PriceBasedPerformancePackageClient { public completeUnlockIx(params: { performancePackage: PublicKey; oracleAccount: PublicKey; - tokenMint: PublicKey; - tokenRecipient: PublicKey; }) { return this.program.methods.completeUnlock().accounts({ performancePackage: params.performancePackage, oracleAccount: params.oracleAccount, + }); + } + + public withdrawTokensIx({ + performancePackage, + oracleAccount, + tokenMint, + recipient, + amount, + payer = this.provider.publicKey, + }: { + performancePackage: PublicKey; + oracleAccount: PublicKey; + tokenMint: PublicKey; + recipient: PublicKey; + amount: BN; + payer?: PublicKey; + }) { + return this.program.methods.withdrawTokens({ amount }).accounts({ + performancePackage, + oracleAccount, performancePackageTokenVault: getAssociatedTokenAddressSync( - params.tokenMint, - params.performancePackage, + tokenMint, + performancePackage, true, ), - tokenMint: params.tokenMint, + tokenMint, recipientTokenAccount: getAssociatedTokenAddressSync( - params.tokenMint, - params.tokenRecipient, + tokenMint, + recipient, true, ), - tokenRecipient: params.tokenRecipient, + recipient, + payer, systemProgram: SystemProgram.programId, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, }); } + // The Dao is the package's oracle account; futarchy's AMM vaults are the Dao's ATAs. + public withdrawViaSellIx({ + performancePackage, + dao, + tokenMint, + quoteMint, + recipient, + amount, + minQuoteOut, + payer = this.provider.publicKey, + }: { + performancePackage: PublicKey; + dao: PublicKey; + tokenMint: PublicKey; + quoteMint: PublicKey; + recipient: PublicKey; + amount: BN; + minQuoteOut: BN; + payer?: PublicKey; + }) { + return this.program.methods + .withdrawViaSell({ amount, minQuoteOut }) + .accounts({ + performancePackage, + dao, + performancePackageTokenVault: getAssociatedTokenAddressSync( + tokenMint, + performancePackage, + true, + ), + tokenMint, + quoteMint, + ammBaseVault: getAssociatedTokenAddressSync(tokenMint, dao, true), + ammQuoteVault: getAssociatedTokenAddressSync(quoteMint, dao, true), + packageQuoteAccount: getAssociatedTokenAddressSync( + quoteMint, + performancePackage, + true, + ), + recipientQuoteAccount: getAssociatedTokenAddressSync( + quoteMint, + recipient, + true, + ), + recipient, + payer, + futarchyProgram: FUTARCHY_V0_6_PROGRAM_ID, + futarchyEventAuthority: getEventAuthorityAddr( + FUTARCHY_V0_6_PROGRAM_ID, + )[0], + systemProgram: SystemProgram.programId, + tokenProgram: TOKEN_PROGRAM_ID, + associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 400_000 }), + ]); + } + public proposeChangeIx(params: { - params: { - changeType: any; - pdaNonce: number; - }; + params: ProposeChangeParams; performancePackage: PublicKey; proposer: PublicKey; }) { @@ -181,7 +295,66 @@ export class PriceBasedPerformancePackageClient { }); } - public async getPerformancePackage(performancePackageAddress: PublicKey) { + public burnPerformancePackageIx({ + performancePackage, + tokenMint, + recipient, + admin = this.provider.publicKey, + spillAccount = admin, + quoteSweep, + }: { + performancePackage: PublicKey; + tokenMint: PublicKey; + recipient: PublicKey; + admin?: PublicKey; + spillAccount?: PublicKey; + quoteSweep?: QuoteSweep; + }) { + return this.program.methods.burnPerformancePackage().accounts({ + performancePackage, + performancePackageTokenVault: getAssociatedTokenAddressSync( + tokenMint, + performancePackage, + true, + ), + recipient, + recipientTokenAccount: getAssociatedTokenAddressSync( + tokenMint, + recipient, + true, + ), + admin, + spillAccount, + tokenMint, + quoteMint: quoteSweep?.quoteMint ?? null, + packageQuoteAccount: quoteSweep + ? getAssociatedTokenAddressSync( + quoteSweep.quoteMint, + performancePackage, + true, + ) + : null, + quoteDestination: quoteSweep?.quoteDestination ?? null, + systemProgram: SystemProgram.programId, + tokenProgram: TOKEN_PROGRAM_ID, + associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, + }); + } + + public resizePerformancePackageIx(params: { + performancePackage: PublicKey; + payer: PublicKey; + }) { + return this.program.methods.resizePerformancePackage().accounts({ + performancePackage: params.performancePackage, + payer: params.payer, + systemProgram: SystemProgram.programId, + }); + } + + public async getPerformancePackage( + performancePackageAddress: PublicKey, + ): Promise { return await this.program.account.performancePackage.fetch( performancePackageAddress, ); diff --git a/sdk/src/price_based_performance_package/v0.6/types/index.ts b/sdk/src/price_based_performance_package/v0.6/types/index.ts index 25186353e..6138459d8 100644 --- a/sdk/src/price_based_performance_package/v0.6/types/index.ts +++ b/sdk/src/price_based_performance_package/v0.6/types/index.ts @@ -13,6 +13,28 @@ export type PerformancePackage = export type OracleConfig = IdlTypes["OracleConfig"]; export type Tranche = IdlTypes["Tranche"]; +export type WithdrawTokensParams = + IdlTypes["WithdrawTokensParams"]; +export type WithdrawViaSellParams = + IdlTypes["WithdrawViaSellParams"]; +export type WithdrawalPolicy = + IdlTypes["WithdrawalPolicy"]; +export type CappedWithdrawal = + IdlTypes["CappedWithdrawal"]; +export type WithdrawalLimits = + IdlTypes["WithdrawalLimits"]; +export type WindowUsage = + IdlTypes["WindowUsage"]; +export type WithdrawalMode = + IdlTypes["WithdrawalMode"]; +export type LimitsParams = + IdlTypes["LimitsParams"]; +export type InitializePerformancePackageWithLimitsParams = + IdlTypes["InitializePerformancePackageWithLimitsParams"]; +export type ChangeType = + IdlTypes["ChangeType"]; +export type ProposeChangeParams = + IdlTypes["ProposeChangeParams"]; export type PerformancePackageInitializedEvent = IdlEvents["PerformancePackageInitialized"]; @@ -20,6 +42,10 @@ export type UnlockStartedEvent = IdlEvents["UnlockStarted"]; export type UnlockCompletedEvent = IdlEvents["UnlockCompleted"]; +export type TokensWithdrawnEvent = + IdlEvents["TokensWithdrawn"]; +export type TokensSoldEvent = + IdlEvents["TokensSold"]; export type ChangeProposedEvent = IdlEvents["ChangeProposed"]; export type ChangeExecutedEvent = @@ -30,6 +56,8 @@ export type PriceBasedPerformancePackageEvent = | PerformancePackageInitializedEvent | UnlockStartedEvent | UnlockCompletedEvent + | TokensWithdrawnEvent + | TokensSoldEvent | ChangeProposedEvent | ChangeExecutedEvent | PerformancePackageAuthorityChangedEvent; diff --git a/sdk/src/price_based_performance_package/v0.6/types/price_based_performance_package.ts b/sdk/src/price_based_performance_package/v0.6/types/price_based_performance_package.ts index 16224ab94..0fa85f09c 100644 --- a/sdk/src/price_based_performance_package/v0.6/types/price_based_performance_package.ts +++ b/sdk/src/price_based_performance_package/v0.6/types/price_based_performance_package.ts @@ -1,5 +1,5 @@ export type PriceBasedPerformancePackage = { - version: "0.6.0"; + version: "0.6.1"; name: "price_based_performance_package"; constants: [ { @@ -9,6 +9,11 @@ export type PriceBasedPerformancePackage = { }; value: "10"; }, + { + name: "PRICE_SCALE"; + type: "u128"; + value: "1_000_000_000_000"; + }, ]; instructions: [ { @@ -90,7 +95,7 @@ export type PriceBasedPerformancePackage = { ]; }, { - name: "startUnlock"; + name: "initializePerformancePackageWithLimits"; accounts: [ { name: "performancePackage"; @@ -98,85 +103,117 @@ export type PriceBasedPerformancePackage = { isSigner: false; }, { - name: "oracleAccount"; + name: "createKey"; + isMut: false; + isSigner: true; + docs: ["Used to derive the PDA"]; + }, + { + name: "tokenMint"; isMut: false; isSigner: false; + docs: ["The mint of the tokens to be locked"]; }, { - name: "recipient"; + name: "grantorTokenAccount"; + isMut: true; + isSigner: false; + docs: ["The token account containing the tokens to be locked"]; + }, + { + name: "grantor"; isMut: false; isSigner: true; - docs: ["Only the token recipient can start unlock"]; + docs: ["The authority of the token account"]; }, { - name: "eventAuthority"; - isMut: false; + name: "performancePackageTokenVault"; + isMut: true; isSigner: false; + docs: ["The locker's token account where tokens will be stored"]; }, { - name: "program"; + name: "payer"; + isMut: true; + isSigner: true; + }, + { + name: "systemProgram"; isMut: false; isSigner: false; }, - ]; - args: []; - }, - { - name: "completeUnlock"; - accounts: [ { - name: "performancePackage"; - isMut: true; + name: "tokenProgram"; + isMut: false; isSigner: false; }, { - name: "oracleAccount"; + name: "associatedTokenProgram"; isMut: false; isSigner: false; }, { - name: "performancePackageTokenVault"; - isMut: true; + name: "eventAuthority"; + isMut: false; isSigner: false; - docs: ["The token account where locked tokens are stored"]; }, { - name: "tokenMint"; + name: "program"; isMut: false; isSigner: false; - docs: ["The token mint - validated via has_one constraint on locker"]; }, + ]; + args: [ { - name: "recipientTokenAccount"; + name: "params"; + type: { + defined: "InitializePerformancePackageWithLimitsParams"; + }; + }, + ]; + }, + { + name: "startUnlock"; + accounts: [ + { + name: "performancePackage"; isMut: true; isSigner: false; - docs: [ - "The recipient's ATA where tokens will be sent - created if needed", - ]; }, { - name: "tokenRecipient"; + name: "oracleAccount"; isMut: false; isSigner: false; }, { - name: "payer"; - isMut: true; + name: "recipient"; + isMut: false; isSigner: true; - docs: ["Payer for creating the ATA if needed"]; + docs: ["Only the token recipient can start unlock"]; }, { - name: "systemProgram"; + name: "eventAuthority"; isMut: false; isSigner: false; }, { - name: "tokenProgram"; + name: "program"; isMut: false; isSigner: false; }, + ]; + args: []; + }, + { + name: "completeUnlock"; + accounts: [ { - name: "associatedTokenProgram"; + name: "performancePackage"; + isMut: true; + isSigner: false; + }, + { + name: "oracleAccount"; isMut: false; isSigner: false; }, @@ -320,6 +357,22 @@ export type PriceBasedPerformancePackage = { name: "performancePackageTokenVault"; isMut: true; isSigner: false; + docs: [ + "Emptied by the payout and the burn, then closed to the spill account", + ]; + }, + { + name: "recipient"; + isMut: false; + isSigner: false; + }, + { + name: "recipientTokenAccount"; + isMut: true; + isSigner: false; + docs: [ + "The recipient's ATA that receives the unlocked balance - created if needed", + ]; }, { name: "admin"; @@ -336,215 +389,524 @@ export type PriceBasedPerformancePackage = { isMut: true; isSigner: false; }, + { + name: "quoteMint"; + isMut: false; + isSigner: false; + isOptional: true; + docs: [ + "The mint of the package's quote ATA; any mint other than the package's token mint", + ]; + }, + { + name: "packageQuoteAccount"; + isMut: true; + isSigner: false; + isOptional: true; + docs: [ + "The package's quote ATA, swept into `quote_destination` and closed when passed", + ]; + }, + { + name: "quoteDestination"; + isMut: true; + isSigner: false; + isOptional: true; + docs: ["Where the quote balance goes, chosen by the admin"]; + }, + { + name: "systemProgram"; + isMut: false; + isSigner: false; + }, { name: "tokenProgram"; isMut: false; isSigner: false; }, + { + name: "associatedTokenProgram"; + isMut: false; + isSigner: false; + }, ]; args: []; }, - ]; - accounts: [ { - name: "performancePackage"; - type: { - kind: "struct"; - fields: [ - { - name: "tranches"; - docs: ["The tranches that make up the performance package"]; - type: { - vec: { - defined: "StoredTranche"; - }; - }; - }, - { - name: "totalTokenAmount"; - docs: ["Total amount of tokens in the performance package"]; - type: "u64"; - }, - { - name: "alreadyUnlockedAmount"; - docs: ["Amount of tokens already unlocked"]; - type: "u64"; - }, - { - name: "minUnlockTimestamp"; - docs: ["The timestamp when unlocking can begin"]; - type: "i64"; - }, - { - name: "oracleConfig"; - docs: ["Where to pull price data from"]; - type: { - defined: "OracleConfig"; - }; - }, - { - name: "twapLengthSeconds"; - docs: [ - "Length of time in seconds for TWAP calculation, between 1 day and 1 year", - ]; - type: "u32"; - }, - { - name: "recipient"; - docs: ["The recipient of the tokens when unlocked"]; - type: "publicKey"; - }, - { - name: "state"; - docs: ["The current state of the locker"]; - type: { - defined: "PerformancePackageState"; - }; - }, - { - name: "createKey"; - docs: ["Used to derive the PDA"]; - type: "publicKey"; - }, - { - name: "pdaBump"; - docs: ["The PDA bump"]; - type: "u8"; - }, - { - name: "performancePackageAuthority"; - docs: [ - "The authorized locker authority that can execute changes, usually the organization", - ]; - type: "publicKey"; - }, - { - name: "tokenMint"; - docs: ["The mint of the locked tokens"]; - type: "publicKey"; - }, - { - name: "seqNum"; - docs: [ - "The sequence number of the performance package, used for indexing events", - ]; - type: "u64"; - }, - { - name: "performancePackageTokenVault"; - docs: ["The vault that stores the tokens"]; - type: "publicKey"; - }, - ]; - }; + name: "resizePerformancePackage"; + accounts: [ + { + name: "performancePackage"; + isMut: true; + isSigner: false; + }, + { + name: "payer"; + isMut: true; + isSigner: true; + }, + { + name: "systemProgram"; + isMut: false; + isSigner: false; + }, + ]; + args: []; }, { - name: "changeRequest"; - type: { - kind: "struct"; - fields: [ - { - name: "performancePackage"; - docs: ["The performance package this change applies to"]; - type: "publicKey"; - }, - { - name: "changeType"; - docs: ["What is being changed"]; - type: { - defined: "ChangeType"; - }; - }, - { - name: "proposedAt"; - docs: ["When the change was proposed"]; - type: "i64"; - }, - { - name: "proposerType"; - docs: [ - "Who proposed this change (either token_recipient or locker_authority)", - ]; - type: { - defined: "ProposerType"; - }; - }, - { - name: "pdaNonce"; - docs: ["Used to derive the PDA along with the proposer"]; - type: "u32"; - }, - { - name: "pdaBump"; - docs: ["The PDA bump"]; - type: "u8"; - }, - ]; - }; - }, - ]; - types: [ - { - name: "CommonFields"; - type: { - kind: "struct"; - fields: [ - { - name: "slot"; - type: "u64"; - }, - { - name: "unixTimestamp"; - type: "i64"; - }, - { - name: "performancePackageSeqNum"; - type: "u64"; - }, - ]; - }; + name: "withdrawTokens"; + accounts: [ + { + name: "performancePackage"; + isMut: true; + isSigner: false; + }, + { + name: "oracleAccount"; + isMut: false; + isSigner: false; + }, + { + name: "performancePackageTokenVault"; + isMut: true; + isSigner: false; + docs: ["The token account where locked tokens are stored"]; + }, + { + name: "tokenMint"; + isMut: false; + isSigner: false; + }, + { + name: "recipientTokenAccount"; + isMut: true; + isSigner: false; + docs: [ + "The recipient's ATA where tokens will be sent - created if needed", + ]; + }, + { + name: "recipient"; + isMut: false; + isSigner: true; + docs: ["Only the recipient can withdraw"]; + }, + { + name: "payer"; + isMut: true; + isSigner: true; + docs: ["Payer for creating the ATA if needed"]; + }, + { + name: "systemProgram"; + isMut: false; + isSigner: false; + }, + { + name: "tokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "associatedTokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: [ + { + name: "params"; + type: { + defined: "WithdrawTokensParams"; + }; + }, + ]; }, { - name: "ChangePerformancePackageAuthorityParams"; - type: { - kind: "struct"; - fields: [ - { - name: "newPerformancePackageAuthority"; - type: "publicKey"; - }, - ]; - }; + name: "withdrawViaSell"; + accounts: [ + { + name: "performancePackage"; + isMut: true; + isSigner: false; + }, + { + name: "dao"; + isMut: true; + isSigner: false; + docs: ["The futarchy Dao whose spot pool buys the tokens"]; + }, + { + name: "performancePackageTokenVault"; + isMut: true; + isSigner: false; + docs: [ + "The token account where locked tokens are stored; the sale is paid out of it", + ]; + }, + { + name: "tokenMint"; + isMut: false; + isSigner: false; + }, + { + name: "quoteMint"; + isMut: false; + isSigner: false; + }, + { + name: "ammBaseVault"; + isMut: true; + isSigner: false; + }, + { + name: "ammQuoteVault"; + isMut: true; + isSigner: false; + }, + { + name: "packageQuoteAccount"; + isMut: true; + isSigner: false; + docs: [ + "The package's quote ATA that receives the proceeds before they are forwarded", + ]; + }, + { + name: "recipientQuoteAccount"; + isMut: true; + isSigner: false; + docs: ["The recipient's quote ATA where the proceeds are sent"]; + }, + { + name: "recipient"; + isMut: false; + isSigner: true; + docs: ["Only the recipient can withdraw"]; + }, + { + name: "payer"; + isMut: true; + isSigner: true; + docs: ["Payer for creating the ATAs if needed"]; + }, + { + name: "futarchyProgram"; + isMut: false; + isSigner: false; + }, + { + name: "futarchyEventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "systemProgram"; + isMut: false; + isSigner: false; + }, + { + name: "tokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "associatedTokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: [ + { + name: "params"; + type: { + defined: "WithdrawViaSellParams"; + }; + }, + ]; }, + ]; + accounts: [ { - name: "InitializePerformancePackageParams"; + name: "performancePackage"; type: { kind: "struct"; fields: [ { name: "tranches"; + docs: ["The tranches that make up the performance package"]; type: { vec: { - defined: "Tranche"; + defined: "StoredTranche"; }; }; }, + { + name: "totalTokenAmount"; + docs: ["Total amount of tokens in the performance package"]; + type: "u64"; + }, + { + name: "alreadyUnlockedAmount"; + docs: ["Amount of tokens already unlocked"]; + type: "u64"; + }, { name: "minUnlockTimestamp"; + docs: ["The timestamp when unlocking can begin"]; type: "i64"; }, { name: "oracleConfig"; + docs: ["Where to pull price data from"]; type: { defined: "OracleConfig"; }; }, { name: "twapLengthSeconds"; + docs: [ + "Length of time in seconds for TWAP calculation, between 1 day and 1 year", + ]; type: "u32"; }, { - name: "grantee"; + name: "recipient"; + docs: ["The recipient of the tokens when unlocked"]; + type: "publicKey"; + }, + { + name: "state"; + docs: ["The current state of the locker"]; + type: { + defined: "PerformancePackageState"; + }; + }, + { + name: "createKey"; + docs: ["Used to derive the PDA"]; + type: "publicKey"; + }, + { + name: "pdaBump"; + docs: ["The PDA bump"]; + type: "u8"; + }, + { + name: "performancePackageAuthority"; + docs: [ + "The authorized locker authority that can execute changes, usually the organization", + ]; + type: "publicKey"; + }, + { + name: "tokenMint"; + docs: ["The mint of the locked tokens"]; + type: "publicKey"; + }, + { + name: "seqNum"; + docs: [ + "The sequence number of the performance package, used for indexing events", + ]; + type: "u64"; + }, + { + name: "performancePackageTokenVault"; + docs: ["The vault that stores the tokens"]; + type: "publicKey"; + }, + { + name: "withdrawalPolicy"; + docs: [ + "Appended in 0.6.1; `None` means uncapped, and so do expired limits", + ]; + type: { + option: { + defined: "WithdrawalPolicy"; + }; + }; + }, + ]; + }; + }, + { + name: "changeRequest"; + type: { + kind: "struct"; + fields: [ + { + name: "performancePackage"; + docs: ["The performance package this change applies to"]; + type: "publicKey"; + }, + { + name: "changeType"; + docs: ["What is being changed"]; + type: { + defined: "ChangeType"; + }; + }, + { + name: "proposedAt"; + docs: ["When the change was proposed"]; + type: "i64"; + }, + { + name: "proposerType"; + docs: [ + "Who proposed this change (either token_recipient or locker_authority)", + ]; + type: { + defined: "ProposerType"; + }; + }, + { + name: "pdaNonce"; + docs: ["Used to derive the PDA along with the proposer"]; + type: "u32"; + }, + { + name: "pdaBump"; + docs: ["The PDA bump"]; + type: "u8"; + }, + ]; + }; + }, + ]; + types: [ + { + name: "CommonFields"; + type: { + kind: "struct"; + fields: [ + { + name: "slot"; + type: "u64"; + }, + { + name: "unixTimestamp"; + type: "i64"; + }, + { + name: "performancePackageSeqNum"; + type: "u64"; + }, + ]; + }; + }, + { + name: "CappedWithdrawal"; + docs: ["Present on a withdrawal that ran under active limits"]; + type: { + kind: "struct"; + fields: [ + { + name: "price"; + docs: [ + "The price the withdrawal was valued at: the higher of the spot pool's observation and its reserve price", + ]; + type: "u128"; + }, + { + name: "quoteValue"; + docs: ["`amount` valued at that price, in quote atoms"]; + type: "u64"; + }, + { + name: "usage"; + docs: ["Window usage after this withdrawal"]; + type: { + defined: "WindowUsage"; + }; + }, + ]; + }; + }, + { + name: "ChangePerformancePackageAuthorityParams"; + type: { + kind: "struct"; + fields: [ + { + name: "newPerformancePackageAuthority"; + type: "publicKey"; + }, + ]; + }; + }, + { + name: "InitializePerformancePackageWithLimitsParams"; + type: { + kind: "struct"; + fields: [ + { + name: "base"; + type: { + defined: "InitializePerformancePackageParams"; + }; + }, + { + name: "limits"; + type: { + option: { + defined: "LimitsParams"; + }; + }; + }, + ]; + }; + }, + { + name: "InitializePerformancePackageParams"; + type: { + kind: "struct"; + fields: [ + { + name: "tranches"; + type: { + vec: { + defined: "Tranche"; + }; + }; + }, + { + name: "minUnlockTimestamp"; + type: "i64"; + }, + { + name: "oracleConfig"; + type: { + defined: "OracleConfig"; + }; + }, + { + name: "twapLengthSeconds"; + type: "u32"; + }, + { + name: "grantee"; type: "publicKey"; }, { @@ -572,13 +934,45 @@ export type PriceBasedPerformancePackage = { ]; }; }, + { + name: "WithdrawTokensParams"; + type: { + kind: "struct"; + fields: [ + { + name: "amount"; + type: "u64"; + }, + ]; + }; + }, + { + name: "WithdrawViaSellParams"; + type: { + kind: "struct"; + fields: [ + { + name: "amount"; + type: "u64"; + }, + { + name: "minQuoteOut"; + type: "u64"; + }, + ]; + }; + }, { name: "OracleConfig"; docs: [ - "Starting at `byte_offset` in `oracle_account`, this program expects to read:", + "Starting at `byte_offset` in `oracle_account`, the unlock instructions read:", "- 16 bytes for the aggregator, stored as a little endian u128", - "- 8 bytes for the slot that the aggregator was last updated, stored as a", - "little endian u64", + "- 8 bytes for the timestamp that the aggregator was last updated, stored as", + "a little endian i64", + "", + "While withdrawal limits are active, `oracle_account` must also be a futarchy", + "`Dao`: the withdraw instructions value withdrawals from its spot pool, at the", + "higher of the pool's damped observation and its reserve price.", "", "The aggregator should be a weighted sum of prices, where the weight is the", "number of seconds between prices. Here's an example:", @@ -644,36 +1038,252 @@ export type PriceBasedPerformancePackage = { }; }, { - name: "PerformancePackageState"; + name: "OldPerformancePackage"; + docs: [ + "The 0.6.0 layout, decoded by the resize before an account is migrated", + ]; type: { - kind: "enum"; - variants: [ + kind: "struct"; + fields: [ { - name: "Locked"; + name: "tranches"; + type: { + vec: { + defined: "StoredTranche"; + }; + }; }, { - name: "Unlocking"; - fields: [ - { - name: "startAggregator"; - docs: ["The aggregator value when unlocking started"]; - type: "u128"; - }, - { - name: "startTimestamp"; - docs: ["The timestamp when unlocking started"]; - type: "i64"; - }, - ]; + name: "totalTokenAmount"; + type: "u64"; }, - ]; - }; - }, - { - name: "ChangeType"; - type: { - kind: "enum"; - variants: [ + { + name: "alreadyUnlockedAmount"; + type: "u64"; + }, + { + name: "minUnlockTimestamp"; + type: "i64"; + }, + { + name: "oracleConfig"; + type: { + defined: "OracleConfig"; + }; + }, + { + name: "twapLengthSeconds"; + type: "u32"; + }, + { + name: "recipient"; + type: "publicKey"; + }, + { + name: "state"; + type: { + defined: "PerformancePackageState"; + }; + }, + { + name: "createKey"; + type: "publicKey"; + }, + { + name: "pdaBump"; + type: "u8"; + }, + { + name: "performancePackageAuthority"; + type: "publicKey"; + }, + { + name: "tokenMint"; + type: "publicKey"; + }, + { + name: "seqNum"; + type: "u64"; + }, + { + name: "performancePackageTokenVault"; + type: "publicKey"; + }, + ]; + }; + }, + { + name: "WithdrawalPolicy"; + docs: [ + "The agreed limits together with the usage they are enforced against", + ]; + type: { + kind: "struct"; + fields: [ + { + name: "limits"; + type: { + defined: "WithdrawalLimits"; + }; + }, + { + name: "usage"; + type: { + defined: "WindowUsage"; + }; + }, + ]; + }; + }, + { + name: "WindowUsage"; + docs: ["Usage in the window the last withdrawal fell in"]; + type: { + kind: "struct"; + fields: [ + { + name: "windowIndex"; + docs: [ + "`(now - limits.start_timestamp) / limits.window_seconds` at the last withdrawal", + ]; + type: "i64"; + }, + { + name: "tokensUsed"; + type: "u64"; + }, + { + name: "quoteUsed"; + type: "u64"; + }, + ]; + }; + }, + { + name: "WithdrawalLimits"; + type: { + kind: "struct"; + fields: [ + { + name: "startTimestamp"; + docs: [ + "Anchor for window boundaries; set by the program when limits take effect or `window_seconds` changes", + ]; + type: "i64"; + }, + { + name: "endTimestamp"; + docs: ["Caps apply while `now < end_timestamp`"]; + type: "i64"; + }, + { + name: "windowSeconds"; + docs: ["Duration of the window in seconds"]; + type: "u32"; + }, + { + name: "maxTokensPerWindow"; + docs: ["Max base tokens withdrawn per window"]; + type: "u64"; + }, + { + name: "maxQuotePerWindow"; + docs: ["Max quote value withdrawn per window, in quote atoms"]; + type: "u64"; + }, + { + name: "withdrawalMode"; + docs: [ + "Which withdrawal routes the recipient may use while the caps are active", + ]; + type: { + defined: "WithdrawalMode"; + }; + }, + ]; + }; + }, + { + name: "LimitsParams"; + docs: [ + "What the two parties agree on; the program supplies `start_timestamp`", + ]; + type: { + kind: "struct"; + fields: [ + { + name: "endTimestamp"; + type: "i64"; + }, + { + name: "windowSeconds"; + type: "u32"; + }, + { + name: "maxTokensPerWindow"; + type: "u64"; + }, + { + name: "maxQuotePerWindow"; + type: "u64"; + }, + { + name: "withdrawalMode"; + type: { + defined: "WithdrawalMode"; + }; + }, + ]; + }; + }, + { + name: "WithdrawalMode"; + type: { + kind: "enum"; + variants: [ + { + name: "Tokens"; + }, + { + name: "Sell"; + }, + { + name: "Both"; + }, + ]; + }; + }, + { + name: "PerformancePackageState"; + type: { + kind: "enum"; + variants: [ + { + name: "Locked"; + }, + { + name: "Unlocking"; + fields: [ + { + name: "startAggregator"; + docs: ["The aggregator value when unlocking started"]; + type: "u128"; + }, + { + name: "startTimestamp"; + docs: ["The timestamp when unlocking started"]; + type: "i64"; + }, + ]; + }, + ]; + }; + }, + { + name: "ChangeType"; + type: { + kind: "enum"; + variants: [ { name: "Oracle"; fields: [ @@ -694,6 +1304,23 @@ export type PriceBasedPerformancePackage = { }, ]; }, + { + name: "UnlockTerms"; + fields: [ + { + name: "minUnlockTimestamp"; + type: "i64"; + }, + { + name: "limits"; + type: { + option: { + defined: "LimitsParams"; + }; + }; + }, + ]; + }, ]; }; }, @@ -790,7 +1417,7 @@ export type PriceBasedPerformancePackage = { ]; }, { - name: "ChangeProposed"; + name: "TokensWithdrawn"; fields: [ { name: "common"; @@ -800,31 +1427,33 @@ export type PriceBasedPerformancePackage = { index: false; }, { - name: "locker"; + name: "performancePackage"; type: "publicKey"; index: false; }, { - name: "changeRequest"; + name: "recipient"; type: "publicKey"; index: false; }, { - name: "proposer"; - type: "publicKey"; + name: "amount"; + type: "u64"; index: false; }, { - name: "changeType"; + name: "capped"; type: { - defined: "ChangeType"; + option: { + defined: "CappedWithdrawal"; + }; }; index: false; }, ]; }, { - name: "ChangeExecuted"; + name: "TokensSold"; fields: [ { name: "common"; @@ -839,26 +1468,38 @@ export type PriceBasedPerformancePackage = { index: false; }, { - name: "changeRequest"; + name: "recipient"; type: "publicKey"; index: false; }, { - name: "executor"; - type: "publicKey"; + name: "amount"; + type: "u64"; index: false; }, { - name: "changeType"; + name: "quoteReceived"; + type: "u64"; + index: false; + }, + { + name: "minQuoteOut"; + type: "u64"; + index: false; + }, + { + name: "capped"; type: { - defined: "ChangeType"; + option: { + defined: "WindowUsage"; + }; }; index: false; }, ]; }, { - name: "PerformancePackageAuthorityChanged"; + name: "ChangeProposed"; fields: [ { name: "common"; @@ -873,33 +1514,101 @@ export type PriceBasedPerformancePackage = { index: false; }, { - name: "oldAuthority"; + name: "changeRequest"; type: "publicKey"; index: false; }, { - name: "newAuthority"; + name: "proposer"; type: "publicKey"; index: false; }, + { + name: "changeType"; + type: { + defined: "ChangeType"; + }; + index: false; + }, ]; }, - ]; - errors: [ - { - code: 6000; - name: "UnlockTimestampNotReached"; - msg: "Unlock timestamp has not been reached yet"; - }, { - code: 6001; - name: "UnlockTimestampInThePast"; - msg: "Unlock timestamp must be in the future"; - }, - { - code: 6002; - name: "InvalidPerformancePackageState"; - msg: "Performance package is not in the expected state"; + name: "ChangeExecuted"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "performancePackage"; + type: "publicKey"; + index: false; + }, + { + name: "changeRequest"; + type: "publicKey"; + index: false; + }, + { + name: "executor"; + type: "publicKey"; + index: false; + }, + { + name: "changeType"; + type: { + defined: "ChangeType"; + }; + index: false; + }, + ]; + }, + { + name: "PerformancePackageAuthorityChanged"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "locker"; + type: "publicKey"; + index: false; + }, + { + name: "oldAuthority"; + type: "publicKey"; + index: false; + }, + { + name: "newAuthority"; + type: "publicKey"; + index: false; + }, + ]; + }, + ]; + errors: [ + { + code: 6000; + name: "UnlockTimestampNotReached"; + msg: "Unlock timestamp has not been reached yet"; + }, + { + code: 6001; + name: "UnlockTimestampInThePast"; + msg: "Unlock timestamp must be in the future"; + }, + { + code: 6002; + name: "InvalidPerformancePackageState"; + msg: "Performance package is not in the expected state"; }, { code: 6003; @@ -966,11 +1675,66 @@ export type PriceBasedPerformancePackage = { name: "RecipientAuthorityMustDiffer"; msg: "Recipient and performance package authority must be different keys"; }, + { + code: 6016; + name: "InvalidWithdrawalLimits"; + msg: "Withdrawal limits must have non-zero caps, a future end, and a window of at least one second"; + }, + { + code: 6017; + name: "InsufficientWithdrawableBalance"; + msg: "Amount exceeds the withdrawable balance"; + }, + { + code: 6018; + name: "TokenWindowLimitExceeded"; + msg: "Token cap for the current window exceeded"; + }, + { + code: 6019; + name: "QuoteWindowLimitExceeded"; + msg: "Quote cap for the current window exceeded"; + }, + { + code: 6020; + name: "InvalidPriceObservation"; + msg: "Oracle price observation is missing or zero"; + }, + { + code: 6021; + name: "WithdrawTokensDisabled"; + msg: "Token withdrawals are disabled by the withdrawal mode"; + }, + { + code: 6022; + name: "WithdrawViaSellDisabled"; + msg: "Sell withdrawals are disabled by the withdrawal mode"; + }, + { + code: 6023; + name: "AccountNotMigrated"; + msg: "Performance package has not been resized to the current layout"; + }, + { + code: 6024; + name: "InvalidQuoteMint"; + msg: "Quote mint must differ from the package's token mint"; + }, + { + code: 6025; + name: "QuoteSweepAccountsIncomplete"; + msg: "The package's quote account and the quote destination must be passed together"; + }, + { + code: 6026; + name: "OracleMintMismatch"; + msg: "Oracle Dao's base mint must be the package's token mint"; + }, ]; }; export const IDL: PriceBasedPerformancePackage = { - version: "0.6.0", + version: "0.6.1", name: "price_based_performance_package", constants: [ { @@ -980,6 +1744,11 @@ export const IDL: PriceBasedPerformancePackage = { }, value: "10", }, + { + name: "PRICE_SCALE", + type: "u128", + value: "1_000_000_000_000", + }, ], instructions: [ { @@ -1060,6 +1829,84 @@ export const IDL: PriceBasedPerformancePackage = { }, ], }, + { + name: "initializePerformancePackageWithLimits", + accounts: [ + { + name: "performancePackage", + isMut: true, + isSigner: false, + }, + { + name: "createKey", + isMut: false, + isSigner: true, + docs: ["Used to derive the PDA"], + }, + { + name: "tokenMint", + isMut: false, + isSigner: false, + docs: ["The mint of the tokens to be locked"], + }, + { + name: "grantorTokenAccount", + isMut: true, + isSigner: false, + docs: ["The token account containing the tokens to be locked"], + }, + { + name: "grantor", + isMut: false, + isSigner: true, + docs: ["The authority of the token account"], + }, + { + name: "performancePackageTokenVault", + isMut: true, + isSigner: false, + docs: ["The locker's token account where tokens will be stored"], + }, + { + name: "payer", + isMut: true, + isSigner: true, + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + { + name: "tokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "associatedTokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: "params", + type: { + defined: "InitializePerformancePackageWithLimitsParams", + }, + }, + ], + }, { name: "startUnlock", accounts: [ @@ -1106,35 +1953,40 @@ export const IDL: PriceBasedPerformancePackage = { isSigner: false, }, { - name: "performancePackageTokenVault", - isMut: true, + name: "eventAuthority", + isMut: false, isSigner: false, - docs: ["The token account where locked tokens are stored"], }, { - name: "tokenMint", + name: "program", isMut: false, isSigner: false, - docs: ["The token mint - validated via has_one constraint on locker"], }, + ], + args: [], + }, + { + name: "proposeChange", + accounts: [ { - name: "recipientTokenAccount", + name: "changeRequest", isMut: true, isSigner: false, - docs: [ - "The recipient's ATA where tokens will be sent - created if needed", - ], }, { - name: "tokenRecipient", - isMut: false, + name: "performancePackage", + isMut: true, isSigner: false, }, + { + name: "proposer", + isMut: false, + isSigner: true, + }, { name: "payer", isMut: true, isSigner: true, - docs: ["Payer for creating the ATA if needed"], }, { name: "systemProgram", @@ -1142,15 +1994,46 @@ export const IDL: PriceBasedPerformancePackage = { isSigner: false, }, { - name: "tokenProgram", + name: "eventAuthority", isMut: false, isSigner: false, }, { - name: "associatedTokenProgram", + name: "program", isMut: false, isSigner: false, }, + ], + args: [ + { + name: "params", + type: { + defined: "ProposeChangeParams", + }, + }, + ], + }, + { + name: "executeChange", + accounts: [ + { + name: "changeRequest", + isMut: true, + isSigner: false, + }, + { + name: "performancePackage", + isMut: true, + isSigner: false, + }, + { + name: "executor", + isMut: true, + isSigner: true, + docs: [ + "The party executing the change (must be opposite of proposer)", + ], + }, { name: "eventAuthority", isMut: false, @@ -1165,166 +2048,740 @@ export const IDL: PriceBasedPerformancePackage = { args: [], }, { - name: "proposeChange", - accounts: [ - { - name: "changeRequest", - isMut: true, - isSigner: false, - }, - { - name: "performancePackage", - isMut: true, - isSigner: false, - }, - { - name: "proposer", - isMut: false, - isSigner: true, - }, - { - name: "payer", - isMut: true, - isSigner: true, - }, - { - name: "systemProgram", - isMut: false, - isSigner: false, - }, - { - name: "eventAuthority", - isMut: false, - isSigner: false, - }, - { - name: "program", - isMut: false, - isSigner: false, - }, - ], - args: [ - { - name: "params", - type: { - defined: "ProposeChangeParams", + name: "changePerformancePackageAuthority", + accounts: [ + { + name: "performancePackage", + isMut: true, + isSigner: false, + }, + { + name: "currentAuthority", + isMut: false, + isSigner: true, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: "params", + type: { + defined: "ChangePerformancePackageAuthorityParams", + }, + }, + ], + }, + { + name: "burnPerformancePackage", + accounts: [ + { + name: "performancePackage", + isMut: true, + isSigner: false, + }, + { + name: "performancePackageTokenVault", + isMut: true, + isSigner: false, + docs: [ + "Emptied by the payout and the burn, then closed to the spill account", + ], + }, + { + name: "recipient", + isMut: false, + isSigner: false, + }, + { + name: "recipientTokenAccount", + isMut: true, + isSigner: false, + docs: [ + "The recipient's ATA that receives the unlocked balance - created if needed", + ], + }, + { + name: "admin", + isMut: true, + isSigner: true, + }, + { + name: "spillAccount", + isMut: true, + isSigner: false, + }, + { + name: "tokenMint", + isMut: true, + isSigner: false, + }, + { + name: "quoteMint", + isMut: false, + isSigner: false, + isOptional: true, + docs: [ + "The mint of the package's quote ATA; any mint other than the package's token mint", + ], + }, + { + name: "packageQuoteAccount", + isMut: true, + isSigner: false, + isOptional: true, + docs: [ + "The package's quote ATA, swept into `quote_destination` and closed when passed", + ], + }, + { + name: "quoteDestination", + isMut: true, + isSigner: false, + isOptional: true, + docs: ["Where the quote balance goes, chosen by the admin"], + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + { + name: "tokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "associatedTokenProgram", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, + { + name: "resizePerformancePackage", + accounts: [ + { + name: "performancePackage", + isMut: true, + isSigner: false, + }, + { + name: "payer", + isMut: true, + isSigner: true, + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, + { + name: "withdrawTokens", + accounts: [ + { + name: "performancePackage", + isMut: true, + isSigner: false, + }, + { + name: "oracleAccount", + isMut: false, + isSigner: false, + }, + { + name: "performancePackageTokenVault", + isMut: true, + isSigner: false, + docs: ["The token account where locked tokens are stored"], + }, + { + name: "tokenMint", + isMut: false, + isSigner: false, + }, + { + name: "recipientTokenAccount", + isMut: true, + isSigner: false, + docs: [ + "The recipient's ATA where tokens will be sent - created if needed", + ], + }, + { + name: "recipient", + isMut: false, + isSigner: true, + docs: ["Only the recipient can withdraw"], + }, + { + name: "payer", + isMut: true, + isSigner: true, + docs: ["Payer for creating the ATA if needed"], + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + { + name: "tokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "associatedTokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: "params", + type: { + defined: "WithdrawTokensParams", + }, + }, + ], + }, + { + name: "withdrawViaSell", + accounts: [ + { + name: "performancePackage", + isMut: true, + isSigner: false, + }, + { + name: "dao", + isMut: true, + isSigner: false, + docs: ["The futarchy Dao whose spot pool buys the tokens"], + }, + { + name: "performancePackageTokenVault", + isMut: true, + isSigner: false, + docs: [ + "The token account where locked tokens are stored; the sale is paid out of it", + ], + }, + { + name: "tokenMint", + isMut: false, + isSigner: false, + }, + { + name: "quoteMint", + isMut: false, + isSigner: false, + }, + { + name: "ammBaseVault", + isMut: true, + isSigner: false, + }, + { + name: "ammQuoteVault", + isMut: true, + isSigner: false, + }, + { + name: "packageQuoteAccount", + isMut: true, + isSigner: false, + docs: [ + "The package's quote ATA that receives the proceeds before they are forwarded", + ], + }, + { + name: "recipientQuoteAccount", + isMut: true, + isSigner: false, + docs: ["The recipient's quote ATA where the proceeds are sent"], + }, + { + name: "recipient", + isMut: false, + isSigner: true, + docs: ["Only the recipient can withdraw"], + }, + { + name: "payer", + isMut: true, + isSigner: true, + docs: ["Payer for creating the ATAs if needed"], + }, + { + name: "futarchyProgram", + isMut: false, + isSigner: false, + }, + { + name: "futarchyEventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + { + name: "tokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "associatedTokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: "params", + type: { + defined: "WithdrawViaSellParams", + }, + }, + ], + }, + ], + accounts: [ + { + name: "performancePackage", + type: { + kind: "struct", + fields: [ + { + name: "tranches", + docs: ["The tranches that make up the performance package"], + type: { + vec: { + defined: "StoredTranche", + }, + }, + }, + { + name: "totalTokenAmount", + docs: ["Total amount of tokens in the performance package"], + type: "u64", + }, + { + name: "alreadyUnlockedAmount", + docs: ["Amount of tokens already unlocked"], + type: "u64", + }, + { + name: "minUnlockTimestamp", + docs: ["The timestamp when unlocking can begin"], + type: "i64", + }, + { + name: "oracleConfig", + docs: ["Where to pull price data from"], + type: { + defined: "OracleConfig", + }, + }, + { + name: "twapLengthSeconds", + docs: [ + "Length of time in seconds for TWAP calculation, between 1 day and 1 year", + ], + type: "u32", + }, + { + name: "recipient", + docs: ["The recipient of the tokens when unlocked"], + type: "publicKey", + }, + { + name: "state", + docs: ["The current state of the locker"], + type: { + defined: "PerformancePackageState", + }, + }, + { + name: "createKey", + docs: ["Used to derive the PDA"], + type: "publicKey", + }, + { + name: "pdaBump", + docs: ["The PDA bump"], + type: "u8", + }, + { + name: "performancePackageAuthority", + docs: [ + "The authorized locker authority that can execute changes, usually the organization", + ], + type: "publicKey", + }, + { + name: "tokenMint", + docs: ["The mint of the locked tokens"], + type: "publicKey", + }, + { + name: "seqNum", + docs: [ + "The sequence number of the performance package, used for indexing events", + ], + type: "u64", + }, + { + name: "performancePackageTokenVault", + docs: ["The vault that stores the tokens"], + type: "publicKey", + }, + { + name: "withdrawalPolicy", + docs: [ + "Appended in 0.6.1; `None` means uncapped, and so do expired limits", + ], + type: { + option: { + defined: "WithdrawalPolicy", + }, + }, + }, + ], + }, + }, + { + name: "changeRequest", + type: { + kind: "struct", + fields: [ + { + name: "performancePackage", + docs: ["The performance package this change applies to"], + type: "publicKey", + }, + { + name: "changeType", + docs: ["What is being changed"], + type: { + defined: "ChangeType", + }, + }, + { + name: "proposedAt", + docs: ["When the change was proposed"], + type: "i64", + }, + { + name: "proposerType", + docs: [ + "Who proposed this change (either token_recipient or locker_authority)", + ], + type: { + defined: "ProposerType", + }, + }, + { + name: "pdaNonce", + docs: ["Used to derive the PDA along with the proposer"], + type: "u32", + }, + { + name: "pdaBump", + docs: ["The PDA bump"], + type: "u8", + }, + ], + }, + }, + ], + types: [ + { + name: "CommonFields", + type: { + kind: "struct", + fields: [ + { + name: "slot", + type: "u64", + }, + { + name: "unixTimestamp", + type: "i64", + }, + { + name: "performancePackageSeqNum", + type: "u64", + }, + ], + }, + }, + { + name: "CappedWithdrawal", + docs: ["Present on a withdrawal that ran under active limits"], + type: { + kind: "struct", + fields: [ + { + name: "price", + docs: [ + "The price the withdrawal was valued at: the higher of the spot pool's observation and its reserve price", + ], + type: "u128", + }, + { + name: "quoteValue", + docs: ["`amount` valued at that price, in quote atoms"], + type: "u64", + }, + { + name: "usage", + docs: ["Window usage after this withdrawal"], + type: { + defined: "WindowUsage", + }, + }, + ], + }, + }, + { + name: "ChangePerformancePackageAuthorityParams", + type: { + kind: "struct", + fields: [ + { + name: "newPerformancePackageAuthority", + type: "publicKey", + }, + ], + }, + }, + { + name: "InitializePerformancePackageWithLimitsParams", + type: { + kind: "struct", + fields: [ + { + name: "base", + type: { + defined: "InitializePerformancePackageParams", + }, + }, + { + name: "limits", + type: { + option: { + defined: "LimitsParams", + }, + }, + }, + ], + }, + }, + { + name: "InitializePerformancePackageParams", + type: { + kind: "struct", + fields: [ + { + name: "tranches", + type: { + vec: { + defined: "Tranche", + }, + }, + }, + { + name: "minUnlockTimestamp", + type: "i64", + }, + { + name: "oracleConfig", + type: { + defined: "OracleConfig", + }, + }, + { + name: "twapLengthSeconds", + type: "u32", + }, + { + name: "grantee", + type: "publicKey", + }, + { + name: "performancePackageAuthority", + type: "publicKey", + }, + ], + }, + }, + { + name: "ProposeChangeParams", + type: { + kind: "struct", + fields: [ + { + name: "changeType", + type: { + defined: "ChangeType", + }, }, - }, - ], + { + name: "pdaNonce", + type: "u32", + }, + ], + }, }, { - name: "executeChange", - accounts: [ - { - name: "changeRequest", - isMut: true, - isSigner: false, - }, - { - name: "performancePackage", - isMut: true, - isSigner: false, - }, - { - name: "executor", - isMut: true, - isSigner: true, - docs: [ - "The party executing the change (must be opposite of proposer)", - ], - }, - { - name: "eventAuthority", - isMut: false, - isSigner: false, - }, - { - name: "program", - isMut: false, - isSigner: false, - }, - ], - args: [], + name: "WithdrawTokensParams", + type: { + kind: "struct", + fields: [ + { + name: "amount", + type: "u64", + }, + ], + }, }, { - name: "changePerformancePackageAuthority", - accounts: [ - { - name: "performancePackage", - isMut: true, - isSigner: false, - }, - { - name: "currentAuthority", - isMut: false, - isSigner: true, - }, - { - name: "eventAuthority", - isMut: false, - isSigner: false, - }, - { - name: "program", - isMut: false, - isSigner: false, - }, - ], - args: [ - { - name: "params", - type: { - defined: "ChangePerformancePackageAuthorityParams", + name: "WithdrawViaSellParams", + type: { + kind: "struct", + fields: [ + { + name: "amount", + type: "u64", }, - }, - ], + { + name: "minQuoteOut", + type: "u64", + }, + ], + }, }, { - name: "burnPerformancePackage", - accounts: [ - { - name: "performancePackage", - isMut: true, - isSigner: false, - }, - { - name: "performancePackageTokenVault", - isMut: true, - isSigner: false, - }, - { - name: "admin", - isMut: true, - isSigner: true, - }, - { - name: "spillAccount", - isMut: true, - isSigner: false, - }, - { - name: "tokenMint", - isMut: true, - isSigner: false, - }, - { - name: "tokenProgram", - isMut: false, - isSigner: false, - }, + name: "OracleConfig", + docs: [ + "Starting at `byte_offset` in `oracle_account`, the unlock instructions read:", + "- 16 bytes for the aggregator, stored as a little endian u128", + "- 8 bytes for the timestamp that the aggregator was last updated, stored as", + "a little endian i64", + "", + "While withdrawal limits are active, `oracle_account` must also be a futarchy", + "`Dao`: the withdraw instructions value withdrawals from its spot pool, at the", + "higher of the pool's damped observation and its reserve price.", + "", + "The aggregator should be a weighted sum of prices, where the weight is the", + "number of seconds between prices. Here's an example:", + "- at second 0, the aggregator is 0", + "- at second 1, the price is 10 and the aggregator is 10 (10 * 1)", + "- at second 4, the price is 11 and 3 seconds have passed, so the aggregator is", + "10 + 11 * 3 = 43", + "", + "This allows our program to read a TWAP over a time period by reading the", + "aggregator value at the beginning and at the end, and dividing the difference", + "by the number of seconds between the two.", ], - args: [], + type: { + kind: "struct", + fields: [ + { + name: "oracleAccount", + type: "publicKey", + }, + { + name: "byteOffset", + type: "u32", + }, + ], + }, }, - ], - accounts: [ { - name: "performancePackage", + name: "Tranche", + type: { + kind: "struct", + fields: [ + { + name: "priceThreshold", + docs: ["The price at which this tranch unlocks"], + type: "u128", + }, + { + name: "tokenAmount", + docs: ["The amount of tokens in this tranch"], + type: "u64", + }, + ], + }, + }, + { + name: "StoredTranche", + type: { + kind: "struct", + fields: [ + { + name: "priceThreshold", + type: "u128", + }, + { + name: "tokenAmount", + type: "u64", + }, + { + name: "isUnlocked", + type: "bool", + }, + ], + }, + }, + { + name: "OldPerformancePackage", + docs: [ + "The 0.6.0 layout, decoded by the resize before an account is migrated", + ], type: { kind: "struct", fields: [ { name: "tranches", - docs: ["The tranches that make up the performance package"], type: { vec: { defined: "StoredTranche", @@ -1333,283 +2790,200 @@ export const IDL: PriceBasedPerformancePackage = { }, { name: "totalTokenAmount", - docs: ["Total amount of tokens in the performance package"], type: "u64", }, { name: "alreadyUnlockedAmount", - docs: ["Amount of tokens already unlocked"], type: "u64", }, { name: "minUnlockTimestamp", - docs: ["The timestamp when unlocking can begin"], type: "i64", }, { name: "oracleConfig", - docs: ["Where to pull price data from"], type: { defined: "OracleConfig", }, }, - { - name: "twapLengthSeconds", - docs: [ - "Length of time in seconds for TWAP calculation, between 1 day and 1 year", - ], + { + name: "twapLengthSeconds", type: "u32", }, { name: "recipient", - docs: ["The recipient of the tokens when unlocked"], type: "publicKey", }, { name: "state", - docs: ["The current state of the locker"], type: { defined: "PerformancePackageState", }, }, { name: "createKey", - docs: ["Used to derive the PDA"], type: "publicKey", }, { name: "pdaBump", - docs: ["The PDA bump"], type: "u8", }, { name: "performancePackageAuthority", - docs: [ - "The authorized locker authority that can execute changes, usually the organization", - ], type: "publicKey", }, { name: "tokenMint", - docs: ["The mint of the locked tokens"], type: "publicKey", }, { name: "seqNum", - docs: [ - "The sequence number of the performance package, used for indexing events", - ], type: "u64", }, { name: "performancePackageTokenVault", - docs: ["The vault that stores the tokens"], type: "publicKey", }, ], }, }, { - name: "changeRequest", + name: "WithdrawalPolicy", + docs: [ + "The agreed limits together with the usage they are enforced against", + ], type: { kind: "struct", fields: [ { - name: "performancePackage", - docs: ["The performance package this change applies to"], - type: "publicKey", - }, - { - name: "changeType", - docs: ["What is being changed"], + name: "limits", type: { - defined: "ChangeType", + defined: "WithdrawalLimits", }, }, { - name: "proposedAt", - docs: ["When the change was proposed"], - type: "i64", - }, - { - name: "proposerType", - docs: [ - "Who proposed this change (either token_recipient or locker_authority)", - ], + name: "usage", type: { - defined: "ProposerType", + defined: "WindowUsage", }, }, - { - name: "pdaNonce", - docs: ["Used to derive the PDA along with the proposer"], - type: "u32", - }, - { - name: "pdaBump", - docs: ["The PDA bump"], - type: "u8", - }, ], }, }, - ], - types: [ { - name: "CommonFields", + name: "WindowUsage", + docs: ["Usage in the window the last withdrawal fell in"], type: { kind: "struct", fields: [ { - name: "slot", - type: "u64", - }, - { - name: "unixTimestamp", + name: "windowIndex", + docs: [ + "`(now - limits.start_timestamp) / limits.window_seconds` at the last withdrawal", + ], type: "i64", }, { - name: "performancePackageSeqNum", + name: "tokensUsed", type: "u64", }, - ], - }, - }, - { - name: "ChangePerformancePackageAuthorityParams", - type: { - kind: "struct", - fields: [ { - name: "newPerformancePackageAuthority", - type: "publicKey", + name: "quoteUsed", + type: "u64", }, ], }, }, { - name: "InitializePerformancePackageParams", + name: "WithdrawalLimits", type: { kind: "struct", fields: [ { - name: "tranches", - type: { - vec: { - defined: "Tranche", - }, - }, - }, - { - name: "minUnlockTimestamp", + name: "startTimestamp", + docs: [ + "Anchor for window boundaries; set by the program when limits take effect or `window_seconds` changes", + ], type: "i64", }, { - name: "oracleConfig", - type: { - defined: "OracleConfig", - }, + name: "endTimestamp", + docs: ["Caps apply while `now < end_timestamp`"], + type: "i64", }, { - name: "twapLengthSeconds", + name: "windowSeconds", + docs: ["Duration of the window in seconds"], type: "u32", }, { - name: "grantee", - type: "publicKey", + name: "maxTokensPerWindow", + docs: ["Max base tokens withdrawn per window"], + type: "u64", }, { - name: "performancePackageAuthority", - type: "publicKey", + name: "maxQuotePerWindow", + docs: ["Max quote value withdrawn per window, in quote atoms"], + type: "u64", }, - ], - }, - }, - { - name: "ProposeChangeParams", - type: { - kind: "struct", - fields: [ { - name: "changeType", + name: "withdrawalMode", + docs: [ + "Which withdrawal routes the recipient may use while the caps are active", + ], type: { - defined: "ChangeType", + defined: "WithdrawalMode", }, }, - { - name: "pdaNonce", - type: "u32", - }, ], }, }, { - name: "OracleConfig", + name: "LimitsParams", docs: [ - "Starting at `byte_offset` in `oracle_account`, this program expects to read:", - "- 16 bytes for the aggregator, stored as a little endian u128", - "- 8 bytes for the slot that the aggregator was last updated, stored as a", - "little endian u64", - "", - "The aggregator should be a weighted sum of prices, where the weight is the", - "number of seconds between prices. Here's an example:", - "- at second 0, the aggregator is 0", - "- at second 1, the price is 10 and the aggregator is 10 (10 * 1)", - "- at second 4, the price is 11 and 3 seconds have passed, so the aggregator is", - "10 + 11 * 3 = 43", - "", - "This allows our program to read a TWAP over a time period by reading the", - "aggregator value at the beginning and at the end, and dividing the difference", - "by the number of seconds between the two.", + "What the two parties agree on; the program supplies `start_timestamp`", ], type: { kind: "struct", fields: [ { - name: "oracleAccount", - type: "publicKey", + name: "endTimestamp", + type: "i64", }, { - name: "byteOffset", + name: "windowSeconds", type: "u32", }, - ], - }, - }, - { - name: "Tranche", - type: { - kind: "struct", - fields: [ { - name: "priceThreshold", - docs: ["The price at which this tranch unlocks"], - type: "u128", + name: "maxTokensPerWindow", + type: "u64", }, { - name: "tokenAmount", - docs: ["The amount of tokens in this tranch"], + name: "maxQuotePerWindow", type: "u64", }, + { + name: "withdrawalMode", + type: { + defined: "WithdrawalMode", + }, + }, ], }, }, { - name: "StoredTranche", + name: "WithdrawalMode", type: { - kind: "struct", - fields: [ + kind: "enum", + variants: [ { - name: "priceThreshold", - type: "u128", + name: "Tokens", }, { - name: "tokenAmount", - type: "u64", + name: "Sell", }, { - name: "isUnlocked", - type: "bool", + name: "Both", }, ], }, @@ -1665,6 +3039,23 @@ export const IDL: PriceBasedPerformancePackage = { }, ], }, + { + name: "UnlockTerms", + fields: [ + { + name: "minUnlockTimestamp", + type: "i64", + }, + { + name: "limits", + type: { + option: { + defined: "LimitsParams", + }, + }, + }, + ], + }, ], }, }, @@ -1760,6 +3151,88 @@ export const IDL: PriceBasedPerformancePackage = { }, ], }, + { + name: "TokensWithdrawn", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "performancePackage", + type: "publicKey", + index: false, + }, + { + name: "recipient", + type: "publicKey", + index: false, + }, + { + name: "amount", + type: "u64", + index: false, + }, + { + name: "capped", + type: { + option: { + defined: "CappedWithdrawal", + }, + }, + index: false, + }, + ], + }, + { + name: "TokensSold", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "performancePackage", + type: "publicKey", + index: false, + }, + { + name: "recipient", + type: "publicKey", + index: false, + }, + { + name: "amount", + type: "u64", + index: false, + }, + { + name: "quoteReceived", + type: "u64", + index: false, + }, + { + name: "minQuoteOut", + type: "u64", + index: false, + }, + { + name: "capped", + type: { + option: { + defined: "WindowUsage", + }, + }, + index: false, + }, + ], + }, { name: "ChangeProposed", fields: [ @@ -1937,5 +3410,60 @@ export const IDL: PriceBasedPerformancePackage = { name: "RecipientAuthorityMustDiffer", msg: "Recipient and performance package authority must be different keys", }, + { + code: 6016, + name: "InvalidWithdrawalLimits", + msg: "Withdrawal limits must have non-zero caps, a future end, and a window of at least one second", + }, + { + code: 6017, + name: "InsufficientWithdrawableBalance", + msg: "Amount exceeds the withdrawable balance", + }, + { + code: 6018, + name: "TokenWindowLimitExceeded", + msg: "Token cap for the current window exceeded", + }, + { + code: 6019, + name: "QuoteWindowLimitExceeded", + msg: "Quote cap for the current window exceeded", + }, + { + code: 6020, + name: "InvalidPriceObservation", + msg: "Oracle price observation is missing or zero", + }, + { + code: 6021, + name: "WithdrawTokensDisabled", + msg: "Token withdrawals are disabled by the withdrawal mode", + }, + { + code: 6022, + name: "WithdrawViaSellDisabled", + msg: "Sell withdrawals are disabled by the withdrawal mode", + }, + { + code: 6023, + name: "AccountNotMigrated", + msg: "Performance package has not been resized to the current layout", + }, + { + code: 6024, + name: "InvalidQuoteMint", + msg: "Quote mint must differ from the package's token mint", + }, + { + code: 6025, + name: "QuoteSweepAccountsIncomplete", + msg: "The package's quote account and the quote destination must be passed together", + }, + { + code: 6026, + name: "OracleMintMismatch", + msg: "Oracle Dao's base mint must be the package's token mint", + }, ], }; diff --git a/sdk/src/price_based_performance_package/v0.6/withdrawalLimits/index.ts b/sdk/src/price_based_performance_package/v0.6/withdrawalLimits/index.ts new file mode 100644 index 000000000..c56fd1178 --- /dev/null +++ b/sdk/src/price_based_performance_package/v0.6/withdrawalLimits/index.ts @@ -0,0 +1,160 @@ +import type { IdlTypes } from "@coral-xyz/anchor"; +import BN from "bn.js"; + +import type { + Dao, + FutarchyProgram, +} from "../../../futarchy/v0.6/types/index.js"; +import type { + PerformancePackage, + WithdrawalLimits, + WithdrawalPolicy, + WindowUsage, +} from "../types/index.js"; + +const PRICE_SCALE = new BN(10).pow(new BN(12)); + +// Futarchy's spot swap fees, in basis points +const MAX_BPS = new BN(10_000); +const PROTOCOL_TAKER_FEE_BPS = new BN(50); +const LP_TAKER_FEE_BPS = new BN(0); + +type SpotPool = IdlTypes["Pool"]; + +// Anchor's decoded Dao type drops the pool struct nested in the PoolState enum, so it is retyped here. +function getSpotPool(dao: Dao): SpotPool { + const state = dao.amm.state as { + spot?: { spot: SpotPool }; + futarchy?: { spot: SpotPool }; + }; + const pool = state.spot?.spot ?? state.futarchy?.spot; + if (pool === undefined) { + throw new Error("the Dao has no spot pool"); + } + return pool; +} + +/** The policy whose limits are still in force at `now`; null when the package has none or they have ended. */ +export function getActiveWithdrawalPolicy( + performancePackage: PerformancePackage, + now: BN | number, +): WithdrawalPolicy | null { + const policy = performancePackage.withdrawalPolicy; + if (policy === null || !new BN(now).lt(policy.limits.endTimestamp)) { + return null; + } + return policy; +} + +/** The window `now` falls in: its index and the timestamp at which the next window starts. */ +export function getWithdrawalWindow( + limits: WithdrawalLimits, + now: BN | number, +): { index: BN; end: BN } { + const windowSeconds = new BN(limits.windowSeconds); + const index = new BN(now).sub(limits.startTimestamp).div(windowSeconds); + const end = limits.startTimestamp.add(index.addn(1).mul(windowSeconds)); + return { index, end }; +} + +/** The counters the next withdrawal is checked against: the stored usage while it is from the current window, zero once the window has rolled. */ +export function getEffectiveWindowUsage( + policy: WithdrawalPolicy, + now: BN | number, +): WindowUsage { + const { index } = getWithdrawalWindow(policy.limits, now); + if (policy.usage.windowIndex.eq(index)) { + return policy.usage; + } + return { windowIndex: index, tokensUsed: new BN(0), quoteUsed: new BN(0) }; +} + +/** Everything in the vault that is not still locked. */ +export function getWithdrawableBalance( + performancePackage: PerformancePackage, + vaultAmount: BN, +): BN { + const locked = performancePackage.totalTokenAmount.sub( + performancePackage.alreadyUnlockedAmount, + ); + return vaultAmount.sub(locked); +} + +/** The price the program values token withdrawals at: the higher of the Dao's spot observation and its reserve price. */ +export function getValuationPrice(dao: Dao): BN { + const pool = getSpotPool(dao); + + const observation = pool.oracle.lastObservation; + if (observation.isZero()) { + throw new Error("the Dao's spot pool has no price observation"); + } + + const reservePrice = pool.baseReserves.isZero() + ? new BN(0) + : pool.quoteReserves.mul(PRICE_SCALE).div(pool.baseReserves); + + return BN.max(observation, reservePrice); +} + +/** The largest amount `withdraw_tokens` accepts at `now`; the Dao is only needed while limits are active. */ +export function getMaxTokenWithdrawal({ + performancePackage, + vaultAmount, + now, + dao, +}: { + performancePackage: PerformancePackage; + vaultAmount: BN; + now: BN | number; + dao?: Dao; +}): BN { + const withdrawable = getWithdrawableBalance(performancePackage, vaultAmount); + const policy = getActiveWithdrawalPolicy(performancePackage, now); + if (policy === null) { + return withdrawable; + } + + const { withdrawalMode, maxTokensPerWindow, maxQuotePerWindow } = + policy.limits; + if ( + withdrawalMode.tokens === undefined && + withdrawalMode.both === undefined + ) { + return new BN(0); + } + if (dao === undefined) { + throw new Error("a Dao is needed to value withdrawals under active limits"); + } + if (!dao.baseMint.equals(performancePackage.tokenMint)) { + throw new Error("the Dao's base mint is not the package's token mint"); + } + + const usage = getEffectiveWindowUsage(policy, now); + const tokensRoom = maxTokensPerWindow.sub(usage.tokensUsed); + const quoteRoom = maxQuotePerWindow.sub(usage.quoteUsed); + const tokensForQuoteRoom = quoteRoom + .mul(PRICE_SCALE) + .div(getValuationPrice(dao)); + + return BN.max( + new BN(0), + BN.min(withdrawable, BN.min(tokensRoom, tokensForQuoteRoom)), + ); +} + +/** The quote atoms the Dao's spot pool pays for `amount` base atoms at its current reserves: the protocol taker fee comes off the input, then the constant-product swap. Exact while the pool is in its spot state and no other swap lands first. */ +export function getSellProceedsEstimate(dao: Dao, amount: BN): BN { + const pool = getSpotPool(dao); + + const inputAfterProtocolFee = amount + .mul(MAX_BPS.sub(PROTOCOL_TAKER_FEE_BPS)) + .div(MAX_BPS); + const inputAfterLpFee = inputAfterProtocolFee.mul( + MAX_BPS.sub(LP_TAKER_FEE_BPS), + ); + + const numerator = inputAfterLpFee.mul(pool.quoteReserves); + const denominator = pool.baseReserves.mul(MAX_BPS).add(inputAfterLpFee); + + return numerator.div(denominator); +} diff --git a/tests/integration/fullLaunch.test.ts b/tests/integration/fullLaunch.test.ts index b2914587c..36d77e336 100644 --- a/tests/integration/fullLaunch.test.ts +++ b/tests/integration/fullLaunch.test.ts @@ -734,17 +734,91 @@ export default async function suite() { .completeUnlockIx({ performancePackage, oracleAccount: dao, - tokenMint: META, - tokenRecipient: insiderMultisigVaultPda, }) .rpc(); - const postUnlockBalance = await this.getTokenBalance( + // should go through 2 tranches, or 40% of 10M = 4M tokens + const unlockedAmount = 4_000_000_000000n; + const storedPackage = + await this.priceBasedPerformancePackage.getPerformancePackage( + performancePackage, + ); + assert.equal( + BigInt(storedPackage.alreadyUnlockedAmount.toString()), + unlockedAmount, + ); + assert.equal( + await this.getTokenBalance(META, insiderMultisigVaultPda), + preUnlockBalance, + ); + + // The insider multisig vault is the recipient, so it withdraws through a Squads vault transaction + const withdrawTx = await this.priceBasedPerformancePackage + .withdrawTokensIx({ + performancePackage, + oracleAccount: dao, + tokenMint: META, + recipient: insiderMultisigVaultPda, + payer: this.payer.publicKey, + amount: new BN(unlockedAmount.toString()), + }) + .transaction(); + + const squadsWithdrawTx = new Transaction().add( + multisig.instructions.vaultTransactionCreate({ + multisigPda: insiderMultisigPda, + transactionIndex: 2n, + creator: cofounder0.publicKey, + rentPayer: this.payer.publicKey, + vaultIndex: 0, + ephemeralSigners: 0, + transactionMessage: new TransactionMessage({ + payerKey: this.payer.publicKey, + recentBlockhash: "", + instructions: withdrawTx.instructions, + }), + }), + multisig.instructions.proposalCreate({ + multisigPda: insiderMultisigPda, + creator: cofounder0.publicKey, + rentPayer: this.payer.publicKey, + transactionIndex: 2n, + isDraft: false, + }), + multisig.instructions.proposalApprove({ + multisigPda: insiderMultisigPda, + transactionIndex: 2n, + member: cofounder0.publicKey, + }), + ); + squadsWithdrawTx.recentBlockhash = ( + await this.banksClient.getLatestBlockhash() + )[0]; + squadsWithdrawTx.feePayer = this.payer.publicKey; + squadsWithdrawTx.sign(this.payer, cofounder0); + await this.banksClient.processTransaction(squadsWithdrawTx); + + const withdrawExecuteIx = + await multisig.instructions.vaultTransactionExecute({ + connection: this.squadsConnection, + multisigPda: insiderMultisigPda, + transactionIndex: 2n, + member: cofounder1.publicKey, + }); + const withdrawExecute = new Transaction().add( + withdrawExecuteIx.instruction, + ); + withdrawExecute.recentBlockhash = ( + await this.banksClient.getLatestBlockhash() + )[0]; + withdrawExecute.feePayer = this.payer.publicKey; + withdrawExecute.sign(this.payer, cofounder1); + await this.banksClient.processTransaction(withdrawExecute); + + const postWithdrawBalance = await this.getTokenBalance( META, insiderMultisigVaultPda, ); - - // should go through 2 tranches, or 40% of 10M = 4M tokens - assert.equal(postUnlockBalance - preUnlockBalance, 4_000_000_000000n); + assert.equal(postWithdrawBalance - preUnlockBalance, unlockedAmount); }); } diff --git a/tests/integration/fullLaunch_v7.test.ts b/tests/integration/fullLaunch_v7.test.ts index e236370d0..d14b15cb6 100644 --- a/tests/integration/fullLaunch_v7.test.ts +++ b/tests/integration/fullLaunch_v7.test.ts @@ -809,17 +809,91 @@ export default async function suite() { .completeUnlockIx({ performancePackage, oracleAccount: dao, - tokenMint: META, - tokenRecipient: insiderMultisigVaultPda, }) .rpc(); - const postUnlockBalance = await this.getTokenBalance( + // should go through 2 tranches, or 40% of 10M = 4M tokens + const unlockedAmount = 4_000_000_000000n; + const storedPackage = + await this.priceBasedPerformancePackage.getPerformancePackage( + performancePackage, + ); + assert.equal( + BigInt(storedPackage.alreadyUnlockedAmount.toString()), + unlockedAmount, + ); + assert.equal( + await this.getTokenBalance(META, insiderMultisigVaultPda), + preUnlockBalance, + ); + + // The insider multisig vault is the recipient, so it withdraws through a Squads vault transaction + const withdrawTx = await this.priceBasedPerformancePackage + .withdrawTokensIx({ + performancePackage, + oracleAccount: dao, + tokenMint: META, + recipient: insiderMultisigVaultPda, + payer: this.payer.publicKey, + amount: new BN(unlockedAmount.toString()), + }) + .transaction(); + + const squadsWithdrawTx = new Transaction().add( + multisig.instructions.vaultTransactionCreate({ + multisigPda: insiderMultisigPda, + transactionIndex: 2n, + creator: cofounder0.publicKey, + rentPayer: this.payer.publicKey, + vaultIndex: 0, + ephemeralSigners: 0, + transactionMessage: new TransactionMessage({ + payerKey: this.payer.publicKey, + recentBlockhash: "", + instructions: withdrawTx.instructions, + }), + }), + multisig.instructions.proposalCreate({ + multisigPda: insiderMultisigPda, + creator: cofounder0.publicKey, + rentPayer: this.payer.publicKey, + transactionIndex: 2n, + isDraft: false, + }), + multisig.instructions.proposalApprove({ + multisigPda: insiderMultisigPda, + transactionIndex: 2n, + member: cofounder0.publicKey, + }), + ); + squadsWithdrawTx.recentBlockhash = ( + await this.banksClient.getLatestBlockhash() + )[0]; + squadsWithdrawTx.feePayer = this.payer.publicKey; + squadsWithdrawTx.sign(this.payer, cofounder0); + await this.banksClient.processTransaction(squadsWithdrawTx); + + const withdrawExecuteIx = + await multisig.instructions.vaultTransactionExecute({ + connection: this.squadsConnection, + multisigPda: insiderMultisigPda, + transactionIndex: 2n, + member: cofounder1.publicKey, + }); + const withdrawExecute = new Transaction().add( + withdrawExecuteIx.instruction, + ); + withdrawExecute.recentBlockhash = ( + await this.banksClient.getLatestBlockhash() + )[0]; + withdrawExecute.feePayer = this.payer.publicKey; + withdrawExecute.sign(this.payer, cofounder1); + await this.banksClient.processTransaction(withdrawExecute); + + const postWithdrawBalance = await this.getTokenBalance( META, insiderMultisigVaultPda, ); - - // should go through 2 tranches, or 40% of 10M = 4M tokens - assert.equal(postUnlockBalance - preUnlockBalance, 4_000_000_000000n); + assert.equal(postWithdrawBalance - preUnlockBalance, unlockedAmount); }); } diff --git a/tests/main.test.ts b/tests/main.test.ts index 7c4d63071..cea1e8d0d 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -34,6 +34,9 @@ import { getProposalAddrV2, InstructionUtils, getPerformancePackageAddr, + InitializePerformancePackageParams, + LimitsParams, + Tranche, DAMM_V2_PROGRAM_ID, LAUNCHPAD_V0_7_MAINNET_METEORA_CONFIG, BidWallClient, @@ -85,7 +88,7 @@ import fullLaunch_v7 from "./integration/fullLaunch_v7.test.js"; import fullLaunch_v8 from "./integration/launchpad_v8_full_lifecycle.test.js"; import gatedLaunchpadV8 from "./integration/gatedLaunchpadV8.test.js"; import trancheLifecycle_v8 from "./integration/launchpad_v8_tranche_lifecycle.test.js"; -import { BN } from "bn.js"; +import BN from "bn.js"; const ONE_BUCK_PRICE = PriceMath.getAmmPrice(1, 6, 6); @@ -147,6 +150,23 @@ export interface TestContext { baseMint: PublicKey; quoteMint: PublicKey; }) => Promise; + setupBasicPerformancePackage: ({ + tokenMint, + oracleAccount, + recipient, + limits, + minUnlockTimestamp, + tranches, + byteOffset, + }: { + tokenMint: PublicKey; + oracleAccount: PublicKey; + recipient: PublicKey; + limits?: LimitsParams; + minUnlockTimestamp?: BN; + tranches?: Tranche[]; + byteOffset?: number; + }) => Promise; initializeProposal: ({ dao, instructions, @@ -670,44 +690,65 @@ before(async function () { tokenMint, oracleAccount, recipient, + limits, + minUnlockTimestamp, + tranches, + byteOffset = 0, }: { tokenMint: PublicKey; oracleAccount: PublicKey; recipient: PublicKey; + limits?: LimitsParams; + minUnlockTimestamp?: BN; + tranches?: Tranche[]; + byteOffset?: number; }): Promise => { const createKey = Keypair.generate(); - await this.priceBasedPerformancePackage - .initializePerformancePackageIx({ - params: { - tranches: [ - { - priceThreshold: new BN(1e12), - tokenAmount: new BN(100 * 10 ** 6), - }, - { - priceThreshold: new BN(2e12), - tokenAmount: new BN(100 * 10 ** 6), - }, - ], - grantee: recipient, - performancePackageAuthority: this.payer.publicKey, - minUnlockTimestamp: new BN( - Number((await this.context.banksClient.getClock()).unixTimestamp) + - 1, - ), - oracleConfig: { - oracleAccount, - byteOffset: 0, - }, - twapLengthSeconds: 24 * 60 * 60, // 1 day, the minimum + const params: InitializePerformancePackageParams = { + tranches: tranches ?? [ + { + priceThreshold: new BN(1e12), + tokenAmount: new BN(100 * 10 ** 6), }, - createKey: createKey.publicKey, - tokenMint, - grantor: this.payer.publicKey, - }) - .signers([createKey]) - .rpc(); + { + priceThreshold: new BN(2e12), + tokenAmount: new BN(100 * 10 ** 6), + }, + ], + grantee: recipient, + performancePackageAuthority: this.payer.publicKey, + minUnlockTimestamp: + minUnlockTimestamp ?? + new BN( + Number((await this.context.banksClient.getClock()).unixTimestamp) + 1, + ), + oracleConfig: { + oracleAccount, + byteOffset, + }, + twapLengthSeconds: 24 * 60 * 60, // 1 day, the minimum + }; + const accounts = { + createKey: createKey.publicKey, + tokenMint, + grantor: this.payer.publicKey, + }; + + if (limits) { + await this.priceBasedPerformancePackage + .initializePerformancePackageWithLimitsIx({ + params: { base: params, limits }, + ...accounts, + }) + .signers([createKey]) + .rpc(); + } else { + await this.priceBasedPerformancePackage + .initializePerformancePackageIx({ params, ...accounts }) + .signers([createKey]) + .rpc(); + } return getPerformancePackageAddr({ createKey: createKey.publicKey, diff --git a/tests/priceBasedPerformancePackage/integration/legacyPackageToCappedWithdrawals.test.ts b/tests/priceBasedPerformancePackage/integration/legacyPackageToCappedWithdrawals.test.ts new file mode 100644 index 000000000..72c7a8d48 --- /dev/null +++ b/tests/priceBasedPerformancePackage/integration/legacyPackageToCappedWithdrawals.test.ts @@ -0,0 +1,319 @@ +import { Keypair, PublicKey } from "@solana/web3.js"; +import { assert } from "chai"; +import BN from "bn.js"; +import { getAssociatedTokenAddressSync } from "@solana/spl-token"; +import { getChangeRequestAddr, LimitsParams } from "@metadaoproject/programs"; +import { + getMaxTokenWithdrawal, + getSellProceedsEstimate, +} from "@metadaoproject/programs/price_based_performance_package/v0.6/withdrawalLimits"; +import { expectError } from "../../utils.js"; +import { setupPackageOnDao, stampDaoOracle, uniqueTxIx } from "../utils.js"; + +// Five tranches of 2,000,000 tokens. The first threshold sits below the pool +// price and unlocks against the Dao's TWAP; the others stay locked above it. +const TRANCHE_AMOUNT = 2_000_000 * 10 ** 6; +const TOTAL_AMOUNT = 5 * TRANCHE_AMOUNT; +const TRANCHE_THRESHOLDS = [5e10, 2e12, 3e12, 4e12, 5e12]; +// The Dao's pool opens at $0.0728 per token +const POOL_BASE = 10_000_000 * 10 ** 6; +const POOL_QUOTE = 728_000 * 10 ** 6; +// Kept by the payer for oracle-stamping buys +const PAYER_RESERVE = 1_000 * 10 ** 6; +// At most 645,000 tokens per 30-day window; the quote cap is generous so the token cap binds +const TOKEN_CAP = 645_000 * 10 ** 6; +const QUOTE_CAP = 10_000_000 * 10 ** 6; +const ONE_HOUR = 60 * 60; +const ONE_DAY = 24 * ONE_HOUR; +const THIRTY_DAYS = 30 * ONE_DAY; +const ONE_YEAR = 365 * ONE_DAY; + +export default function suite() { + let tokenMint: PublicKey; + let quoteMint: PublicKey; + let dao: PublicKey; + let performancePackage: PublicKey; + let recipient: Keypair; + + async function clock(ctx: Mocha.Context): Promise { + return Number((await ctx.banksClient.getClock()).unixTimestamp); + } + + function storedPackage(ctx: Mocha.Context) { + return ctx.priceBasedPerformancePackage.getPerformancePackage( + performancePackage, + ); + } + + async function usage(ctx: Mocha.Context) { + const { usage } = (await storedPackage(ctx)).withdrawalPolicy; + return { + windowIndex: usage.windowIndex.toString(), + tokensUsed: usage.tokensUsed.toString(), + quoteUsed: usage.quoteUsed.toString(), + }; + } + + // Plain JSON, so BNs and keys compare by value. + function asJson(value: unknown) { + return JSON.parse(JSON.stringify(value)); + } + + function vaultBalance(ctx: Mocha.Context) { + return ctx.getTokenBalance(tokenMint, performancePackage); + } + + function startUnlock(ctx: Mocha.Context) { + return ctx.priceBasedPerformancePackage + .startUnlockIx({ + performancePackage, + oracleAccount: dao, + recipient: recipient.publicKey, + }) + .preInstructions([uniqueTxIx()]) + .signers([recipient]) + .rpc(); + } + + function withdrawTokens(ctx: Mocha.Context, amount: number) { + return ctx.priceBasedPerformancePackage + .withdrawTokensIx({ + performancePackage, + oracleAccount: dao, + tokenMint, + recipient: recipient.publicKey, + amount: new BN(amount), + }) + .preInstructions([uniqueTxIx()]) + .signers([recipient]) + .rpc(); + } + + // The SDK's maximum token withdrawal at the current clock. + async function maxWithdrawal(ctx: Mocha.Context): Promise { + return getMaxTokenWithdrawal({ + performancePackage: await storedPackage(ctx), + vaultAmount: new BN((await vaultBalance(ctx)).toString()), + now: await clock(ctx), + dao: await ctx.futarchy.getDao(dao), + }).toString(); + } + + it("a legacy package becomes capped and cliff-free", async function () { + recipient = Keypair.generate(); + tokenMint = await this.createMint(this.payer.publicKey, 6); + quoteMint = await this.createMint(this.payer.publicKey, 6); + await this.mintTo( + tokenMint, + this.payer.publicKey, + this.payer, + POOL_BASE + TOTAL_AMOUNT + PAYER_RESERVE, + ); + await this.mintTo( + quoteMint, + this.payer.publicKey, + this.payer, + POOL_QUOTE + PAYER_RESERVE, + ); + + // Created without limits, with a cliff 30 days ahead + const cliff = (await clock(this)) + THIRTY_DAYS; + ({ dao, performancePackage } = await setupPackageOnDao(this, { + tokenMint, + quoteMint, + recipient: recipient.publicKey, + tranches: TRANCHE_THRESHOLDS.map((threshold) => ({ + priceThreshold: new BN(threshold), + tokenAmount: new BN(TRANCHE_AMOUNT), + })), + minUnlockTimestamp: new BN(cliff), + })); + await this.futarchy + .provideLiquidityIx({ + dao, + baseMint: tokenMint, + quoteMint, + quoteAmount: new BN(POOL_QUOTE), + maxBaseAmount: new BN(POOL_BASE), + }) + .rpc(); + + let stored = await storedPackage(this); + assert.isNull(stored.withdrawalPolicy); + assert.equal(stored.minUnlockTimestamp.toString(), cliff.toString()); + assert.equal(await vaultBalance(this), BigInt(TOTAL_AMOUNT)); + + // Nothing is unlocked and the cliff has not passed + const earlyWithdraw = expectError( + "InsufficientWithdrawableBalance", + "withdrew before anything unlocked", + ); + await withdrawTokens(this, 1).then(earlyWithdraw[0], earlyWithdraw[1]); + const earlyUnlock = expectError( + "UnlockTimestampNotReached", + "started an unlock before the cliff", + ); + await startUnlock(this).then(earlyUnlock[0], earlyUnlock[1]); + assert.equal(await vaultBalance(this), BigInt(TOTAL_AMOUNT)); + + // The Dao's TWAP only starts recording after its one-day start delay + await this.advanceBySeconds(ONE_DAY + 1); + + // The authority proposes new unlock terms: the cliff is lifted to now and + // withdrawals become capped. The recipient executes an hour later. + const proposedAt = await clock(this); + const limits: LimitsParams = { + endTimestamp: new BN(proposedAt + ONE_YEAR), + windowSeconds: THIRTY_DAYS, + maxTokensPerWindow: new BN(TOKEN_CAP), + maxQuotePerWindow: new BN(QUOTE_CAP), + withdrawalMode: { both: {} }, + }; + const pdaNonce = Math.floor(Math.random() * 1_000_000); + await this.priceBasedPerformancePackage + .proposeChangeIx({ + params: { + changeType: { + unlockTerms: { minUnlockTimestamp: new BN(proposedAt), limits }, + }, + pdaNonce, + }, + performancePackage, + proposer: this.payer.publicKey, + }) + .rpc(); + const [changeRequest] = getChangeRequestAddr({ + performancePackage, + proposer: this.payer.publicKey, + pdaNonce, + }); + + await this.advanceBySeconds(ONE_HOUR); + const executedAt = await clock(this); + await this.priceBasedPerformancePackage + .executeChangeIx({ + performancePackage, + changeRequest, + executor: recipient.publicKey, + }) + .signers([recipient]) + .rpc(); + + stored = await storedPackage(this); + assert.equal(stored.minUnlockTimestamp.toString(), proposedAt.toString()); + assert.deepEqual( + asJson(stored.withdrawalPolicy.limits), + asJson({ startTimestamp: new BN(executedAt), ...limits }), + ); + assert.deepEqual(await usage(this), { + windowIndex: "0", + tokensUsed: "0", + quoteUsed: "0", + }); + + // The first tranche unlocks off the Dao's TWAP; the tokens stay in the vault + await stampDaoOracle(this, { dao, tokenMint, quoteMint }); + await startUnlock(this); + assert.isDefined((await storedPackage(this)).state.unlocking); + + await this.advanceBySeconds(stored.twapLengthSeconds); + await stampDaoOracle(this, { dao, tokenMint, quoteMint }); + await this.priceBasedPerformancePackage + .completeUnlockIx({ performancePackage, oracleAccount: dao }) + .preInstructions([uniqueTxIx()]) + .rpc(); + + stored = await storedPackage(this); + assert.isDefined(stored.state.locked); + assert.isTrue(stored.tranches[0].isUnlocked); + assert.isFalse(stored.tranches[1].isUnlocked); + assert.equal( + stored.alreadyUnlockedAmount.toString(), + TRANCHE_AMOUNT.toString(), + ); + assert.equal(await vaultBalance(this), BigInt(TOTAL_AMOUNT)); + assert.equal( + await this.getTokenBalance(tokenMint, recipient.publicKey), + 0n, + ); + + // The token cap is the most that can leave in this window + assert.equal(await maxWithdrawal(this), TOKEN_CAP.toString()); + await withdrawTokens(this, TOKEN_CAP); + + assert.equal( + await this.getTokenBalance(tokenMint, recipient.publicKey), + BigInt(TOKEN_CAP), + ); + assert.equal(await vaultBalance(this), BigInt(TOTAL_AMOUNT - TOKEN_CAP)); + const overCap = expectError( + "TokenWindowLimitExceeded", + "withdrew past the window's token cap", + ); + await withdrawTokens(this, 1).then(overCap[0], overCap[1]); + assert.equal(await maxWithdrawal(this), "0"); + assert.equal((await usage(this)).tokensUsed, TOKEN_CAP.toString()); + + // In the next window half the cap is sold into the pool and the other + // half withdrawn as tokens; both routes count against the same window + await this.advanceBySeconds(THIRTY_DAYS); + const halfCap = TOKEN_CAP / 2; + const poolBaseBefore = await this.getTokenBalance(tokenMint, dao); + const estimate = getSellProceedsEstimate( + await this.futarchy.getDao(dao), + new BN(halfCap), + ); + + await this.priceBasedPerformancePackage + .withdrawViaSellIx({ + performancePackage, + dao, + tokenMint, + quoteMint, + recipient: recipient.publicKey, + amount: new BN(halfCap), + minQuoteOut: new BN(0), + }) + .preInstructions([uniqueTxIx()]) + .signers([recipient]) + .rpc(); + + assert.equal( + await this.getTokenBalance(tokenMint, dao), + poolBaseBefore + BigInt(halfCap), + ); + assert.equal( + await vaultBalance(this), + BigInt(TOTAL_AMOUNT - TOKEN_CAP - halfCap), + ); + assert.equal( + (await this.getTokenBalance(quoteMint, recipient.publicKey)).toString(), + estimate.toString(), + ); + assert.deepEqual(await usage(this), { + windowIndex: "1", + tokensUsed: halfCap.toString(), + quoteUsed: estimate.toString(), + }); + const packageQuoteAccount = getAssociatedTokenAddressSync( + quoteMint, + performancePackage, + true, + ); + assert.isNotNull(await this.banksClient.getAccount(packageQuoteAccount)); + assert.equal(await this.getTokenBalance(quoteMint, performancePackage), 0n); + + await withdrawTokens(this, halfCap); + + assert.equal( + await this.getTokenBalance(tokenMint, recipient.publicKey), + BigInt(TOKEN_CAP + halfCap), + ); + assert.equal( + await vaultBalance(this), + BigInt(TOTAL_AMOUNT - 2 * TOKEN_CAP), + ); + assert.equal((await usage(this)).tokensUsed, TOKEN_CAP.toString()); + assert.equal(await maxWithdrawal(this), "0"); + }); +} diff --git a/tests/priceBasedPerformancePackage/main.test.ts b/tests/priceBasedPerformancePackage/main.test.ts index a590ae64d..eee68a958 100644 --- a/tests/priceBasedPerformancePackage/main.test.ts +++ b/tests/priceBasedPerformancePackage/main.test.ts @@ -1,15 +1,26 @@ import initializePerformancePackage from "./unit/initializePerformancePackage.test.js"; +import initializePerformancePackageWithLimits from "./unit/initializePerformancePackageWithLimits.test.js"; import startUnlock from "./unit/startUnlock.test.js"; import completeUnlock from "./unit/completeUnlock.test.js"; +import withdrawTokens from "./unit/withdrawTokens.test.js"; +import withdrawViaSell from "./unit/withdrawViaSell.test.js"; import proposeChange from "./unit/proposeChange.test.js"; import changePerformancePackageAuthority from "./unit/changePerformancePackageAuthority.test.js"; import executeChange from "./unit/executeChange.test.js"; import burnPerformancePackage from "./unit/burnPerformancePackage.test.js"; +import resizePerformancePackage from "./unit/resizePerformancePackage.test.js"; +import legacyPackageToCappedWithdrawals from "./integration/legacyPackageToCappedWithdrawals.test.js"; export default function suite() { describe("#initialize_performance_package", initializePerformancePackage); + describe( + "#initialize_performance_package_with_limits", + initializePerformancePackageWithLimits, + ); describe("#start_unlock", startUnlock); describe("#complete_unlock", completeUnlock); + describe("#withdraw_tokens", withdrawTokens); + describe("#withdraw_via_sell", withdrawViaSell); describe("#propose_change", proposeChange); describe( "#change_performance_package_authority", @@ -17,4 +28,9 @@ export default function suite() { ); describe("#execute_change", executeChange); describe("#burn_performance_package", burnPerformancePackage); + describe("#resize_performance_package", resizePerformancePackage); + describe( + "legacy package to capped withdrawals", + legacyPackageToCappedWithdrawals, + ); } diff --git a/tests/priceBasedPerformancePackage/unit/burnPerformancePackage.test.ts b/tests/priceBasedPerformancePackage/unit/burnPerformancePackage.test.ts index 73a0af2b5..7dd47fb93 100644 --- a/tests/priceBasedPerformancePackage/unit/burnPerformancePackage.test.ts +++ b/tests/priceBasedPerformancePackage/unit/burnPerformancePackage.test.ts @@ -5,150 +5,376 @@ import { SystemProgram, } from "@solana/web3.js"; import { assert } from "chai"; -import { mintTo, getAccount } from "spl-token-bankrun"; import BN from "bn.js"; -import { getPerformancePackageAddr, Tranche } from "@metadaoproject/programs"; +import { getMint } from "spl-token-bankrun"; +import { + ACCOUNT_SIZE, + ASSOCIATED_TOKEN_PROGRAM_ID, + TOKEN_PROGRAM_ID, + getAssociatedTokenAddressSync, +} from "@solana/spl-token"; +import { QuoteSweep } from "@metadaoproject/programs"; import { expectError } from "../../utils.js"; -import { getAssociatedTokenAddress } from "@solana/spl-token"; +import { runUnlockCycle, setDaoOracle, setupPackageOnDao } from "../utils.js"; + +const TRANCHE_AMOUNT = 100 * 10 ** 6; +const TOTAL_AMOUNT = 2 * TRANCHE_AMOUNT; +const STRAY_QUOTE_AMOUNT = 500 * 10 ** 6; +const THIRTY_DAYS = 30 * 24 * 60 * 60; +const ONE_YEAR = 365 * 24 * 60 * 60; export default function () { - let createKey: Keypair; let tokenMint: PublicKey; - let tokenAuthority: Keypair; - let tokenAccount: PublicKey; let recipient: Keypair; + let admin: Keypair; + let spillAccount: Keypair; let performancePackage: PublicKey; - let oracleAccount: Keypair; + let oracle: PublicKey; beforeEach(async function () { - // Create test accounts - createKey = Keypair.generate(); - tokenAuthority = Keypair.generate(); recipient = Keypair.generate(); - oracleAccount = Keypair.generate(); + admin = Keypair.generate(); + spillAccount = Keypair.generate(); + oracle = Keypair.generate().publicKey; - // Fund accounts with SOL using SystemProgram - const fundingTx = new Transaction().add( - SystemProgram.transfer({ - fromPubkey: this.payer.publicKey, - toPubkey: createKey.publicKey, - lamports: 1000000000, // 1 SOL - }), - SystemProgram.transfer({ - fromPubkey: this.payer.publicKey, - toPubkey: tokenAuthority.publicKey, - lamports: 1000000000, // 1 SOL - }), + const fundTx = new Transaction().add( SystemProgram.transfer({ fromPubkey: this.payer.publicKey, - toPubkey: recipient.publicKey, - lamports: 1000000000, // 1 SOL + toPubkey: admin.publicKey, + lamports: 1_000_000_000, }), ); - fundingTx.recentBlockhash = ( - await this.context.banksClient.getLatestBlockhash() - )[0]; - fundingTx.sign(this.payer); - await this.banksClient.processTransaction(fundingTx); + fundTx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + fundTx.sign(this.payer); + await this.banksClient.processTransaction(fundTx); - // Create token mint - tokenMint = await this.createMint(tokenAuthority.publicKey, 6); + tokenMint = await this.createMint(this.payer.publicKey, 6); + await this.mintTo( + tokenMint, + this.payer.publicKey, + this.payer, + TOTAL_AMOUNT, + ); - // Create token account - tokenAccount = await this.createTokenAccount( + performancePackage = await this.setupBasicPerformancePackage({ tokenMint, - tokenAuthority.publicKey, + oracleAccount: oracle, + recipient: recipient.publicKey, + }); + + // Move past the one-second cliff + await this.advanceBySeconds(2); + }); + + // Unlocks every tranche with a threshold at or below `twapPrice`. + async function unlockTranches( + ctx: Mocha.Context, + twapPrice: bigint, + writeOracle?: (values: { aggregator: bigint }) => Promise, + ) { + await runUnlockCycle(ctx, { + performancePackage, + oracleAccount: oracle, + recipient, + twapPrice, + writeOracle, + }); + } + + function burnIx(ctx: Mocha.Context, quoteSweep?: QuoteSweep) { + return ctx.priceBasedPerformancePackage.burnPerformancePackageIx({ + performancePackage, + tokenMint, + recipient: recipient.publicKey, + admin: admin.publicKey, + spillAccount: spillAccount.publicKey, + quoteSweep, + }); + } + + function vaultAddress() { + return getAssociatedTokenAddressSync(tokenMint, performancePackage, true); + } + + async function lamportsOf(ctx: Mocha.Context, accounts: PublicKey[]) { + let total = 0n; + for (const account of accounts) { + total += BigInt((await ctx.banksClient.getAccount(account)).lamports); + } + return total; + } + + function withdrawIx(ctx: Mocha.Context, amount: number) { + return ctx.priceBasedPerformancePackage + .withdrawTokensIx({ + performancePackage, + oracleAccount: oracle, + tokenMint, + recipient: recipient.publicKey, + amount: new BN(amount), + }) + .signers([recipient]); + } + + async function mintSupply(ctx: Mocha.Context): Promise { + return (await getMint(ctx.banksClient, tokenMint)).supply; + } + + it("pays out the unlocked balance, burns the locked remainder and closes the package to the spill account", async function () { + await unlockTranches(this, BigInt(1e12)); + + const supplyBefore = await mintSupply(this); + const closedLamports = await lamportsOf(this, [ + performancePackage, + vaultAddress(), + ]); + + await burnIx(this).signers([admin]).rpc(); + + assert.equal( + await this.getTokenBalance(tokenMint, recipient.publicKey), + BigInt(TRANCHE_AMOUNT), + ); + assert.equal( + supplyBefore - (await mintSupply(this)), + BigInt(TOTAL_AMOUNT - TRANCHE_AMOUNT), + ); + + assert.isNull(await this.banksClient.getAccount(performancePackage)); + assert.isNull(await this.banksClient.getAccount(vaultAddress())); + assert.equal( + await this.banksClient.getBalance(spillAccount.publicKey), + closedLamports, ); + }); + + it("burns the whole vault when nothing is unlocked", async function () { + const supplyBefore = await mintSupply(this); - // Mint some tokens to the token account - await mintTo( - this.context.banksClient, + await burnIx(this).signers([admin]).rpc(); + + assert.equal( + await this.getTokenBalance(tokenMint, recipient.publicKey), + 0n, + ); + assert.equal(await this.getTokenBalance(tokenMint, performancePackage), 0n); + assert.equal(supplyBefore - (await mintSupply(this)), BigInt(TOTAL_AMOUNT)); + assert.isNull(await this.banksClient.getAccount(performancePackage)); + }); + + it("transfers and burns nothing when everything is unlocked and already withdrawn", async function () { + await unlockTranches(this, BigInt(2e12)); + await withdrawIx(this, TOTAL_AMOUNT).rpc(); + assert.equal(await this.getTokenBalance(tokenMint, performancePackage), 0n); + + const supplyBefore = await mintSupply(this); + + await burnIx(this).signers([admin]).rpc(); + + assert.equal( + await this.getTokenBalance(tokenMint, recipient.publicKey), + BigInt(TOTAL_AMOUNT), + ); + assert.equal(await mintSupply(this), supplyBefore); + assert.isNull(await this.banksClient.getAccount(performancePackage)); + assert.isNull(await this.banksClient.getAccount(vaultAddress())); + }); + + it("pays the whole withdrawable balance when the window's token cap is used up", async function () { + tokenMint = await this.createMint(this.payer.publicKey, 6); + const quoteMint = await this.createMint(this.payer.publicKey, 6); + await this.mintTo( + tokenMint, + this.payer.publicKey, this.payer, + TOTAL_AMOUNT, + ); + const now = Number((await this.banksClient.getClock()).unixTimestamp); + ({ dao: oracle, performancePackage } = await setupPackageOnDao(this, { tokenMint, - tokenAccount, - tokenAuthority, - 1000000, + quoteMint, + recipient: recipient.publicKey, + limits: { + endTimestamp: new BN(now + ONE_YEAR), + windowSeconds: THIRTY_DAYS, + maxTokensPerWindow: new BN(TRANCHE_AMOUNT / 2), + maxQuotePerWindow: new BN(1_000 * 10 ** 6), + withdrawalMode: { both: {} }, + }, + })); + await this.advanceBySeconds(2); + await unlockTranches(this, BigInt(1e12), (values) => + setDaoOracle(this, oracle, values), ); + await setDaoOracle(this, oracle, { + lastObservation: BigInt(1e12), + reserves: { base: 1_000_000n * 10n ** 6n, quote: 1_000_000n * 10n ** 6n }, + }); - performancePackage = getPerformancePackageAddr({ - createKey: createKey.publicKey, - })[0]; + await withdrawIx(this, TRANCHE_AMOUNT / 2).rpc(); + const callbacks = expectError( + "TokenWindowLimitExceeded", + "withdrew past the window's token cap", + ); + await withdrawIx(this, 1).rpc().then(callbacks[0], callbacks[1]); - let tranches: Tranche[] = [ - { - priceThreshold: new BN(1000000), - tokenAmount: new BN(100000), - }, - { - priceThreshold: new BN(2000000), - tokenAmount: new BN(200000), - }, - ]; - const params = { - tranches, - grantee: recipient.publicKey, - performancePackageAuthority: this.payer.publicKey, - minUnlockTimestamp: new BN( - Number((await this.context.banksClient.getClock()).unixTimestamp) + - 3600, - ), // 1 hour from now - oracleConfig: { - oracleAccount: oracleAccount.publicKey, - byteOffset: 0, - }, - twapLengthSeconds: 86_400, // 1 day - tokenRecipient: recipient.publicKey, - }; - - const tx = await this.priceBasedPerformancePackage - .initializePerformancePackageIx({ - params, - createKey: createKey.publicKey, - tokenMint, - grantorTokenAccount: tokenAccount, - grantor: tokenAuthority.publicKey, - }) - .transaction(); + const supplyBefore = await mintSupply(this); + + await burnIx(this).signers([admin]).rpc(); - tx.recentBlockhash = ( - await this.context.banksClient.getLatestBlockhash() - )[0]; - tx.sign(createKey, this.payer, tokenAuthority); - await this.banksClient.processTransaction(tx); + assert.equal( + await this.getTokenBalance(tokenMint, recipient.publicKey), + BigInt(TRANCHE_AMOUNT), + ); + assert.equal(await this.getTokenBalance(tokenMint, performancePackage), 0n); + assert.equal( + supplyBefore - (await mintSupply(this)), + BigInt(TOTAL_AMOUNT - TRANCHE_AMOUNT), + ); + assert.isNull(await this.banksClient.getAccount(performancePackage)); }); - it("should burn a performance package successfully", async function () { - const performancePackageTokenVault = await getAssociatedTokenAddress( + it("creates a missing recipient ATA at the admin's expense", async function () { + await unlockTranches(this, BigInt(1e12)); + + const recipientTokenAccount = getAssociatedTokenAddressSync( tokenMint, + recipient.publicKey, + ); + assert.isNull(await this.banksClient.getAccount(recipientTokenAccount)); + const adminBefore = await this.banksClient.getBalance(admin.publicKey); + + await burnIx(this).signers([admin]).rpc(); + + assert.isNotNull(await this.banksClient.getAccount(recipientTokenAccount)); + assert.equal( + await this.getTokenBalance(tokenMint, recipient.publicKey), + BigInt(TRANCHE_AMOUNT), + ); + + const rent = await this.banksClient.getRent(); + assert.equal( + adminBefore - (await this.banksClient.getBalance(admin.publicKey)), + rent.minimumBalance(BigInt(ACCOUNT_SIZE)), + ); + }); + + it("sweeps the quote account to the destination and closes it along with the vault", async function () { + const quoteMint = await this.createMint(this.payer.publicKey, 6); + await this.mintTo( + quoteMint, + performancePackage, + this.payer, + STRAY_QUOTE_AMOUNT, + ); + const destinationOwner = Keypair.generate().publicKey; + const quoteDestination = await this.createTokenAccount( + quoteMint, + destinationOwner, + ); + const packageQuoteAccount = getAssociatedTokenAddressSync( + quoteMint, performancePackage, true, ); + const closedLamports = await lamportsOf(this, [ + performancePackage, + vaultAddress(), + packageQuoteAccount, + ]); + + await burnIx(this, { quoteMint, quoteDestination }).signers([admin]).rpc(); - // Confirm that the performance package and token vault accounts are not closed... yet - let performancePackageAccount = - await this.banksClient.getAccount(performancePackage); - assert.isNotNull(performancePackageAccount); + assert.equal( + await this.getTokenBalance(quoteMint, destinationOwner), + BigInt(STRAY_QUOTE_AMOUNT), + ); + assert.isNull(await this.banksClient.getAccount(packageQuoteAccount)); + assert.isNull(await this.banksClient.getAccount(vaultAddress())); + assert.isNull(await this.banksClient.getAccount(performancePackage)); + assert.equal( + await this.banksClient.getBalance(spillAccount.publicKey), + closedLamports, + ); + }); - let performancePackageTokenVaultAccount = await this.banksClient.getAccount( - performancePackageTokenVault, + it("closes an empty quote account", async function () { + const quoteMint = await this.createMint(this.payer.publicKey, 6); + const packageQuoteAccount = await this.createTokenAccount( + quoteMint, + performancePackage, + ); + const destinationOwner = Keypair.generate().publicKey; + const quoteDestination = await this.createTokenAccount( + quoteMint, + destinationOwner, ); - assert.isNotNull(performancePackageTokenVaultAccount); - // Burn the performance package + await burnIx(this, { quoteMint, quoteDestination }).signers([admin]).rpc(); + + assert.equal(await this.getTokenBalance(quoteMint, destinationOwner), 0n); + assert.isNull(await this.banksClient.getAccount(packageQuoteAccount)); + assert.isNull(await this.banksClient.getAccount(performancePackage)); + }); + + it("rejects the package's token mint as the quote mint", async function () { + const quoteDestination = await this.createTokenAccount( + tokenMint, + Keypair.generate().publicKey, + ); + + const callbacks = expectError( + "InvalidQuoteMint", + "swept the vault as a quote account", + ); + await burnIx(this, { quoteMint: tokenMint, quoteDestination }) + .signers([admin]) + .rpc() + .then(callbacks[0], callbacks[1]); + + assert.isNotNull(await this.banksClient.getAccount(performancePackage)); + assert.equal( + await this.getTokenBalance(tokenMint, performancePackage), + BigInt(TOTAL_AMOUNT), + ); + }); + + it("rejects a quote account without a destination", async function () { + const quoteMint = await this.createMint(this.payer.publicKey, 6); + const packageQuoteAccount = await this.createTokenAccount( + quoteMint, + performancePackage, + ); + + const callbacks = expectError( + "QuoteSweepAccountsIncomplete", + "burned with the quote account but no destination", + ); + // The SDK only builds the sweep as a pair, so the accounts are assembled by hand await this.priceBasedPerformancePackage.program.methods .burnPerformancePackage() .accounts({ performancePackage, - performancePackageTokenVault, + performancePackageTokenVault: vaultAddress(), + recipient: recipient.publicKey, + recipientTokenAccount: getAssociatedTokenAddressSync( + tokenMint, + recipient.publicKey, + ), + admin: admin.publicKey, + spillAccount: spillAccount.publicKey, tokenMint, - spillAccount: this.payer.publicKey, - admin: this.payer.publicKey, // This should be the MetaDAO operational multisig vault in production + quoteMint, + packageQuoteAccount, + quoteDestination: null, + systemProgram: SystemProgram.programId, + tokenProgram: TOKEN_PROGRAM_ID, + associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, }) - .rpc(); + .signers([admin]) + .rpc() + .then(callbacks[0], callbacks[1]); - // Confirm that the performance package account is closed - performancePackageAccount = - await this.banksClient.getAccount(performancePackage); - assert.isNull(performancePackageAccount); + assert.isNotNull(await this.banksClient.getAccount(performancePackage)); }); } diff --git a/tests/priceBasedPerformancePackage/unit/completeUnlock.test.ts b/tests/priceBasedPerformancePackage/unit/completeUnlock.test.ts index e31845d32..ee8a31b9d 100644 --- a/tests/priceBasedPerformancePackage/unit/completeUnlock.test.ts +++ b/tests/priceBasedPerformancePackage/unit/completeUnlock.test.ts @@ -1,40 +1,28 @@ -import { - PublicKey, - Keypair, - Transaction, - SystemProgram, - TransactionInstruction, - ComputeBudgetProgram, -} from "@solana/web3.js"; +import { PublicKey, Keypair } from "@solana/web3.js"; import { assert } from "chai"; -import * as token from "@solana/spl-token"; import BN from "bn.js"; -import { getPerformancePackageAddr } from "@metadaoproject/programs"; import { expectError } from "../../utils.js"; +import { runUnlockCycle, setMockOracle } from "../utils.js"; + +const TRANCHE_AMOUNT = 100 * 10 ** 6; +const TOTAL_AMOUNT = 2 * TRANCHE_AMOUNT; export default function () { - let createKey: Keypair; let tokenMint: PublicKey; - let tokenAuthority: Keypair; let recipient: Keypair; let performancePackage: PublicKey; - let performancePackageTokenAccount: PublicKey; let oracleAccount: Keypair; beforeEach(async function () { - // Generate new keys for each test to avoid account conflicts - createKey = Keypair.generate(); recipient = Keypair.generate(); - // tokenAuthority = Keypair.generate(); oracleAccount = Keypair.generate(); tokenMint = await this.createMint(this.payer.publicKey, 6); - await this.mintTo( tokenMint, this.payer.publicKey, this.payer, - 200 * 10 ** 6, + TOTAL_AMOUNT, ); performancePackage = await this.setupBasicPerformancePackage({ @@ -42,271 +30,184 @@ export default function () { oracleAccount: oracleAccount.publicKey, recipient: recipient.publicKey, }); - }); - it("should unlock 100% of tokens when price meets threshold", async function () { - // Advance time and start unlock + // Move past the one-second cliff await this.advanceBySeconds(2); + }); - // Set initial oracle data: 16 bytes aggregator (u128) + 8 bytes timestamp (i64) - const initialOracleData = Buffer.alloc(24); - // Write aggregator value (u128 little endian) - price of 1000000 - initialOracleData.writeBigUInt64LE(BigInt(1e12), 0); - initialOracleData.writeBigUInt64LE(BigInt(0), 8); - // Write timestamp (i64 little endian) - current timestamp - const currentTimestamp = await this.context.banksClient - .getClock() - .then((c) => c.unixTimestamp); - initialOracleData.writeBigInt64LE(BigInt(currentTimestamp), 16); - this.context.setAccount(oracleAccount.publicKey, { - executable: false, - owner: SystemProgram.programId, - lamports: 1000000000, - data: initialOracleData, - }); - - await this.priceBasedPerformancePackage - .startUnlockIx({ - performancePackage, - oracleAccount: oracleAccount.publicKey, - recipient: recipient.publicKey, - }) - .signers([recipient]) - .rpc(); - - // Advance time past TWAP calculation period - await this.advanceBySeconds(86_400); - - // Set final oracle data with higher price (meets threshold) - const finalOracleData = Buffer.alloc(24); - finalOracleData.writeBigUInt64LE(BigInt((2 * 86_400 + 1) * 1e12), 0); - finalOracleData.writeBigUInt64LE(BigInt(0), 8); - // Write timestamp (i64 little endian) - current timestamp - const finalTimestamp = await this.context.banksClient - .getClock() - .then((c) => c.unixTimestamp); - finalOracleData.writeBigInt64LE(BigInt(finalTimestamp), 16); - await this.context.setAccount(oracleAccount.publicKey, { - executable: false, - owner: SystemProgram.programId, - lamports: 1000000000, - data: finalOracleData, + it("unlocks every tranche the TWAP clears and leaves the tokens in the vault", async function () { + await runUnlockCycle(this, { + performancePackage, + oracleAccount: oracleAccount.publicKey, + recipient, + twapPrice: BigInt(2e12), }); - // Complete unlock - await this.priceBasedPerformancePackage - .completeUnlockIx({ - performancePackage, - oracleAccount: oracleAccount.publicKey, - tokenMint, - tokenRecipient: recipient.publicKey, - }) - .rpc(); - - const performancePackageAccount = + const storedPackage = await this.priceBasedPerformancePackage.getPerformancePackage( performancePackage, ); + for (const tranche of storedPackage.tranches) { + assert.isTrue(tranche.isUnlocked); + } assert.equal( - await this.getTokenBalance(tokenMint, recipient.publicKey), - BigInt(200 * 10 ** 6), - "Recipient should have 100000 tokens", + storedPackage.alreadyUnlockedAmount.toString(), + TOTAL_AMOUNT.toString(), ); + assert.equal(storedPackage.seqNum.toNumber(), 2); + assert.exists(storedPackage.state.locked); + assert.equal( - await this.getTokenBalance(tokenMint, performancePackage), + await this.getTokenBalance(tokenMint, recipient.publicKey), 0n, - "PerformancePackage token account should be empty", ); - - for (const tranche of performancePackageAccount.tranches) { - assert.equal(tranche.isUnlocked, true, "Tranche should be unlocked"); - } - - assert.exists( - performancePackageAccount.state.locked, - "PerformancePackage should be in Locked state", + assert.equal( + await this.getTokenBalance(tokenMint, performancePackage), + BigInt(TOTAL_AMOUNT), ); }); - it("should unlock the first tranche when the price meets that threshold", async function () { - // Advance time and start unlock - await this.advanceBySeconds(2); - - // Set initial oracle data: 16 bytes aggregator (u128) + 8 bytes timestamp (i64) - const initialOracleData = Buffer.alloc(24); - // Write aggregator value (u128 little endian) - price of 1000000 - initialOracleData.writeBigUInt64LE(BigInt(1e12), 0); - initialOracleData.writeBigUInt64LE(BigInt(0), 8); - // Write timestamp (i64 little endian) - current timestamp - const currentTimestamp = await this.context.banksClient - .getClock() - .then((c) => c.unixTimestamp); - initialOracleData.writeBigInt64LE(BigInt(currentTimestamp), 16); - this.context.setAccount(oracleAccount.publicKey, { - executable: false, - owner: SystemProgram.programId, - lamports: 1000000000, - data: initialOracleData, + it("leaves a withdrawable balance equal to the unlocked amount", async function () { + await runUnlockCycle(this, { + performancePackage, + oracleAccount: oracleAccount.publicKey, + recipient, + twapPrice: BigInt(1e12), }); + const storedPackage = + await this.priceBasedPerformancePackage.getPerformancePackage( + performancePackage, + ); + const vaultBalance = await this.getTokenBalance( + tokenMint, + performancePackage, + ); + const locked = + BigInt(storedPackage.totalTokenAmount.toString()) - + BigInt(storedPackage.alreadyUnlockedAmount.toString()); + assert.equal(vaultBalance - locked, BigInt(TRANCHE_AMOUNT)); + await this.priceBasedPerformancePackage - .startUnlockIx({ + .withdrawTokensIx({ performancePackage, oracleAccount: oracleAccount.publicKey, + tokenMint, recipient: recipient.publicKey, + amount: new BN(TRANCHE_AMOUNT), }) .signers([recipient]) .rpc(); - // Advance time past TWAP calculation period - await this.advanceBySeconds(86_400); + assert.equal( + await this.getTokenBalance(tokenMint, recipient.publicKey), + BigInt(TRANCHE_AMOUNT), + ); + assert.equal( + await this.getTokenBalance(tokenMint, performancePackage), + BigInt(TRANCHE_AMOUNT), + ); + }); - // Set final oracle data with higher price (meets threshold) - const finalOracleData = Buffer.alloc(24); - finalOracleData.writeBigUInt64LE(BigInt((86_400 + 1) * 1e12), 0); - finalOracleData.writeBigUInt64LE(BigInt(0), 8); - // Write timestamp (i64 little endian) - current timestamp - const finalTimestamp = await this.context.banksClient - .getClock() - .then((c) => c.unixTimestamp); - finalOracleData.writeBigInt64LE(BigInt(finalTimestamp), 16); - this.context.setAccount(oracleAccount.publicKey, { - executable: false, - owner: SystemProgram.programId, - lamports: 1000000000, - data: finalOracleData, + it("unlocks the first tranche, then the second in a later cycle", async function () { + await runUnlockCycle(this, { + performancePackage, + oracleAccount: oracleAccount.publicKey, + recipient, + twapPrice: BigInt(1e12), }); - // Complete unlock + let storedPackage = + await this.priceBasedPerformancePackage.getPerformancePackage( + performancePackage, + ); + assert.isTrue(storedPackage.tranches[0].isUnlocked); + assert.isFalse(storedPackage.tranches[1].isUnlocked); + assert.equal( + storedPackage.alreadyUnlockedAmount.toString(), + TRANCHE_AMOUNT.toString(), + ); + assert.exists(storedPackage.state.locked); + await this.priceBasedPerformancePackage - .completeUnlockIx({ + .withdrawTokensIx({ performancePackage, oracleAccount: oracleAccount.publicKey, tokenMint, - tokenRecipient: recipient.publicKey, + recipient: recipient.publicKey, + amount: new BN(TRANCHE_AMOUNT), }) + .signers([recipient]) .rpc(); - const performancePackageAccount = - await this.priceBasedPerformancePackage.getPerformancePackage( - performancePackage, - ); - assert.equal( await this.getTokenBalance(tokenMint, recipient.publicKey), - BigInt(100 * 10 ** 6), - "Recipient should have 100 tokens", + BigInt(TRANCHE_AMOUNT), ); assert.equal( await this.getTokenBalance(tokenMint, performancePackage), - BigInt(100 * 10 ** 6), - "PerformancePackage token account should be empty", - ); - - assert.equal( - performancePackageAccount.tranches[0].isUnlocked, - true, - "First tranche should be unlocked", - ); - assert.equal( - performancePackageAccount.tranches[1].isUnlocked, - false, - "Second tranche should not be unlocked", + BigInt(TRANCHE_AMOUNT), ); - assert.exists( - performancePackageAccount.state.locked, - "PerformancePackage should be in Locked state", - ); - - // now try it again with a higher price await this.advanceBySeconds(1000); - - initialOracleData.writeBigUInt64LE(BigInt(14e12), 0); - initialOracleData.writeBigUInt64LE(BigInt(0), 8); - // Write timestamp (i64 little endian) - current timestamp - const initialTimestamp2 = await this.context.banksClient - .getClock() - .then((c) => c.unixTimestamp); - initialOracleData.writeBigInt64LE(BigInt(initialTimestamp2), 16); - this.context.setAccount(oracleAccount.publicKey, { - executable: false, - owner: SystemProgram.programId, - lamports: 1000000000, - data: initialOracleData, + await runUnlockCycle(this, { + performancePackage, + oracleAccount: oracleAccount.publicKey, + recipient, + twapPrice: BigInt(2e12), }); + storedPackage = + await this.priceBasedPerformancePackage.getPerformancePackage( + performancePackage, + ); + assert.isTrue(storedPackage.tranches[0].isUnlocked); + assert.isTrue(storedPackage.tranches[1].isUnlocked); + assert.equal( + storedPackage.alreadyUnlockedAmount.toString(), + TOTAL_AMOUNT.toString(), + ); + assert.exists(storedPackage.state.locked); + await this.priceBasedPerformancePackage - .startUnlockIx({ + .withdrawTokensIx({ performancePackage, oracleAccount: oracleAccount.publicKey, + tokenMint, recipient: recipient.publicKey, + amount: new BN(TRANCHE_AMOUNT), }) - .preInstructions([ - ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), - ]) .signers([recipient]) .rpc(); - await this.advanceBySeconds(86_400); + assert.equal( + await this.getTokenBalance(tokenMint, recipient.publicKey), + BigInt(TOTAL_AMOUNT), + ); + assert.equal(await this.getTokenBalance(tokenMint, performancePackage), 0n); + }); - finalOracleData.writeBigUInt64LE(BigInt((2 * 86_400 + 14) * 1e12), 0); - finalOracleData.writeBigUInt64LE(BigInt(0), 8); - // Write timestamp (i64 little endian) - current timestamp - const finalTimestamp2 = await this.context.banksClient - .getClock() - .then((c) => c.unixTimestamp); - finalOracleData.writeBigInt64LE(BigInt(finalTimestamp2), 16); - this.context.setAccount(oracleAccount.publicKey, { - executable: false, - owner: SystemProgram.programId, - lamports: 1000000000, - data: finalOracleData, + it("changes only the state and sequence number when the TWAP clears nothing", async function () { + await runUnlockCycle(this, { + performancePackage, + oracleAccount: oracleAccount.publicKey, + recipient, + twapPrice: BigInt(0.5e12), }); - await this.priceBasedPerformancePackage - .completeUnlockIx({ - performancePackage, - oracleAccount: oracleAccount.publicKey, - tokenMint, - tokenRecipient: recipient.publicKey, - }) - .preInstructions([ - ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), - ]) - .rpc(); - - const performancePackageAccount2 = + const storedPackage = await this.priceBasedPerformancePackage.getPerformancePackage( performancePackage, ); - - assert.equal( - performancePackageAccount2.tranches[0].isUnlocked, - true, - "First tranche should be unlocked", - ); - assert.equal( - performancePackageAccount2.tranches[1].isUnlocked, - true, - "Second tranche should be unlocked", - ); - - assert.equal( - await this.getTokenBalance(tokenMint, recipient.publicKey), - BigInt(200 * 10 ** 6), - "Recipient should have 200 tokens", - ); + assert.isFalse(storedPackage.tranches[0].isUnlocked); + assert.isFalse(storedPackage.tranches[1].isUnlocked); + assert.equal(storedPackage.alreadyUnlockedAmount.toNumber(), 0); + assert.equal(storedPackage.seqNum.toNumber(), 2); + assert.exists(storedPackage.state.locked); assert.equal( await this.getTokenBalance(tokenMint, performancePackage), - 0n, - "PerformancePackage token account should be empty", - ); - - assert.exists( - performancePackageAccount2.state.locked, - "PerformancePackage should be in Locked state", + BigInt(TOTAL_AMOUNT), ); }); @@ -320,33 +221,15 @@ export default function () { .completeUnlockIx({ performancePackage, oracleAccount: oracleAccount.publicKey, - tokenMint, - tokenRecipient: recipient.publicKey, }) .rpc() .then(callbacks[0], callbacks[1]); }); it("should fail if TWAP calculation period has not elapsed", async function () { - // Advance time and start unlock - await this.advanceBySeconds(2); - - const initialOracleData = Buffer.alloc(24); - // Write aggregator value (u128 little endian) - price of 1000000 - initialOracleData.writeBigUInt64LE(BigInt(1000000), 0); - initialOracleData.writeBigUInt64LE(BigInt(0), 8); - // Write timestamp (i64 little endian) - current timestamp - const currentTimestamp = await this.context.banksClient - .getClock() - .then((c) => c.unixTimestamp); - initialOracleData.writeBigInt64LE(BigInt(currentTimestamp), 16); - await this.context.setAccount(oracleAccount.publicKey, { - executable: false, - owner: SystemProgram.programId, - lamports: 1000000000, - data: initialOracleData, + await setMockOracle(this, oracleAccount.publicKey, { + aggregator: BigInt(1e12), }); - await this.priceBasedPerformancePackage .startUnlockIx({ performancePackage, @@ -356,8 +239,7 @@ export default function () { .signers([recipient]) .rpc(); - // Try to complete unlock before TWAP period elapses - await this.advanceBySeconds(5); // Only 5 seconds, need 10 + await this.advanceBySeconds(5); const callbacks = expectError( "TwapPeriodNotElapsed", @@ -368,8 +250,6 @@ export default function () { .completeUnlockIx({ performancePackage, oracleAccount: oracleAccount.publicKey, - tokenMint, - tokenRecipient: recipient.publicKey, }) .rpc() .then(callbacks[0], callbacks[1]); diff --git a/tests/priceBasedPerformancePackage/unit/executeChange.test.ts b/tests/priceBasedPerformancePackage/unit/executeChange.test.ts index bf559e515..eb8656735 100644 --- a/tests/priceBasedPerformancePackage/unit/executeChange.test.ts +++ b/tests/priceBasedPerformancePackage/unit/executeChange.test.ts @@ -7,7 +7,28 @@ import { import { assert } from "chai"; import BN from "bn.js"; import { expectError } from "../../utils.js"; -import { getChangeRequestAddr } from "@metadaoproject/programs"; +import { getChangeRequestAddr, LimitsParams } from "@metadaoproject/programs"; +import { + getActiveWithdrawalPolicy, + getMaxTokenWithdrawal, + getSellProceedsEstimate, +} from "@metadaoproject/programs/price_based_performance_package/v0.6/withdrawalLimits"; +import { + runUnlockCycle, + setMockOracle, + setupSellablePackage, + uniqueTxIx, +} from "../utils.js"; + +const ONE_HOUR = 60 * 60; +const ONE_DAY = 24 * ONE_HOUR; +const THIRTY_DAYS = 30 * ONE_DAY; +const ONE_YEAR = 365 * ONE_DAY; +// At most 645,000 tokens per window; the quote cap is generous so the token cap binds +const TOKEN_CAP = 645_000 * 10 ** 6; +const QUOTE_CAP = 10_000_000 * 10 ** 6; +const PARTIAL_WITHDRAWAL = 300_000 * 10 ** 6; +const NO_USAGE = { windowIndex: "0", tokensUsed: "0", quoteUsed: "0" }; export default function () { let createKey: Keypair; @@ -482,4 +503,450 @@ export default function () { .rpc() .then(callbacks[0], callbacks[1]); }); + + describe("with UnlockTerms", function () { + type UnlockTerms = { minUnlockTimestamp: BN; limits: LimitsParams | null }; + let quoteMint: PublicKey; + let dao: PublicKey; + + function storedPackage(ctx: Mocha.Context) { + return ctx.priceBasedPerformancePackage.getPerformancePackage( + performancePackage, + ); + } + + async function clock(ctx: Mocha.Context): Promise { + return Number((await ctx.banksClient.getClock()).unixTimestamp); + } + + // Plain JSON, so BNs and keys compare by value. + function asJson(value: unknown) { + return JSON.parse(JSON.stringify(value)); + } + + function cappedLimits( + now: number, + overrides: Partial = {}, + ): LimitsParams { + return { + endTimestamp: new BN(now + ONE_YEAR), + windowSeconds: THIRTY_DAYS, + maxTokensPerWindow: new BN(TOKEN_CAP), + maxQuotePerWindow: new BN(QUOTE_CAP), + withdrawalMode: { both: {} }, + ...overrides, + }; + } + + // Proposes the terms under a fresh nonce and returns the request address. + // The authority (the payer) proposes unless a signer is given. + async function propose( + ctx: Mocha.Context, + terms: UnlockTerms, + proposer: Keypair | null = null, + ): Promise { + const proposerKey = proposer?.publicKey ?? ctx.payer.publicKey; + const pdaNonce = Math.floor(Math.random() * 1_000_000); + + await ctx.priceBasedPerformancePackage + .proposeChangeIx({ + params: { changeType: { unlockTerms: terms }, pdaNonce }, + performancePackage, + proposer: proposerKey, + }) + .signers(proposer ? [proposer] : []) + .rpc(); + + return getChangeRequestAddr({ + performancePackage, + proposer: proposerKey, + pdaNonce, + })[0]; + } + + // The authority (the payer) executes unless a signer is given. + function execute( + ctx: Mocha.Context, + changeRequest: PublicKey, + executor: Keypair | null = null, + ) { + return ctx.priceBasedPerformancePackage + .executeChangeIx({ + performancePackage, + changeRequest, + executor: executor?.publicKey ?? ctx.payer.publicKey, + }) + .signers(executor ? [executor] : []) + .rpc(); + } + + // The authority proposes and the recipient executes; returns the execution clock. + async function proposeAndExecute( + ctx: Mocha.Context, + terms: UnlockTerms, + ): Promise { + const changeRequest = await propose(ctx, terms); + const executedAt = await clock(ctx); + await execute(ctx, changeRequest, recipient); + return executedAt; + } + + async function usage(ctx: Mocha.Context) { + const { usage } = (await storedPackage(ctx)).withdrawalPolicy; + return { + windowIndex: usage.windowIndex.toString(), + tokensUsed: usage.tokensUsed.toString(), + quoteUsed: usage.quoteUsed.toString(), + }; + } + + function startUnlock(ctx: Mocha.Context) { + return ctx.priceBasedPerformancePackage + .startUnlockIx({ + performancePackage, + oracleAccount: oracleAccount.publicKey, + recipient: recipient.publicKey, + }) + .preInstructions([uniqueTxIx()]) + .signers([recipient]) + .rpc(); + } + + function withdrawTokens( + ctx: Mocha.Context, + amount: number, + oracle: PublicKey = dao, + ) { + return ctx.priceBasedPerformancePackage + .withdrawTokensIx({ + performancePackage, + oracleAccount: oracle, + tokenMint, + recipient: recipient.publicKey, + amount: new BN(amount), + }) + .preInstructions([uniqueTxIx()]) + .signers([recipient]) + .rpc(); + } + + function sell(ctx: Mocha.Context, amount: number) { + return ctx.priceBasedPerformancePackage + .withdrawViaSellIx({ + performancePackage, + dao, + tokenMint, + quoteMint, + recipient: recipient.publicKey, + amount: new BN(amount), + minQuoteOut: new BN(0), + }) + .preInstructions([uniqueTxIx()]) + .signers([recipient]) + .rpc(); + } + + // The SDK's maximum token withdrawal at the current clock. + async function maxWithdrawal(ctx: Mocha.Context): Promise { + const vaultAmount = new BN( + (await ctx.getTokenBalance(tokenMint, performancePackage)).toString(), + ); + return getMaxTokenWithdrawal({ + performancePackage: await storedPackage(ctx), + vaultAmount, + now: await clock(ctx), + dao: await ctx.futarchy.getDao(dao), + }).toString(); + } + + // Replaces the package from the outer setup with one on a Dao whose pool + // is seeded, created with `limits` and its first tranche unlocked. + async function setupCappedPackage( + ctx: Mocha.Context, + limits: LimitsParams, + ) { + ({ tokenMint, quoteMint, dao, performancePackage } = + await setupSellablePackage(ctx, { recipient, limits })); + } + + it("removes the cliff so an unlock can start at once", async function () { + const now = await clock(this); + tokenMint = await this.createMint(this.payer.publicKey, 6); + await this.mintTo( + tokenMint, + this.payer.publicKey, + this.payer, + 200 * 10 ** 6, + ); + performancePackage = await this.setupBasicPerformancePackage({ + tokenMint, + oracleAccount: oracleAccount.publicKey, + recipient: recipient.publicKey, + minUnlockTimestamp: new BN(now + THIRTY_DAYS), + }); + await setMockOracle(this, oracleAccount.publicKey, { + aggregator: 1_000_000n, + }); + + const callbacks = expectError( + "UnlockTimestampNotReached", + "started an unlock before the cliff", + ); + await startUnlock(this).then(callbacks[0], callbacks[1]); + + await proposeAndExecute(this, { + minUnlockTimestamp: new BN(now), + limits: null, + }); + + const stored = await storedPackage(this); + assert.equal(stored.minUnlockTimestamp.toString(), now.toString()); + assert.isNull(stored.withdrawalPolicy); + + await startUnlock(this); + assert.isDefined((await storedPackage(this)).state.unlocking); + }); + + it("stores limits on a package without any, anchored at the execution clock with zero usage", async function () { + const now = await clock(this); + const limits = cappedLimits(now); + const changeRequest = await propose(this, { + minUnlockTimestamp: new BN(now), + limits, + }); + await this.advanceBySeconds(ONE_HOUR); + + const executedAt = await clock(this); + await execute(this, changeRequest, recipient); + + const stored = await storedPackage(this); + assert.equal(stored.minUnlockTimestamp.toString(), now.toString()); + assert.deepEqual( + asJson(stored.withdrawalPolicy.limits), + asJson({ startTimestamp: new BN(executedAt), ...limits }), + ); + assert.deepEqual(await usage(this), NO_USAGE); + }); + + it("keeps the window and its usage when the new limits share the window length", async function () { + await setupCappedPackage(this, cappedLimits(await clock(this))); + await withdrawTokens(this, PARTIAL_WITHDRAWAL); + const { minUnlockTimestamp, withdrawalPolicy: before } = + await storedPackage(this); + const usageBefore = await usage(this); + + const newLimits = cappedLimits(await clock(this), { + endTimestamp: before.limits.endTimestamp.addn(ONE_YEAR), + maxTokensPerWindow: new BN(2 * TOKEN_CAP), + maxQuotePerWindow: new BN(2 * QUOTE_CAP), + withdrawalMode: { tokens: {} }, + }); + await this.advanceBySeconds(ONE_HOUR); + await proposeAndExecute(this, { minUnlockTimestamp, limits: newLimits }); + + const after = (await storedPackage(this)).withdrawalPolicy; + assert.deepEqual( + asJson(after.limits), + asJson({ startTimestamp: before.limits.startTimestamp, ...newLimits }), + ); + assert.deepEqual(await usage(this), usageBefore); + + const room = 2 * TOKEN_CAP - PARTIAL_WITHDRAWAL; + assert.equal(await maxWithdrawal(this), room.toString()); + const callbacks = expectError( + "TokenWindowLimitExceeded", + "withdrew past the cap shared with the earlier withdrawal", + ); + await withdrawTokens(this, room + 1).then(callbacks[0], callbacks[1]); + await withdrawTokens(this, room); + assert.equal((await usage(this)).tokensUsed, (2 * TOKEN_CAP).toString()); + }); + + it("re-anchors at the execution clock and carries the counters into the first window when the window length changes", async function () { + await setupCappedPackage( + this, + cappedLimits(await clock(this), { windowSeconds: ONE_DAY }), + ); + await withdrawTokens(this, PARTIAL_WITHDRAWAL); + const { minUnlockTimestamp } = await storedPackage(this); + const usageBefore = await usage(this); + assert.notEqual(usageBefore.windowIndex, "0"); + + const newLimits = cappedLimits(await clock(this)); + const executedAt = await proposeAndExecute(this, { + minUnlockTimestamp, + limits: newLimits, + }); + + const after = (await storedPackage(this)).withdrawalPolicy; + assert.deepEqual( + asJson(after.limits), + asJson({ startTimestamp: new BN(executedAt), ...newLimits }), + ); + assert.deepEqual(await usage(this), { ...usageBefore, windowIndex: "0" }); + + const room = TOKEN_CAP - PARTIAL_WITHDRAWAL; + assert.equal(await maxWithdrawal(this), room.toString()); + const callbacks = expectError( + "TokenWindowLimitExceeded", + "withdrew past the cap carried into the new window", + ); + await withdrawTokens(this, room + 1).then(callbacks[0], callbacks[1]); + await withdrawTokens(this, room); + const usageAfter = await usage(this); + assert.equal(usageAfter.windowIndex, "0"); + assert.equal(usageAfter.tokensUsed, TOKEN_CAP.toString()); + }); + + it("removes the limits, uncapping both routes", async function () { + await setupCappedPackage(this, cappedLimits(await clock(this))); + const { minUnlockTimestamp } = await storedPackage(this); + + await proposeAndExecute(this, { minUnlockTimestamp, limits: null }); + + assert.isNull((await storedPackage(this)).withdrawalPolicy); + await withdrawTokens(this, 2 * TOKEN_CAP); + const estimate = getSellProceedsEstimate( + await this.futarchy.getDao(dao), + new BN(2 * TOKEN_CAP), + ); + await sell(this, 2 * TOKEN_CAP); + + assert.equal( + await this.getTokenBalance(tokenMint, recipient.publicKey), + BigInt(2 * TOKEN_CAP), + ); + assert.equal( + (await this.getTokenBalance(quoteMint, recipient.publicKey)).toString(), + estimate.toString(), + ); + }); + + it("applies a mode change to the next withdrawal", async function () { + const now = await clock(this); + await setupCappedPackage( + this, + cappedLimits(now, { withdrawalMode: { tokens: {} } }), + ); + const { minUnlockTimestamp } = await storedPackage(this); + const sellDisabled = expectError( + "WithdrawViaSellDisabled", + "sold under mode Tokens", + ); + await sell(this, TOKEN_CAP).then(sellDisabled[0], sellDisabled[1]); + + await proposeAndExecute(this, { + minUnlockTimestamp, + limits: cappedLimits(now, { withdrawalMode: { sell: {} } }), + }); + + const tokensDisabled = expectError( + "WithdrawTokensDisabled", + "withdrew tokens under mode Sell", + ); + await withdrawTokens(this, TOKEN_CAP).then( + tokensDisabled[0], + tokensDisabled[1], + ); + await sell(this, TOKEN_CAP); + assert.equal((await usage(this)).tokensUsed, TOKEN_CAP.toString()); + }); + + it("is allowed while the package is unlocking", async function () { + const now = await clock(this); + await this.advanceBySeconds(2); + await setMockOracle(this, oracleAccount.publicKey, { + aggregator: 1_000_000n, + }); + await startUnlock(this); + + await proposeAndExecute(this, { + minUnlockTimestamp: new BN(now), + limits: cappedLimits(now), + }); + + const stored = await storedPackage(this); + assert.isDefined(stored.state.unlocking); + assert.equal(stored.minUnlockTimestamp.toString(), now.toString()); + assert.isNotNull(stored.withdrawalPolicy); + }); + + it("rejects execution by the proposing party", async function () { + const changeRequest = await propose( + this, + { minUnlockTimestamp: new BN(await clock(this)), limits: null }, + recipient, + ); + + const callbacks = expectError( + "UnauthorizedLockerAuthority", + "the recipient executed their own proposal", + ); + await execute(this, changeRequest, recipient).then( + callbacks[0], + callbacks[1], + ); + }); + + it("stores a request whose end passed before execution as an inert policy", async function () { + await this.advanceBySeconds(2); + await runUnlockCycle(this, { + performancePackage, + oracleAccount: oracleAccount.publicKey, + recipient, + twapPrice: BigInt(1e12), + }); + const now = await clock(this); + const limits = cappedLimits(now, { + endTimestamp: new BN(now + ONE_HOUR), + windowSeconds: ONE_HOUR, + maxTokensPerWindow: new BN(1), + maxQuotePerWindow: new BN(1), + withdrawalMode: { sell: {} }, + }); + const changeRequest = await propose(this, { + minUnlockTimestamp: new BN(now), + limits, + }); + await this.advanceBySeconds(2 * ONE_HOUR); + + const executedAt = await clock(this); + await execute(this, changeRequest, recipient); + + const stored = await storedPackage(this); + assert.deepEqual( + asJson(stored.withdrawalPolicy.limits), + asJson({ startTimestamp: new BN(executedAt), ...limits }), + ); + assert.isNull(getActiveWithdrawalPolicy(stored, executedAt)); + + await withdrawTokens(this, 100 * 10 ** 6, oracleAccount.publicKey); + assert.equal( + await this.getTokenBalance(tokenMint, recipient.publicKey), + BigInt(100 * 10 ** 6), + ); + assert.deepEqual(await usage(this), NO_USAGE); + }); + + it("closes the request and pays its rent to the executor", async function () { + const changeRequest = await propose(this, { + minUnlockTimestamp: new BN(await clock(this)), + limits: null, + }); + const rent = BigInt( + (await this.banksClient.getAccount(changeRequest)).lamports, + ); + const balanceBefore = await this.banksClient.getBalance( + recipient.publicKey, + ); + + await execute(this, changeRequest, recipient); + + assert.isNull(await this.banksClient.getAccount(changeRequest)); + assert.equal( + await this.banksClient.getBalance(recipient.publicKey), + balanceBefore + rent, + ); + }); + }); } diff --git a/tests/priceBasedPerformancePackage/unit/initializePerformancePackage.test.ts b/tests/priceBasedPerformancePackage/unit/initializePerformancePackage.test.ts index a88207fdd..70452f184 100644 --- a/tests/priceBasedPerformancePackage/unit/initializePerformancePackage.test.ts +++ b/tests/priceBasedPerformancePackage/unit/initializePerformancePackage.test.ts @@ -167,6 +167,11 @@ export default function () { tokenMint.toString(), ); assert.exists(storedPerformancePackage.state.locked); + assert.isNull(storedPerformancePackage.withdrawalPolicy); + + const rawPerformancePackage = + await this.banksClient.getAccount(performancePackage); + assert.equal(rawPerformancePackage.data.length, 582); // Verify tokens were transferred const storedPerformancePackageTokenAccount = await getAccount( diff --git a/tests/priceBasedPerformancePackage/unit/initializePerformancePackageWithLimits.test.ts b/tests/priceBasedPerformancePackage/unit/initializePerformancePackageWithLimits.test.ts new file mode 100644 index 000000000..f935d0db7 --- /dev/null +++ b/tests/priceBasedPerformancePackage/unit/initializePerformancePackageWithLimits.test.ts @@ -0,0 +1,250 @@ +import { PublicKey, Keypair } from "@solana/web3.js"; +import { assert } from "chai"; +import BN from "bn.js"; +import { + getPerformancePackageAddr, + InitializePerformancePackageParams, + InitializePerformancePackageWithLimitsParams, + LimitsParams, + PriceBasedPerformancePackageClient, +} from "@metadaoproject/programs"; +import { expectError } from "../../utils.js"; + +const TRANCHE_AMOUNT = 100 * 10 ** 6; +const THIRTY_DAYS = 30 * 24 * 60 * 60; +const ONE_YEAR = 365 * 24 * 60 * 60; +const INVALID_LIMITS_MESSAGE = + "Withdrawal limits must have non-zero caps, a future end, and a window of at least one second"; + +type StoredPackage = Awaited< + ReturnType +>; + +export default function () { + let tokenMint: PublicKey; + let recipient: Keypair; + let oracleAccount: Keypair; + let now: number; + let base: InitializePerformancePackageParams; + let limits: LimitsParams; + + beforeEach(async function () { + recipient = Keypair.generate(); + oracleAccount = Keypair.generate(); + tokenMint = await this.createMint(this.payer.publicKey, 6); + await this.mintTo( + tokenMint, + this.payer.publicKey, + this.payer, + 10 * TRANCHE_AMOUNT, + ); + + now = Number((await this.banksClient.getClock()).unixTimestamp); + base = { + tranches: [ + { priceThreshold: new BN(1e12), tokenAmount: new BN(TRANCHE_AMOUNT) }, + { priceThreshold: new BN(2e12), tokenAmount: new BN(TRANCHE_AMOUNT) }, + ], + grantee: recipient.publicKey, + performancePackageAuthority: this.payer.publicKey, + minUnlockTimestamp: new BN(now + THIRTY_DAYS), + oracleConfig: { oracleAccount: oracleAccount.publicKey, byteOffset: 0 }, + twapLengthSeconds: 24 * 60 * 60, + }; + limits = { + endTimestamp: new BN(now + ONE_YEAR), + windowSeconds: THIRTY_DAYS, + maxTokensPerWindow: new BN(50 * 10 ** 6), + maxQuotePerWindow: new BN(1_000 * 10 ** 6), + withdrawalMode: { both: {} }, + }; + }); + + // Builds the instruction under a fresh create key and returns the package it creates. + function initializeWithLimitsIx( + ctx: Mocha.Context, + params: InitializePerformancePackageWithLimitsParams, + ) { + const createKey = Keypair.generate(); + const builder = ctx.priceBasedPerformancePackage + .initializePerformancePackageWithLimitsIx({ + params, + createKey: createKey.publicKey, + tokenMint, + grantor: ctx.payer.publicKey, + }) + .signers([createKey]); + + return { + builder, + performancePackage: getPerformancePackageAddr({ + createKey: createKey.publicKey, + })[0], + }; + } + + // Everything the two initialisers must agree on, as plain JSON so BNs and keys compare by value. + function sharedFields(pkg: StoredPackage) { + const { createKey, pdaBump, performancePackageTokenVault, ...rest } = pkg; + return JSON.parse(JSON.stringify(rest)); + } + + it("stores the limits anchored at the creation clock with zero usage", async function () { + const { builder, performancePackage } = initializeWithLimitsIx(this, { + base, + limits, + }); + await builder.rpc(); + + const stored = + await this.priceBasedPerformancePackage.getPerformancePackage( + performancePackage, + ); + const policy = stored.withdrawalPolicy; + assert.equal(policy.limits.startTimestamp.toString(), now.toString()); + assert.equal( + policy.limits.endTimestamp.toString(), + limits.endTimestamp.toString(), + ); + assert.equal(policy.limits.windowSeconds, THIRTY_DAYS); + assert.equal(policy.limits.maxTokensPerWindow.toString(), "50000000"); + assert.equal(policy.limits.maxQuotePerWindow.toString(), "1000000000"); + assert.isDefined(policy.limits.withdrawalMode.both); + assert.equal(policy.usage.windowIndex.toString(), "0"); + assert.equal(policy.usage.tokensUsed.toString(), "0"); + assert.equal(policy.usage.quoteUsed.toString(), "0"); + + assert.equal(stored.recipient.toString(), recipient.publicKey.toString()); + assert.equal( + stored.performancePackageAuthority.toString(), + this.payer.publicKey.toString(), + ); + assert.equal( + stored.minUnlockTimestamp.toString(), + base.minUnlockTimestamp.toString(), + ); + assert.equal( + stored.totalTokenAmount.toString(), + (2 * TRANCHE_AMOUNT).toString(), + ); + assert.equal(stored.alreadyUnlockedAmount.toString(), "0"); + assert.equal(stored.seqNum.toString(), "0"); + assert.isDefined(stored.state.locked); + assert.equal( + await this.getTokenBalance(tokenMint, performancePackage), + BigInt(2 * TRANCHE_AMOUNT), + ); + + const raw = await this.banksClient.getAccount(performancePackage); + assert.equal(raw.data.length, 582); + }); + + it("stores no policy without limits and otherwise matches the original instruction", async function () { + const { builder, performancePackage } = initializeWithLimitsIx(this, { + base, + limits: null, + }); + await builder.rpc(); + + const legacyCreateKey = Keypair.generate(); + await this.priceBasedPerformancePackage + .initializePerformancePackageIx({ + params: base, + createKey: legacyCreateKey.publicKey, + tokenMint, + grantor: this.payer.publicKey, + }) + .signers([legacyCreateKey]) + .rpc(); + const legacyPackage = getPerformancePackageAddr({ + createKey: legacyCreateKey.publicKey, + })[0]; + + const stored = + await this.priceBasedPerformancePackage.getPerformancePackage( + performancePackage, + ); + assert.isNull(stored.withdrawalPolicy); + assert.deepEqual( + sharedFields(stored), + sharedFields( + await this.priceBasedPerformancePackage.getPerformancePackage( + legacyPackage, + ), + ), + ); + + const raw = await this.banksClient.getAccount(performancePackage); + assert.equal(raw.data.length, 582); + }); + + it("rejects a zero token cap", async function () { + const callbacks = expectError( + "InvalidWithdrawalLimits", + INVALID_LIMITS_MESSAGE, + ); + + await initializeWithLimitsIx(this, { + base, + limits: { ...limits, maxTokensPerWindow: new BN(0) }, + }) + .builder.rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("rejects a zero quote cap", async function () { + const callbacks = expectError( + "InvalidWithdrawalLimits", + INVALID_LIMITS_MESSAGE, + ); + + await initializeWithLimitsIx(this, { + base, + limits: { ...limits, maxQuotePerWindow: new BN(0) }, + }) + .builder.rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("rejects an end timestamp at or before now", async function () { + const callbacks = expectError( + "InvalidWithdrawalLimits", + INVALID_LIMITS_MESSAGE, + ); + + await initializeWithLimitsIx(this, { + base, + limits: { ...limits, endTimestamp: new BN(now) }, + }) + .builder.rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("rejects a window of zero seconds", async function () { + const callbacks = expectError( + "InvalidWithdrawalLimits", + INVALID_LIMITS_MESSAGE, + ); + + await initializeWithLimitsIx(this, { + base, + limits: { ...limits, windowSeconds: 0 }, + }) + .builder.rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("still rejects a grantee equal to the authority", async function () { + const callbacks = expectError( + "RecipientAuthorityMustDiffer", + "Recipient and performance package authority must be different keys", + ); + + await initializeWithLimitsIx(this, { + base: { ...base, grantee: this.payer.publicKey }, + limits, + }) + .builder.rpc() + .then(callbacks[0], callbacks[1]); + }); +} diff --git a/tests/priceBasedPerformancePackage/unit/proposeChange.test.ts b/tests/priceBasedPerformancePackage/unit/proposeChange.test.ts index 23f5d8b59..51490c13e 100644 --- a/tests/priceBasedPerformancePackage/unit/proposeChange.test.ts +++ b/tests/priceBasedPerformancePackage/unit/proposeChange.test.ts @@ -6,7 +6,14 @@ import { } from "@solana/web3.js"; import { assert } from "chai"; import BN from "bn.js"; +import { LimitsParams } from "@metadaoproject/programs"; import { expectError } from "../../utils.js"; +import { setMockOracle } from "../utils.js"; + +const THIRTY_DAYS = 30 * 24 * 60 * 60; +const ONE_YEAR = 365 * 24 * 60 * 60; +const INVALID_LIMITS_MESSAGE = + "Withdrawal limits must have non-zero caps, a future end, and a window of at least one second"; export default function () { let createKey: Keypair; @@ -258,4 +265,197 @@ export default function () { ); assert.equal(changeRequest.changeType.oracle.newOracleConfig.byteOffset, 8); }); + + describe("with UnlockTerms", function () { + type UnlockTerms = { minUnlockTimestamp: BN; limits: LimitsParams | null }; + let now: number; + let limits: LimitsParams; + + beforeEach(async function () { + now = Number((await this.banksClient.getClock()).unixTimestamp); + limits = { + endTimestamp: new BN(now + ONE_YEAR), + windowSeconds: THIRTY_DAYS, + maxTokensPerWindow: new BN(50 * 10 ** 6), + maxQuotePerWindow: new BN(1_000 * 10 ** 6), + withdrawalMode: { both: {} }, + }; + }); + + // Builds the proposal under a fresh nonce. The authority (the payer) + // proposes unless a signer is given. + function proposeIx( + ctx: Mocha.Context, + terms: UnlockTerms, + proposer: Keypair | null = null, + ) { + const proposerKey = proposer?.publicKey ?? ctx.payer.publicKey; + const pdaNonce = Math.floor(Math.random() * 1_000_000); + const builder = ctx.priceBasedPerformancePackage + .proposeChangeIx({ + params: { changeType: { unlockTerms: terms }, pdaNonce }, + performancePackage, + proposer: proposerKey, + }) + .signers(proposer ? [proposer] : []); + const changeRequest = + ctx.priceBasedPerformancePackage.getChangeRequestAddress( + performancePackage, + proposerKey, + pdaNonce, + ); + return { builder, changeRequest }; + } + + // Plain JSON, so BNs and keys compare by value. + function asJson(value: unknown) { + return JSON.parse(JSON.stringify(value)); + } + + async function expectInvalidLimits( + ctx: Mocha.Context, + override: Partial, + ) { + const callbacks = expectError( + "InvalidWithdrawalLimits", + INVALID_LIMITS_MESSAGE, + ); + + await proposeIx(ctx, { + minUnlockTimestamp: new BN(now), + limits: { ...limits, ...override }, + }) + .builder.rpc() + .then(callbacks[0], callbacks[1]); + } + + it("stores the recipient's proposal", async function () { + const terms = { minUnlockTimestamp: new BN(now), limits }; + const { builder, changeRequest } = proposeIx(this, terms, recipient); + await builder.rpc(); + + const stored = + await this.priceBasedPerformancePackage.getChangeRequest(changeRequest); + assert.isDefined(stored.proposerType.recipient); + assert.equal( + stored.performancePackage.toString(), + performancePackage.toString(), + ); + assert.deepEqual( + asJson(stored.changeType), + asJson({ unlockTerms: terms }), + ); + }); + + it("stores the authority's proposal", async function () { + const terms = { minUnlockTimestamp: new BN(now + THIRTY_DAYS), limits }; + const { builder, changeRequest } = proposeIx(this, terms); + await builder.rpc(); + + const stored = + await this.priceBasedPerformancePackage.getChangeRequest(changeRequest); + assert.isDefined(stored.proposerType.authority); + assert.deepEqual( + asJson(stored.changeType), + asJson({ unlockTerms: terms }), + ); + }); + + it("is allowed while the package is unlocking, unlike an oracle change", async function () { + await this.advanceBySeconds(2); + await setMockOracle(this, oracleAccount.publicKey, { + aggregator: 1_000_000n, + }); + await this.priceBasedPerformancePackage + .startUnlockIx({ + performancePackage, + oracleAccount: oracleAccount.publicKey, + recipient: recipient.publicKey, + }) + .signers([recipient]) + .rpc(); + + const { builder, changeRequest } = proposeIx( + this, + { minUnlockTimestamp: new BN(now), limits }, + recipient, + ); + await builder.rpc(); + + const stored = + await this.priceBasedPerformancePackage.getChangeRequest(changeRequest); + assert.isDefined(stored.changeType.unlockTerms); + + const callbacks = expectError( + "InvalidPerformancePackageState", + "proposed an oracle change while unlocking", + ); + await this.priceBasedPerformancePackage + .proposeChangeIx({ + params: { + changeType: { + oracle: { + newOracleConfig: { + oracleAccount: Keypair.generate().publicKey, + byteOffset: 0, + }, + }, + }, + pdaNonce: Math.floor(Math.random() * 1_000_000), + }, + performancePackage, + proposer: recipient.publicKey, + }) + .signers([recipient]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("rejects a zero token cap", async function () { + await expectInvalidLimits(this, { maxTokensPerWindow: new BN(0) }); + }); + + it("rejects a zero quote cap", async function () { + await expectInvalidLimits(this, { maxQuotePerWindow: new BN(0) }); + }); + + it("rejects an end timestamp at or before now", async function () { + await expectInvalidLimits(this, { endTimestamp: new BN(now) }); + }); + + it("rejects a window of zero seconds", async function () { + await expectInvalidLimits(this, { windowSeconds: 0 }); + }); + + it("allows no limits and a cliff in the past", async function () { + const terms = { + minUnlockTimestamp: new BN(now - ONE_YEAR), + limits: null, + }; + const { builder, changeRequest } = proposeIx(this, terms); + await builder.rpc(); + + const stored = + await this.priceBasedPerformancePackage.getChangeRequest(changeRequest); + assert.deepEqual( + asJson(stored.changeType), + asJson({ unlockTerms: terms }), + ); + }); + + it("rejects a third party", async function () { + const callbacks = expectError( + "UnauthorizedChangeRequest", + "an outsider proposed unlock terms", + ); + + await proposeIx( + this, + { minUnlockTimestamp: new BN(now), limits }, + Keypair.generate(), + ) + .builder.rpc() + .then(callbacks[0], callbacks[1]); + }); + }); } diff --git a/tests/priceBasedPerformancePackage/unit/resizePerformancePackage.test.ts b/tests/priceBasedPerformancePackage/unit/resizePerformancePackage.test.ts new file mode 100644 index 000000000..8ac8eb0ee --- /dev/null +++ b/tests/priceBasedPerformancePackage/unit/resizePerformancePackage.test.ts @@ -0,0 +1,486 @@ +import { + PublicKey, + Keypair, + Transaction, + SystemProgram, + ComputeBudgetProgram, +} from "@solana/web3.js"; +import { assert } from "chai"; +import BN from "bn.js"; +import { getAssociatedTokenAddressSync } from "@solana/spl-token"; +import { expectError } from "../../utils.js"; +import { writeOldLayoutPackage } from "../utils.js"; + +const OLD_SIZE = 520; +const NEW_SIZE = 582; + +// Plain JSON view of a fetched package so two snapshots can be deep-compared. +const snapshot = (performancePackage: any) => + JSON.parse(JSON.stringify(performancePackage)); + +export default function () { + let tokenMint: PublicKey; + let recipient: Keypair; + let oracleAccount: Keypair; + let rentPayer: Keypair; + let performancePackage: PublicKey; + + beforeEach(async function () { + recipient = Keypair.generate(); + oracleAccount = Keypair.generate(); + rentPayer = Keypair.generate(); + + const fundTx = new Transaction().add( + SystemProgram.transfer({ + fromPubkey: this.payer.publicKey, + toPubkey: rentPayer.publicKey, + lamports: 1_000_000_000, + }), + ); + fundTx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + fundTx.sign(this.payer); + await this.banksClient.processTransaction(fundTx); + + tokenMint = await this.createMint(this.payer.publicKey, 6); + await this.createTokenAccount(tokenMint, this.payer.publicKey); + await this.mintTo( + tokenMint, + this.payer.publicKey, + this.payer, + 200 * 10 ** 6, + ); + + performancePackage = await this.setupBasicPerformancePackage({ + tokenMint, + oracleAccount: oracleAccount.publicKey, + recipient: recipient.publicKey, + }); + }); + + it("restores the current layout from an old-layout package", async function () { + const before = + await this.priceBasedPerformancePackage.getPerformancePackage( + performancePackage, + ); + + await writeOldLayoutPackage(this, performancePackage); + const truncated = await this.banksClient.getAccount(performancePackage); + assert.equal(truncated.data.length, OLD_SIZE); + + const rent = await this.banksClient.getRent(); + const rentPayerBefore = await this.banksClient.getBalance( + rentPayer.publicKey, + ); + + await this.priceBasedPerformancePackage + .resizePerformancePackageIx({ + performancePackage, + payer: rentPayer.publicKey, + }) + .signers([rentPayer]) + .rpc(); + + const resized = await this.banksClient.getAccount(performancePackage); + assert.equal(resized.data.length, NEW_SIZE); + assert.equal( + BigInt(resized.lamports), + rent.minimumBalance(BigInt(NEW_SIZE)), + ); + + const rentPayerAfter = await this.banksClient.getBalance( + rentPayer.publicKey, + ); + assert.equal( + rentPayerBefore - rentPayerAfter, + rent.minimumBalance(BigInt(NEW_SIZE)) - + rent.minimumBalance(BigInt(OLD_SIZE)), + ); + + const after = + await this.priceBasedPerformancePackage.getPerformancePackage( + performancePackage, + ); + assert.isNull(after.withdrawalPolicy); + assert.deepEqual(snapshot(after), snapshot(before)); + }); + + it("is a no-op on a package that is already resized", async function () { + await writeOldLayoutPackage(this, performancePackage); + await this.priceBasedPerformancePackage + .resizePerformancePackageIx({ + performancePackage, + payer: this.payer.publicKey, + }) + .rpc(); + + const before = + await this.priceBasedPerformancePackage.getPerformancePackage( + performancePackage, + ); + const lamportsBefore = ( + await this.banksClient.getAccount(performancePackage) + ).lamports; + const rentPayerBefore = await this.banksClient.getBalance( + rentPayer.publicKey, + ); + + await this.priceBasedPerformancePackage + .resizePerformancePackageIx({ + performancePackage, + payer: rentPayer.publicKey, + }) + .signers([rentPayer]) + .rpc(); + + const account = await this.banksClient.getAccount(performancePackage); + assert.equal(account.data.length, NEW_SIZE); + assert.equal(account.lamports, lamportsBefore); + assert.equal( + await this.banksClient.getBalance(rentPayer.publicKey), + rentPayerBefore, + ); + + const after = + await this.priceBasedPerformancePackage.getPerformancePackage( + performancePackage, + ); + assert.deepEqual(snapshot(after), snapshot(before)); + }); + + it("rejects an account owned by another program", async function () { + const vault = getAssociatedTokenAddressSync( + tokenMint, + performancePackage, + true, + ); + + const callbacks = expectError( + "AccountOwnedByWrongProgram", + "resized an account owned by another program", + ); + + await this.priceBasedPerformancePackage + .resizePerformancePackageIx({ + performancePackage: vault, + payer: this.payer.publicKey, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("rejects an account of this program with another discriminator", async function () { + const pdaNonce = 1; + await this.priceBasedPerformancePackage + .proposeChangeIx({ + performancePackage, + proposer: this.payer.publicKey, + params: { + changeType: { + recipient: { newRecipient: Keypair.generate().publicKey }, + }, + pdaNonce, + }, + }) + .rpc(); + const changeRequest = + this.priceBasedPerformancePackage.getChangeRequestAddress( + performancePackage, + this.payer.publicKey, + pdaNonce, + ); + + const callbacks = expectError( + "AccountDiscriminatorMismatch", + "resized a change request", + ); + + await this.priceBasedPerformancePackage + .resizePerformancePackageIx({ + performancePackage: changeRequest, + payer: this.payer.publicKey, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("rejects a package whose size is neither the old nor the current layout", async function () { + const account = await this.banksClient.getAccount(performancePackage); + this.context.setAccount(performancePackage, { + ...account, + data: account.data.slice(0, 500), + }); + + const callbacks = expectError( + "AccountDidNotDeserialize", + "resized a package of unknown size", + ); + + await this.priceBasedPerformancePackage + .resizePerformancePackageIx({ + performancePackage, + payer: this.payer.publicKey, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("creates new packages at the current size", async function () { + const account = await this.banksClient.getAccount(performancePackage); + assert.equal(account.data.length, NEW_SIZE); + + const storedPerformancePackage = + await this.priceBasedPerformancePackage.getPerformancePackage( + performancePackage, + ); + assert.isNull(storedPerformancePackage.withdrawalPolicy); + }); + + // Writes a 24-byte mock oracle: aggregator u128 at 0, last updated timestamp i64 at 16. + async function setOracle(ctx: Mocha.Context, aggregator: bigint) { + const data = Buffer.alloc(24); + data.writeBigUInt64LE(aggregator, 0); + data.writeBigInt64LE( + BigInt((await ctx.banksClient.getClock()).unixTimestamp), + 16, + ); + ctx.context.setAccount(oracleAccount.publicKey, { + executable: false, + owner: SystemProgram.programId, + lamports: 1_000_000_000, + data, + }); + } + + // Runs the instruction against a truncated package, expects AccountNotMigrated, + // then resizes the package and runs the same instruction again. + async function assertGatedUntilResized( + ctx: Mocha.Context, + buildIx: () => any, + signers: Keypair[] = [], + ) { + await writeOldLayoutPackage(ctx, performancePackage); + + const callbacks = expectError( + "AccountNotMigrated", + "ran an instruction against a truncated package", + ); + await buildIx().signers(signers).rpc().then(callbacks[0], callbacks[1]); + + await ctx.priceBasedPerformancePackage + .resizePerformancePackageIx({ + performancePackage, + payer: ctx.payer.publicKey, + }) + .rpc(); + + await buildIx() + .preInstructions([ + // A different compute-unit price makes the transaction hash unique so the + // retry is not rejected as already processed. + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ]) + .signers(signers) + .rpc(); + } + + it("gates start_unlock until the package is resized", async function () { + await this.advanceBySeconds(2); + await setOracle(this, BigInt(1e12)); + + await assertGatedUntilResized( + this, + () => + this.priceBasedPerformancePackage.startUnlockIx({ + performancePackage, + oracleAccount: oracleAccount.publicKey, + recipient: recipient.publicKey, + }), + [recipient], + ); + + const after = + await this.priceBasedPerformancePackage.getPerformancePackage( + performancePackage, + ); + assert.isDefined(after.state.unlocking); + }); + + it("gates complete_unlock until the package is resized", async function () { + await this.advanceBySeconds(2); + await setOracle(this, BigInt(1e12)); + await this.priceBasedPerformancePackage + .startUnlockIx({ + performancePackage, + oracleAccount: oracleAccount.publicKey, + recipient: recipient.publicKey, + }) + .signers([recipient]) + .rpc(); + + await this.advanceBySeconds(86_400); + await setOracle(this, BigInt(2 * 86_400 + 1) * BigInt(1e12)); + + await assertGatedUntilResized(this, () => + this.priceBasedPerformancePackage.completeUnlockIx({ + performancePackage, + oracleAccount: oracleAccount.publicKey, + }), + ); + + const after = + await this.priceBasedPerformancePackage.getPerformancePackage( + performancePackage, + ); + assert.equal( + after.alreadyUnlockedAmount.toString(), + (200 * 10 ** 6).toString(), + ); + }); + + it("gates withdraw_tokens until the package is resized", async function () { + await this.advanceBySeconds(2); + await setOracle(this, BigInt(1e12)); + await this.priceBasedPerformancePackage + .startUnlockIx({ + performancePackage, + oracleAccount: oracleAccount.publicKey, + recipient: recipient.publicKey, + }) + .signers([recipient]) + .rpc(); + + await this.advanceBySeconds(86_400); + await setOracle(this, BigInt(2 * 86_400 + 1) * BigInt(1e12)); + await this.priceBasedPerformancePackage + .completeUnlockIx({ + performancePackage, + oracleAccount: oracleAccount.publicKey, + }) + .rpc(); + + await assertGatedUntilResized( + this, + () => + this.priceBasedPerformancePackage.withdrawTokensIx({ + performancePackage, + oracleAccount: oracleAccount.publicKey, + tokenMint, + recipient: recipient.publicKey, + amount: new BN(200 * 10 ** 6), + }), + [recipient], + ); + + assert.equal( + await this.getTokenBalance(tokenMint, recipient.publicKey), + BigInt(200 * 10 ** 6), + ); + }); + + it("gates propose_change until the package is resized", async function () { + const newRecipient = Keypair.generate(); + const pdaNonce = 1; + + await assertGatedUntilResized( + this, + () => + this.priceBasedPerformancePackage.proposeChangeIx({ + performancePackage, + proposer: recipient.publicKey, + params: { + changeType: { + recipient: { newRecipient: newRecipient.publicKey }, + }, + pdaNonce, + }, + }), + [recipient], + ); + + const changeRequest = + await this.priceBasedPerformancePackage.getChangeRequest( + this.priceBasedPerformancePackage.getChangeRequestAddress( + performancePackage, + recipient.publicKey, + pdaNonce, + ), + ); + assert.equal( + changeRequest.performancePackage.toString(), + performancePackage.toString(), + ); + }); + + it("gates execute_change until the package is resized", async function () { + const newRecipient = Keypair.generate(); + const pdaNonce = 1; + await this.priceBasedPerformancePackage + .proposeChangeIx({ + performancePackage, + proposer: recipient.publicKey, + params: { + changeType: { + recipient: { newRecipient: newRecipient.publicKey }, + }, + pdaNonce, + }, + }) + .signers([recipient]) + .rpc(); + const changeRequest = + this.priceBasedPerformancePackage.getChangeRequestAddress( + performancePackage, + recipient.publicKey, + pdaNonce, + ); + + await assertGatedUntilResized(this, () => + this.priceBasedPerformancePackage.executeChangeIx({ + performancePackage, + changeRequest, + executor: this.payer.publicKey, + }), + ); + + const after = + await this.priceBasedPerformancePackage.getPerformancePackage( + performancePackage, + ); + assert.equal(after.recipient.toString(), newRecipient.publicKey.toString()); + assert.isNull(await this.banksClient.getAccount(changeRequest)); + }); + + it("gates change_performance_package_authority until the package is resized", async function () { + const newAuthority = Keypair.generate(); + + await assertGatedUntilResized(this, () => + this.priceBasedPerformancePackage.changePerformancePackageAuthorityIx({ + performancePackage, + currentAuthority: this.payer.publicKey, + newPerformancePackageAuthority: newAuthority.publicKey, + }), + ); + + const after = + await this.priceBasedPerformancePackage.getPerformancePackage( + performancePackage, + ); + assert.equal( + after.performancePackageAuthority.toString(), + newAuthority.publicKey.toString(), + ); + }); + + it("gates burn_performance_package until the package is resized", async function () { + await assertGatedUntilResized(this, () => + this.priceBasedPerformancePackage.burnPerformancePackageIx({ + performancePackage, + tokenMint, + recipient: recipient.publicKey, + admin: this.payer.publicKey, + }), + ); + + assert.isNull(await this.banksClient.getAccount(performancePackage)); + }); +} diff --git a/tests/priceBasedPerformancePackage/unit/withdrawTokens.test.ts b/tests/priceBasedPerformancePackage/unit/withdrawTokens.test.ts new file mode 100644 index 000000000..a169965af --- /dev/null +++ b/tests/priceBasedPerformancePackage/unit/withdrawTokens.test.ts @@ -0,0 +1,780 @@ +import { + ComputeBudgetProgram, + PublicKey, + Keypair, + Transaction, + SystemProgram, +} from "@solana/web3.js"; +import { assert } from "chai"; +import BN from "bn.js"; +import { ACCOUNT_SIZE, getAssociatedTokenAddressSync } from "@solana/spl-token"; +import { LimitsParams, Tranche } from "@metadaoproject/programs"; +import { + getActiveWithdrawalPolicy, + getMaxTokenWithdrawal, +} from "@metadaoproject/programs/price_based_performance_package/v0.6/withdrawalLimits"; +import { expectError } from "../../utils.js"; +import { + runUnlockCycle, + setDaoOracle, + setMockOracle, + setupPackageOnDao, +} from "../utils.js"; + +const TRANCHE_AMOUNT = 100 * 10 ** 6; +const TOTAL_AMOUNT = 2 * TRANCHE_AMOUNT; + +// Capped setup: 2,580,000 tokens unlocked, at most 645,000 tokens or 100,000 USDC +// per 30-day window, priced at $0.072752391878 per token. The pool's reserve price +// sits just below the observation, so the observation is used. +const CAPPED_TRANCHE_AMOUNT = 2_580_000 * 10 ** 6; +const TOKEN_CAP = 645_000 * 10 ** 6; +const QUOTE_CAP = 100_000 * 10 ** 6; +const OBSERVATION = 72_752_391_878n; +const TOKEN_CAP_QUOTE_VALUE = "46925292762"; +const ONE_MILLION_TOKENS = 1_000_000n * 10n ** 6n; +const RESERVES_BELOW_OBSERVATION = { + base: ONE_MILLION_TOKENS, + quote: 72_000_000_000n, +}; +const ONE_HOUR = 60 * 60; +const THIRTY_DAYS = 30 * 24 * ONE_HOUR; +const ONE_YEAR = 365 * 24 * ONE_HOUR; + +export default function () { + let tokenMint: PublicKey; + let recipient: Keypair; + let rentPayer: Keypair; + let performancePackage: PublicKey; + let oracle: PublicKey; + + beforeEach(async function () { + recipient = Keypair.generate(); + rentPayer = Keypair.generate(); + + const fundTx = new Transaction().add( + SystemProgram.transfer({ + fromPubkey: this.payer.publicKey, + toPubkey: rentPayer.publicKey, + lamports: 1_000_000_000, + }), + ); + fundTx.recentBlockhash = (await this.banksClient.getLatestBlockhash())[0]; + fundTx.sign(this.payer); + await this.banksClient.processTransaction(fundTx); + }); + + function withdrawTokensIx( + ctx: Mocha.Context, + amount: number, + overrides: { recipient?: PublicKey; payer?: PublicKey } = {}, + ) { + return ctx.priceBasedPerformancePackage.withdrawTokensIx({ + performancePackage, + oracleAccount: oracle, + tokenMint, + recipient: overrides.recipient ?? recipient.publicKey, + payer: overrides.payer, + amount: new BN(amount), + }); + } + + function storedPackage(ctx: Mocha.Context) { + return ctx.priceBasedPerformancePackage.getPerformancePackage( + performancePackage, + ); + } + + // The SDK's maximum token withdrawal at the current clock. The Dao is only + // fetched while limits are active, as the program only reads it then. + async function maxWithdrawal(ctx: Mocha.Context) { + const stored = await storedPackage(ctx); + const now = Number((await ctx.banksClient.getClock()).unixTimestamp); + const vaultAmount = new BN( + (await ctx.getTokenBalance(tokenMint, performancePackage)).toString(), + ); + const dao = getActiveWithdrawalPolicy(stored, now) + ? await ctx.futarchy.getDao(oracle) + : undefined; + + return getMaxTokenWithdrawal({ + performancePackage: stored, + vaultAmount, + now, + dao, + }).toString(); + } + + describe("without limits", function () { + beforeEach(async function () { + oracle = Keypair.generate().publicKey; + tokenMint = await this.createMint(this.payer.publicKey, 6); + await this.mintTo( + tokenMint, + this.payer.publicKey, + this.payer, + TOTAL_AMOUNT, + ); + + performancePackage = await this.setupBasicPerformancePackage({ + tokenMint, + oracleAccount: oracle, + recipient: recipient.publicKey, + }); + + // Move past the one-second cliff + await this.advanceBySeconds(2); + }); + + // Unlocks the first tranche, leaving TRANCHE_AMOUNT withdrawable. + async function unlockFirstTranche(ctx: Mocha.Context) { + await runUnlockCycle(ctx, { + performancePackage, + oracleAccount: oracle, + recipient, + twapPrice: BigInt(1e12), + }); + } + + it("delivers the whole withdrawable balance and creates the recipient's ATA at the payer's expense", async function () { + await unlockFirstTranche(this); + + const recipientTokenAccount = getAssociatedTokenAddressSync( + tokenMint, + recipient.publicKey, + ); + assert.isNull(await this.banksClient.getAccount(recipientTokenAccount)); + + const seqNumBefore = (await storedPackage(this)).seqNum.toNumber(); + const rentPayerBefore = await this.banksClient.getBalance( + rentPayer.publicKey, + ); + + await withdrawTokensIx(this, TRANCHE_AMOUNT, { + payer: rentPayer.publicKey, + }) + .signers([recipient, rentPayer]) + .rpc(); + + assert.equal( + await this.getTokenBalance(tokenMint, recipient.publicKey), + BigInt(TRANCHE_AMOUNT), + ); + assert.equal( + await this.getTokenBalance(tokenMint, performancePackage), + BigInt(TRANCHE_AMOUNT), + ); + + const stored = await storedPackage(this); + assert.equal(stored.seqNum.toNumber(), seqNumBefore + 1); + assert.equal( + stored.alreadyUnlockedAmount.toString(), + TRANCHE_AMOUNT.toString(), + ); + + const rent = await this.banksClient.getRent(); + assert.equal( + rentPayerBefore - + (await this.banksClient.getBalance(rentPayer.publicKey)), + rent.minimumBalance(BigInt(ACCOUNT_SIZE)), + ); + }); + + it("delivers part of the balance and then the rest", async function () { + await unlockFirstTranche(this); + assert.equal(await maxWithdrawal(this), TRANCHE_AMOUNT.toString()); + + await withdrawTokensIx(this, 40 * 10 ** 6) + .signers([recipient]) + .rpc(); + assert.equal( + await this.getTokenBalance(tokenMint, recipient.publicKey), + BigInt(40 * 10 ** 6), + ); + assert.equal(await maxWithdrawal(this), (60 * 10 ** 6).toString()); + + await withdrawTokensIx(this, 60 * 10 ** 6) + .signers([recipient]) + .rpc(); + assert.equal( + await this.getTokenBalance(tokenMint, recipient.publicKey), + BigInt(TRANCHE_AMOUNT), + ); + assert.equal( + await this.getTokenBalance(tokenMint, performancePackage), + BigInt(TOTAL_AMOUNT - TRANCHE_AMOUNT), + ); + assert.equal(await maxWithdrawal(this), "0"); + }); + + it("rejects one atom more than the withdrawable balance", async function () { + await unlockFirstTranche(this); + + const callbacks = expectError( + "InsufficientWithdrawableBalance", + "withdrew more than the unlocked amount", + ); + + await withdrawTokensIx(this, TRANCHE_AMOUNT + 1) + .signers([recipient]) + .rpc() + .then(callbacks[0], callbacks[1]); + + assert.equal( + await this.getTokenBalance(tokenMint, performancePackage), + BigInt(TOTAL_AMOUNT), + ); + }); + + it("rejects any amount while nothing is unlocked", async function () { + const callbacks = expectError( + "InsufficientWithdrawableBalance", + "withdrew from a package with nothing unlocked", + ); + + await withdrawTokensIx(this, 1) + .signers([recipient]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("rejects a zero amount", async function () { + await unlockFirstTranche(this); + + const callbacks = expectError( + "RequireGtViolated", + "withdrew zero tokens", + ); + + await withdrawTokensIx(this, 0) + .signers([recipient]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("rejects a signer other than the recipient", async function () { + await unlockFirstTranche(this); + const other = Keypair.generate(); + + const callbacks = expectError( + "ConstraintHasOne", + "a non-recipient withdrew tokens", + ); + + await withdrawTokensIx(this, TRANCHE_AMOUNT, { + recipient: other.publicKey, + }) + .signers([other]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("is allowed while the package is unlocking", async function () { + await unlockFirstTranche(this); + + await setMockOracle(this, oracle, { aggregator: BigInt(1e12) }); + await this.priceBasedPerformancePackage + .startUnlockIx({ + performancePackage, + oracleAccount: oracle, + recipient: recipient.publicKey, + }) + .signers([recipient]) + .rpc(); + + await withdrawTokensIx(this, TRANCHE_AMOUNT).signers([recipient]).rpc(); + + const stored = await storedPackage(this); + assert.isDefined(stored.state.unlocking); + assert.equal( + await this.getTokenBalance(tokenMint, recipient.publicKey), + BigInt(TRANCHE_AMOUNT), + ); + }); + }); + + describe("under a withdrawal policy", function () { + let startTimestamp: number; + let withdrawalCount = 0; + + // Creates a package with the limits above on a fresh mint, with a + // Dao as its oracle, unlocks the first tranche and patches the Dao's spot + // pool to `observation` and `reserves`. + async function setupCappedPackage( + ctx: Mocha.Context, + { + limits = {}, + tranches = [ + { + priceThreshold: new BN(1e12), + tokenAmount: new BN(CAPPED_TRANCHE_AMOUNT), + }, + { + priceThreshold: new BN(2e12), + tokenAmount: new BN(CAPPED_TRANCHE_AMOUNT), + }, + ], + observation = OBSERVATION, + reserves = RESERVES_BELOW_OBSERVATION, + }: { + limits?: Partial; + tranches?: Tranche[]; + observation?: bigint; + reserves?: { base: bigint; quote: bigint }; + } = {}, + ) { + tokenMint = await ctx.createMint(ctx.payer.publicKey, 6); + const quoteMint = await ctx.createMint(ctx.payer.publicKey, 6); + await ctx.mintTo( + tokenMint, + ctx.payer.publicKey, + ctx.payer, + tranches.reduce( + (sum, tranche) => sum + tranche.tokenAmount.toNumber(), + 0, + ), + ); + + const now = Number((await ctx.banksClient.getClock()).unixTimestamp); + ({ dao: oracle, performancePackage } = await setupPackageOnDao(ctx, { + tokenMint, + quoteMint, + recipient: recipient.publicKey, + tranches, + limits: { + endTimestamp: new BN(now + ONE_YEAR), + windowSeconds: THIRTY_DAYS, + maxTokensPerWindow: new BN(TOKEN_CAP), + maxQuotePerWindow: new BN(QUOTE_CAP), + withdrawalMode: { both: {} }, + ...limits, + }, + })); + startTimestamp = ( + await storedPackage(ctx) + ).withdrawalPolicy.limits.startTimestamp.toNumber(); + + // Move past the one-second cliff + await ctx.advanceBySeconds(2); + await runUnlockCycle(ctx, { + performancePackage, + oracleAccount: oracle, + recipient, + twapPrice: BigInt(1e12), + writeOracle: (values) => setDaoOracle(ctx, oracle, values), + }); + await setDaoOracle(ctx, oracle, { + lastObservation: observation, + reserves, + }); + } + + // A distinct compute-unit price on every call gives repeated identical + // withdrawals different transaction hashes, so none is rejected as a duplicate. + function withdraw(ctx: Mocha.Context, amount: number) { + withdrawalCount += 1; + return withdrawTokensIx(ctx, amount) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitPrice({ + microLamports: withdrawalCount, + }), + ]) + .signers([recipient]) + .rpc(); + } + + async function expectWithdrawError( + ctx: Mocha.Context, + amount: number, + error: string, + message: string, + ) { + const callbacks = expectError(error, message); + await withdraw(ctx, amount).then(callbacks[0], callbacks[1]); + } + + async function usage(ctx: Mocha.Context) { + const { usage } = (await storedPackage(ctx)).withdrawalPolicy; + return { + windowIndex: usage.windowIndex.toString(), + tokensUsed: usage.tokensUsed.toString(), + quoteUsed: usage.quoteUsed.toString(), + }; + } + + async function advanceTo(ctx: Mocha.Context, timestamp: number) { + const now = Number((await ctx.banksClient.getClock()).unixTimestamp); + await ctx.advanceBySeconds(timestamp - now); + } + + it("ignores an expired policy, its mode included, and does not read the oracle", async function () { + const now = Number((await this.banksClient.getClock()).unixTimestamp); + await setupCappedPackage(this, { + limits: { + endTimestamp: new BN(now + 1), + withdrawalMode: { sell: {} }, + }, + }); + await setMockOracle(this, oracle, { aggregator: 0n }); + assert.equal(await maxWithdrawal(this), CAPPED_TRANCHE_AMOUNT.toString()); + + await withdraw(this, CAPPED_TRANCHE_AMOUNT); + + assert.equal( + await this.getTokenBalance(tokenMint, recipient.publicKey), + BigInt(CAPPED_TRANCHE_AMOUNT), + ); + assert.deepEqual(await usage(this), { + windowIndex: "0", + tokensUsed: "0", + quoteUsed: "0", + }); + }); + + it("delivers 645,000 tokens valued at the observation", async function () { + await setupCappedPackage(this); + const seqNumBefore = (await storedPackage(this)).seqNum.toNumber(); + assert.equal(await maxWithdrawal(this), TOKEN_CAP.toString()); + + await withdraw(this, TOKEN_CAP); + + assert.equal( + await this.getTokenBalance(tokenMint, recipient.publicKey), + BigInt(TOKEN_CAP), + ); + assert.equal( + await this.getTokenBalance(tokenMint, performancePackage), + BigInt(2 * CAPPED_TRANCHE_AMOUNT - TOKEN_CAP), + ); + assert.deepEqual(await usage(this), { + windowIndex: "0", + tokensUsed: TOKEN_CAP.toString(), + quoteUsed: TOKEN_CAP_QUOTE_VALUE, + }); + assert.equal( + (await storedPackage(this)).seqNum.toNumber(), + seqNumBefore + 1, + ); + assert.equal(await maxWithdrawal(this), "0"); + }); + + it("rejects one atom over the token cap in the same window", async function () { + await setupCappedPackage(this); + await withdraw(this, TOKEN_CAP); + assert.equal(await maxWithdrawal(this), "0"); + + await expectWithdrawError( + this, + 1, + "TokenWindowLimitExceeded", + "withdrew past the window's token cap", + ); + + assert.deepEqual(await usage(this), { + windowIndex: "0", + tokensUsed: TOKEN_CAP.toString(), + quoteUsed: TOKEN_CAP_QUOTE_VALUE, + }); + }); + + it("frees the cap and resets usage in the next window", async function () { + await setupCappedPackage(this); + await withdraw(this, TOKEN_CAP); + + await this.advanceBySeconds(THIRTY_DAYS); + assert.equal(await maxWithdrawal(this), TOKEN_CAP.toString()); + await withdraw(this, TOKEN_CAP); + + assert.equal( + await this.getTokenBalance(tokenMint, recipient.publicKey), + BigInt(2 * TOKEN_CAP), + ); + assert.deepEqual(await usage(this), { + windowIndex: "1", + tokensUsed: TOKEN_CAP.toString(), + quoteUsed: TOKEN_CAP_QUOTE_VALUE, + }); + }); + + it("binds on the quote cap when the reserve price is above the observation", async function () { + await setupCappedPackage(this, { + reserves: { base: ONE_MILLION_TOKENS, quote: 465_000_000_000n }, + }); + assert.equal(await maxWithdrawal(this), "215053763440"); + + await withdraw(this, 215_053_763_440); + assert.deepEqual(await usage(this), { + windowIndex: "0", + tokensUsed: "215053763440", + quoteUsed: QUOTE_CAP.toString(), + }); + assert.equal(await maxWithdrawal(this), "0"); + + await expectWithdrawError( + this, + 1, + "QuoteWindowLimitExceeded", + "withdrew past the window's quote cap", + ); + }); + + it("values at the observation when the reserve price has been dumped below it", async function () { + await setupCappedPackage(this, { + reserves: { base: ONE_MILLION_TOKENS, quote: 1_000_000_000n }, + }); + + await withdraw(this, TOKEN_CAP); + + assert.equal((await usage(this)).quoteUsed, TOKEN_CAP_QUOTE_VALUE); + }); + + it("values at the observation when the pool has no base reserves", async function () { + await setupCappedPackage(this, { + reserves: { base: 0n, quote: 1_000_000n }, + }); + assert.equal(await maxWithdrawal(this), TOKEN_CAP.toString()); + + await withdraw(this, TOKEN_CAP); + + assert.equal((await usage(this)).quoteUsed, TOKEN_CAP_QUOTE_VALUE); + }); + + it("shares the window's counters across withdrawals", async function () { + await setupCappedPackage(this); + + assert.equal(await maxWithdrawal(this), TOKEN_CAP.toString()); + await withdraw(this, 200_000 * 10 ** 6); + assert.equal(await maxWithdrawal(this), (445_000 * 10 ** 6).toString()); + await withdraw(this, 200_000 * 10 ** 6); + assert.equal(await maxWithdrawal(this), (245_000 * 10 ** 6).toString()); + await withdraw(this, 245_000 * 10 ** 6); + assert.equal(await maxWithdrawal(this), "0"); + + // Each withdrawal's value is rounded up on its own + assert.deepEqual(await usage(this), { + windowIndex: "0", + tokensUsed: TOKEN_CAP.toString(), + quoteUsed: "46925292763", + }); + + await expectWithdrawError( + this, + 1, + "TokenWindowLimitExceeded", + "withdrew past the token cap shared by earlier withdrawals", + ); + }); + + it("allows a full cap on each side of a window boundary", async function () { + await setupCappedPackage(this); + + await advanceTo(this, startTimestamp + THIRTY_DAYS - 1); + assert.equal(await maxWithdrawal(this), TOKEN_CAP.toString()); + await withdraw(this, TOKEN_CAP); + assert.equal((await usage(this)).windowIndex, "0"); + assert.equal(await maxWithdrawal(this), "0"); + + await this.advanceBySeconds(2); + assert.equal(await maxWithdrawal(this), TOKEN_CAP.toString()); + await withdraw(this, TOKEN_CAP); + + assert.equal( + await this.getTokenBalance(tokenMint, recipient.publicKey), + BigInt(2 * TOKEN_CAP), + ); + assert.deepEqual(await usage(this), { + windowIndex: "1", + tokensUsed: TOKEN_CAP.toString(), + quoteUsed: TOKEN_CAP_QUOTE_VALUE, + }); + }); + + it("rounds the quote value up", async function () { + await setupCappedPackage(this); + + // One token is worth 72,752.391878 quote atoms at the observation + await withdraw(this, 1 * 10 ** 6); + + assert.equal((await usage(this)).quoteUsed, "72753"); + }); + + it("rejects the token route under mode Sell", async function () { + await setupCappedPackage(this, { + limits: { withdrawalMode: { sell: {} } }, + }); + assert.equal(await maxWithdrawal(this), "0"); + + await expectWithdrawError( + this, + 1, + "WithdrawTokensDisabled", + "withdrew tokens under mode Sell", + ); + }); + + it("allows the token route under modes Tokens and Both", async function () { + const modes: LimitsParams["withdrawalMode"][] = [ + { tokens: {} }, + { both: {} }, + ]; + + for (const withdrawalMode of modes) { + await setupCappedPackage(this, { limits: { withdrawalMode } }); + assert.equal(await maxWithdrawal(this), TOKEN_CAP.toString()); + + await withdraw(this, TOKEN_CAP); + + assert.equal((await usage(this)).tokensUsed, TOKEN_CAP.toString()); + } + }); + + it("rejects a zero observation even with a reserve price", async function () { + await setupCappedPackage(this, { observation: 0n }); + await maxWithdrawal(this).then( + () => + assert.fail( + "computed a maximum withdrawal against a zero observation", + ), + (error: Error) => assert.include(error.message, "no price observation"), + ); + + await expectWithdrawError( + this, + 1, + "InvalidPriceObservation", + "withdrew against a zero observation", + ); + }); + + it("rejects an oracle account that is not a Dao", async function () { + await setupCappedPackage(this); + await setMockOracle(this, oracle, { aggregator: 0n }); + + await expectWithdrawError( + this, + 1, + "AccountOwnedByWrongProgram", + "withdrew against a mock oracle", + ); + }); + + it("rejects an oracle Dao whose base mint is another token", async function () { + tokenMint = await this.createMint(this.payer.publicKey, 6); + await this.mintTo( + tokenMint, + this.payer.publicKey, + this.payer, + TOTAL_AMOUNT, + ); + const otherMint = await this.createMint(this.payer.publicKey, 6); + const quoteMint = await this.createMint(this.payer.publicKey, 6); + oracle = await this.setupBasicDao({ baseMint: otherMint, quoteMint }); + const now = Number((await this.banksClient.getClock()).unixTimestamp); + performancePackage = await this.setupBasicPerformancePackage({ + tokenMint, + oracleAccount: oracle, + byteOffset: 9, + recipient: recipient.publicKey, + limits: { + endTimestamp: new BN(now + ONE_YEAR), + windowSeconds: THIRTY_DAYS, + maxTokensPerWindow: new BN(TOKEN_CAP), + maxQuotePerWindow: new BN(QUOTE_CAP), + withdrawalMode: { both: {} }, + }, + }); + // Tokens donated to the vault are withdrawable without an unlock + await this.mintTo(tokenMint, performancePackage, this.payer, TOKEN_CAP); + + await maxWithdrawal(this).then( + () => assert.fail("computed a maximum against another token's Dao"), + (error: Error) => + assert.include(error.message, "not the package's token mint"), + ); + + await expectWithdrawError( + this, + 1, + "OracleMintMismatch", + "withdrew against another token's Dao", + ); + assert.deepEqual(await usage(this), { + windowIndex: "0", + tokensUsed: "0", + quoteUsed: "0", + }); + assert.equal( + (await this.getTokenBalance(tokenMint, performancePackage)).toString(), + (TOTAL_AMOUNT + TOKEN_CAP).toString(), + ); + }); + + it("rolls a one-hour window every hour", async function () { + await setupCappedPackage(this, { limits: { windowSeconds: ONE_HOUR } }); + const now = Number((await this.banksClient.getClock()).unixTimestamp); + const windowIndex = Math.floor((now - startTimestamp) / ONE_HOUR); + assert.equal(await maxWithdrawal(this), TOKEN_CAP.toString()); + + await withdraw(this, TOKEN_CAP); + assert.deepEqual(await usage(this), { + windowIndex: windowIndex.toString(), + tokensUsed: TOKEN_CAP.toString(), + quoteUsed: TOKEN_CAP_QUOTE_VALUE, + }); + assert.equal(await maxWithdrawal(this), "0"); + await expectWithdrawError( + this, + 1, + "TokenWindowLimitExceeded", + "withdrew past the hourly token cap", + ); + + await this.advanceBySeconds(ONE_HOUR); + assert.equal(await maxWithdrawal(this), TOKEN_CAP.toString()); + await withdraw(this, TOKEN_CAP); + assert.deepEqual(await usage(this), { + windowIndex: (windowIndex + 1).toString(), + tokensUsed: TOKEN_CAP.toString(), + quoteUsed: TOKEN_CAP_QUOTE_VALUE, + }); + }); + + it("rejects a quote value that does not fit in a u64", async function () { + await setupCappedPackage(this, { observation: 10n ** 26n }); + assert.equal(await maxWithdrawal(this), "0"); + + await expectWithdrawError( + this, + TOKEN_CAP, + "QuoteWindowLimitExceeded", + "withdrew at an observation whose value overflows", + ); + }); + + it("is bound by a withdrawable balance below both caps", async function () { + const trancheAmount = 100_000 * 10 ** 6; + await setupCappedPackage(this, { + tranches: [ + { priceThreshold: new BN(1e12), tokenAmount: new BN(trancheAmount) }, + { priceThreshold: new BN(2e12), tokenAmount: new BN(trancheAmount) }, + ], + }); + assert.equal(await maxWithdrawal(this), trancheAmount.toString()); + + await expectWithdrawError( + this, + trancheAmount + 1, + "InsufficientWithdrawableBalance", + "withdrew more than the unlocked tranche", + ); + + await withdraw(this, trancheAmount); + assert.deepEqual(await usage(this), { + windowIndex: "0", + tokensUsed: trancheAmount.toString(), + quoteUsed: "7275239188", + }); + assert.equal(await maxWithdrawal(this), "0"); + }); + }); +} diff --git a/tests/priceBasedPerformancePackage/unit/withdrawViaSell.test.ts b/tests/priceBasedPerformancePackage/unit/withdrawViaSell.test.ts new file mode 100644 index 000000000..d35a2a4c0 --- /dev/null +++ b/tests/priceBasedPerformancePackage/unit/withdrawViaSell.test.ts @@ -0,0 +1,642 @@ +import { ComputeBudgetProgram, Keypair, PublicKey } from "@solana/web3.js"; +import { assert } from "chai"; +import BN from "bn.js"; +import { getAssociatedTokenAddressSync } from "@solana/spl-token"; +import { MEMO_PROGRAM_ID } from "@solana/spl-memo"; +import { LimitsParams } from "@metadaoproject/programs"; +import { getSellProceedsEstimate } from "@metadaoproject/programs/price_based_performance_package/v0.6/withdrawalLimits"; +import { expectError } from "../../utils.js"; +import { + setMockOracle, + setupPackageOnDao, + writeOldLayoutPackage, +} from "../utils.js"; + +// Two tranches of 2,580,000 tokens; the first unlocks against the Dao's TWAP, +// the second stays locked. +const TRANCHE_AMOUNT = 2_580_000 * 10 ** 6; +const FIRST_THRESHOLD = new BN(5e10); +const LOCKED_THRESHOLD = new BN(2e12); +// At most 645,000 tokens or 100,000 USDC per 30-day window +const TOKEN_CAP = 645_000 * 10 ** 6; +const QUOTE_CAP = 100_000 * 10 ** 6; +// The Dao's pool opens at $0.0728 per token +const POOL_BASE = 10_000_000 * 10 ** 6; +const POOL_QUOTE = 728_000 * 10 ** 6; +// Kept by the payer for oracle-stamping buys and proposal liquidity +const PAYER_RESERVE = 1_000 * 10 ** 6; +const ORACLE_STAMP_BUY = 1 * 10 ** 6; +const ONE_DAY = 24 * 60 * 60; +const THIRTY_DAYS = 30 * ONE_DAY; +const ONE_YEAR = 365 * ONE_DAY; +const NO_USAGE = { windowIndex: "0", tokensUsed: "0", quoteUsed: "0" }; + +export default function () { + let tokenMint: PublicKey; + let quoteMint: PublicKey; + let recipient: Keypair; + let performancePackage: PublicKey; + let dao: PublicKey; + let uniqueTxCount = 0; + + beforeEach(function () { + recipient = Keypair.generate(); + }); + + // A distinct compute-unit price gives otherwise identical transactions + // different hashes, so a repeat is not rejected as already processed. + function uniqueTxIx() { + uniqueTxCount += 1; + return ComputeBudgetProgram.setComputeUnitPrice({ + microLamports: uniqueTxCount, + }); + } + + function storedPackage(ctx: Mocha.Context) { + return ctx.priceBasedPerformancePackage.getPerformancePackage( + performancePackage, + ); + } + + async function usage(ctx: Mocha.Context) { + const { usage } = (await storedPackage(ctx)).withdrawalPolicy; + return { + windowIndex: usage.windowIndex.toString(), + tokensUsed: usage.tokensUsed.toString(), + quoteUsed: usage.quoteUsed.toString(), + }; + } + + async function spotPool(ctx: Mocha.Context) { + const state = (await ctx.futarchy.getDao(dao)).amm.state as any; + return state.spot?.spot ?? state.futarchy.spot; + } + + async function reserves(ctx: Mocha.Context) { + const pool = await spotPool(ctx); + return { + base: pool.baseReserves.toString(), + quote: pool.quoteReserves.toString(), + }; + } + + async function estimateProceeds( + ctx: Mocha.Context, + amount: number, + ): Promise { + const estimate = getSellProceedsEstimate( + await ctx.futarchy.getDao(dao), + new BN(amount), + ); + return BigInt(estimate.toString()); + } + + type SellOverrides = { + minQuoteOut?: number; + signer?: Keypair; + oracle?: PublicKey; + }; + + function sellIx( + ctx: Mocha.Context, + amount: number, + { minQuoteOut = 0, signer = recipient, oracle = dao }: SellOverrides = {}, + ) { + return ctx.priceBasedPerformancePackage + .withdrawViaSellIx({ + performancePackage, + dao: oracle, + tokenMint, + quoteMint, + recipient: signer.publicKey, + amount: new BN(amount), + minQuoteOut: new BN(minQuoteOut), + }) + .preInstructions([uniqueTxIx()]); + } + + function sell( + ctx: Mocha.Context, + amount: number, + overrides: SellOverrides = {}, + ) { + return sellIx(ctx, amount, overrides) + .signers([overrides.signer ?? recipient]) + .rpc(); + } + + async function expectSellError( + ctx: Mocha.Context, + amount: number, + error: string, + message: string, + overrides: SellOverrides = {}, + ) { + const callbacks = expectError(error, message); + await sell(ctx, amount, overrides).then(callbacks[0], callbacks[1]); + } + + function withdrawTokens(ctx: Mocha.Context, amount: number) { + return ctx.priceBasedPerformancePackage + .withdrawTokensIx({ + performancePackage, + oracleAccount: dao, + tokenMint, + recipient: recipient.publicKey, + amount: new BN(amount), + }) + .preInstructions([uniqueTxIx()]) + .signers([recipient]) + .rpc(); + } + + // A small buy from the payer, which moves the Dao oracle's last updated + // timestamp to now. + async function stampOracle(ctx: Mocha.Context) { + await ctx.futarchy + .spotSwapIx({ + dao, + baseMint: tokenMint, + quoteMint, + swapType: "buy", + inputAmount: new BN(ORACLE_STAMP_BUY), + }) + .preInstructions([uniqueTxIx()]) + .rpc(); + } + + async function startUnlock(ctx: Mocha.Context) { + await ctx.priceBasedPerformancePackage + .startUnlockIx({ + performancePackage, + oracleAccount: dao, + recipient: recipient.publicKey, + }) + .preInstructions([uniqueTxIx()]) + .signers([recipient]) + .rpc(); + } + + // Unlocks the first tranche off the Dao's own TWAP: the oracle is stamped + // right before start_unlock and again one TWAP length later for + // complete_unlock. + async function unlockFirstTranche(ctx: Mocha.Context) { + await stampOracle(ctx); + await startUnlock(ctx); + + await ctx.advanceBySeconds((await storedPackage(ctx)).twapLengthSeconds); + await stampOracle(ctx); + await ctx.priceBasedPerformancePackage + .completeUnlockIx({ performancePackage, oracleAccount: dao }) + .preInstructions([uniqueTxIx()]) + .rpc(); + } + + // Creates a package with the limits above on a fresh mint, with a Dao as its + // oracle, seeds the Dao's pool and unlocks the first tranche. `limits: null` + // creates the package without limits. + async function setupSellablePackage( + ctx: Mocha.Context, + { limits = {} }: { limits?: Partial | null } = {}, + ) { + tokenMint = await ctx.createMint(ctx.payer.publicKey, 6); + quoteMint = await ctx.createMint(ctx.payer.publicKey, 6); + await ctx.mintTo( + tokenMint, + ctx.payer.publicKey, + ctx.payer, + POOL_BASE + 2 * TRANCHE_AMOUNT + PAYER_RESERVE, + ); + await ctx.mintTo( + quoteMint, + ctx.payer.publicKey, + ctx.payer, + POOL_QUOTE + PAYER_RESERVE, + ); + + const now = Number((await ctx.banksClient.getClock()).unixTimestamp); + ({ dao, performancePackage } = await setupPackageOnDao(ctx, { + tokenMint, + quoteMint, + recipient: recipient.publicKey, + tranches: [ + { + priceThreshold: FIRST_THRESHOLD, + tokenAmount: new BN(TRANCHE_AMOUNT), + }, + { + priceThreshold: LOCKED_THRESHOLD, + tokenAmount: new BN(TRANCHE_AMOUNT), + }, + ], + limits: + limits === null + ? undefined + : { + endTimestamp: new BN(now + ONE_YEAR), + windowSeconds: THIRTY_DAYS, + maxTokensPerWindow: new BN(TOKEN_CAP), + maxQuotePerWindow: new BN(QUOTE_CAP), + withdrawalMode: { both: {} }, + ...limits, + }, + })); + + await ctx.futarchy + .provideLiquidityIx({ + dao, + baseMint: tokenMint, + quoteMint, + quoteAmount: new BN(POOL_QUOTE), + maxBaseAmount: new BN(POOL_BASE), + }) + .rpc(); + + // The Dao's TWAP only starts recording after its one-day start delay + await ctx.advanceBySeconds(ONE_DAY + 1); + await unlockFirstTranche(ctx); + } + + it("sells under mode Both and forwards the proceeds to the recipient", async function () { + await setupSellablePackage(this); + const seqNumBefore = (await storedPackage(this)).seqNum.toNumber(); + const poolBaseBefore = await this.getTokenBalance(tokenMint, dao); + const packageQuoteAccount = getAssociatedTokenAddressSync( + quoteMint, + performancePackage, + true, + ); + assert.isNull(await this.banksClient.getAccount(packageQuoteAccount)); + + // 645,000 tokens into the $0.0728 pool are worth about 43,900 USDC after the 0.5% fee + const estimate = await estimateProceeds(this, TOKEN_CAP); + assert.isAbove(Number(estimate), 43_000 * 10 ** 6); + assert.isBelow(Number(estimate), 44_000 * 10 ** 6); + + await sell(this, TOKEN_CAP); + + assert.equal( + await this.getTokenBalance(tokenMint, performancePackage), + BigInt(2 * TRANCHE_AMOUNT - TOKEN_CAP), + ); + assert.equal( + await this.getTokenBalance(tokenMint, dao), + poolBaseBefore + BigInt(TOKEN_CAP), + ); + assert.equal( + await this.getTokenBalance(quoteMint, recipient.publicKey), + estimate, + ); + assert.isNotNull(await this.banksClient.getAccount(packageQuoteAccount)); + assert.equal(await this.getTokenBalance(quoteMint, performancePackage), 0n); + + assert.deepEqual(await usage(this), { + windowIndex: "0", + tokensUsed: TOKEN_CAP.toString(), + quoteUsed: estimate.toString(), + }); + assert.equal( + (await storedPackage(this)).seqNum.toNumber(), + seqNumBefore + 1, + ); + }); + + it("forwards only the pool's proceeds, leaving a donation in the package's quote account", async function () { + await setupSellablePackage(this); + const donation = 1 * 10 ** 6; + await this.mintTo(quoteMint, performancePackage, this.payer, donation); + const estimate = await estimateProceeds(this, TOKEN_CAP); + + await sell(this, TOKEN_CAP); + + assert.equal( + await this.getTokenBalance(quoteMint, recipient.publicKey), + estimate, + ); + assert.equal( + await this.getTokenBalance(quoteMint, performancePackage), + BigInt(donation), + ); + assert.equal((await usage(this)).quoteUsed, estimate.toString()); + }); + + it("rejects proceeds above the quote cap", async function () { + await setupSellablePackage(this, { + limits: { maxQuotePerWindow: new BN(10_000 * 10 ** 6) }, + }); + const reservesBefore = await reserves(this); + + await expectSellError( + this, + TOKEN_CAP, + "QuoteWindowLimitExceeded", + "sold for more than the window's quote cap", + ); + + assert.equal( + await this.getTokenBalance(tokenMint, performancePackage), + BigInt(2 * TRANCHE_AMOUNT), + ); + assert.deepEqual(await reserves(this), reservesBefore); + assert.equal( + await this.getTokenBalance(quoteMint, recipient.publicKey), + 0n, + ); + assert.deepEqual(await usage(this), NO_USAGE); + }); + + it("rejects an amount above the token cap", async function () { + await setupSellablePackage(this); + const reservesBefore = await reserves(this); + + await expectSellError( + this, + TOKEN_CAP + 1, + "TokenWindowLimitExceeded", + "sold more than the window's token cap", + ); + + assert.equal( + await this.getTokenBalance(tokenMint, performancePackage), + BigInt(2 * TRANCHE_AMOUNT), + ); + assert.deepEqual(await reserves(this), reservesBefore); + assert.deepEqual(await usage(this), NO_USAGE); + }); + + it("rejects a minimum quote out above the pool's price", async function () { + await setupSellablePackage(this); + const reservesBefore = await reserves(this); + const estimate = await estimateProceeds(this, TOKEN_CAP); + + await expectSellError( + this, + TOKEN_CAP, + "RequireGteViolated", + "sold for less than the minimum quote out", + { minQuoteOut: Number(estimate) + 1 }, + ); + + assert.equal( + await this.getTokenBalance(tokenMint, performancePackage), + BigInt(2 * TRANCHE_AMOUNT), + ); + assert.deepEqual(await reserves(this), reservesBefore); + assert.deepEqual(await usage(this), NO_USAGE); + + await sell(this, TOKEN_CAP, { minQuoteOut: Number(estimate) }); + assert.equal( + await this.getTokenBalance(quoteMint, recipient.publicKey), + estimate, + ); + }); + + it("rejects the sell route under mode Tokens", async function () { + await setupSellablePackage(this, { + limits: { withdrawalMode: { tokens: {} } }, + }); + + await expectSellError( + this, + TOKEN_CAP, + "WithdrawViaSellDisabled", + "sold under mode Tokens", + ); + }); + + it("allows only the sell route under mode Sell", async function () { + await setupSellablePackage(this, { + limits: { withdrawalMode: { sell: {} } }, + }); + + const callbacks = expectError( + "WithdrawTokensDisabled", + "withdrew tokens under mode Sell", + ); + await withdrawTokens(this, TOKEN_CAP).then(callbacks[0], callbacks[1]); + + const estimate = await estimateProceeds(this, TOKEN_CAP); + await sell(this, TOKEN_CAP); + + assert.equal( + await this.getTokenBalance(quoteMint, recipient.publicKey), + estimate, + ); + assert.equal((await usage(this)).tokensUsed, TOKEN_CAP.toString()); + }); + + it("sells uncapped when the package has no limits", async function () { + await setupSellablePackage(this, { limits: null }); + const estimate = await estimateProceeds(this, TRANCHE_AMOUNT); + + await sell(this, TRANCHE_AMOUNT); + + assert.equal( + await this.getTokenBalance(quoteMint, recipient.publicKey), + estimate, + ); + assert.equal( + await this.getTokenBalance(tokenMint, performancePackage), + BigInt(TRANCHE_AMOUNT), + ); + assert.isNull((await storedPackage(this)).withdrawalPolicy); + }); + + it("sells uncapped under an expired policy, its mode included", async function () { + const now = Number((await this.banksClient.getClock()).unixTimestamp); + await setupSellablePackage(this, { + limits: { + endTimestamp: new BN(now + 1), + withdrawalMode: { tokens: {} }, + }, + }); + const estimate = await estimateProceeds(this, TRANCHE_AMOUNT); + + await sell(this, TRANCHE_AMOUNT); + + assert.equal( + await this.getTokenBalance(quoteMint, recipient.publicKey), + estimate, + ); + assert.deepEqual(await usage(this), NO_USAGE); + }); + + it("rejects a signer other than the recipient", async function () { + await setupSellablePackage(this); + + await expectSellError( + this, + TOKEN_CAP, + "ConstraintHasOne", + "a non-recipient sold tokens", + { signer: Keypair.generate() }, + ); + }); + + it("rejects a Dao other than the package's oracle account", async function () { + await setupSellablePackage(this); + const otherDao = await this.setupBasicDao({ + baseMint: tokenMint, + quoteMint, + }); + + await expectSellError( + this, + TOKEN_CAP, + "ConstraintAddress", + "sold through a Dao that is not the package's oracle", + { oracle: otherDao }, + ); + }); + + it("rejects an oracle Dao whose base mint is another token", async function () { + await setupSellablePackage(this); + const otherMint = await this.createMint(this.payer.publicKey, 6); + const otherDao = await this.setupBasicDao({ + baseMint: otherMint, + quoteMint, + }); + performancePackage = await this.setupBasicPerformancePackage({ + tokenMint, + oracleAccount: otherDao, + byteOffset: 9, + recipient: recipient.publicKey, + }); + + const callbacks = expectError( + "OracleMintMismatch", + "sold through a Dao on another token", + ); + await sellIx(this, 1, { oracle: otherDao }) + .accounts({ + ammBaseVault: getAssociatedTokenAddressSync(otherMint, otherDao, true), + }) + .signers([recipient]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("rejects an oracle account that is not a Dao", async function () { + await setupSellablePackage(this); + const mockOracle = Keypair.generate().publicKey; + await setMockOracle(this, mockOracle, { aggregator: 0n }); + performancePackage = await this.setupBasicPerformancePackage({ + tokenMint, + oracleAccount: mockOracle, + recipient: recipient.publicKey, + }); + + await expectSellError( + this, + 1, + "AccountOwnedByWrongProgram", + "sold through a mock oracle", + { oracle: mockOracle }, + ); + }); + + it("sells while the Dao has a live proposal", async function () { + await setupSellablePackage(this); + await this.initializeAndLaunchProposal({ + dao, + instructions: [ + { + programId: MEMO_PROGRAM_ID, + keys: [], + data: Buffer.from("hello, world"), + }, + ], + }); + assert.isDefined((await this.futarchy.getDao(dao)).amm.state.futarchy); + + // With a proposal live the pool also pays out the arbitrage profit, so the spot estimate is a floor + const estimate = await estimateProceeds(this, TOKEN_CAP); + await sell(this, TOKEN_CAP); + + const received = await this.getTokenBalance(quoteMint, recipient.publicKey); + assert.isAtLeast(Number(received), Number(estimate)); + assert.equal( + await this.getTokenBalance(tokenMint, performancePackage), + BigInt(2 * TRANCHE_AMOUNT - TOKEN_CAP), + ); + assert.equal(await this.getTokenBalance(quoteMint, performancePackage), 0n); + assert.deepEqual(await usage(this), { + windowIndex: "0", + tokensUsed: TOKEN_CAP.toString(), + quoteUsed: received.toString(), + }); + }); + + it("counts a token withdrawal and a sale against one window", async function () { + // The token route values at the oracle's observation, still near the $1 it started at + await setupSellablePackage(this, { + limits: { maxQuotePerWindow: new BN(1_000_000 * 10 ** 6) }, + }); + const tokenAmount = 300_000 * 10 ** 6; + const sellAmount = TOKEN_CAP - tokenAmount; + + await withdrawTokens(this, tokenAmount); + const { quoteUsed: tokenValue } = await usage(this); + const estimate = await estimateProceeds(this, sellAmount); + + await sell(this, sellAmount); + + assert.deepEqual(await usage(this), { + windowIndex: "0", + tokensUsed: TOKEN_CAP.toString(), + quoteUsed: (BigInt(tokenValue) + estimate).toString(), + }); + + await expectSellError( + this, + 1, + "TokenWindowLimitExceeded", + "sold past the token cap shared with a token withdrawal", + ); + const callbacks = expectError( + "TokenWindowLimitExceeded", + "withdrew past the token cap shared with a sale", + ); + await withdrawTokens(this, 1).then(callbacks[0], callbacks[1]); + }); + + it("rejects more than the withdrawable balance and a zero amount", async function () { + await setupSellablePackage(this); + + await expectSellError( + this, + TRANCHE_AMOUNT + 1, + "InsufficientWithdrawableBalance", + "sold more than the unlocked tranche", + ); + await expectSellError(this, 0, "RequireGtViolated", "sold zero tokens"); + }); + + it("is allowed while the package is unlocking", async function () { + await setupSellablePackage(this); + await startUnlock(this); + assert.isDefined((await storedPackage(this)).state.unlocking); + const estimate = await estimateProceeds(this, TOKEN_CAP); + + await sell(this, TOKEN_CAP); + + const stored = await storedPackage(this); + assert.isDefined(stored.state.unlocking); + assert.equal( + await this.getTokenBalance(quoteMint, recipient.publicKey), + estimate, + ); + }); + + it("rejects a package that has not been resized", async function () { + await setupSellablePackage(this); + await writeOldLayoutPackage(this, performancePackage); + + await expectSellError( + this, + TOKEN_CAP, + "AccountNotMigrated", + "sold from a package in the old layout", + ); + }); +} diff --git a/tests/priceBasedPerformancePackage/utils.ts b/tests/priceBasedPerformancePackage/utils.ts new file mode 100644 index 000000000..616d7848b --- /dev/null +++ b/tests/priceBasedPerformancePackage/utils.ts @@ -0,0 +1,359 @@ +import { + ComputeBudgetProgram, + Keypair, + PublicKey, + SystemProgram, + TransactionInstruction, +} from "@solana/web3.js"; +import BN from "bn.js"; +import { LimitsParams, Tranche } from "@metadaoproject/programs"; + +const OLD_PERFORMANCE_PACKAGE_SIZE = 520; + +// Rewrites a package as the 520-byte v0.6.0 layout, rent-exempt at that size. +export async function writeOldLayoutPackage( + ctx: Mocha.Context, + performancePackage: PublicKey, +): Promise { + const account = await ctx.banksClient.getAccount(performancePackage); + const rent = await ctx.banksClient.getRent(); + ctx.context.setAccount(performancePackage, { + ...account, + lamports: Number(rent.minimumBalance(BigInt(OLD_PERFORMANCE_PACKAGE_SIZE))), + data: account.data.slice(0, OLD_PERFORMANCE_PACKAGE_SIZE), + }); +} + +// Writes a 24-byte mock oracle: aggregator u128 at 0, last updated timestamp i64 at 16. +export async function setMockOracle( + ctx: Mocha.Context, + oracle: PublicKey, + { + aggregator, + lastUpdatedTimestamp, + }: { aggregator: bigint; lastUpdatedTimestamp?: bigint }, +): Promise { + const data = Buffer.alloc(24); + writeU128(data, aggregator, 0); + data.writeBigInt64LE( + lastUpdatedTimestamp ?? (await ctx.banksClient.getClock()).unixTimestamp, + 16, + ); + ctx.context.setAccount(oracle, { + executable: false, + owner: SystemProgram.programId, + lamports: 1_000_000_000, + data, + }); +} + +function writeU128(data: Buffer, value: bigint, offset: number) { + data.writeBigUInt64LE(value & ((1n << 64n) - 1n), offset); + data.writeBigUInt64LE(value >> 64n, offset + 8); +} + +// Offsets of the spot pool inside a Dao account, the same in both PoolState +// variants: the discriminator and variant tag take 9 bytes, then the TwapOracle +// (aggregator at 0, last updated timestamp at 16, last observation at 48, 100 +// bytes in all) and the quote and base reserves. +const DAO_SPOT_POOL_OFFSET = 9; +const DAO_AGGREGATOR_OFFSET = DAO_SPOT_POOL_OFFSET; +const DAO_LAST_UPDATED_OFFSET = DAO_SPOT_POOL_OFFSET + 16; +const DAO_LAST_OBSERVATION_OFFSET = DAO_SPOT_POOL_OFFSET + 48; +const DAO_QUOTE_RESERVES_OFFSET = DAO_SPOT_POOL_OFFSET + 100; +const DAO_BASE_RESERVES_OFFSET = DAO_SPOT_POOL_OFFSET + 108; + +// Patches a Dao's spot pool in place. Fields left out keep their stored values; +// setting the aggregator also stamps the last updated timestamp, like the mock. +export async function setDaoOracle( + ctx: Mocha.Context, + dao: PublicKey, + { + aggregator, + lastUpdatedTimestamp, + lastObservation, + reserves, + }: { + aggregator?: bigint; + lastUpdatedTimestamp?: bigint; + lastObservation?: bigint; + reserves?: { base: bigint; quote: bigint }; + }, +): Promise { + const account = await ctx.banksClient.getAccount(dao); + const data = Buffer.from(account.data); + + if (aggregator !== undefined) { + writeU128(data, aggregator, DAO_AGGREGATOR_OFFSET); + } + if (aggregator !== undefined || lastUpdatedTimestamp !== undefined) { + data.writeBigInt64LE( + lastUpdatedTimestamp ?? (await ctx.banksClient.getClock()).unixTimestamp, + DAO_LAST_UPDATED_OFFSET, + ); + } + if (lastObservation !== undefined) { + writeU128(data, lastObservation, DAO_LAST_OBSERVATION_OFFSET); + } + if (reserves !== undefined) { + data.writeBigUInt64LE(reserves.quote, DAO_QUOTE_RESERVES_OFFSET); + data.writeBigUInt64LE(reserves.base, DAO_BASE_RESERVES_OFFSET); + } + + ctx.context.setAccount(dao, { ...account, data }); + + // Read the Dao back through the SDK so stale offsets fail loudly + const state = (await ctx.futarchy.getDao(dao)).amm.state as any; + const pool = state.spot?.spot ?? state.futarchy.spot; + if ( + (aggregator !== undefined && + pool.oracle.aggregator.toString() !== aggregator.toString()) || + (lastObservation !== undefined && + pool.oracle.lastObservation.toString() !== lastObservation.toString()) || + (reserves !== undefined && + (pool.quoteReserves.toString() !== reserves.quote.toString() || + pool.baseReserves.toString() !== reserves.base.toString())) + ) { + throw new Error("setDaoOracle wrote to the wrong offsets"); + } +} + +// Creates a Dao on `tokenMint` and a package that uses it as its oracle at byte +// offset 9, where the spot pool's TwapOracle starts. +export async function setupPackageOnDao( + ctx: Mocha.Context, + { + tokenMint, + quoteMint, + recipient, + limits, + tranches, + minUnlockTimestamp, + }: { + tokenMint: PublicKey; + quoteMint: PublicKey; + recipient: PublicKey; + limits?: LimitsParams; + tranches?: Tranche[]; + minUnlockTimestamp?: BN; + }, +): Promise<{ dao: PublicKey; performancePackage: PublicKey }> { + const dao = await ctx.setupBasicDao({ baseMint: tokenMint, quoteMint }); + const performancePackage = await ctx.setupBasicPerformancePackage({ + tokenMint, + oracleAccount: dao, + byteOffset: 9, + recipient, + limits, + tranches, + minUnlockTimestamp, + }); + + return { dao, performancePackage }; +} + +// Starts an unlock, advances one TWAP length, and completes it at a TWAP of +// `twapPrice`. Oracle values go to the 24-byte mock unless `writeOracle` says +// otherwise, as it does when the oracle is a Dao. +export async function runUnlockCycle( + ctx: Mocha.Context, + { + performancePackage, + oracleAccount, + recipient, + twapPrice, + writeOracle = (values) => setMockOracle(ctx, oracleAccount, values), + }: { + performancePackage: PublicKey; + oracleAccount: PublicKey; + recipient: Keypair; + twapPrice: bigint; + writeOracle?: (values: { aggregator: bigint }) => Promise; + }, +): Promise { + const { twapLengthSeconds, seqNum } = + await ctx.priceBasedPerformancePackage.getPerformancePackage( + performancePackage, + ); + // The sequence number differs on every cycle, so using it as the compute-unit + // price makes each transaction hash unique and avoids duplicate-processing errors. + const uniqueTxIx = ComputeBudgetProgram.setComputeUnitPrice({ + microLamports: seqNum.toNumber(), + }); + const startAggregator = BigInt(1e12); + + await writeOracle({ aggregator: startAggregator }); + await ctx.priceBasedPerformancePackage + .startUnlockIx({ + performancePackage, + oracleAccount, + recipient: recipient.publicKey, + }) + .preInstructions([uniqueTxIx]) + .signers([recipient]) + .rpc(); + + await ctx.advanceBySeconds(twapLengthSeconds); + await writeOracle({ + aggregator: startAggregator + twapPrice * BigInt(twapLengthSeconds), + }); + await ctx.priceBasedPerformancePackage + .completeUnlockIx({ performancePackage, oracleAccount }) + .preInstructions([uniqueTxIx]) + .rpc(); +} + +// The sellable package: two tranches of 2,580,000 tokens on a Dao whose pool +// opens at $0.0728 per token. The first tranche unlocks against the Dao's TWAP +// and the second stays locked. +export const SELLABLE_TRANCHE_AMOUNT = 2_580_000 * 10 ** 6; +const SELLABLE_FIRST_THRESHOLD = new BN(5e10); +const SELLABLE_LOCKED_THRESHOLD = new BN(2e12); +const SELLABLE_POOL_BASE = 10_000_000 * 10 ** 6; +const SELLABLE_POOL_QUOTE = 728_000 * 10 ** 6; +// Kept by the payer for oracle-stamping buys +const PAYER_RESERVE = 1_000 * 10 ** 6; +const ORACLE_STAMP_BUY = 1 * 10 ** 6; +const ONE_DAY = 24 * 60 * 60; + +let uniqueTxCount = 0; + +// A distinct compute-unit price gives otherwise identical transactions +// different hashes, so a repeat is not rejected as already processed. +export function uniqueTxIx(): TransactionInstruction { + uniqueTxCount += 1; + return ComputeBudgetProgram.setComputeUnitPrice({ + microLamports: uniqueTxCount, + }); +} + +// A small buy from the payer, which moves the Dao oracle's last updated +// timestamp to now. +export async function stampDaoOracle( + ctx: Mocha.Context, + { + dao, + tokenMint, + quoteMint, + }: { dao: PublicKey; tokenMint: PublicKey; quoteMint: PublicKey }, +): Promise { + await ctx.futarchy + .spotSwapIx({ + dao, + baseMint: tokenMint, + quoteMint, + swapType: "buy", + inputAmount: new BN(ORACLE_STAMP_BUY), + }) + .preInstructions([uniqueTxIx()]) + .rpc(); +} + +// Unlocks the first tranche off the Dao's own TWAP: the oracle is stamped +// right before start_unlock and again one TWAP length later for +// complete_unlock. +export async function unlockFirstTrancheOnDao( + ctx: Mocha.Context, + { + performancePackage, + dao, + tokenMint, + quoteMint, + recipient, + }: { + performancePackage: PublicKey; + dao: PublicKey; + tokenMint: PublicKey; + quoteMint: PublicKey; + recipient: Keypair; + }, +): Promise { + await stampDaoOracle(ctx, { dao, tokenMint, quoteMint }); + await ctx.priceBasedPerformancePackage + .startUnlockIx({ + performancePackage, + oracleAccount: dao, + recipient: recipient.publicKey, + }) + .preInstructions([uniqueTxIx()]) + .signers([recipient]) + .rpc(); + + const { twapLengthSeconds } = + await ctx.priceBasedPerformancePackage.getPerformancePackage( + performancePackage, + ); + await ctx.advanceBySeconds(twapLengthSeconds); + await stampDaoOracle(ctx, { dao, tokenMint, quoteMint }); + await ctx.priceBasedPerformancePackage + .completeUnlockIx({ performancePackage, oracleAccount: dao }) + .preInstructions([uniqueTxIx()]) + .rpc(); +} + +// Creates fresh mints, a Dao with a seeded pool and a package on it holding +// two tranches of SELLABLE_TRANCHE_AMOUNT, then unlocks the first tranche. +// Without `limits` the package is uncapped. +export async function setupSellablePackage( + ctx: Mocha.Context, + { recipient, limits }: { recipient: Keypair; limits?: LimitsParams }, +): Promise<{ + tokenMint: PublicKey; + quoteMint: PublicKey; + dao: PublicKey; + performancePackage: PublicKey; +}> { + const tokenMint = await ctx.createMint(ctx.payer.publicKey, 6); + const quoteMint = await ctx.createMint(ctx.payer.publicKey, 6); + await ctx.mintTo( + tokenMint, + ctx.payer.publicKey, + ctx.payer, + SELLABLE_POOL_BASE + 2 * SELLABLE_TRANCHE_AMOUNT + PAYER_RESERVE, + ); + await ctx.mintTo( + quoteMint, + ctx.payer.publicKey, + ctx.payer, + SELLABLE_POOL_QUOTE + PAYER_RESERVE, + ); + + const { dao, performancePackage } = await setupPackageOnDao(ctx, { + tokenMint, + quoteMint, + recipient: recipient.publicKey, + tranches: [ + { + priceThreshold: SELLABLE_FIRST_THRESHOLD, + tokenAmount: new BN(SELLABLE_TRANCHE_AMOUNT), + }, + { + priceThreshold: SELLABLE_LOCKED_THRESHOLD, + tokenAmount: new BN(SELLABLE_TRANCHE_AMOUNT), + }, + ], + limits, + }); + + await ctx.futarchy + .provideLiquidityIx({ + dao, + baseMint: tokenMint, + quoteMint, + quoteAmount: new BN(SELLABLE_POOL_QUOTE), + maxBaseAmount: new BN(SELLABLE_POOL_BASE), + }) + .rpc(); + + // The Dao's TWAP only starts recording after its one-day start delay + await ctx.advanceBySeconds(ONE_DAY + 1); + await unlockFirstTrancheOnDao(ctx, { + performancePackage, + dao, + tokenMint, + quoteMint, + recipient, + }); + + return { tokenMint, quoteMint, dao, performancePackage }; +}