diff --git a/staking/Cargo.lock b/staking/Cargo.lock index 6259e30d..6686f8e7 100644 --- a/staking/Cargo.lock +++ b/staking/Cargo.lock @@ -3076,7 +3076,7 @@ dependencies = [ [[package]] name = "pyth-staking-program" -version = "2.1.0" +version = "2.2.0" dependencies = [ "ahash 0.8.11", "anchor-lang", diff --git a/staking/integration-tests/src/solana/instructions.rs b/staking/integration-tests/src/solana/instructions.rs index 6d1d2cfa..0e4c3cc7 100644 --- a/staking/integration-tests/src/solana/instructions.rs +++ b/staking/integration-tests/src/solana/instructions.rs @@ -46,6 +46,34 @@ pub fn create_account( account.pubkey() } +/// Creates an empty, program owned, rent exempt account at `address`. +/// +/// Unlike `create_account` this doesn't go through the system program, so it works for addresses +/// whose keypair we don't have. This is needed to test instructions that only accept a hardcoded +/// set of stake accounts. +pub fn create_account_at( + svm: &mut litesvm::LiteSVM, + address: Pubkey, + size: usize, + owner: Pubkey, +) -> Pubkey { + let lamports = svm.minimum_balance_for_rent_exemption(size); + + svm.set_account( + address, + solana_sdk::account::Account { + lamports, + data: vec![0; size], + owner, + executable: false, + rent_epoch: 0, + }, + ) + .unwrap(); + + address +} + pub fn create_token_account( svm: &mut litesvm::LiteSVM, payer: &Keypair, diff --git a/staking/integration-tests/src/staking/helper_functions.rs b/staking/integration-tests/src/staking/helper_functions.rs index d3d09e57..c0e7f6ce 100644 --- a/staking/integration-tests/src/staking/helper_functions.rs +++ b/staking/integration-tests/src/staking/helper_functions.rs @@ -10,6 +10,7 @@ use { solana::instructions::{ airdrop_spl, create_account, + create_account_at, }, utils::constants::STAKED_TOKENS, }, @@ -17,6 +18,7 @@ use { pubkey::Pubkey, signature::Keypair, }, + staking::state::vesting::VestingSchedule, }; @@ -34,7 +36,56 @@ pub fn initialize_new_stake_account( staking::ID, ); - create_stake_account(svm, payer, pyth_token_mint, stake_account_positions).unwrap(); + initialize_stake_account( + svm, + payer, + pyth_token_mint, + join_dao, + airdrop, + stake_account_positions, + VestingSchedule::FullyVested, + ) +} + +/// Same as `initialize_new_stake_account`, but the positions account is placed at +/// `stake_account_positions` instead of at a fresh keypair, and the lock is configurable. +pub fn initialize_new_stake_account_at( + svm: &mut litesvm::LiteSVM, + payer: &Keypair, + pyth_token_mint: &Keypair, + join_dao: bool, + airdrop: bool, + stake_account_positions: Pubkey, + lock: VestingSchedule, +) -> Pubkey { + create_account_at( + svm, + stake_account_positions, + staking::state::positions::PositionData::LEN, + staking::ID, + ); + + initialize_stake_account( + svm, + payer, + pyth_token_mint, + join_dao, + airdrop, + stake_account_positions, + lock, + ) +} + +fn initialize_stake_account( + svm: &mut litesvm::LiteSVM, + payer: &Keypair, + pyth_token_mint: &Keypair, + join_dao: bool, + airdrop: bool, + stake_account_positions: Pubkey, + lock: VestingSchedule, +) -> Pubkey { + create_stake_account(svm, payer, pyth_token_mint, stake_account_positions, lock).unwrap(); if join_dao { join_dao_llc(svm, payer, stake_account_positions).unwrap(); diff --git a/staking/integration-tests/src/staking/instructions.rs b/staking/integration-tests/src/staking/instructions.rs index 31619b42..b6157e4c 100644 --- a/staking/integration-tests/src/staking/instructions.rs +++ b/staking/integration-tests/src/staking/instructions.rs @@ -41,6 +41,7 @@ use { staking::state::{ global_config::GlobalConfig, positions::TargetWithParameters, + vesting::VestingSchedule, voter_weight_record::VoterWeightAction, }, }; @@ -257,6 +258,7 @@ pub fn create_stake_account( payer: &Keypair, pyth_token_mint: &Keypair, stake_account_positions: Pubkey, + lock: VestingSchedule, ) -> TransactionResult { let stake_account_metadata = get_stake_account_metadata_address(stake_account_positions); let stake_account_custody = get_stake_account_custody_address(stake_account_positions); @@ -265,7 +267,7 @@ pub fn create_stake_account( let create_stake_account_data = staking::instruction::CreateStakeAccount { owner: payer.pubkey(), - lock: staking::state::vesting::VestingSchedule::FullyVested, + lock, }; let create_stake_account_accs = staking::accounts::CreateStakeAccount { payer: payer.pubkey(), @@ -545,6 +547,38 @@ pub fn transfer_account( svm.send_transaction(tx) } +/// The instruction is permissionless, `payer` only pays the fee. +/// `stake_account_metadata_override` lets a test pass a metadata account that doesn't belong to +/// `stake_account_positions`; when `None` the correct PDA is derived. +pub fn shorten_vesting_schedule( + svm: &mut litesvm::LiteSVM, + payer: &Keypair, + stake_account_positions: Pubkey, + stake_account_metadata_override: Option, +) -> TransactionResult { + let stake_account_metadata = stake_account_metadata_override + .unwrap_or_else(|| get_stake_account_metadata_address(stake_account_positions)); + + let accs = staking::accounts::ShortenVestingSchedule { + stake_account_positions, + stake_account_metadata, + }; + + let ix = Instruction::new_with_bytes( + staking::ID, + &staking::instruction::ShortenVestingSchedule {}.data(), + accs.to_account_metas(None), + ); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&payer.pubkey()), + &[&payer], + svm.latest_blockhash(), + ); + + svm.send_transaction(tx) +} + pub fn create_voter_record( svm: &mut litesvm::LiteSVM, payer: &Keypair, diff --git a/staking/integration-tests/tests/shorten_vesting_schedule.rs b/staking/integration-tests/tests/shorten_vesting_schedule.rs new file mode 100644 index 00000000..c8a857a1 --- /dev/null +++ b/staking/integration-tests/tests/shorten_vesting_schedule.rs @@ -0,0 +1,184 @@ +use { + anchor_lang::error::ErrorCode, + integration_tests::{ + assert_anchor_program_error, + setup::{ + setup, + SetupProps, + SetupResult, + }, + solana::utils::fetch_account_data, + staking::{ + helper_functions::{ + initialize_new_stake_account, + initialize_new_stake_account_at, + }, + instructions::shorten_vesting_schedule, + pda::get_stake_account_metadata_address, + }, + }, + solana_sdk::{ + native_token::LAMPORTS_PER_SOL, + pubkey::Pubkey, + signature::Keypair, + signer::Signer, + }, + staking::{ + error::ErrorCode as StakingError, + state::{ + stake_account::StakeAccountMetadataV2, + vesting::VestingSchedule, + }, + ADDRESSES_TO_SHORTEN_VESTING_SCHEDULE, + }, +}; + +const INITIAL_BALANCE: u64 = 1_000_000; +const START_DATE: i64 = 100; +const PERIOD_DURATION: u64 = 3600; +const NUM_PERIODS: u64 = 4; + +fn periodic_vesting(num_periods: u64) -> VestingSchedule { + VestingSchedule::PeriodicVesting { + initial_balance: INITIAL_BALANCE, + start_date: START_DATE, + period_duration: PERIOD_DURATION, + num_periods, + } +} + +fn fetch_lock(svm: &mut litesvm::LiteSVM, stake_account_positions: Pubkey) -> VestingSchedule { + let metadata: StakeAccountMetadataV2 = fetch_account_data( + svm, + &get_stake_account_metadata_address(stake_account_positions), + ); + metadata.lock +} + +#[test] +fn test_shorten_vesting_schedule() { + let SetupResult { + mut svm, + payer, + pyth_token_mint, + publisher_keypair: _, + pool_data_pubkey: _, + reward_program_authority: _, + maybe_publisher_index: _, + } = setup(SetupProps { + init_config: true, + init_target: true, + init_mint: true, + init_pool_data: true, + init_publishers: true, + reward_amount_override: None, + }); + + let owner = Keypair::new(); + svm.airdrop(&owner.pubkey(), LAMPORTS_PER_SOL).unwrap(); + + // A stake account at one of the hardcoded addresses, i.e. eligible for shortening. + let whitelisted_stake_account_positions = initialize_new_stake_account_at( + &mut svm, + &owner, + &pyth_token_mint, + true, + true, + ADDRESSES_TO_SHORTEN_VESTING_SCHEDULE[0], + periodic_vesting(NUM_PERIODS), + ); + + // A stake account at an arbitrary address, i.e. not eligible for shortening. + let other_stake_account_positions = + initialize_new_stake_account(&mut svm, &owner, &pyth_token_mint, true, true); + + // Wrong stake account address: the account isn't in the hardcoded list. + assert_anchor_program_error!( + shorten_vesting_schedule(&mut svm, &payer, other_stake_account_positions, None), + StakingError::UnauthorizedVestingScheduleShortening, + 0 + ); + + // Wrong metadata account: the metadata of another stake account doesn't match the seeds. + assert_anchor_program_error!( + shorten_vesting_schedule( + &mut svm, + &payer, + whitelisted_stake_account_positions, + Some(get_stake_account_metadata_address( + other_stake_account_positions + )), + ), + ErrorCode::ConstraintSeeds, + 0 + ); + + // Neither failed instruction touched the vesting schedules. + assert_eq!( + fetch_lock(&mut svm, whitelisted_stake_account_positions), + periodic_vesting(NUM_PERIODS) + ); + assert_eq!( + fetch_lock(&mut svm, other_stake_account_positions), + VestingSchedule::FullyVested + ); + + // Happy path: only `num_periods` changes, and it becomes 1. + shorten_vesting_schedule(&mut svm, &payer, whitelisted_stake_account_positions, None).unwrap(); + assert_eq!( + fetch_lock(&mut svm, whitelisted_stake_account_positions), + periodic_vesting(1) + ); + + // Idempotence: shortening an already shortened schedule is a no-op. + svm.expire_blockhash(); + shorten_vesting_schedule(&mut svm, &payer, whitelisted_stake_account_positions, None).unwrap(); + assert_eq!( + fetch_lock(&mut svm, whitelisted_stake_account_positions), + periodic_vesting(1) + ); +} + +#[test] +fn test_shorten_vesting_schedule_is_a_noop_for_other_schedules() { + let SetupResult { + mut svm, + payer, + pyth_token_mint, + publisher_keypair: _, + pool_data_pubkey: _, + reward_program_authority: _, + maybe_publisher_index: _, + } = setup(SetupProps { + init_config: true, + init_target: true, + init_mint: true, + init_pool_data: true, + init_publishers: true, + reward_amount_override: None, + }); + + let owner = Keypair::new(); + svm.airdrop(&owner.pubkey(), LAMPORTS_PER_SOL).unwrap(); + + let lock = VestingSchedule::PeriodicVestingAfterListing { + initial_balance: INITIAL_BALANCE, + period_duration: PERIOD_DURATION, + num_periods: NUM_PERIODS, + }; + + let stake_account_positions = initialize_new_stake_account_at( + &mut svm, + &owner, + &pyth_token_mint, + true, + true, + ADDRESSES_TO_SHORTEN_VESTING_SCHEDULE[1], + lock, + ); + + shorten_vesting_schedule(&mut svm, &payer, stake_account_positions, None).unwrap(); + + // Only `PeriodicVesting` is shortened, everything else is left alone. + assert_eq!(fetch_lock(&mut svm, stake_account_positions), lock); +} diff --git a/staking/programs/staking/Cargo.toml b/staking/programs/staking/Cargo.toml index 486b58ee..7c9604b8 100644 --- a/staking/programs/staking/Cargo.toml +++ b/staking/programs/staking/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pyth-staking-program" -version = "2.1.0" +version = "2.2.0" description = "Created with Anchor" edition = "2018" diff --git a/staking/programs/staking/src/context.rs b/staking/programs/staking/src/context.rs index cc11fe17..8c3e82e3 100644 --- a/staking/programs/staking/src/context.rs +++ b/staking/programs/staking/src/context.rs @@ -2,6 +2,7 @@ use { crate::{ error::ErrorCode, state::*, + ADDRESSES_TO_SHORTEN_VESTING_SCHEDULE, }, anchor_lang::prelude::*, anchor_spl::token::{ @@ -288,7 +289,6 @@ pub struct UpdateMaxVoterWeight<'info> { pub system_program: Program<'info, System>, } - #[derive(Accounts)] pub struct CreateTarget<'info> { #[account(mut)] @@ -476,6 +476,22 @@ pub struct TransferAccount<'info> { pub config: Account<'info, global_config::GlobalConfig>, } +#[derive(Accounts)] +pub struct ShortenVestingSchedule<'info> { + #[account(constraint = ADDRESSES_TO_SHORTEN_VESTING_SCHEDULE.contains(&stake_account_positions.key()) @ ErrorCode::UnauthorizedVestingScheduleShortening)] + pub stake_account_positions: AccountLoader<'info, positions::PositionData>, + + #[account( + mut, + seeds = [ + STAKE_ACCOUNT_METADATA_SEED.as_bytes(), + stake_account_positions.key().as_ref() + ], + bump = stake_account_metadata.metadata_bump, + )] + pub stake_account_metadata: Account<'info, stake_account::StakeAccountMetadataV2>, +} + #[derive(Accounts)] #[instruction(slash_ratio: u64)] pub struct SlashAccount<'info> { diff --git a/staking/programs/staking/src/error.rs b/staking/programs/staking/src/error.rs index 61b33614..0e50cd3e 100644 --- a/staking/programs/staking/src/error.rs +++ b/staking/programs/staking/src/error.rs @@ -84,6 +84,10 @@ pub enum ErrorCode { InvalidSlashRatio, #[msg("The target account is only expected when dealing with the governance target")] // 6039 UnexpectedTargetAccount, + #[msg( + "The vesting schedule associated with this stake account is not eligible for shortening" + )] // 6040 + UnauthorizedVestingScheduleShortening, #[msg("Other")] //6040 Other, } diff --git a/staking/programs/staking/src/lib.rs b/staking/programs/staking/src/lib.rs index 2ea58122..41e2ddf9 100644 --- a/staking/programs/staking/src/lib.rs +++ b/staking/programs/staking/src/lib.rs @@ -37,7 +37,6 @@ use { }, }; - pub mod context; pub mod error; pub mod state; @@ -45,6 +44,42 @@ pub mod utils; #[cfg(feature = "wasm")] pub mod wasm; +pub const ADDRESSES_TO_SHORTEN_VESTING_SCHEDULE: [Pubkey; 33] = [ + pubkey!("AunhvBL2HQE4rG59prhTYLrigZC5XTJk4Qtmd6X4Djfq"), + pubkey!("2JiezfC6KDNJQuYnHTUjkqEtuLYANc6CQJojDqxJUFec"), + pubkey!("3n6KpqyC165khFA7qLfCoTKdpoaSDiN45c9VxA3f7xGm"), + pubkey!("GGcmnU9Gt26zM6RwbqSKp1K1zxAj31BX9yE3WTdqhtC6"), + pubkey!("5qtUb3iTNJHqnLFGgUEZHfDejADXM2qeEMsWEVJBcXDR"), + pubkey!("C2CPuMSitGsvpC2GjtqdEuytuufreaSUXGswGe928BrX"), + pubkey!("DPC1Gs7uwg2p6frBfTbmNGEnf2kiNxLG6CK9iV8kZy9k"), + pubkey!("FeGPVVmGSUorESTUh9Vadf7X4c8R47scQAZRzbKGxCSS"), + pubkey!("D6Qy7iBy9W5cVseeGViV27riU8qHQ1NRF2gT3ak3atcs"), + pubkey!("aUFNWZoy9hiGC1yNMZk2yKDaJ2cwwpB2sHdj1cnRMx5"), + pubkey!("CjMkdx5d2tcCNCLap3K1rMfyLp5798him48UeXH8K5k7"), + pubkey!("CBE6bipgEKAE8JYqGVZWuysE64MqWjbDevraJcXBSYog"), + pubkey!("8kdnTR2MW2KUBrzHyybfGNyw9H1Lu4Zg57utGyDossD1"), + pubkey!("JvCmc172ZMXHVysoX1qWaZjXJrc4kCjY4nLCQ5WSXYa"), + pubkey!("2Xa18G2nFfGcU5PcJsaBV2jthDZKY1orLCyHdBmu3uNo"), + pubkey!("8RrU678P2Es35dfpGhkzdVXq87tJsgqF3CjTbWtCq7G9"), + pubkey!("Gezp6sKA4XSvUcevL6RuUEZmvmkTDN9RpesPz3BPJZ3k"), + pubkey!("J88qzDXKfNqw8qDz6GdmonJKKinPcMNqm5qYZQti7e7a"), + pubkey!("8xFfHTJZufRWyFTpeLaZ97TaKpxqBNim1NP69kyjxtsd"), + pubkey!("F5gijHK2GQ3XyUrr64czr4TuQnVCHWiT7me1EQXP4W6J"), + pubkey!("2MsHhVogEwhSbaxjGgMFGesrn4eSExhHHKLGrbscnu5N"), + pubkey!("AVa4Z24MGc5vTEJXhAybd5of41pyrzsK7jVVWLkA3PPg"), + pubkey!("9aqPksdfu6Kv6LRE9Rt1gmeuSdsbuwKf5BESeBvaZYiA"), + pubkey!("5gfYrp8cWTbUsjSobUiUMwWjUCGd5CGeqfmQ6Cgop8J6"), + pubkey!("GGxD85ccf1QqyGBkC8BVD9fX3se5336UYXcKY6o1n4nA"), + pubkey!("9vLKNQPNysVpKiD5QqmJvCRb8b9mFuMZjZJa6KYYTEJo"), + pubkey!("GAXNojYYCTF78MdTGtNaJNoTY1Wuc85BXTZG9bFR8Z9n"), + pubkey!("XjRuaPsf6vtrptXJSAEaH1FfvR9qFbPo7C1VL9JsK4y"), + pubkey!("ByhczXw53TArwfSZySU5Uv4Y78Hx9SRbhmr6ZWE6xhvE"), + pubkey!("HQqMNPzJGv8zpigwMygoUhqjjUawCQ8ZC783MVqA31hK"), + pubkey!("8XEZpXQYRVyjk2inGhR3zHVM7mawm7TtrUNJjWvG4JvW"), + pubkey!("E5X7cQLtq1RdzdttiKt93FoD8A7qoFNhmQzRzquKvg9s"), + pubkey!("B6SzhpQhLnty2JjBGubvyvqmrqq9wzsuAYGNSJuTgvME"), +]; + declare_id!("pytS9TjG1qyAZypk7n8rw8gfW9sUaqqYyMhJQ4E7JCQ"); #[program] pub mod staking { @@ -250,7 +285,6 @@ pub mod staking { let config = &ctx.accounts.config; let current_epoch = get_current_epoch(config)?; - if let TargetWithParameters::IntegrityPool { .. } = target_with_parameters { require!( ctx.accounts @@ -283,7 +317,6 @@ pub mod staking { return Err(error!(ErrorCode::ClosePositionWithZero)); } - let i: usize = index.into(); let stake_account_positions = &mut DynamicPositionArray::load_mut(&ctx.accounts.stake_account_positions)?; @@ -413,7 +446,6 @@ pub mod staking { let config = &ctx.accounts.config; let current_epoch = get_current_epoch(config)?; - let unvested_balance = ctx .accounts .stake_account_metadata @@ -632,7 +664,6 @@ pub mod staking { Ok(()) } - /** * A split request can only be accepted by the `pda_authority` from * the config account. If accepted, `amount` tokens are transferred to a new stake account @@ -710,7 +741,6 @@ pub mod staking { .new_stake_account_metadata .set_lock(new_vesting_schedule); - transfer( CpiContext::from(&*ctx.accounts).with_signer(&[&[ AUTHORITY_SEED.as_bytes(), @@ -723,7 +753,6 @@ pub mod staking { ctx.accounts.source_stake_account_custody.reload()?; ctx.accounts.new_stake_account_custody.reload()?; - // Post-check utils::risk::validate( source_stake_account_positions, @@ -817,6 +846,26 @@ pub mod staking { Ok(()) } + pub fn shorten_vesting_schedule(ctx: Context) -> Result<()> { + let stake_account_metadata = &mut ctx.accounts.stake_account_metadata; + + if let VestingSchedule::PeriodicVesting { + initial_balance, + start_date, + period_duration, + num_periods: _, + } = stake_account_metadata.lock + { + stake_account_metadata.lock = VestingSchedule::PeriodicVesting { + initial_balance, + start_date, + period_duration, + num_periods: 1, + }; + } + Ok(()) + } + pub fn slash_account( ctx: Context, // a number between 0 and 1 with 6 decimals of precision diff --git a/staking/target/idl/staking.json b/staking/target/idl/staking.json index acfa8631..427c754f 100644 --- a/staking/target/idl/staking.json +++ b/staking/target/idl/staking.json @@ -2,7 +2,7 @@ "address": "pytS9TjG1qyAZypk7n8rw8gfW9sUaqqYyMhJQ4E7JCQ", "metadata": { "name": "staking", - "version": "2.1.0", + "version": "2.2.0", "spec": "0.1.0", "description": "Created with Anchor" }, @@ -1516,6 +1516,56 @@ } ] }, + { + "name": "shorten_vesting_schedule", + "discriminator": [ + 15, + 10, + 44, + 24, + 77, + 125, + 58, + 186 + ], + "accounts": [ + { + "name": "stake_account_positions" + }, + { + "name": "stake_account_metadata", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 115, + 116, + 97, + 107, + 101, + 95, + 109, + 101, + 116, + 97, + 100, + 97, + 116, + 97 + ] + }, + { + "kind": "account", + "path": "stake_account_positions" + } + ] + } + } + ], + "args": [] + }, { "name": "slash_account", "discriminator": [ @@ -2715,6 +2765,11 @@ }, { "code": 6040, + "name": "UnauthorizedVestingScheduleShortening", + "msg": "The vesting schedule associated with this stake account is not eligible for shortening" + }, + { + "code": 6041, "name": "Other", "msg": "Other" } diff --git a/staking/target/types/staking.ts b/staking/target/types/staking.ts index 69aac8a5..08410669 100644 --- a/staking/target/types/staking.ts +++ b/staking/target/types/staking.ts @@ -8,7 +8,7 @@ export type Staking = { "address": "pytS9TjG1qyAZypk7n8rw8gfW9sUaqqYyMhJQ4E7JCQ", "metadata": { "name": "staking", - "version": "2.1.0", + "version": "2.2.0", "spec": "0.1.0", "description": "Created with Anchor" }, @@ -1522,6 +1522,56 @@ export type Staking = { } ] }, + { + "name": "shortenVestingSchedule", + "discriminator": [ + 15, + 10, + 44, + 24, + 77, + 125, + 58, + 186 + ], + "accounts": [ + { + "name": "stakeAccountPositions" + }, + { + "name": "stakeAccountMetadata", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 115, + 116, + 97, + 107, + 101, + 95, + 109, + 101, + 116, + 97, + 100, + 97, + 116, + 97 + ] + }, + { + "kind": "account", + "path": "stakeAccountPositions" + } + ] + } + } + ], + "args": [] + }, { "name": "slashAccount", "discriminator": [ @@ -2721,6 +2771,11 @@ export type Staking = { }, { "code": 6040, + "name": "unauthorizedVestingScheduleShortening", + "msg": "The vesting schedule associated with this stake account is not eligible for shortening" + }, + { + "code": 6041, "name": "other", "msg": "other" }