diff --git a/staking/app/StakeConnection.ts b/staking/app/StakeConnection.ts index 98476efd..29b11d97 100644 --- a/staking/app/StakeConnection.ts +++ b/staking/app/StakeConnection.ts @@ -122,6 +122,11 @@ export class StakeConnection { ); } + /** The public key of the user of the staking program. This connection sends transactions as this user. */ + public userPublicKey(): PublicKey { + return this.provider.wallet.publicKey; + } + public async getAllStakeAccountAddresses(): Promise { // Use the raw web3.js connection so that anchor doesn't try to borsh deserialize the zero-copy serialized account const allAccts = await this.provider.connection.getProgramAccounts( @@ -539,9 +544,17 @@ export class StakeConnection { ) { throw Error(`Unexpected account state ${vestingAccountState}`); } - const owner: PublicKey = stakeAccount.stakeAccountMetadata.owner; + const balanceSummary = stakeAccount.getBalanceSummary(await this.getTime()); - const amountBN = balanceSummary.unvested.unlocked.toBN(); + await this.lockTokens(stakeAccount, balanceSummary.unvested.unlocked); + } + + /** + * Locks the specified amount of tokens in governance. + */ + public async lockTokens(stakeAccount: StakeAccount, amount: PythBalance) { + const owner: PublicKey = stakeAccount.stakeAccountMetadata.owner; + const amountBN = amount.toBN(); const transaction: Transaction = new Transaction(); @@ -860,7 +873,11 @@ export class StakeConnection { .rpc(); } - public async acceptSplit(stakeAccount: StakeAccount) { + public async acceptSplit( + stakeAccount: StakeAccount, + amount: PythBalance, + recipient: PublicKey + ) { const newStakeAccountKeypair = new Keypair(); const instructions = []; @@ -872,7 +889,7 @@ export class StakeConnection { ); await this.program.methods - .acceptSplit() + .acceptSplit(amount.toBN(), recipient) .accounts({ sourceStakeAccountPositions: stakeAccount.address, newStakeAccountPositions: newStakeAccountKeypair.publicKey, diff --git a/staking/programs/staking/src/context.rs b/staking/programs/staking/src/context.rs index 33f9599f..068cb2c1 100644 --- a/staking/programs/staking/src/context.rs +++ b/staking/programs/staking/src/context.rs @@ -311,6 +311,7 @@ pub struct RequestSplit<'info> { } #[derive(Accounts)] +#[instruction(amount: u64, recipient: Pubkey)] pub struct AcceptSplit<'info> { // Native payer: #[account(mut, address = config.pda_authority)] diff --git a/staking/programs/staking/src/error.rs b/staking/programs/staking/src/error.rs index 653858b7..53a383fe 100644 --- a/staking/programs/staking/src/error.rs +++ b/staking/programs/staking/src/error.rs @@ -65,6 +65,15 @@ pub enum ErrorCode { NotLlcMember, #[msg("Invalid LLC agreement")] // 6030 InvalidLlcAgreement, - #[msg("Other")] //6031 + #[msg("Can't split 0 tokens from an account")] // 6031 + SplitZeroTokens, + #[msg("Can't split more tokens than are in the account")] // 6032 + SplitTooManyTokens, + #[msg("Can't split a token account with staking positions. Unstake your tokens first.")] + // 6033 + SplitWithStake, + #[msg("The approval arguments do not match the split request.")] // 6034 + InvalidApproval, + #[msg("Other")] //6035 Other, } diff --git a/staking/programs/staking/src/lib.rs b/staking/programs/staking/src/lib.rs index 44b55b0a..fff4c844 100644 --- a/staking/programs/staking/src/lib.rs +++ b/staking/programs/staking/src/lib.rs @@ -2,6 +2,7 @@ #![allow(dead_code)] #![allow(clippy::upper_case_acronyms)] #![allow(clippy::result_large_err)] +#![allow(clippy::too_many_arguments)] // Objects of type Result must be used, otherwise we might // call a function that returns a Result and not handle the error @@ -53,6 +54,7 @@ pub mod staking { /// Creates a global config for the program use super::*; + pub fn init_config(ctx: Context, global_config: GlobalConfig) -> Result<()> { let config_account = &mut ctx.accounts.config_account; config_account.bump = *ctx.bumps.get("config_account").unwrap(); @@ -124,25 +126,20 @@ pub mod staking { config.check_frozen()?; let stake_account_metadata = &mut ctx.accounts.stake_account_metadata; - stake_account_metadata.metadata_bump = *ctx.bumps.get("stake_account_metadata").unwrap(); - stake_account_metadata.custody_bump = *ctx.bumps.get("stake_account_custody").unwrap(); - stake_account_metadata.authority_bump = *ctx.bumps.get("custody_authority").unwrap(); - stake_account_metadata.voter_bump = *ctx.bumps.get("voter_record").unwrap(); - stake_account_metadata.owner = owner; - stake_account_metadata.next_index = 0; - - stake_account_metadata.lock = lock; - stake_account_metadata.transfer_epoch = None; - stake_account_metadata.signed_agreement_hash = None; + stake_account_metadata.initialize( + *ctx.bumps.get("stake_account_metadata").unwrap(), + *ctx.bumps.get("stake_account_custody").unwrap(), + *ctx.bumps.get("custody_authority").unwrap(), + *ctx.bumps.get("voter_record").unwrap(), + &owner, + ); + stake_account_metadata.set_lock(lock); let stake_account_positions = &mut ctx.accounts.stake_account_positions.load_init()?; - stake_account_positions.owner = owner; + stake_account_positions.initialize(&owner); let voter_record = &mut ctx.accounts.voter_record; - - voter_record.realm = config.pyth_governance_realm; - voter_record.governing_token_mint = config.pyth_token_mint; - voter_record.governing_token_owner = owner; + voter_record.initialize(config, &owner); Ok(()) } @@ -573,32 +570,131 @@ pub mod staking { * the config account. If accepted, `amount` tokens are transferred to a new stake account * owned by the `recipient` and the split request is reset (by setting `amount` to 0). * The recipient of a transfer can't vote during the epoch of the transfer. + * + * The `pda_authority` must explicitly approve both the amount of tokens and recipient, and + * these parameters must match the request (in the `split_request` account). */ - pub fn accept_split(ctx: Context) -> Result<()> { - // TODO : Split vesting schedule between both accounts + pub fn accept_split(ctx: Context, amount: u64, recipient: Pubkey) -> Result<()> { + let config = &ctx.accounts.config; + config.check_frozen()?; - // TODO : Transfer stake positions to the new account if need + let current_epoch = get_current_epoch(config)?; - // TODO Check both accounts are valid after the transfer + let split_request = &ctx.accounts.source_stake_account_split_request; + require!( + split_request.amount == amount && split_request.recipient == recipient, + ErrorCode::InvalidApproval + ); + + // Initialize new accounts + ctx.accounts.new_stake_account_metadata.initialize( + *ctx.bumps.get("new_stake_account_metadata").unwrap(), + *ctx.bumps.get("new_stake_account_custody").unwrap(), + *ctx.bumps.get("new_custody_authority").unwrap(), + *ctx.bumps.get("new_voter_record").unwrap(), + &split_request.recipient, + ); + + let new_stake_account_positions = + &mut ctx.accounts.new_stake_account_positions.load_init()?; + new_stake_account_positions.initialize(&split_request.recipient); + + let new_voter_record = &mut ctx.accounts.new_voter_record; + new_voter_record.initialize(config, &split_request.recipient); + + // Pre-check invariants + // Note that the accept operation requires the positions account to be empty, which should trivially + // pass this invariant check. However, we explicitly check invariants everywhere else, so may + // as well check in this operation also. + let source_stake_account_positions = + &mut ctx.accounts.source_stake_account_positions.load_mut()?; + utils::risk::validate( + source_stake_account_positions, + ctx.accounts.source_stake_account_custody.amount, + ctx.accounts + .source_stake_account_metadata + .lock + .get_unvested_balance( + utils::clock::get_current_time(config), + config.pyth_token_list_time, + )?, + current_epoch, + config.unlocking_duration, + )?; - // Transfer tokens - { - let split_request = &ctx.accounts.source_stake_account_split_request; - transfer( - CpiContext::from(&*ctx.accounts).with_signer(&[&[ - AUTHORITY_SEED.as_bytes(), - ctx.accounts.source_stake_account_positions.key().as_ref(), - &[ctx.accounts.source_stake_account_metadata.authority_bump], - ]]), + // Check that there aren't any positions (i.e., staked tokens) in the source account. + // This check allows us to create an empty positions account on behalf of the recipient and + // not worry about moving positions from the source account to the new account. + require!( + ctx.accounts.source_stake_account_metadata.next_index == 0, + ErrorCode::SplitWithStake + ); + + require!(split_request.amount > 0, ErrorCode::SplitZeroTokens); + + // Split vesting account + let (source_vesting_schedule, new_vesting_schedule) = ctx + .accounts + .source_stake_account_metadata + .lock + .split_vesting_schedule( split_request.amount, + ctx.accounts.source_stake_account_custody.amount, )?; - } + ctx.accounts + .source_stake_account_metadata + .set_lock(source_vesting_schedule); + ctx.accounts + .new_stake_account_metadata + .set_lock(new_vesting_schedule); + + + transfer( + CpiContext::from(&*ctx.accounts).with_signer(&[&[ + AUTHORITY_SEED.as_bytes(), + ctx.accounts.source_stake_account_positions.key().as_ref(), + &[ctx.accounts.source_stake_account_metadata.authority_bump], + ]]), + split_request.amount, + )?; + + ctx.accounts.source_stake_account_custody.reload()?; + ctx.accounts.new_stake_account_custody.reload()?; + + + // Post-check + utils::risk::validate( + source_stake_account_positions, + ctx.accounts.source_stake_account_custody.amount, + ctx.accounts + .source_stake_account_metadata + .lock + .get_unvested_balance( + utils::clock::get_current_time(config), + config.pyth_token_list_time, + )?, + current_epoch, + config.unlocking_duration, + )?; + + utils::risk::validate( + new_stake_account_positions, + ctx.accounts.new_stake_account_custody.amount, + ctx.accounts + .new_stake_account_metadata + .lock + .get_unvested_balance( + utils::clock::get_current_time(config), + config.pyth_token_list_time, + )?, + current_epoch, + config.unlocking_duration, + )?; // Delete current request - { - ctx.accounts.source_stake_account_split_request.amount = 0; - } - err!(ErrorCode::NotImplemented) + ctx.accounts.source_stake_account_split_request.amount = 0; + + Ok(()) } /** diff --git a/staking/programs/staking/src/state/positions.rs b/staking/programs/staking/src/state/positions.rs index 0a4d22b8..5835054a 100644 --- a/staking/programs/staking/src/state/positions.rs +++ b/staking/programs/staking/src/state/positions.rs @@ -47,6 +47,10 @@ impl Default for PositionData { } } impl PositionData { + pub fn initialize(&mut self, owner: &Pubkey) { + self.owner = *owner; + } + /// Finds first index available for a new position, increments the internal counter pub fn reserve_new_index(&mut self, next_index: &mut u8) -> Result { let res = *next_index as usize; @@ -403,7 +407,6 @@ pub mod tests { } } - #[quickcheck] fn prop(input: Vec) -> bool { let mut position_data = PositionData::default(); diff --git a/staking/programs/staking/src/state/stake_account.rs b/staking/programs/staking/src/state/stake_account.rs index 26962d47..c0f840e7 100644 --- a/staking/programs/staking/src/state/stake_account.rs +++ b/staking/programs/staking/src/state/stake_account.rs @@ -41,6 +41,30 @@ impl StakeAccountMetadataV2 { } } +impl StakeAccountMetadataV2 { + pub fn initialize( + &mut self, + metadata_bump: u8, + custody_bump: u8, + authority_bump: u8, + voter_bump: u8, + owner: &Pubkey, + ) { + self.metadata_bump = metadata_bump; + self.custody_bump = custody_bump; + self.authority_bump = authority_bump; + self.voter_bump = voter_bump; + self.owner = *owner; + self.next_index = 0; + self.transfer_epoch = None; + self.signed_agreement_hash = None; + } + + pub fn set_lock(&mut self, lock: VestingSchedule) { + self.lock = lock; + } +} + #[cfg(test)] pub mod tests { use { diff --git a/staking/programs/staking/src/state/vesting.rs b/staking/programs/staking/src/state/vesting.rs index 516505c9..fb9e0b53 100644 --- a/staking/programs/staking/src/state/vesting.rs +++ b/staking/programs/staking/src/state/vesting.rs @@ -20,7 +20,7 @@ use { /// Represents how a given initial balance vests over time /// It is unit-less, but units must be consistent #[repr(u8)] -#[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, Copy, BorshSchema)] +#[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, Copy, BorshSchema, PartialEq)] pub enum VestingSchedule { /// No vesting, i.e. balance is fully vested at all time FullyVested, @@ -233,6 +233,65 @@ impl VestingSchedule { amount, })) } + + pub fn split_vesting_schedule( + &self, + transferred_amount: u64, + total_amount: u64, + ) -> Result<(VestingSchedule, VestingSchedule)> { + let remaining_amount = total_amount + .checked_sub(transferred_amount) + .ok_or(ErrorCode::GenericOverflow)?; + + // Note that the arithmetic below may lose precision. The calculations round down + // the number of vesting tokens for both of the new accounts, which means that splitting + // may vest some dust (1 of the smallest decimal point) of PYTH for both the source and + // destination accounts. + match self { + VestingSchedule::FullyVested => { + Ok((VestingSchedule::FullyVested, VestingSchedule::FullyVested)) + } + VestingSchedule::PeriodicVesting { + initial_balance, + start_date, + period_duration, + num_periods, + } => Ok(( + VestingSchedule::PeriodicVesting { + initial_balance: ((remaining_amount as u128) * (*initial_balance as u128) + / (total_amount as u128)) as u64, + start_date: *start_date, + period_duration: *period_duration, + num_periods: *num_periods, + }, + VestingSchedule::PeriodicVesting { + initial_balance: ((transferred_amount as u128) * (*initial_balance as u128) + / (total_amount as u128)) as u64, + start_date: *start_date, + period_duration: *period_duration, + num_periods: *num_periods, + }, + )), + VestingSchedule::PeriodicVestingAfterListing { + initial_balance, + period_duration, + num_periods, + } => Ok(( + VestingSchedule::PeriodicVestingAfterListing { + initial_balance: ((remaining_amount as u128) * (*initial_balance as u128) + / (total_amount as u128)) as u64, + period_duration: *period_duration, + num_periods: *num_periods, + }, + VestingSchedule::PeriodicVestingAfterListing { + initial_balance: ((transferred_amount as u128) * (*initial_balance as u128) + / (total_amount as u128)) as u64, + period_duration: *period_duration, + num_periods: *num_periods, + }, + )), + } + } } #[cfg(test)] @@ -240,8 +299,14 @@ pub mod tests { use { crate::state::vesting::{ VestingEvent, - VestingSchedule, + VestingSchedule::{ + self, + PeriodicVesting, + PeriodicVestingAfterListing, + }, }, + quickcheck::TestResult, + quickcheck_macros::quickcheck, std::convert::TryInto, }; @@ -570,4 +635,169 @@ pub mod tests { None ); } + + const START_TIMESTAMP: i64 = 10; + const PERIOD_DURATION: u64 = 5; + const NUM_PERIODS: u64 = 4; + + #[quickcheck] + fn test_split_props(transferred: u64, total: u64, initial_balance: u64) -> TestResult { + if transferred > total || total == 0 { + return TestResult::discard(); + } + let received = total - transferred; + + let schedule = VestingSchedule::FullyVested; + let (remaining_schedule, transferred_schedule) = + schedule.split_vesting_schedule(transferred, total).unwrap(); + + assert_eq!(remaining_schedule, VestingSchedule::FullyVested); + assert_eq!(transferred_schedule, VestingSchedule::FullyVested); + + let schedule = PeriodicVesting { + initial_balance, + // all of these fields should be preserved in the result + start_date: START_TIMESTAMP, + period_duration: PERIOD_DURATION, + num_periods: NUM_PERIODS, + }; + let (remaining_schedule, transferred_schedule) = + schedule.split_vesting_schedule(transferred, total).unwrap(); + + match (remaining_schedule, transferred_schedule) { + ( + PeriodicVesting { + initial_balance: r, .. + }, + PeriodicVesting { + initial_balance: t, .. + }, + ) => { + let sum = r + t; + assert!(initial_balance.saturating_sub(2) <= sum && sum <= initial_balance); + } + _ => { + panic!("Test failed"); + } + } + + let schedule = PeriodicVestingAfterListing { + initial_balance, + // all of these fields should be preserved in the result + period_duration: PERIOD_DURATION, + num_periods: NUM_PERIODS, + }; + let (remaining_schedule, transferred_schedule) = + schedule.split_vesting_schedule(transferred, total).unwrap(); + + match (remaining_schedule, transferred_schedule) { + ( + PeriodicVestingAfterListing { + initial_balance: r, .. + }, + PeriodicVestingAfterListing { + initial_balance: t, .. + }, + ) => { + let sum = r + t; + assert!(initial_balance.saturating_sub(2) <= sum && sum <= initial_balance); + } + _ => { + panic!("Test failed"); + } + } + + for timestamp in 0..(START_TIMESTAMP + (PERIOD_DURATION * NUM_PERIODS + 1) as i64) { + let initial_unvested = schedule + .get_unvested_balance(timestamp, Some(START_TIMESTAMP)) + .unwrap(); + let remaining_unvested = remaining_schedule + .get_unvested_balance(timestamp, Some(START_TIMESTAMP)) + .unwrap(); + let transferred_unvested = transferred_schedule + .get_unvested_balance(timestamp, Some(START_TIMESTAMP)) + .unwrap(); + + assert!( + initial_unvested.saturating_sub(2) <= (remaining_unvested + transferred_unvested) + && (remaining_unvested + transferred_unvested) <= initial_unvested + ); + + if initial_unvested <= total { + assert!(transferred_unvested <= transferred); + assert!(remaining_unvested <= received); + } + } + + TestResult::passed() + } + + fn test_split_helper( + transferred: u64, + total: u64, + initial_balance: u64, + expected_remaining: u64, + expected_transferred: u64, + ) { + let schedule = PeriodicVesting { + initial_balance, + // all of these fields should be preserved in the result + start_date: 203, + period_duration: 100, + num_periods: 12, + }; + let (remaining_schedule, transferred_schedule) = + schedule.split_vesting_schedule(transferred, total).unwrap(); + + let t = PeriodicVesting { + initial_balance: expected_transferred, + start_date: 203, + period_duration: 100, + num_periods: 12, + }; + let r = PeriodicVesting { + initial_balance: expected_remaining, + start_date: 203, + period_duration: 100, + num_periods: 12, + }; + + assert_eq!(remaining_schedule, r); + assert_eq!(transferred_schedule, t); + + let schedule = PeriodicVestingAfterListing { + initial_balance, + period_duration: 100, + num_periods: 12, + }; + let (remaining_schedule, transferred_schedule) = + schedule.split_vesting_schedule(transferred, total).unwrap(); + + let t = PeriodicVestingAfterListing { + initial_balance: expected_transferred, + period_duration: 100, + num_periods: 12, + }; + let r = PeriodicVestingAfterListing { + initial_balance: expected_remaining, + period_duration: 100, + num_periods: 12, + }; + + assert_eq!(remaining_schedule, r); + assert_eq!(transferred_schedule, t); + } + + #[test] + fn test_split() { + test_split_helper(10, 100, 100, 90, 10); + test_split_helper(10, 1000, 100, 99, 1); + test_split_helper(1, 1000, 100, 99, 0); + + test_split_helper(10, 10, 1000, 0, 1000); + test_split_helper(9, 10, 1000, 100, 900); + test_split_helper(10, 100, 1000, 900, 100); + + test_split_helper(1, 3, 1000, 666, 333); + } } diff --git a/staking/programs/staking/src/state/voter_weight_record.rs b/staking/programs/staking/src/state/voter_weight_record.rs index 09bf6142..cb8c0fc6 100644 --- a/staking/programs/staking/src/state/voter_weight_record.rs +++ b/staking/programs/staking/src/state/voter_weight_record.rs @@ -1,6 +1,9 @@ -use anchor_lang::prelude::{ - borsh::BorshSchema, - *, +use { + super::global_config::GlobalConfig, + anchor_lang::prelude::{ + borsh::BorshSchema, + *, + }, }; /// Copied this struct from https://github.com/solana-labs/solana-program-library/blob/master/governance/addin-api/src/voter_weight.rs @@ -62,6 +65,12 @@ pub struct VoterWeightRecord { impl VoterWeightRecord { pub const LEN: usize = 8 + 32 + 32 + 32 + 8 + 9 + 2 + 33 + 8; + + pub fn initialize(&mut self, config: &GlobalConfig, owner: &Pubkey) { + self.realm = config.pyth_governance_realm; + self.governing_token_mint = config.pyth_token_mint; + self.governing_token_owner = *owner; + } } /// The governance action VoterWeight is evaluated for #[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, Copy, BorshSchema)] diff --git a/staking/programs/staking/src/utils/risk.rs b/staking/programs/staking/src/utils/risk.rs index 0a0e9466..66c6c145 100644 --- a/staking/programs/staking/src/utils/risk.rs +++ b/staking/programs/staking/src/utils/risk.rs @@ -20,7 +20,6 @@ use { }, }; - /// Validates that a proposed set of positions meets all risk requirements /// stake_account_positions is untrusted, while everything else is trusted /// If it passes the risk check, it returns the max amount of vested balance diff --git a/staking/target/idl/staking.json b/staking/target/idl/staking.json index 716d63a8..b1f433f6 100644 --- a/staking/target/idl/staking.json +++ b/staking/target/idl/staking.json @@ -910,7 +910,7 @@ { "name": "acceptSplit", "docs": [ - "* A split request can only be accepted by the `pda_authority`` from\n * the config account. If accepted, `amount` tokens are transferred to a new stake account\n * owned by the `recipient` and the split request is reset (by setting `amount` to 0).\n * The recipient of a transfer can't vote during the epoch of the transfer." + "* A split request can only be accepted by the `pda_authority`` from\n * the config account. If accepted, `amount` tokens are transferred to a new stake account\n * owned by the `recipient` and the split request is reset (by setting `amount` to 0).\n * The recipient of a transfer can't vote during the epoch of the transfer.\n *\n * The `pda_authority` must explicitly approve both the amount of tokens and recipient, and\n * these parameters must match the request (in the `split_request` account)." ], "accounts": [ { @@ -1121,7 +1121,16 @@ "isSigner": false } ], - "args": [] + "args": [ + { + "name": "amount", + "type": "u64" + }, + { + "name": "recipient", + "type": "publicKey" + } + ] }, { "name": "joinDaoLlc", @@ -1929,6 +1938,26 @@ }, { "code": 6031, + "name": "SplitZeroTokens", + "msg": "Can't split 0 tokens from an account" + }, + { + "code": 6032, + "name": "SplitTooManyTokens", + "msg": "Can't split more tokens than are in the account" + }, + { + "code": 6033, + "name": "SplitWithStake", + "msg": "Can't split a token account with staking positions. Unstake your tokens first." + }, + { + "code": 6034, + "name": "InvalidApproval", + "msg": "The approval arguments do not match the split request." + }, + { + "code": 6035, "name": "Other", "msg": "Other" } diff --git a/staking/target/types/staking.ts b/staking/target/types/staking.ts index 8afd5133..f37352f7 100644 --- a/staking/target/types/staking.ts +++ b/staking/target/types/staking.ts @@ -910,7 +910,7 @@ export type Staking = { { "name": "acceptSplit", "docs": [ - "* A split request can only be accepted by the `pda_authority`` from\n * the config account. If accepted, `amount` tokens are transferred to a new stake account\n * owned by the `recipient` and the split request is reset (by setting `amount` to 0).\n * The recipient of a transfer can't vote during the epoch of the transfer." + "* A split request can only be accepted by the `pda_authority`` from\n * the config account. If accepted, `amount` tokens are transferred to a new stake account\n * owned by the `recipient` and the split request is reset (by setting `amount` to 0).\n * The recipient of a transfer can't vote during the epoch of the transfer.\n *\n * The `pda_authority` must explicitly approve both the amount of tokens and recipient, and\n * these parameters must match the request (in the `split_request` account)." ], "accounts": [ { @@ -1121,7 +1121,16 @@ export type Staking = { "isSigner": false } ], - "args": [] + "args": [ + { + "name": "amount", + "type": "u64" + }, + { + "name": "recipient", + "type": "publicKey" + } + ] }, { "name": "joinDaoLlc", @@ -1929,6 +1938,26 @@ export type Staking = { }, { "code": 6031, + "name": "SplitZeroTokens", + "msg": "Can't split 0 tokens from an account" + }, + { + "code": 6032, + "name": "SplitTooManyTokens", + "msg": "Can't split more tokens than are in the account" + }, + { + "code": 6033, + "name": "SplitWithStake", + "msg": "Can't split a token account with staking positions. Unstake your tokens first." + }, + { + "code": 6034, + "name": "InvalidApproval", + "msg": "The approval arguments do not match the split request." + }, + { + "code": 6035, "name": "Other", "msg": "Other" } @@ -2847,7 +2876,7 @@ export const IDL: Staking = { { "name": "acceptSplit", "docs": [ - "* A split request can only be accepted by the `pda_authority`` from\n * the config account. If accepted, `amount` tokens are transferred to a new stake account\n * owned by the `recipient` and the split request is reset (by setting `amount` to 0).\n * The recipient of a transfer can't vote during the epoch of the transfer." + "* A split request can only be accepted by the `pda_authority`` from\n * the config account. If accepted, `amount` tokens are transferred to a new stake account\n * owned by the `recipient` and the split request is reset (by setting `amount` to 0).\n * The recipient of a transfer can't vote during the epoch of the transfer.\n *\n * The `pda_authority` must explicitly approve both the amount of tokens and recipient, and\n * these parameters must match the request (in the `split_request` account)." ], "accounts": [ { @@ -3058,7 +3087,16 @@ export const IDL: Staking = { "isSigner": false } ], - "args": [] + "args": [ + { + "name": "amount", + "type": "u64" + }, + { + "name": "recipient", + "type": "publicKey" + } + ] }, { "name": "joinDaoLlc", @@ -3866,6 +3904,26 @@ export const IDL: Staking = { }, { "code": 6031, + "name": "SplitZeroTokens", + "msg": "Can't split 0 tokens from an account" + }, + { + "code": 6032, + "name": "SplitTooManyTokens", + "msg": "Can't split more tokens than are in the account" + }, + { + "code": 6033, + "name": "SplitWithStake", + "msg": "Can't split a token account with staking positions. Unstake your tokens first." + }, + { + "code": 6034, + "name": "InvalidApproval", + "msg": "The approval arguments do not match the split request." + }, + { + "code": 6035, "name": "Other", "msg": "Other" } diff --git a/staking/tests/split_vesting_account.ts b/staking/tests/split_vesting_account.ts index 4b15343d..b34a886f 100644 --- a/staking/tests/split_vesting_account.ts +++ b/staking/tests/split_vesting_account.ts @@ -1,6 +1,7 @@ import { ANCHOR_CONFIG_PATH, CustomAbortController, + getDummyAgreementHash, getPortNumber, makeDefaultConfig, readAnchorConfig, @@ -11,8 +12,12 @@ import path from "path"; import { Keypair, PublicKey, Transaction } from "@solana/web3.js"; import { StakeConnection, PythBalance, VestingAccountState } from "../app"; import { BN, Wallet } from "@project-serum/anchor"; -import { assertBalanceMatches } from "./utils/api_utils"; +import { + assertBalanceMatches, + OptionalBalanceSummary, +} from "./utils/api_utils"; import assert from "assert"; +import { expectFailWithCode } from "./utils/utils"; const ONE_MONTH = new BN(3600 * 24 * 30.5); const portNumber = getPortNumber(path.basename(__filename)); @@ -20,21 +25,13 @@ const portNumber = getPortNumber(path.basename(__filename)); describe("split vesting account", async () => { const pythMintAccount = new Keypair(); const pythMintAuthority = new Keypair(); - let EPOCH_DURATION: BN; let stakeConnection: StakeConnection; let controller: CustomAbortController; - let owner: PublicKey; - let pdaAuthority = new Keypair(); let pdaConnection: StakeConnection; - let sam = new Keypair(); - let samConnection: StakeConnection; - - let alice = new Keypair(); - before(async () => { const config = readAnchorConfig(ANCHOR_CONFIG_PATH); ({ controller, stakeConnection } = await standardSetup( @@ -49,32 +46,41 @@ describe("split vesting account", async () => { ) )); - EPOCH_DURATION = stakeConnection.config.epochDuration; - owner = stakeConnection.provider.wallet.publicKey; + pdaConnection = await connect(pdaAuthority); + }); - samConnection = await StakeConnection.createStakeConnection( + /** Create a stake connection for a keypair and airdrop the key some SOL so it can send transactions. */ + async function connect(keypair: Keypair): Promise { + let connection = await StakeConnection.createStakeConnection( stakeConnection.provider.connection, - new Wallet(sam), + new Wallet(keypair), stakeConnection.program.programId ); - pdaConnection = await StakeConnection.createStakeConnection( - stakeConnection.provider.connection, - new Wallet(pdaAuthority), - stakeConnection.program.programId + await connection.provider.connection.requestAirdrop( + keypair.publicKey, + 1_000_000_000_000 ); - }); - it("create a vesting account", async () => { + return connection; + } + + async function setupSplit( + totalBalance: string, + vestingInitialBalance: string, + lockedBalance: string + ): Promise<[StakeConnection, StakeConnection]> { + let samConnection = await connect(new Keypair()); + await samConnection.provider.connection.requestAirdrop( - sam.publicKey, + samConnection.userPublicKey(), 1_000_000_000_000 ); await requestPythAirdrop( - sam.publicKey, + samConnection.userPublicKey(), pythMintAccount.publicKey, pythMintAuthority, - PythBalance.fromString("200"), + PythBalance.fromString(totalBalance), samConnection.provider.connection ); @@ -82,10 +88,10 @@ describe("split vesting account", async () => { const stakeAccountKeypair = await samConnection.withCreateAccount( transaction.instructions, - sam.publicKey, + samConnection.userPublicKey(), { periodicVesting: { - initialBalance: PythBalance.fromString("100").toBN(), + initialBalance: PythBalance.fromString(vestingInitialBalance).toBN(), startDate: await stakeConnection.getTime(), periodDuration: ONE_MONTH, numPeriods: new BN(72), @@ -93,10 +99,15 @@ describe("split vesting account", async () => { } ); + await samConnection.withJoinDaoLlc( + transaction.instructions, + stakeAccountKeypair.publicKey + ); + transaction.instructions.push( await samConnection.buildTransferInstruction( stakeAccountKeypair.publicKey, - PythBalance.fromString("100").toBN() + PythBalance.fromString(totalBalance).toBN() ) ); @@ -106,40 +117,226 @@ describe("split vesting account", async () => { { skipPreflight: true } ); - let stakeAccount = await samConnection.getMainAccount(sam.publicKey); + let stakeAccount = await samConnection.getMainAccount( + samConnection.userPublicKey() + ); assert( VestingAccountState.UnvestedTokensFullyUnlocked == stakeAccount.getVestingAccountState(await samConnection.getTime()) ); await assertBalanceMatches( samConnection, - sam.publicKey, + samConnection.userPublicKey(), { unvested: { - unlocked: PythBalance.fromString("100"), + unlocked: PythBalance.fromString(vestingInitialBalance), }, }, await samConnection.getTime() ); + + let lockedPythBalance = PythBalance.fromString(lockedBalance); + if (lockedPythBalance.gt(PythBalance.zero())) { + // locking 0 tokens is an error + await samConnection.lockTokens(stakeAccount, lockedPythBalance); + } + + let aliceConnection = await connect(new Keypair()); + return [samConnection, aliceConnection]; + } + + async function assertMainAccountBalance( + samConnection: StakeConnection, + expectedState: VestingAccountState, + expectedBalance: OptionalBalanceSummary + ) { + let sourceStakeAccount = await samConnection.getMainAccount( + samConnection.userPublicKey() + ); + + assert( + expectedState == + sourceStakeAccount.getVestingAccountState(await samConnection.getTime()) + ); + await assertBalanceMatches( + samConnection, + samConnection.userPublicKey(), + expectedBalance, + await samConnection.getTime() + ); + } + + it("split/accept flow success", async () => { + let [samConnection, aliceConnection] = await setupSplit("100", "100", "0"); + + let stakeAccount = await samConnection.getMainAccount( + samConnection.userPublicKey() + ); + + await samConnection.requestSplit( + stakeAccount, + PythBalance.fromString("33"), + aliceConnection.userPublicKey() + ); + + await pdaConnection.acceptSplit( + stakeAccount, + PythBalance.fromString("33"), + aliceConnection.userPublicKey() + ); + + await assertMainAccountBalance( + samConnection, + VestingAccountState.UnvestedTokensFullyUnlocked, + { + unvested: { + unlocked: PythBalance.fromString("67"), + }, + } + ); + await assertMainAccountBalance( + aliceConnection, + VestingAccountState.UnvestedTokensFullyUnlocked, + { + unvested: { + unlocked: PythBalance.fromString("33"), + }, + } + ); }); - it("request split", async () => { - await pdaConnection.provider.connection.requestAirdrop( - pdaAuthority.publicKey, - 1_000_000_000_000 + it("split/accept flow full amount", async () => { + let [samConnection, aliceConnection] = await setupSplit("100", "100", "0"); + + let stakeAccount = await samConnection.getMainAccount( + samConnection.userPublicKey() ); - let stakeAccount = await samConnection.getMainAccount(sam.publicKey); await samConnection.requestSplit( stakeAccount, - PythBalance.fromString("50"), - alice.publicKey + PythBalance.fromString("100"), + aliceConnection.userPublicKey() ); - try { - await pdaConnection.acceptSplit(stakeAccount); - throw Error("This should've failed"); - } catch {} + await pdaConnection.acceptSplit( + stakeAccount, + PythBalance.fromString("100"), + aliceConnection.userPublicKey() + ); + + await assertMainAccountBalance( + samConnection, + VestingAccountState.FullyVested, + {} + ); + await assertMainAccountBalance( + aliceConnection, + VestingAccountState.UnvestedTokensFullyUnlocked, + { + unvested: { + unlocked: PythBalance.fromString("100"), + }, + } + ); + + const aliceStakeAccount = await aliceConnection.getMainAccount( + aliceConnection.userPublicKey() + ); + await aliceConnection.program.methods + .joinDaoLlc(getDummyAgreementHash()) + .accounts({ stakeAccountPositions: aliceStakeAccount.address }) + .rpc(); + await aliceConnection.program.methods + .updateVoterWeight({ createGovernance: {} }) + .accounts({ + stakeAccountPositions: aliceStakeAccount.address, + }) + .rpc(); + }); + + it("split/accept flow fails if account has locked tokens", async () => { + let [samConnection, aliceConnection] = await setupSplit("100", "100", "1"); + + let stakeAccount = await samConnection.getMainAccount( + samConnection.userPublicKey() + ); + + await samConnection.requestSplit( + stakeAccount, + PythBalance.fromString("33"), + aliceConnection.userPublicKey() + ); + + await expectFailWithCode( + pdaConnection.acceptSplit( + stakeAccount, + PythBalance.fromString("33"), + aliceConnection.userPublicKey() + ), + "SplitWithStake" + ); + + await assertMainAccountBalance( + samConnection, + VestingAccountState.UnvestedTokensPartiallyLocked, + { + unvested: { + unlocked: PythBalance.fromString("99"), + locking: PythBalance.fromString("1"), + }, + } + ); + }); + + it("split/accept flow fails if accept has mismatched args", async () => { + let [samConnection, aliceConnection] = await setupSplit("100", "100", "0"); + + let stakeAccount = await samConnection.getMainAccount( + samConnection.userPublicKey() + ); + + await samConnection.requestSplit( + stakeAccount, + PythBalance.fromString("33"), + aliceConnection.userPublicKey() + ); + + // wrong balance + await expectFailWithCode( + pdaConnection.acceptSplit( + stakeAccount, + PythBalance.fromString("34"), + aliceConnection.userPublicKey() + ), + "InvalidApproval" + ); + + // wrong recipient + await expectFailWithCode( + pdaConnection.acceptSplit( + stakeAccount, + PythBalance.fromString("33"), + samConnection.userPublicKey() + ), + "InvalidApproval" + ); + + // wrong signer + await expectFailWithCode( + aliceConnection.acceptSplit( + stakeAccount, + PythBalance.fromString("33"), + aliceConnection.userPublicKey() + ), + "ConstraintAddress" + ); + + // Passing the correct arguments should succeed + await pdaConnection.acceptSplit( + stakeAccount, + PythBalance.fromString("33"), + aliceConnection.userPublicKey() + ); }); after(async () => { diff --git a/staking/tests/utils/before.ts b/staking/tests/utils/before.ts index 0823d215..cca5da40 100644 --- a/staking/tests/utils/before.ts +++ b/staking/tests/utils/before.ts @@ -133,12 +133,23 @@ export async function startValidatorRaw(portNumber: number, otherArgs: string) { ); const controller = new CustomAbortController(internalController); + let numRetries = 0; while (true) { try { await new Promise((resolve) => setTimeout(resolve, 1000)); - await connection.getEpochInfo(); + await connection.getSlot(); break; - } catch (e) {} + } catch (e) { + // Bound the number of retries so the tests don't hang if there's some problem blocking + // the connection to the validator. + if (numRetries == 10) { + console.log( + `Failed to start validator or connect to running validator. Caught exception: ${e}` + ); + throw e; + } + numRetries += 1; + } } return { controller, connection }; } @@ -193,7 +204,7 @@ export async function startValidator(portNumber: number, config: AnchorConfig) { export function getConnection(portNumber: number): Connection { return new Connection( - `http://localhost:${portNumber}`, + `http://127.0.0.1:${portNumber}`, AnchorProvider.defaultOptions().commitment ); } @@ -482,7 +493,7 @@ export async function standardSetup( .rpc(); const connection = new Connection( - `http://localhost:${portNumber}`, + `http://127.0.0.1:${portNumber}`, AnchorProvider.defaultOptions().commitment ); diff --git a/staking/tests/utils/utils.ts b/staking/tests/utils/utils.ts index b6554d2d..18b3c7be 100644 --- a/staking/tests/utils/utils.ts +++ b/staking/tests/utils/utils.ts @@ -112,3 +112,28 @@ export async function expectFailApi(promise: Promise, error: string) { assert.equal(err.message, error); } } + +/** + * Awaits the api request and checks whether the error message matches the provided string + * @param promise : api promise + * @param errorCode : expected string + */ +export async function expectFailWithCode( + promise: Promise, + errorCode: string +) { + let actualErrorCode: string | undefined = undefined; + try { + await promise; + assert(false, "Operation should fail"); + } catch (err) { + if (err instanceof AnchorError) { + actualErrorCode = err.error.errorCode.code; + } + } + assert.equal( + actualErrorCode, + errorCode, + `Call did not fail with the expected error code.` + ); +}