diff --git a/staking/app/StakeConnection.ts b/staking/app/StakeConnection.ts index 9c44f3b3..823a95d4 100644 --- a/staking/app/StakeConnection.ts +++ b/staking/app/StakeConnection.ts @@ -810,6 +810,42 @@ export class StakeConnection { }) .rpc(); } + + public async requestSplit( + stakeAccount: StakeAccount, + amount: PythBalance, + recipient: PublicKey + ) { + await this.program.methods + .requestSplit(amount.toBN(), recipient) + .accounts({ + stakeAccountPositions: stakeAccount.address, + }) + .rpc(); + } + + public async acceptSplit(stakeAccount: StakeAccount) { + const newStakeAccountKeypair = new Keypair(); + + const instructions = []; + instructions.push( + await this.program.account.positionData.createInstruction( + newStakeAccountKeypair, + wasm.Constants.POSITIONS_ACCOUNT_SIZE() + ) + ); + + await this.program.methods + .acceptSplit() + .accounts({ + sourceStakeAccountPositions: stakeAccount.address, + newStakeAccountPositions: newStakeAccountKeypair.publicKey, + mint: this.config.pythTokenMint, + }) + .signers([newStakeAccountKeypair]) + .preInstructions(instructions) + .rpc(); + } } export interface BalanceSummary { withdrawable: PythBalance; diff --git a/staking/programs/staking/src/context.rs b/staking/programs/staking/src/context.rs index 3c41baa7..86161572 100644 --- a/staking/programs/staking/src/context.rs +++ b/staking/programs/staking/src/context.rs @@ -19,6 +19,7 @@ pub const TARGET_SEED: &str = "target"; pub const MAX_VOTER_RECORD_SEED: &str = "max_voter"; pub const VOTING_TARGET_SEED: &str = "voting"; pub const DATA_TARGET_SEED: &str = "staking"; +pub const SPLIT_REQUEST: &str = "split_request"; impl positions::Target { pub fn get_seed(&self) -> Vec { @@ -278,6 +279,97 @@ pub struct CreateTarget<'info> { pub system_program: Program<'info, System>, } +#[derive(Accounts)] +#[instruction(amount : u64, recipient : Pubkey)] +pub struct RequestSplit<'info> { + // Native payer: + #[account(mut, address = stake_account_metadata.owner)] + pub payer: Signer<'info>, + // Stake program accounts: + pub stake_account_positions: AccountLoader<'info, positions::PositionData>, + #[account(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>, + #[account(init_if_needed, payer = payer, space=split_request::SplitRequest::LEN , seeds = [SPLIT_REQUEST.as_bytes(), stake_account_positions.key().as_ref()], bump)] + pub stake_account_split_request: Account<'info, split_request::SplitRequest>, + #[account(seeds = [CONFIG_SEED.as_bytes()], bump = config.bump)] + pub config: Account<'info, global_config::GlobalConfig>, + // Primitive accounts : + pub system_program: Program<'info, System>, +} + +#[derive(Accounts)] +pub struct AcceptSplit<'info> { + // Native payer: + #[account(mut, address = config.pda_authority)] + pub payer: Signer<'info>, + // Current stake accounts: + #[account(mut)] + pub source_stake_account_positions: AccountLoader<'info, positions::PositionData>, + #[account(mut, seeds = [STAKE_ACCOUNT_METADATA_SEED.as_bytes(), source_stake_account_positions.key().as_ref()], bump = source_stake_account_metadata.metadata_bump)] + pub source_stake_account_metadata: Box>, + #[account(seeds = [SPLIT_REQUEST.as_bytes(), source_stake_account_positions.key().as_ref()], bump)] + pub source_stake_account_split_request: Box>, + #[account( + mut, + seeds = [CUSTODY_SEED.as_bytes(), source_stake_account_positions.key().as_ref()], + bump = source_stake_account_metadata.custody_bump, + )] + pub source_stake_account_custody: Box>, + /// CHECK : This AccountInfo is safe because it's a checked PDA + #[account(seeds = [AUTHORITY_SEED.as_bytes(), source_stake_account_positions.key().as_ref()], bump = source_stake_account_metadata.authority_bump)] + pub source_custody_authority: AccountInfo<'info>, + + // New stake accounts : + #[account(zero)] + pub new_stake_account_positions: AccountLoader<'info, positions::PositionData>, + #[account(init, payer = payer, space = stake_account::StakeAccountMetadataV2::LEN, seeds = [STAKE_ACCOUNT_METADATA_SEED.as_bytes(), new_stake_account_positions.key().as_ref()], bump)] + pub new_stake_account_metadata: Box>, + #[account( + init, + seeds = [CUSTODY_SEED.as_bytes(), new_stake_account_positions.key().as_ref()], + bump, + payer = payer, + token::mint = mint, + token::authority = new_custody_authority, + )] + pub new_stake_account_custody: Box>, + /// CHECK : This AccountInfo is safe because it's a checked PDA + #[account(seeds = [AUTHORITY_SEED.as_bytes(), new_stake_account_positions.key().as_ref()], bump)] + pub new_custody_authority: AccountInfo<'info>, + #[account( + init, + payer = payer, + space = voter_weight_record::VoterWeightRecord::LEN, + seeds = [VOTER_RECORD_SEED.as_bytes(), new_stake_account_positions.key().as_ref()], + bump)] + pub new_voter_record: Box>, + + #[account(seeds = [CONFIG_SEED.as_bytes()], bump = config.bump)] + pub config: Box>, + + // Pyth token mint: + #[account(address = config.pyth_token_mint)] + pub mint: Box>, + // Primitive accounts : + pub rent: Sysvar<'info, Rent>, + pub token_program: Program<'info, Token>, + pub system_program: Program<'info, System>, +} + +impl<'a, 'b, 'c, 'info> From<&AcceptSplit<'info>> + for CpiContext<'a, 'b, 'c, 'info, Transfer<'info>> +{ + fn from(accounts: &AcceptSplit<'info>) -> CpiContext<'a, 'b, 'c, 'info, Transfer<'info>> { + let cpi_accounts = Transfer { + from: accounts.source_stake_account_custody.to_account_info(), + to: accounts.new_stake_account_custody.to_account_info(), + authority: accounts.source_custody_authority.to_account_info(), + }; + let cpi_program = accounts.token_program.to_account_info(); + CpiContext::new(cpi_program, cpi_accounts) + } +} + // Anchor's parser doesn't understand cfg(feature), so the IDL gets messed // up if we try to use it here. We can just keep the definition the same. #[derive(Accounts)] diff --git a/staking/programs/staking/src/lib.rs b/staking/programs/staking/src/lib.rs index ba40e52f..ccf8e881 100644 --- a/staking/programs/staking/src/lib.rs +++ b/staking/programs/staking/src/lib.rs @@ -534,4 +534,52 @@ pub mod staking { Err(error!(ErrorCode::DebuggingOnly)) } } + + /** + * Any user of the staking program can request to split their account and + * give a part of it to another user. + * This is mostly useful to transfer unvested tokens. Each user can only have one active + * request at a time. + * In the first step, the user requests a split by specifying the `amount` of tokens + * they want to give to the other user and the `recipient`'s pubkey. + */ + pub fn request_split(ctx: Context, amount: u64, recipient: Pubkey) -> Result<()> { + ctx.accounts.stake_account_split_request.amount = amount; + ctx.accounts.stake_account_split_request.recipient = recipient; + 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 + * 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. + */ + pub fn accept_split(ctx: Context) -> Result<()> { + // TODO : Split vesting schedule between both accounts + + // TODO : Transfer stake positions to the new account if need + + // TODO Check both accounts are valid after the transfer + + // 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], + ]]), + split_request.amount, + )?; + } + + // Delete current request + { + ctx.accounts.source_stake_account_split_request.amount = 0; + } + err!(ErrorCode::NotImplemented) + } } diff --git a/staking/programs/staking/src/state/mod.rs b/staking/programs/staking/src/state/mod.rs index caf06de6..6e310945 100644 --- a/staking/programs/staking/src/state/mod.rs +++ b/staking/programs/staking/src/state/mod.rs @@ -1,6 +1,7 @@ pub mod global_config; pub mod max_voter_weight_record; pub mod positions; +pub mod split_request; pub mod stake_account; pub mod target; pub mod vesting; diff --git a/staking/programs/staking/src/state/split_request.rs b/staking/programs/staking/src/state/split_request.rs new file mode 100644 index 00000000..15e72304 --- /dev/null +++ b/staking/programs/staking/src/state/split_request.rs @@ -0,0 +1,17 @@ +use { + anchor_lang::prelude::*, + borsh::BorshSchema, +}; + +#[account] +#[derive(Default, BorshSchema)] +pub struct SplitRequest { + pub amount: u64, + pub recipient: Pubkey, +} + +impl SplitRequest { + pub const LEN: usize = 8 // Discriminant + + 8 // Amount + + 32; // Recipient +} diff --git a/staking/target/idl/staking.json b/staking/target/idl/staking.json index 1c4bd776..ca2078ff 100644 --- a/staking/target/idl/staking.json +++ b/staking/target/idl/staking.json @@ -791,6 +791,307 @@ "type": "i64" } ] + }, + { + "name": "requestSplit", + "docs": [ + "* Any user of the staking program can request to split their account and\n * give a part of it to another user.\n * This is mostly useful to transfer unvested tokens. Each user can only have one active\n * request at a time.\n * In the first step, the user requests a split by specifying the `amount` of tokens\n * they want to give to the other user and the `recipient`'s pubkey." + ], + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "stakeAccountPositions", + "isMut": false, + "isSigner": false + }, + { + "name": "stakeAccountMetadata", + "isMut": false, + "isSigner": false, + "pda": { + "seeds": [ + { + "kind": "const", + "type": "string", + "value": "stake_metadata" + }, + { + "kind": "account", + "type": "publicKey", + "path": "stake_account_positions" + } + ] + } + }, + { + "name": "stakeAccountSplitRequest", + "isMut": true, + "isSigner": false, + "pda": { + "seeds": [ + { + "kind": "const", + "type": "string", + "value": "split_request" + }, + { + "kind": "account", + "type": "publicKey", + "path": "stake_account_positions" + } + ] + } + }, + { + "name": "config", + "isMut": false, + "isSigner": false, + "pda": { + "seeds": [ + { + "kind": "const", + "type": "string", + "value": "config" + } + ] + } + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "amount", + "type": "u64" + }, + { + "name": "recipient", + "type": "publicKey" + } + ] + }, + { + "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." + ], + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "sourceStakeAccountPositions", + "isMut": true, + "isSigner": false + }, + { + "name": "sourceStakeAccountMetadata", + "isMut": true, + "isSigner": false, + "pda": { + "seeds": [ + { + "kind": "const", + "type": "string", + "value": "stake_metadata" + }, + { + "kind": "account", + "type": "publicKey", + "path": "source_stake_account_positions" + } + ] + } + }, + { + "name": "sourceStakeAccountSplitRequest", + "isMut": false, + "isSigner": false, + "pda": { + "seeds": [ + { + "kind": "const", + "type": "string", + "value": "split_request" + }, + { + "kind": "account", + "type": "publicKey", + "path": "source_stake_account_positions" + } + ] + } + }, + { + "name": "sourceStakeAccountCustody", + "isMut": true, + "isSigner": false, + "pda": { + "seeds": [ + { + "kind": "const", + "type": "string", + "value": "custody" + }, + { + "kind": "account", + "type": "publicKey", + "path": "source_stake_account_positions" + } + ] + } + }, + { + "name": "sourceCustodyAuthority", + "isMut": false, + "isSigner": false, + "docs": [ + "CHECK : This AccountInfo is safe because it's a checked PDA" + ], + "pda": { + "seeds": [ + { + "kind": "const", + "type": "string", + "value": "authority" + }, + { + "kind": "account", + "type": "publicKey", + "path": "source_stake_account_positions" + } + ] + } + }, + { + "name": "newStakeAccountPositions", + "isMut": true, + "isSigner": false + }, + { + "name": "newStakeAccountMetadata", + "isMut": true, + "isSigner": false, + "pda": { + "seeds": [ + { + "kind": "const", + "type": "string", + "value": "stake_metadata" + }, + { + "kind": "account", + "type": "publicKey", + "path": "new_stake_account_positions" + } + ] + } + }, + { + "name": "newStakeAccountCustody", + "isMut": true, + "isSigner": false, + "pda": { + "seeds": [ + { + "kind": "const", + "type": "string", + "value": "custody" + }, + { + "kind": "account", + "type": "publicKey", + "path": "new_stake_account_positions" + } + ] + } + }, + { + "name": "newCustodyAuthority", + "isMut": false, + "isSigner": false, + "docs": [ + "CHECK : This AccountInfo is safe because it's a checked PDA" + ], + "pda": { + "seeds": [ + { + "kind": "const", + "type": "string", + "value": "authority" + }, + { + "kind": "account", + "type": "publicKey", + "path": "new_stake_account_positions" + } + ] + } + }, + { + "name": "newVoterRecord", + "isMut": true, + "isSigner": false, + "pda": { + "seeds": [ + { + "kind": "const", + "type": "string", + "value": "voter_weight" + }, + { + "kind": "account", + "type": "publicKey", + "path": "new_stake_account_positions" + } + ] + } + }, + { + "name": "config", + "isMut": false, + "isSigner": false, + "pda": { + "seeds": [ + { + "kind": "const", + "type": "string", + "value": "config" + } + ] + } + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] } ], "accounts": [ @@ -946,6 +1247,22 @@ ] } }, + { + "name": "SplitRequest", + "type": { + "kind": "struct", + "fields": [ + { + "name": "amount", + "type": "u64" + }, + { + "name": "recipient", + "type": "publicKey" + } + ] + } + }, { "name": "StakeAccountMetadataV2", "docs": [ diff --git a/staking/target/types/staking.ts b/staking/target/types/staking.ts index 7aa0f227..96eff643 100644 --- a/staking/target/types/staking.ts +++ b/staking/target/types/staking.ts @@ -791,6 +791,307 @@ export type Staking = { "type": "i64" } ] + }, + { + "name": "requestSplit", + "docs": [ + "* Any user of the staking program can request to split their account and\n * give a part of it to another user.\n * This is mostly useful to transfer unvested tokens. Each user can only have one active\n * request at a time.\n * In the first step, the user requests a split by specifying the `amount` of tokens\n * they want to give to the other user and the `recipient`'s pubkey." + ], + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "stakeAccountPositions", + "isMut": false, + "isSigner": false + }, + { + "name": "stakeAccountMetadata", + "isMut": false, + "isSigner": false, + "pda": { + "seeds": [ + { + "kind": "const", + "type": "string", + "value": "stake_metadata" + }, + { + "kind": "account", + "type": "publicKey", + "path": "stake_account_positions" + } + ] + } + }, + { + "name": "stakeAccountSplitRequest", + "isMut": true, + "isSigner": false, + "pda": { + "seeds": [ + { + "kind": "const", + "type": "string", + "value": "split_request" + }, + { + "kind": "account", + "type": "publicKey", + "path": "stake_account_positions" + } + ] + } + }, + { + "name": "config", + "isMut": false, + "isSigner": false, + "pda": { + "seeds": [ + { + "kind": "const", + "type": "string", + "value": "config" + } + ] + } + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "amount", + "type": "u64" + }, + { + "name": "recipient", + "type": "publicKey" + } + ] + }, + { + "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." + ], + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "sourceStakeAccountPositions", + "isMut": true, + "isSigner": false + }, + { + "name": "sourceStakeAccountMetadata", + "isMut": true, + "isSigner": false, + "pda": { + "seeds": [ + { + "kind": "const", + "type": "string", + "value": "stake_metadata" + }, + { + "kind": "account", + "type": "publicKey", + "path": "source_stake_account_positions" + } + ] + } + }, + { + "name": "sourceStakeAccountSplitRequest", + "isMut": false, + "isSigner": false, + "pda": { + "seeds": [ + { + "kind": "const", + "type": "string", + "value": "split_request" + }, + { + "kind": "account", + "type": "publicKey", + "path": "source_stake_account_positions" + } + ] + } + }, + { + "name": "sourceStakeAccountCustody", + "isMut": true, + "isSigner": false, + "pda": { + "seeds": [ + { + "kind": "const", + "type": "string", + "value": "custody" + }, + { + "kind": "account", + "type": "publicKey", + "path": "source_stake_account_positions" + } + ] + } + }, + { + "name": "sourceCustodyAuthority", + "isMut": false, + "isSigner": false, + "docs": [ + "CHECK : This AccountInfo is safe because it's a checked PDA" + ], + "pda": { + "seeds": [ + { + "kind": "const", + "type": "string", + "value": "authority" + }, + { + "kind": "account", + "type": "publicKey", + "path": "source_stake_account_positions" + } + ] + } + }, + { + "name": "newStakeAccountPositions", + "isMut": true, + "isSigner": false + }, + { + "name": "newStakeAccountMetadata", + "isMut": true, + "isSigner": false, + "pda": { + "seeds": [ + { + "kind": "const", + "type": "string", + "value": "stake_metadata" + }, + { + "kind": "account", + "type": "publicKey", + "path": "new_stake_account_positions" + } + ] + } + }, + { + "name": "newStakeAccountCustody", + "isMut": true, + "isSigner": false, + "pda": { + "seeds": [ + { + "kind": "const", + "type": "string", + "value": "custody" + }, + { + "kind": "account", + "type": "publicKey", + "path": "new_stake_account_positions" + } + ] + } + }, + { + "name": "newCustodyAuthority", + "isMut": false, + "isSigner": false, + "docs": [ + "CHECK : This AccountInfo is safe because it's a checked PDA" + ], + "pda": { + "seeds": [ + { + "kind": "const", + "type": "string", + "value": "authority" + }, + { + "kind": "account", + "type": "publicKey", + "path": "new_stake_account_positions" + } + ] + } + }, + { + "name": "newVoterRecord", + "isMut": true, + "isSigner": false, + "pda": { + "seeds": [ + { + "kind": "const", + "type": "string", + "value": "voter_weight" + }, + { + "kind": "account", + "type": "publicKey", + "path": "new_stake_account_positions" + } + ] + } + }, + { + "name": "config", + "isMut": false, + "isSigner": false, + "pda": { + "seeds": [ + { + "kind": "const", + "type": "string", + "value": "config" + } + ] + } + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] } ], "accounts": [ @@ -946,6 +1247,22 @@ export type Staking = { ] } }, + { + "name": "splitRequest", + "type": { + "kind": "struct", + "fields": [ + { + "name": "amount", + "type": "u64" + }, + { + "name": "recipient", + "type": "publicKey" + } + ] + } + }, { "name": "stakeAccountMetadataV2", "docs": [ @@ -1489,29 +1806,356 @@ export type Staking = { "msg": "Can't vote during an account's transfer epoch" }, { - "code": 6029, - "name": "Other", - "msg": "Other" - } - ] -}; - -export const IDL: Staking = { - "version": "1.0.0", - "name": "staking", - "instructions": [ - { - "name": "initConfig", + "code": 6029, + "name": "Other", + "msg": "Other" + } + ] +}; + +export const IDL: Staking = { + "version": "1.0.0", + "name": "staking", + "instructions": [ + { + "name": "initConfig", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "configAccount", + "isMut": true, + "isSigner": false, + "pda": { + "seeds": [ + { + "kind": "const", + "type": "string", + "value": "config" + } + ] + } + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "globalConfig", + "type": { + "defined": "GlobalConfig" + } + } + ] + }, + { + "name": "updateGovernanceAuthority", + "accounts": [ + { + "name": "governanceSigner", + "isMut": false, + "isSigner": true + }, + { + "name": "config", + "isMut": true, + "isSigner": false, + "pda": { + "seeds": [ + { + "kind": "const", + "type": "string", + "value": "config" + } + ] + } + } + ], + "args": [ + { + "name": "newAuthority", + "type": "publicKey" + } + ] + }, + { + "name": "updateFreeze", + "accounts": [ + { + "name": "governanceSigner", + "isMut": false, + "isSigner": true + }, + { + "name": "config", + "isMut": true, + "isSigner": false, + "pda": { + "seeds": [ + { + "kind": "const", + "type": "string", + "value": "config" + } + ] + } + } + ], + "args": [ + { + "name": "freeze", + "type": "bool" + } + ] + }, + { + "name": "updateTokenListTime", + "accounts": [ + { + "name": "governanceSigner", + "isMut": false, + "isSigner": true + }, + { + "name": "config", + "isMut": true, + "isSigner": false, + "pda": { + "seeds": [ + { + "kind": "const", + "type": "string", + "value": "config" + } + ] + } + } + ], + "args": [ + { + "name": "tokenListTime", + "type": { + "option": "i64" + } + } + ] + }, + { + "name": "createStakeAccount", + "docs": [ + "Trustless instruction that creates a stake account for a user", + "The main account i.e. the position accounts needs to be initialized outside of the program", + "otherwise we run into stack limits" + ], + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "stakeAccountPositions", + "isMut": true, + "isSigner": false + }, + { + "name": "stakeAccountMetadata", + "isMut": true, + "isSigner": false, + "pda": { + "seeds": [ + { + "kind": "const", + "type": "string", + "value": "stake_metadata" + }, + { + "kind": "account", + "type": "publicKey", + "path": "stake_account_positions" + } + ] + } + }, + { + "name": "stakeAccountCustody", + "isMut": true, + "isSigner": false, + "pda": { + "seeds": [ + { + "kind": "const", + "type": "string", + "value": "custody" + }, + { + "kind": "account", + "type": "publicKey", + "path": "stake_account_positions" + } + ] + } + }, + { + "name": "custodyAuthority", + "isMut": false, + "isSigner": false, + "docs": [ + "CHECK : This AccountInfo is safe because it's a checked PDA" + ], + "pda": { + "seeds": [ + { + "kind": "const", + "type": "string", + "value": "authority" + }, + { + "kind": "account", + "type": "publicKey", + "path": "stake_account_positions" + } + ] + } + }, + { + "name": "voterRecord", + "isMut": true, + "isSigner": false, + "pda": { + "seeds": [ + { + "kind": "const", + "type": "string", + "value": "voter_weight" + }, + { + "kind": "account", + "type": "publicKey", + "path": "stake_account_positions" + } + ] + } + }, + { + "name": "config", + "isMut": false, + "isSigner": false, + "pda": { + "seeds": [ + { + "kind": "const", + "type": "string", + "value": "config" + } + ] + } + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "owner", + "type": "publicKey" + }, + { + "name": "lock", + "type": { + "defined": "VestingSchedule" + } + } + ] + }, + { + "name": "createPosition", + "docs": [ + "Creates a position", + "Looks for the first available place in the array, fails if array is full", + "Computes risk and fails if new positions exceed risk limit" + ], "accounts": [ { "name": "payer", - "isMut": true, + "isMut": false, "isSigner": true }, { - "name": "configAccount", + "name": "stakeAccountPositions", + "isMut": true, + "isSigner": false + }, + { + "name": "stakeAccountMetadata", "isMut": true, "isSigner": false, + "pda": { + "seeds": [ + { + "kind": "const", + "type": "string", + "value": "stake_metadata" + }, + { + "kind": "account", + "type": "publicKey", + "path": "stake_account_positions" + } + ] + } + }, + { + "name": "stakeAccountCustody", + "isMut": false, + "isSigner": false, + "pda": { + "seeds": [ + { + "kind": "const", + "type": "string", + "value": "custody" + }, + { + "kind": "account", + "type": "publicKey", + "path": "stake_account_positions" + } + ] + } + }, + { + "name": "config", + "isMut": false, + "isSigner": false, "pda": { "seeds": [ { @@ -1523,65 +2167,151 @@ export const IDL: Staking = { } }, { - "name": "rent", + "name": "targetAccount", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "targetWithParameters", + "type": { + "defined": "TargetWithParameters" + } + }, + { + "name": "amount", + "type": "u64" + } + ] + }, + { + "name": "closePosition", + "accounts": [ + { + "name": "payer", "isMut": false, + "isSigner": true + }, + { + "name": "stakeAccountPositions", + "isMut": true, "isSigner": false }, { - "name": "systemProgram", + "name": "stakeAccountMetadata", + "isMut": true, + "isSigner": false, + "pda": { + "seeds": [ + { + "kind": "const", + "type": "string", + "value": "stake_metadata" + }, + { + "kind": "account", + "type": "publicKey", + "path": "stake_account_positions" + } + ] + } + }, + { + "name": "stakeAccountCustody", + "isMut": false, + "isSigner": false, + "pda": { + "seeds": [ + { + "kind": "const", + "type": "string", + "value": "custody" + }, + { + "kind": "account", + "type": "publicKey", + "path": "stake_account_positions" + } + ] + } + }, + { + "name": "config", "isMut": false, + "isSigner": false, + "pda": { + "seeds": [ + { + "kind": "const", + "type": "string", + "value": "config" + } + ] + } + }, + { + "name": "targetAccount", + "isMut": true, "isSigner": false } ], "args": [ { - "name": "globalConfig", + "name": "index", + "type": "u8" + }, + { + "name": "amount", + "type": "u64" + }, + { + "name": "targetWithParameters", "type": { - "defined": "GlobalConfig" + "defined": "TargetWithParameters" } } ] }, { - "name": "updateGovernanceAuthority", + "name": "withdrawStake", "accounts": [ { - "name": "governanceSigner", + "name": "payer", "isMut": false, "isSigner": true }, { - "name": "config", + "name": "destination", "isMut": true, + "isSigner": false + }, + { + "name": "stakeAccountPositions", + "isMut": false, + "isSigner": false + }, + { + "name": "stakeAccountMetadata", + "isMut": false, "isSigner": false, "pda": { "seeds": [ { "kind": "const", "type": "string", - "value": "config" + "value": "stake_metadata" + }, + { + "kind": "account", + "type": "publicKey", + "path": "stake_account_positions" } ] } - } - ], - "args": [ - { - "name": "newAuthority", - "type": "publicKey" - } - ] - }, - { - "name": "updateFreeze", - "accounts": [ - { - "name": "governanceSigner", - "isMut": false, - "isSigner": true }, { - "name": "config", + "name": "stakeAccountCustody", "isMut": true, "isSigner": false, "pda": { @@ -1589,30 +2319,41 @@ export const IDL: Staking = { { "kind": "const", "type": "string", - "value": "config" + "value": "custody" + }, + { + "kind": "account", + "type": "publicKey", + "path": "stake_account_positions" } ] } - } - ], - "args": [ - { - "name": "freeze", - "type": "bool" - } - ] - }, - { - "name": "updateTokenListTime", - "accounts": [ + }, { - "name": "governanceSigner", + "name": "custodyAuthority", "isMut": false, - "isSigner": true + "isSigner": false, + "docs": [ + "CHECK : This AccountInfo is safe because it's a checked PDA" + ], + "pda": { + "seeds": [ + { + "kind": "const", + "type": "string", + "value": "authority" + }, + { + "kind": "account", + "type": "publicKey", + "path": "stake_account_positions" + } + ] + } }, { "name": "config", - "isMut": true, + "isMut": false, "isSigner": false, "pda": { "seeds": [ @@ -1623,38 +2364,36 @@ export const IDL: Staking = { } ] } + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false } ], "args": [ { - "name": "tokenListTime", - "type": { - "option": "i64" - } + "name": "amount", + "type": "u64" } ] }, { - "name": "createStakeAccount", - "docs": [ - "Trustless instruction that creates a stake account for a user", - "The main account i.e. the position accounts needs to be initialized outside of the program", - "otherwise we run into stack limits" - ], + "name": "updateVoterWeight", "accounts": [ { "name": "payer", - "isMut": true, + "isMut": false, "isSigner": true }, { "name": "stakeAccountPositions", - "isMut": true, + "isMut": false, "isSigner": false }, { "name": "stakeAccountMetadata", - "isMut": true, + "isMut": false, "isSigner": false, "pda": { "seeds": [ @@ -1673,7 +2412,7 @@ export const IDL: Staking = { }, { "name": "stakeAccountCustody", - "isMut": true, + "isMut": false, "isSigner": false, "pda": { "seeds": [ @@ -1691,18 +2430,15 @@ export const IDL: Staking = { } }, { - "name": "custodyAuthority", - "isMut": false, + "name": "voterRecord", + "isMut": true, "isSigner": false, - "docs": [ - "CHECK : This AccountInfo is safe because it's a checked PDA" - ], "pda": { "seeds": [ { "kind": "const", "type": "string", - "value": "authority" + "value": "voter_weight" }, { "kind": "account", @@ -1713,111 +2449,107 @@ export const IDL: Staking = { } }, { - "name": "voterRecord", - "isMut": true, + "name": "config", + "isMut": false, "isSigner": false, "pda": { "seeds": [ { "kind": "const", "type": "string", - "value": "voter_weight" - }, - { - "kind": "account", - "type": "publicKey", - "path": "stake_account_positions" + "value": "config" } ] } }, { - "name": "config", - "isMut": false, + "name": "governanceTarget", + "isMut": true, "isSigner": false, "pda": { "seeds": [ { "kind": "const", "type": "string", - "value": "config" + "value": "target" + }, + { + "kind": "const", + "type": "string", + "value": "voting" } ] } - }, - { - "name": "mint", - "isMut": false, - "isSigner": false - }, - { - "name": "rent", - "isMut": false, - "isSigner": false - }, - { - "name": "tokenProgram", - "isMut": false, - "isSigner": false - }, - { - "name": "systemProgram", - "isMut": false, - "isSigner": false } ], "args": [ { - "name": "owner", - "type": "publicKey" - }, - { - "name": "lock", + "name": "action", "type": { - "defined": "VestingSchedule" + "defined": "VoterWeightAction" } } ] }, { - "name": "createPosition", - "docs": [ - "Creates a position", - "Looks for the first available place in the array, fails if array is full", - "Computes risk and fails if new positions exceed risk limit" - ], + "name": "updateMaxVoterWeight", "accounts": [ { "name": "payer", - "isMut": false, + "isMut": true, "isSigner": true }, { - "name": "stakeAccountPositions", + "name": "maxVoterRecord", "isMut": true, - "isSigner": false + "isSigner": false, + "pda": { + "seeds": [ + { + "kind": "const", + "type": "string", + "value": "max_voter" + } + ] + } }, { - "name": "stakeAccountMetadata", - "isMut": true, + "name": "config", + "isMut": false, "isSigner": false, "pda": { "seeds": [ { "kind": "const", "type": "string", - "value": "stake_metadata" - }, - { - "kind": "account", - "type": "publicKey", - "path": "stake_account_positions" + "value": "config" } ] } }, { - "name": "stakeAccountCustody", + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "createTarget", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "governanceSigner", + "isMut": false, + "isSigner": true + }, + { + "name": "config", "isMut": false, "isSigner": false, "pda": { @@ -1825,19 +2557,37 @@ export const IDL: Staking = { { "kind": "const", "type": "string", - "value": "custody" - }, - { - "kind": "account", - "type": "publicKey", - "path": "stake_account_positions" + "value": "config" } ] } }, { - "name": "config", + "name": "targetAccount", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "target", + "type": { + "defined": "Target" + } + } + ] + }, + { + "name": "advanceClock", + "accounts": [ + { + "name": "config", + "isMut": true, "isSigner": false, "pda": { "seeds": [ @@ -1848,42 +2598,34 @@ export const IDL: Staking = { } ] } - }, - { - "name": "targetAccount", - "isMut": true, - "isSigner": false } ], "args": [ { - "name": "targetWithParameters", - "type": { - "defined": "TargetWithParameters" - } - }, - { - "name": "amount", - "type": "u64" + "name": "seconds", + "type": "i64" } ] }, { - "name": "closePosition", + "name": "requestSplit", + "docs": [ + "* Any user of the staking program can request to split their account and\n * give a part of it to another user.\n * This is mostly useful to transfer unvested tokens. Each user can only have one active\n * request at a time.\n * In the first step, the user requests a split by specifying the `amount` of tokens\n * they want to give to the other user and the `recipient`'s pubkey." + ], "accounts": [ { "name": "payer", - "isMut": false, + "isMut": true, "isSigner": true }, { "name": "stakeAccountPositions", - "isMut": true, + "isMut": false, "isSigner": false }, { "name": "stakeAccountMetadata", - "isMut": true, + "isMut": false, "isSigner": false, "pda": { "seeds": [ @@ -1901,15 +2643,15 @@ export const IDL: Staking = { } }, { - "name": "stakeAccountCustody", - "isMut": false, + "name": "stakeAccountSplitRequest", + "isMut": true, "isSigner": false, "pda": { "seeds": [ { "kind": "const", "type": "string", - "value": "custody" + "value": "split_request" }, { "kind": "account", @@ -1934,49 +2676,41 @@ export const IDL: Staking = { } }, { - "name": "targetAccount", - "isMut": true, + "name": "systemProgram", + "isMut": false, "isSigner": false } ], "args": [ - { - "name": "index", - "type": "u8" - }, { "name": "amount", "type": "u64" }, { - "name": "targetWithParameters", - "type": { - "defined": "TargetWithParameters" - } + "name": "recipient", + "type": "publicKey" } ] }, { - "name": "withdrawStake", + "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." + ], "accounts": [ { "name": "payer", - "isMut": false, + "isMut": true, "isSigner": true }, { - "name": "destination", + "name": "sourceStakeAccountPositions", "isMut": true, "isSigner": false }, { - "name": "stakeAccountPositions", - "isMut": false, - "isSigner": false - }, - { - "name": "stakeAccountMetadata", - "isMut": false, + "name": "sourceStakeAccountMetadata", + "isMut": true, "isSigner": false, "pda": { "seeds": [ @@ -1988,95 +2722,79 @@ export const IDL: Staking = { { "kind": "account", "type": "publicKey", - "path": "stake_account_positions" + "path": "source_stake_account_positions" } ] } }, { - "name": "stakeAccountCustody", - "isMut": true, + "name": "sourceStakeAccountSplitRequest", + "isMut": false, "isSigner": false, "pda": { "seeds": [ { "kind": "const", "type": "string", - "value": "custody" + "value": "split_request" }, { "kind": "account", "type": "publicKey", - "path": "stake_account_positions" + "path": "source_stake_account_positions" } ] } }, { - "name": "custodyAuthority", - "isMut": false, + "name": "sourceStakeAccountCustody", + "isMut": true, "isSigner": false, - "docs": [ - "CHECK : This AccountInfo is safe because it's a checked PDA" - ], "pda": { "seeds": [ { "kind": "const", "type": "string", - "value": "authority" + "value": "custody" }, { "kind": "account", "type": "publicKey", - "path": "stake_account_positions" + "path": "source_stake_account_positions" } ] } }, { - "name": "config", + "name": "sourceCustodyAuthority", "isMut": false, "isSigner": false, + "docs": [ + "CHECK : This AccountInfo is safe because it's a checked PDA" + ], "pda": { "seeds": [ { "kind": "const", "type": "string", - "value": "config" + "value": "authority" + }, + { + "kind": "account", + "type": "publicKey", + "path": "source_stake_account_positions" } ] } }, { - "name": "tokenProgram", - "isMut": false, - "isSigner": false - } - ], - "args": [ - { - "name": "amount", - "type": "u64" - } - ] - }, - { - "name": "updateVoterWeight", - "accounts": [ - { - "name": "payer", - "isMut": false, - "isSigner": true - }, - { - "name": "stakeAccountPositions", - "isMut": false, + "name": "newStakeAccountPositions", + "isMut": true, "isSigner": false }, { - "name": "stakeAccountMetadata", - "isMut": false, + "name": "newStakeAccountMetadata", + "isMut": true, "isSigner": false, "pda": { "seeds": [ @@ -2088,14 +2806,14 @@ export const IDL: Staking = { { "kind": "account", "type": "publicKey", - "path": "stake_account_positions" + "path": "new_stake_account_positions" } ] } }, { - "name": "stakeAccountCustody", - "isMut": false, + "name": "newStakeAccountCustody", + "isMut": true, "isSigner": false, "pda": { "seeds": [ @@ -2107,46 +2825,35 @@ export const IDL: Staking = { { "kind": "account", "type": "publicKey", - "path": "stake_account_positions" + "path": "new_stake_account_positions" } ] } }, { - "name": "voterRecord", - "isMut": true, + "name": "newCustodyAuthority", + "isMut": false, "isSigner": false, + "docs": [ + "CHECK : This AccountInfo is safe because it's a checked PDA" + ], "pda": { "seeds": [ { "kind": "const", "type": "string", - "value": "voter_weight" + "value": "authority" }, { "kind": "account", "type": "publicKey", - "path": "stake_account_positions" - } - ] - } - }, - { - "name": "config", - "isMut": false, - "isSigner": false, - "pda": { - "seeds": [ - { - "kind": "const", - "type": "string", - "value": "config" + "path": "new_stake_account_positions" } ] } }, { - "name": "governanceTarget", + "name": "newVoterRecord", "isMut": true, "isSigner": false, "pda": { @@ -2154,44 +2861,12 @@ export const IDL: Staking = { { "kind": "const", "type": "string", - "value": "target" + "value": "voter_weight" }, { - "kind": "const", - "type": "string", - "value": "voting" - } - ] - } - } - ], - "args": [ - { - "name": "action", - "type": { - "defined": "VoterWeightAction" - } - } - ] - }, - { - "name": "updateMaxVoterWeight", - "accounts": [ - { - "name": "payer", - "isMut": true, - "isSigner": true - }, - { - "name": "maxVoterRecord", - "isMut": true, - "isSigner": false, - "pda": { - "seeds": [ - { - "kind": "const", - "type": "string", - "value": "max_voter" + "kind": "account", + "type": "publicKey", + "path": "new_stake_account_positions" } ] } @@ -2211,43 +2886,18 @@ export const IDL: Staking = { } }, { - "name": "systemProgram", + "name": "mint", "isMut": false, "isSigner": false - } - ], - "args": [] - }, - { - "name": "createTarget", - "accounts": [ - { - "name": "payer", - "isMut": true, - "isSigner": true }, { - "name": "governanceSigner", + "name": "rent", "isMut": false, - "isSigner": true + "isSigner": false }, { - "name": "config", + "name": "tokenProgram", "isMut": false, - "isSigner": false, - "pda": { - "seeds": [ - { - "kind": "const", - "type": "string", - "value": "config" - } - ] - } - }, - { - "name": "targetAccount", - "isMut": true, "isSigner": false }, { @@ -2256,39 +2906,7 @@ export const IDL: Staking = { "isSigner": false } ], - "args": [ - { - "name": "target", - "type": { - "defined": "Target" - } - } - ] - }, - { - "name": "advanceClock", - "accounts": [ - { - "name": "config", - "isMut": true, - "isSigner": false, - "pda": { - "seeds": [ - { - "kind": "const", - "type": "string", - "value": "config" - } - ] - } - } - ], - "args": [ - { - "name": "seconds", - "type": "i64" - } - ] + "args": [] } ], "accounts": [ @@ -2444,6 +3062,22 @@ export const IDL: Staking = { ] } }, + { + "name": "splitRequest", + "type": { + "kind": "struct", + "fields": [ + { + "name": "amount", + "type": "u64" + }, + { + "name": "recipient", + "type": "publicKey" + } + ] + } + }, { "name": "stakeAccountMetadataV2", "docs": [ diff --git a/staking/tests/split_vesting_account.ts b/staking/tests/split_vesting_account.ts new file mode 100644 index 00000000..4b15343d --- /dev/null +++ b/staking/tests/split_vesting_account.ts @@ -0,0 +1,148 @@ +import { + ANCHOR_CONFIG_PATH, + CustomAbortController, + getPortNumber, + makeDefaultConfig, + readAnchorConfig, + requestPythAirdrop, + standardSetup, +} from "./utils/before"; +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 assert from "assert"; + +const ONE_MONTH = new BN(3600 * 24 * 30.5); +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( + portNumber, + config, + pythMintAccount, + pythMintAuthority, + makeDefaultConfig( + pythMintAccount.publicKey, + PublicKey.unique(), + pdaAuthority.publicKey + ) + )); + + EPOCH_DURATION = stakeConnection.config.epochDuration; + owner = stakeConnection.provider.wallet.publicKey; + + samConnection = await StakeConnection.createStakeConnection( + stakeConnection.provider.connection, + new Wallet(sam), + stakeConnection.program.programId + ); + + pdaConnection = await StakeConnection.createStakeConnection( + stakeConnection.provider.connection, + new Wallet(pdaAuthority), + stakeConnection.program.programId + ); + }); + + it("create a vesting account", async () => { + await samConnection.provider.connection.requestAirdrop( + sam.publicKey, + 1_000_000_000_000 + ); + await requestPythAirdrop( + sam.publicKey, + pythMintAccount.publicKey, + pythMintAuthority, + PythBalance.fromString("200"), + samConnection.provider.connection + ); + + const transaction = new Transaction(); + + const stakeAccountKeypair = await samConnection.withCreateAccount( + transaction.instructions, + sam.publicKey, + { + periodicVesting: { + initialBalance: PythBalance.fromString("100").toBN(), + startDate: await stakeConnection.getTime(), + periodDuration: ONE_MONTH, + numPeriods: new BN(72), + }, + } + ); + + transaction.instructions.push( + await samConnection.buildTransferInstruction( + stakeAccountKeypair.publicKey, + PythBalance.fromString("100").toBN() + ) + ); + + await samConnection.provider.sendAndConfirm( + transaction, + [stakeAccountKeypair], + { skipPreflight: true } + ); + + let stakeAccount = await samConnection.getMainAccount(sam.publicKey); + assert( + VestingAccountState.UnvestedTokensFullyUnlocked == + stakeAccount.getVestingAccountState(await samConnection.getTime()) + ); + await assertBalanceMatches( + samConnection, + sam.publicKey, + { + unvested: { + unlocked: PythBalance.fromString("100"), + }, + }, + await samConnection.getTime() + ); + }); + + it("request split", async () => { + await pdaConnection.provider.connection.requestAirdrop( + pdaAuthority.publicKey, + 1_000_000_000_000 + ); + + let stakeAccount = await samConnection.getMainAccount(sam.publicKey); + await samConnection.requestSplit( + stakeAccount, + PythBalance.fromString("50"), + alice.publicKey + ); + + try { + await pdaConnection.acceptSplit(stakeAccount); + throw Error("This should've failed"); + } catch {} + }); + + after(async () => { + controller.abort(); + }); +}); diff --git a/staking/tests/utils/before.ts b/staking/tests/utils/before.ts index 7ba6f168..a87843b9 100644 --- a/staking/tests/utils/before.ts +++ b/staking/tests/utils/before.ts @@ -324,7 +324,8 @@ export async function initConfig( export function makeDefaultConfig( pythMint: PublicKey, - governanceProgram: PublicKey = PublicKey.unique() + governanceProgram: PublicKey = PublicKey.unique(), + pdaAuthority: PublicKey = PublicKey.unique() ): GlobalConfig { return { governanceAuthority: null, @@ -337,7 +338,7 @@ export function makeDefaultConfig( bump: 0, pythTokenListTime: null, governanceProgram, - pdaAuthority: PublicKey.unique(), + pdaAuthority, }; }