diff --git a/clients/cli/src/cli.rs b/clients/cli/src/cli.rs index b0e77c25..2cb2f598 100644 --- a/clients/cli/src/cli.rs +++ b/clients/cli/src/cli.rs @@ -86,6 +86,10 @@ pub enum Command { /// Display info for one or all single-validator stake pool(s) Display(DisplayCli), + + /// Deposit liquid sol into a pool in exchange for pool tokens, less a one percent + /// fee. + DepositSol(DepositSolCli), } #[derive(Clone, Debug, Parser)] @@ -285,6 +289,30 @@ pub struct CreateOnRampCli { pub vote_account_address: Option, } +#[derive(Clone, Debug, Args)] +#[clap(group(pool_source_group()))] +pub struct DepositSolCli { + /// Lamports to deposit into pool + pub lamports: u64, + + /// The pool to deposit into + #[clap(short, long = "pool", value_parser = |p: &str| parse_address(p, "pool_address"))] + pub pool_address: Option, + + /// The vote account corresponding to the pool to deposit into + #[clap(long = "vote-account", value_parser = |p: &str| parse_address(p, "vote_account_address"))] + pub vote_account_address: Option, + + /// The wallet to deposit lamports from. Defaults to the client + /// keypair + #[clap(long, id = "DEPOSIT_SOURCE_KEYPAIR", value_parser = SignerSourceParserBuilder::default().allow_all().build())] + pub from: Option, + + /// The token account to mint to. Defaults to the client keypair's + /// associated token account + #[clap(long = "token-account", value_parser = |p: &str| parse_address(p, "token_account_address"))] + pub token_account_address: Option, +} fn pool_source_group() -> ArgGroup<'static> { ArgGroup::new("pool-source") .required(true) @@ -338,3 +366,12 @@ pub fn pool_address_from_args(maybe_pool: Option, maybe_vote: Option command_display(config, command_config).await, + Command::DepositSol(command_config) => { + command_deposit_sol(config, command_config, matches, wallet_manager).await + } } } } @@ -822,6 +825,190 @@ async fn command_create_onramp(config: &Config, command_config: CreateOnRampCli) )) } +// deposit liquid sol +async fn command_deposit_sol( + config: &Config, + command_config: DepositSolCli, + matches: &ArgMatches, + wallet_manager: &mut Option>, +) -> CommandResult { + let payer = config.fee_payer()?; + let owner = config.default_signer()?; + + let deposit_source = command_config + .from + .and_then(|source| { + signer_from_source(matches, &source, "from", wallet_manager) + .ok() + .map(Arc::from) + }) + .unwrap_or(owner.clone()); + + let deposit_amount = command_config.lamports; + + let pool_address = pool_address_from_args( + command_config.pool_address, + command_config.vote_account_address, + ); + + let pool_stake_address = find_pool_stake_address(&spl_single_pool::id(), &pool_address); + let onramp_address = find_pool_onramp_address(&spl_single_pool::id(), &pool_address); + let pool_mint_address = find_pool_mint_address(&spl_single_pool::id(), &pool_address); + + pool_is_initialized(config, pool_address).await?; + + let vote_account_address = get_vote_address_from_pool(config, pool_address).await?; + + if get_initialized_account(config, onramp_address) + .await? + .is_none() + { + return Err(format!( + "Pool {} onramp {} does not exist; run `spl-single-pool manage create-on-ramp ...` \ + to create it", + pool_address, onramp_address + ) + .into()); + } + + let current_epoch = config.rpc_client.get_epoch_info().await?.epoch; + + if let Some((_, stake)) = quarantine::get_stake_info(config, &pool_stake_address).await? { + if stake.delegation.activation_epoch >= current_epoch { + return Err(format!( + "Pool {} stake {} is still activating; must be fully active", + pool_address, pool_stake_address + ) + .into()); + } + + if stake.delegation.deactivation_epoch < u64::MAX { + return Err(format!( + "Pool {} stake {} is deactivating or deactivated", + pool_address, pool_stake_address + ) + .into()); + } + } else { + // pool existence already validated and pool exists => stake exists + unreachable!(); + }; + + { + let deposit_source_balance = config + .program_client + .get_account(deposit_source.pubkey()) + .await? + .map(|account| account.lamports) + .unwrap_or(0); + + if deposit_source_balance < deposit_amount { + return Err(format!( + "Insufficient lamports in {} for deposit: has {}, needs {}", + deposit_source.pubkey(), + deposit_source_balance, + deposit_amount, + ) + .into()); + } + } + + println_display( + config, + format!( + "Depositing liquid sol from account {} into pool {}\n", + deposit_source.pubkey(), + pool_address + ), + ); + + let token = Token::new( + config.program_client.clone(), + &spl_token::id(), + &pool_mint_address, + None, + payer.clone(), + ); + + let mut instructions = vec![]; + + // use token account provided, or get/create the associated account for the client keypair + let token_account_address = if let Some(account) = command_config.token_account_address { + account + } else { + let address = token.get_associated_token_address(&owner.pubkey()); + if get_initialized_account(config, address).await?.is_none() { + instructions.push(create_associated_token_account( + &payer.pubkey(), + &owner.pubkey(), + &pool_mint_address, + &spl_token::id(), + )); + } + address + }; + + let previous_token_amount = match token.get_account_info(&token_account_address).await { + Ok(account) => account.base.amount, + Err(_) => 0, + }; + + // use escrow account for lamports to avoid exposing wallet signer to program + let escrow_deposit_account = Keypair::new(); + + instructions.extend(spl_single_pool::instruction::deposit_liquid( + &spl_single_pool::id(), + &vote_account_address, + &deposit_source.pubkey(), + &escrow_deposit_account.pubkey(), + &token_account_address, + deposit_amount, + )); + + let mut signers = vec![]; + for signer in [ + payer.clone(), + deposit_source, + Arc::new(escrow_deposit_account), + ] { + if !signers.contains(&signer) { + signers.push(signer); + } + } + + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&payer.pubkey()), + &signers, + config.program_client.get_latest_blockhash().await?, + ); + + let signature = process_transaction(config, transaction).await?; + + let token_amount = if config.dry_run { + None + } else { + Some( + token + .get_account_info(&token_account_address) + .await? + .base + .amount + - previous_token_amount, + ) + }; + + Ok(format_output( + config, + "DepositSol".to_string(), + DepositOutput { + pool_address, + token_amount, + signature, + }, + )) +} + async fn get_initialized_account( config: &Config, pubkey: Pubkey, diff --git a/clients/cli/tests/test.rs b/clients/cli/tests/test.rs index dadb1f15..3afb89fe 100644 --- a/clients/cli/tests/test.rs +++ b/clients/cli/tests/test.rs @@ -543,3 +543,23 @@ async fn create_onramp(raise_minimum_delegation: bool) { .unwrap(); assert!(status.success()); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn deposit_sol() { + let env = setup(true, true).await; + + wait_for_next_epoch(&env.rpc_client).await; + + let args = vec![ + "deposit-sol".to_string(), + "-C".to_string(), + env.config_file_path, + "--vote-account".to_string(), + env.vote_account.to_string(), + "100".to_string(), + ]; + + let status = Command::new(SVSP_CLI).args(&args).status().unwrap(); + assert!(status.success()); +} diff --git a/clients/js-legacy/src/instructions.ts b/clients/js-legacy/src/instructions.ts index 35c3d6e5..1910c826 100644 --- a/clients/js-legacy/src/instructions.ts +++ b/clients/js-legacy/src/instructions.ts @@ -86,4 +86,19 @@ export class SinglePoolInstruction { ); return modernInstructionToLegacy(instruction); } + + static async depositSol( + voteAccount: PublicKey, + userWallet: PublicKey, + userTokenAccount: PublicKey, + lamports: number | bigint, + ): Promise { + const instruction = await PoolInstructionModern.depositSol( + voteAccount.toBase58() as VoteAccountAddress, + userWallet.toBase58() as Address, + userTokenAccount.toBase58() as Address, + BigInt(lamports), + ); + return modernInstructionToLegacy(instruction); + } } diff --git a/clients/js-legacy/src/transactions.ts b/clients/js-legacy/src/transactions.ts index e13374d0..ddee0c52 100644 --- a/clients/js-legacy/src/transactions.ts +++ b/clients/js-legacy/src/transactions.ts @@ -27,6 +27,14 @@ interface WithdrawParams { userTokenAuthority?: PublicKey; } +interface DepositSolParams { + connection: Connection; + voteAccount: PublicKey; + userWallet: PublicKey; + lamports: number | bigint; + userTokenAccount?: PublicKey; +} + export class SinglePoolProgram { static programId: PublicKey = new PublicKey(PoolProgramModern.programAddress); static space: number = Number(PoolProgramModern.space); @@ -105,4 +113,11 @@ export class SinglePoolProgram { return modernTransactionToLegacy(modernTransaction); } + + static async depositSol(params: DepositSolParams) { + const modernParams = paramsToModern(params); + const modernTransaction = await PoolProgramModern.depositSol(modernParams); + + return modernTransactionToLegacy(modernTransaction); + } } diff --git a/clients/js-legacy/tests/transactions.test.ts b/clients/js-legacy/tests/transactions.test.ts index 56372e59..392bc9a9 100644 --- a/clients/js-legacy/tests/transactions.test.ts +++ b/clients/js-legacy/tests/transactions.test.ts @@ -448,3 +448,36 @@ test('get vote account address', async (t) => { const chainVoteAccount = await getVoteAccountAddressForPool(connection, poolAddress); t.true(chainVoteAccount.equals(voteAccountAddress), 'got correct vote account'); }); + +test('deposit sol', async (t) => { + const context = await startWithContext(); + const svm = context.svm; + const payer = context.payer; + const connection = new LiteConnection(svm, payer); + + const voteAccountAddress = new PublicKey(voteAccount.pubkey); + const poolAddress = await findPoolAddress(SinglePoolProgram.programId, voteAccountAddress); + const onrampAddress = await findPoolOnRampAddress(SinglePoolProgram.programId, poolAddress); + + // initialize pool + let transaction = await SinglePoolProgram.initialize( + connection, + voteAccountAddress, + payer.publicKey, + ); + await processTransaction(context, transaction); + context.advanceEpoch(); + + // deposit sol + transaction = await SinglePoolProgram.depositSol({ + connection, + voteAccount: voteAccountAddress, + userWallet: payer.publicKey, + lamports: LAMPORTS_PER_SOL, + }); + await processTransaction(context, transaction); + + const stakeRent = await connection.getMinimumBalanceForRentExemption(StakeProgram.space); + const onrampAccount = svm.getAccount(onrampAddress); + t.is(onrampAccount.lamports, LAMPORTS_PER_SOL + stakeRent, 'sol has been deposited'); +}); diff --git a/clients/js/src/instructions.ts b/clients/js/src/instructions.ts index aa74f1ad..94d658b1 100644 --- a/clients/js/src/instructions.ts +++ b/clients/js/src/instructions.ts @@ -157,6 +157,29 @@ type InitializeOnRampInstruction = Instruction & > & InstructionWithData; +type DepositSolInstruction = Instruction & + InstructionWithAccounts< + [ + ReadonlyAccount, + ReadonlyAccount, + WritableAccount, + WritableAccount, + WritableAccount, + ReadonlyAccount, + ReadonlyAccount, + WritableSignerAccount
, // user lamport + WritableAccount
, // user token + ReadonlyAccount, + ReadonlyAccount, + ReadonlyAccount, + ReadonlyAccount, + ReadonlyAccount, + ReadonlyAccount, + ReadonlyAccount, + ] + > & + InstructionWithData; + const enum SinglePoolInstructionType { InitializePool = 0, ReplenishPool, @@ -165,6 +188,7 @@ const enum SinglePoolInstructionType { CreateTokenMetadata, UpdateTokenMetadata, InitializeOnRamp, + DepositSol, } export const SinglePoolInstruction = { @@ -175,6 +199,7 @@ export const SinglePoolInstruction = { createTokenMetadata: createTokenMetadataInstruction, updateTokenMetadata: updateTokenMetadataInstruction, initializeOnRamp: initializeOnRampInstruction, + depositSol: depositSolInstruction, }; export async function initializePoolInstruction( @@ -430,3 +455,45 @@ export async function initializeOnRampInstruction( programAddress, }; } + +export async function depositSolInstruction( + voteAccount: VoteAccountAddress, + userLamportAccount: Address, + userTokenAccount: Address, + lamports: bigint, +): Promise { + const programAddress = SINGLE_POOL_PROGRAM_ID; + const pool = await findPoolAddress(programAddress, voteAccount); + const [stake, onramp, mint, stakeAuthority, mintAuthority] = await Promise.all([ + findPoolStakeAddress(programAddress, pool), + findPoolOnRampAddress(programAddress, pool), + findPoolMintAddress(programAddress, pool), + findPoolStakeAuthorityAddress(programAddress, pool), + findPoolMintAuthorityAddress(programAddress, pool), + ]); + + const data = new Uint8Array([SinglePoolInstructionType.DepositSol, ...u64(lamports)]); + + return { + data, + accounts: [ + { address: voteAccount, role: AccountRole.READONLY }, + { address: pool, role: AccountRole.READONLY }, + { address: stake, role: AccountRole.WRITABLE }, + { address: onramp, role: AccountRole.WRITABLE }, + { address: mint, role: AccountRole.WRITABLE }, + { address: stakeAuthority, role: AccountRole.READONLY }, + { address: mintAuthority, role: AccountRole.READONLY }, + { address: userLamportAccount, role: AccountRole.WRITABLE_SIGNER }, + { address: userTokenAccount, role: AccountRole.WRITABLE }, + { address: SYSVAR_CLOCK_ID, role: AccountRole.READONLY }, + { address: SYSVAR_STAKE_HISTORY_ID, role: AccountRole.READONLY }, + { address: STAKE_CONFIG_ID, role: AccountRole.READONLY }, + { address: SYSTEM_PROGRAM_ID, role: AccountRole.READONLY }, + { address: TOKEN_PROGRAM_ID, role: AccountRole.READONLY }, + { address: STAKE_PROGRAM_ID, role: AccountRole.READONLY }, + { address: SINGLE_POOL_PROGRAM_ID, role: AccountRole.READONLY }, + ], + programAddress, + }; +} diff --git a/clients/js/src/transactions.ts b/clients/js/src/transactions.ts index e9bf9c17..7f0ccec1 100644 --- a/clients/js/src/transactions.ts +++ b/clients/js/src/transactions.ts @@ -40,6 +40,7 @@ import { createTokenMetadataInstruction, updateTokenMetadataInstruction, initializeOnRampInstruction, + depositSolInstruction, } from './instructions.js'; import { STAKE_PROGRAM_ID, @@ -74,6 +75,14 @@ interface WithdrawParams { userTokenAuthority?: Address; } +interface DepositSolParams { + rpc: Rpc; + voteAccount: VoteAccountAddress; + userWallet: Address; + lamports: bigint; + userTokenAccount?: Address; +} + export const SINGLE_POOL_ACCOUNT_SIZE = 33n; export const SinglePoolProgram = { @@ -86,6 +95,7 @@ export const SinglePoolProgram = { createTokenMetadata: createTokenMetadataTransaction, updateTokenMetadata: updateTokenMetadataTransaction, initializeOnRamp: initializeOnRampTransaction, + depositSol: depositSolTransaction, }; async function getInitializeInstructionPlan( @@ -336,3 +346,43 @@ export async function initializeOnRampTransaction( return transaction; } + +export async function depositSolTransaction(params: DepositSolParams) { + const { rpc, voteAccount, userWallet, lamports } = params; + + let transaction = { instructions: [] as any, version: 'legacy' as TransactionVersion }; + + const pool = await findPoolAddress(SINGLE_POOL_PROGRAM_ID, voteAccount); + const mint = await findPoolMintAddress(SINGLE_POOL_PROGRAM_ID, pool); + + const userAssociatedTokenAccount = await getAssociatedTokenAddress(mint, userWallet); + const userTokenAccount = params.userTokenAccount || userAssociatedTokenAccount; + + if ( + userTokenAccount == userAssociatedTokenAccount && + (await rpc.getAccountInfo(userAssociatedTokenAccount).send()) == null + ) { + transaction = appendTransactionMessageInstruction( + TokenInstruction.createAssociatedTokenAccount({ + payer: userWallet, + associatedAccount: userAssociatedTokenAccount, + owner: userWallet, + mint, + }), + transaction, + ); + } + + // NOTE in our rust instruction builder, we transfer lamports to an escrow account. + // this allows us to give greater assurance to the end user that their signing + // authority cannot be misused by our benevolent, yet untrusted, program. + // unfortunately this is not possible in js middleware but dapps may wish to consider + // doing similar by injecting a system transfer between these two instructions + + transaction = appendTransactionMessageInstruction( + await depositSolInstruction(voteAccount, userWallet, userTokenAccount, lamports), + transaction, + ); + + return transaction; +} diff --git a/program/src/error.rs b/program/src/error.rs index 7555ea09..02cb9abc 100644 --- a/program/src/error.rs +++ b/program/src/error.rs @@ -110,6 +110,9 @@ pub enum SinglePoolError { /// This can only occur if the Stake Program minimum delegation increases above 1 sol. #[error("WithdrawalViolatesPoolRequirements")] WithdrawalViolatesPoolRequirements, + /// The user-owned lamport source cannot be validated for `DepositSol`. + #[error("InvalidDepositSolSource")] + InvalidDepositSolSource, } impl From for ProgramError { fn from(e: SinglePoolError) -> Self { @@ -171,6 +174,8 @@ impl ToStr for SinglePoolError { SinglePoolError::WithdrawalViolatesPoolRequirements => "Error: Withdrawal would render the pool stake account impossible to redelegate. \ This can only occur if the Stake Program minimum delegation increases above 1 sol.", + SinglePoolError::InvalidDepositSolSource => + "Error: The user-owned lamport source cannot be validated for `DepositSol`.", } } } diff --git a/program/src/instruction.rs b/program/src/instruction.rs index c8177cf2..7cd25cd6 100644 --- a/program/src/instruction.rs +++ b/program/src/instruction.rs @@ -54,7 +54,7 @@ pub enum SinglePoolInstruction { /// - Delegate the on-ramp if it has excess lamports to activate. /// /// Combined, these operations allow harvesting and delegating MEV rewards - /// and will eventually allow depositing liquid sol for pool tokens. + /// and also enable depositing liquid sol for pool tokens via `DepositSol`. /// /// This instruction is idempotent and gracefully skips operations that /// would fail or have no effect, up to no-op. This allows it to be @@ -166,7 +166,28 @@ pub enum SinglePoolInstruction { /// 5. `[]` Stake program InitializePoolOnRamp, - /// (reserved for future use) + /// Deposit liquid sol into the pool. The output is a "pool" token + /// representing fractional ownership of the pool stake. Inputs are + /// converted to the current ratio, less a fee of `DEPOSIT_SOL_FEE_BPS`. + /// This instruction invokes `ReplenishPool` to immediately delegate + /// any newly added sol if possible. + /// + /// 0. `[]` Validator vote account + /// 1. `[]` Pool account + /// 2. `[w]` Pool stake account + /// 3. `[w]` Pool on-ramp account + /// 4. `[w]` Pool token mint + /// 5. `[]` Pool stake authority + /// 6. `[]` Pool mint authority + /// 7. `[w, s]` User system account to deposit from + /// 8. `[w]` User account to receive pool tokens + /// 9. `[]` Clock sysvar + /// 10. `[]` Stake history sysvar + /// 11. `[]` Stake config sysvar + /// 12. `[]` System program + /// 13. `[]` Token program + /// 14. `[]` Stake program + /// 15. `[]` Single-validator stake pool program DepositSol { /// Amount of sol to deposit lamports: u64, @@ -343,6 +364,78 @@ pub fn deposit_stake( } } +/// Creates the necessary instructions to deposit liquid sol. +/// `escrow_deposit_account` should be the pubkey of an unused keypair. +/// This avoids passing a wallet signature into an opaque program, +/// a best practice for safety given its untrammeled authority. +/// The escrow account does not have to meet rent exemption because it is +/// opened and closed in the span of one transaction. +pub fn deposit_liquid( + program_id: &Pubkey, + vote_account_address: &Pubkey, + user_wallet: &Pubkey, + escrow_deposit_account: &Pubkey, + user_token_account: &Pubkey, + lamports: u64, +) -> Vec { + vec![ + system_instruction::transfer(user_wallet, escrow_deposit_account, lamports), + deposit_sol( + program_id, + vote_account_address, + escrow_deposit_account, + user_token_account, + lamports, + ), + ] +} + +/// Creates a `DepositSol` instruction. +/// It is recommended as a matter of hygiene to use the `deposit_liquid()` helper, +/// to isolate user wallet signing authority from the program. +pub fn deposit_sol( + program_id: &Pubkey, + vote_account_address: &Pubkey, + user_deposit_account: &Pubkey, + user_token_account: &Pubkey, + lamports: u64, +) -> Instruction { + let pool_address = find_pool_address(program_id, vote_account_address); + + let data = borsh::to_vec(&SinglePoolInstruction::DepositSol { lamports }).unwrap(); + let accounts = vec![ + AccountMeta::new_readonly(*vote_account_address, false), + AccountMeta::new_readonly(pool_address, false), + AccountMeta::new(find_pool_stake_address(program_id, &pool_address), false), + AccountMeta::new(find_pool_onramp_address(program_id, &pool_address), false), + AccountMeta::new(find_pool_mint_address(program_id, &pool_address), false), + AccountMeta::new_readonly( + find_pool_stake_authority_address(program_id, &pool_address), + false, + ), + AccountMeta::new_readonly( + find_pool_mint_authority_address(program_id, &pool_address), + false, + ), + AccountMeta::new(*user_deposit_account, true), + AccountMeta::new(*user_token_account, false), + AccountMeta::new_readonly(sysvar::clock::id(), false), + AccountMeta::new_readonly(stake_history::id(), false), + #[allow(deprecated)] + AccountMeta::new_readonly(stake::config::id(), false), + AccountMeta::new_readonly(system_program::id(), false), + AccountMeta::new_readonly(spl_token::id(), false), + AccountMeta::new_readonly(stake::program::id(), false), + AccountMeta::new_readonly(*program_id, false), + ]; + + Instruction { + program_id: *program_id, + accounts, + data, + } +} + /// Creates all necessary instructions to withdraw stake into a given stake /// account. If a new stake account is required, the user should first include /// `system_instruction::create_account` with account size diff --git a/program/src/lib.rs b/program/src/lib.rs index a148c12d..412b1e7e 100644 --- a/program/src/lib.rs +++ b/program/src/lib.rs @@ -15,6 +15,13 @@ use {solana_native_token::LAMPORTS_PER_SOL, solana_pubkey::Pubkey}; solana_pubkey::declare_id!("SVSPxpvHdN29nkVg9rPapPNDddN5DipNLRUFhyjFThE"); +/// Fee charged for the `DepositSol` instruction. This fee may be adjusted down in +/// the future depending on how `DepositSol` is used in practice. Care should be +/// taken if using this number for calculations in third-party libraries or programs, +/// as it is not guaranteed to remain at this value. +pub const DEPOSIT_SOL_FEE_BPS: u64 = 100; +const MAX_BPS: u64 = 10_000; + const POOL_PREFIX: &[u8] = b"pool"; const POOL_STAKE_PREFIX: &[u8] = b"stake"; const POOL_ONRAMP_PREFIX: &[u8] = b"onramp"; diff --git a/program/src/processor.rs b/program/src/processor.rs index f41fe49f..7e89b71f 100644 --- a/program/src/processor.rs +++ b/program/src/processor.rs @@ -9,19 +9,19 @@ use { pda::find_metadata_account, state::DataV2, }, - instruction::SinglePoolInstruction, + instruction::{self as svsp_instruction, SinglePoolInstruction}, state::{SinglePool, SinglePoolAccountType}, - MINT_DECIMALS, PERPETUAL_NEW_WARMUP_COOLDOWN_RATE_EPOCH, PHANTOM_TOKEN_AMOUNT, - POOL_MINT_AUTHORITY_PREFIX, POOL_MINT_PREFIX, POOL_MPL_AUTHORITY_PREFIX, - POOL_ONRAMP_PREFIX, POOL_PREFIX, POOL_STAKE_AUTHORITY_PREFIX, POOL_STAKE_PREFIX, - VOTE_STATE_AUTHORIZED_WITHDRAWER_END, VOTE_STATE_AUTHORIZED_WITHDRAWER_START, - VOTE_STATE_DISCRIMINATOR_END, + DEPOSIT_SOL_FEE_BPS, MAX_BPS, MINT_DECIMALS, PERPETUAL_NEW_WARMUP_COOLDOWN_RATE_EPOCH, + PHANTOM_TOKEN_AMOUNT, POOL_MINT_AUTHORITY_PREFIX, POOL_MINT_PREFIX, + POOL_MPL_AUTHORITY_PREFIX, POOL_ONRAMP_PREFIX, POOL_PREFIX, POOL_STAKE_AUTHORITY_PREFIX, + POOL_STAKE_PREFIX, VOTE_STATE_AUTHORIZED_WITHDRAWER_END, + VOTE_STATE_AUTHORIZED_WITHDRAWER_START, VOTE_STATE_DISCRIMINATOR_END, }, borsh::BorshDeserialize, solana_account_info::{next_account_info, AccountInfo}, solana_borsh::v1::try_from_slice_unchecked, solana_clock::Clock, - solana_cpi::invoke_signed, + solana_cpi::{invoke, invoke_signed}, solana_msg::msg, solana_native_token::LAMPORTS_PER_SOL, solana_program_entrypoint::ProgramResult, @@ -1562,6 +1562,160 @@ impl Processor { Ok(()) } + fn process_deposit_sol( + program_id: &Pubkey, + accounts: &[AccountInfo], + deposit_amount: u64, + ) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + let vote_account_info = next_account_info(account_info_iter)?; + let pool_info = next_account_info(account_info_iter)?; + let pool_stake_info = next_account_info(account_info_iter)?; + let pool_onramp_info = next_account_info(account_info_iter)?; + let pool_mint_info = next_account_info(account_info_iter)?; + let pool_stake_authority_info = next_account_info(account_info_iter)?; + let pool_mint_authority_info = next_account_info(account_info_iter)?; + let user_lamport_account_info = next_account_info(account_info_iter)?; + let user_token_account_info = next_account_info(account_info_iter)?; + let clock_info = next_account_info(account_info_iter)?; + let clock = &Clock::from_account_info(clock_info)?; + let stake_history_info = next_account_info(account_info_iter)?; + let stake_config_info = next_account_info(account_info_iter)?; + let system_program_info = next_account_info(account_info_iter)?; + let token_program_info = next_account_info(account_info_iter)?; + let stake_program_info = next_account_info(account_info_iter)?; + + let rent = Rent::get()?; + let stake_history = &StakeHistorySysvar(clock.epoch); + + check_vote_account(vote_account_info)?; + check_pool_address(program_id, vote_account_info.key, pool_info.key)?; + + SinglePool::from_account_info(pool_info, program_id)?; + + check_pool_stake_address(program_id, pool_info.key, pool_stake_info.key)?; + check_pool_onramp_address(program_id, pool_info.key, pool_onramp_info.key)?; + let token_supply = check_pool_mint_with_supply(program_id, pool_info.key, pool_mint_info)?; + check_pool_stake_authority_address( + program_id, + pool_info.key, + pool_stake_authority_info.key, + )?; + let mint_authority_bump_seed = check_pool_mint_authority_address( + program_id, + pool_info.key, + pool_mint_authority_info.key, + )?; + check_system_program(system_program_info.key)?; + check_token_program(token_program_info.key)?; + check_stake_program(stake_program_info.key)?; + + if deposit_amount == 0 { + return Err(SinglePoolError::DepositTooSmall.into()); + } + + // we require the pool to be fully active for this instruction to minimize complexity + { + let (_, pool_stake_state) = get_stake_state(pool_stake_info)?; + let pool_stake_status = pool_stake_state + .delegation + .stake_activating_and_deactivating( + clock.epoch, + stake_history, + PERPETUAL_NEW_WARMUP_COOLDOWN_RATE_EPOCH, + ); + + if !is_stake_fully_active(&pool_stake_status) { + return Err(SinglePoolError::WrongStakeState.into()); + } + }; + + // we require onramp to exist, though we dont care what state its in + match deserialize_stake(pool_onramp_info) { + Ok(StakeStateV2::Initialized(_)) | Ok(StakeStateV2::Stake(_, _, _)) => (), + _ => return Err(SinglePoolError::OnRampDoesntExist.into()), + } + + // deposit source must be a system account for transfer to succeed + if !user_lamport_account_info.is_signer + || user_lamport_account_info.owner != &system_program::id() + || user_lamport_account_info.lamports() < deposit_amount + { + return Err(SinglePoolError::InvalidDepositSolSource.into()); + } + + let pre_total_nav = pool_net_asset_value(pool_stake_info, pool_onramp_info, &rent); + let pre_onramp_lamports = pool_onramp_info.lamports(); + + // transfer sol to pool onramp + invoke( + &system_instruction::transfer( + user_lamport_account_info.key, + pool_onramp_info.key, + deposit_amount, + ), + &[user_lamport_account_info.clone(), pool_onramp_info.clone()], + )?; + + // sanity, should be impossible + if pool_onramp_info.lamports() != pre_onramp_lamports.saturating_add(deposit_amount) { + return Err(SinglePoolError::UnexpectedMathError.into()); + } + + let new_pool_tokens = { + let raw_tokens = calculate_deposit_amount(token_supply, pre_total_nav, deposit_amount) + .ok_or(SinglePoolError::UnexpectedMathError)?; + + // we round division down and reject deposits too small to generate a fee + // this is to avoid pathological cases where eg someone deposits 2 lamps and pays 50% + let deposit_sol_fee = raw_tokens + .checked_mul(DEPOSIT_SOL_FEE_BPS) + .and_then(|n| n.checked_div(MAX_BPS)) + .ok_or(SinglePoolError::UnexpectedMathError)?; + + // because we abort on no fee, we know new_pool_tokens gt 0 + if deposit_sol_fee == 0 { + return Err(SinglePoolError::DepositTooSmall.into()); + } + + raw_tokens.saturating_sub(deposit_sol_fee) + }; + + // sanity, should be impossible per above + if new_pool_tokens == 0 { + return Err(SinglePoolError::UnexpectedMathError.into()); + } + + // mint tokens to the user corresponding to their sol deposit + Self::token_mint_to( + pool_info.key, + token_program_info.clone(), + pool_mint_info.clone(), + user_token_account_info.clone(), + pool_mint_authority_info.clone(), + mint_authority_bump_seed, + new_pool_tokens, + )?; + + // replenish to delegate the deposit. this safely returns Ok if onramp doesnt meet minimum delegation + invoke( + &svsp_instruction::replenish_pool(program_id, vote_account_info.key), + &[ + vote_account_info.clone(), + pool_info.clone(), + pool_stake_info.clone(), + pool_onramp_info.clone(), + pool_stake_authority_info.clone(), + clock_info.clone(), + stake_history_info.clone(), + stake_config_info.clone(), + stake_program_info.clone(), + ], + )?; + + Ok(()) + } + /// Processes [Instruction](enum.Instruction.html). pub fn process(program_id: &Pubkey, accounts: &[AccountInfo], input: &[u8]) -> ProgramResult { let instruction = SinglePoolInstruction::try_from_slice(input)?; @@ -1602,9 +1756,9 @@ impl Processor { msg!("Instruction: InitializePoolOnRamp"); Self::process_initialize_pool_onramp(program_id, accounts) } - SinglePoolInstruction::DepositSol { lamports: _ } => { - msg!("Instruction: DepositSol (NOT IMPLEMENTED)"); - Err(ProgramError::InvalidInstructionData) + SinglePoolInstruction::DepositSol { lamports } => { + msg!("Instruction: DepositSol"); + Self::process_deposit_sol(program_id, accounts, lamports) } } } diff --git a/program/tests/accounts.rs b/program/tests/accounts.rs index b7459a62..5d333479 100644 --- a/program/tests/accounts.rs +++ b/program/tests/accounts.rs @@ -25,20 +25,20 @@ use { #[derive(Clone, Debug, PartialEq, Eq)] enum TestMode { - Initialize, - Deposit, - Withdraw, + InitializePool, + DepositStake, + WithdrawStake, + DepositSol, } -// build a full transaction for initialize, deposit, and withdraw -// this is used to test knocking out individual accounts, for the sake of -// confirming the pubkeys are checked +// build a full transaction for initialize, deposit, withdraw, and depositsol +// this tests that dummying individual accounts triggers the appropriate errors async fn build_instructions( context: &mut ProgramTestContext, accounts: &SinglePoolAccounts, test_mode: TestMode, ) -> (Vec, usize) { - let initialize_instructions = if test_mode == TestMode::Initialize { + let initialize_instructions = if test_mode == TestMode::InitializePool { let slot = context.genesis_config().epoch_schedule.first_normal_slot + 1; context.warp_to_slot(slot).unwrap(); @@ -86,7 +86,7 @@ async fn build_instructions( vec![] }; - let deposit_instructions = instruction::deposit( + let deposit_stake_instructions = instruction::deposit( &id(), &accounts.pool, &accounts.alice_stake.pubkey(), @@ -95,9 +95,9 @@ async fn build_instructions( &accounts.alice.pubkey(), ); - let withdraw_instructions = if test_mode == TestMode::Withdraw { + let withdraw_stake_instructions = if test_mode == TestMode::WithdrawStake { let transaction = Transaction::new_signed_with_payer( - &deposit_instructions, + &deposit_stake_instructions, Some(&accounts.alice.pubkey()), &[&accounts.alice], context.last_blockhash, @@ -131,12 +131,23 @@ async fn build_instructions( vec![] }; + // self-transfer is unidiomatic but this way we can test the full helper without messing with signers + let deposit_sol_instructions = instruction::deposit_liquid( + &id(), + &accounts.vote_account.pubkey(), + &accounts.alice.pubkey(), + &accounts.alice.pubkey(), + &accounts.alice_token, + TEST_STAKE_AMOUNT, + ); + // ints hardcoded to guard against instructions moving with code changes // if these asserts fail, update them to match the new multi-instruction builders let (instructions, index, enum_tag) = match test_mode { - TestMode::Initialize => (initialize_instructions, 4, 0), - TestMode::Deposit => (deposit_instructions, 2, 2), - TestMode::Withdraw => (withdraw_instructions, 1, 3), + TestMode::InitializePool => (initialize_instructions, 4, 0), + TestMode::DepositStake => (deposit_stake_instructions, 2, 2), + TestMode::WithdrawStake => (withdraw_stake_instructions, 1, 3), + TestMode::DepositSol => (deposit_sol_instructions, 1, 7), }; assert_eq!(instructions[index].program_id, id()); @@ -148,7 +159,7 @@ async fn build_instructions( // test that account addresses are checked properly #[test_matrix( [StakeProgramVersion::Stable, StakeProgramVersion::Beta, StakeProgramVersion::Edge], - [TestMode::Initialize, TestMode::Deposit, TestMode::Withdraw] + [TestMode::InitializePool, TestMode::DepositStake, TestMode::WithdrawStake, TestMode::DepositSol] )] #[tokio::test] async fn fail_account_checks(stake_version: StakeProgramVersion, test_mode: TestMode) { @@ -187,7 +198,9 @@ async fn fail_account_checks(stake_version: StakeProgramVersion, test_mode: Test .unwrap_err(); // these specific accounts we can also make sure we hit the explicit check, before we use it - if instruction_pubkey == accounts.pool { + if instruction_pubkey == accounts.vote_account.pubkey() { + check_error(e, ProgramError::IncorrectProgramId) + } else if instruction_pubkey == accounts.pool { check_error(e, SinglePoolError::InvalidPoolAccount) } else if instruction_pubkey == accounts.stake_account { check_error(e, SinglePoolError::InvalidPoolStakeAccount) @@ -267,9 +280,13 @@ fn make_basic_instruction( SinglePoolInstruction::InitializePoolOnRamp => { instruction::initialize_pool_onramp(&id(), &accounts.pool) } - SinglePoolInstruction::DepositSol { .. } => { - unimplemented!() - } + SinglePoolInstruction::DepositSol { .. } => instruction::deposit_sol( + &id(), + &accounts.vote_account.pubkey(), + &Pubkey::default(), + &Pubkey::default(), + 0, + ), } } @@ -319,7 +336,7 @@ fn consistent_account_order() { }, ), make_basic_instruction(&accounts, SinglePoolInstruction::InitializePoolOnRamp), - // TODO SinglePoolInstruction::DepositSol + make_basic_instruction(&accounts, SinglePoolInstruction::DepositSol { lamports: 0 }), ]; for instruction in instructions { diff --git a/program/tests/deposit_sol.rs b/program/tests/deposit_sol.rs new file mode 100644 index 00000000..18377f86 --- /dev/null +++ b/program/tests/deposit_sol.rs @@ -0,0 +1,300 @@ +#![allow(clippy::arithmetic_side_effects)] + +mod helpers; + +use { + helpers::*, + solana_account::AccountSharedData, + solana_keypair::Keypair, + solana_native_token::LAMPORTS_PER_SOL, + solana_program_test::*, + solana_pubkey::Pubkey, + solana_signer::Signer, + solana_transaction::Transaction, + spl_single_pool::{error::SinglePoolError, id, instruction}, + test_case::test_matrix, +}; + +async fn deposit_sol( + context: &mut ProgramTestContext, + accounts: &SinglePoolAccounts, + lamports: u64, +) -> Result<(), BanksClientError> { + let proxy_keypair = Keypair::new(); + + let instructions = instruction::deposit_liquid( + &id(), + &accounts.vote_account.pubkey(), + &accounts.alice.pubkey(), + &proxy_keypair.pubkey(), + &accounts.alice_token, + lamports, + ); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&context.payer.pubkey()), + &[&context.payer, &accounts.alice, &proxy_keypair], + context.last_blockhash, + ); + + context.banks_client.process_transaction(transaction).await +} + +#[test_matrix( + [StakeProgramVersion::Stable, StakeProgramVersion::Beta, StakeProgramVersion::Edge], + [1000, TEST_STAKE_AMOUNT], + [0, LAMPORTS_PER_SOL * 3] +)] +#[tokio::test] +async fn success( + stake_version: StakeProgramVersion, + deposit_amount: u64, + additional_pool_value: u64, +) { + let Some(program_test) = program_test(stake_version) else { + return; + }; + let mut context = program_test.start_with_context().await; + + let accounts = SinglePoolAccounts::default(); + let minimum_pool_balance = accounts.initialize(&mut context).await; + + advance_epoch(&mut context).await; + + let minimum_delegation = get_minimum_delegation( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + ) + .await; + + if additional_pool_value > 0 { + transfer( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &accounts.onramp_account, + additional_pool_value, + ) + .await; + } + + let wallet_lamports_before = get_account(&mut context.banks_client, &accounts.alice.pubkey()) + .await + .lamports; + + let onramp_lamports_before = get_account(&mut context.banks_client, &accounts.onramp_account) + .await + .lamports; + + // phantom tokens + let token_supply_before = + get_token_supply(&mut context.banks_client, &accounts.mint).await + LAMPORTS_PER_SOL; + + assert_eq!( + 0, + get_token_balance(&mut context.banks_client, &accounts.alice_token).await + ); + + // note our 1000 deposit case also tests that we *dont* need our proxy to make rent + deposit_sol(&mut context, &accounts, deposit_amount) + .await + .unwrap(); + + let wallet_lamports_after = get_account(&mut context.banks_client, &accounts.alice.pubkey()) + .await + .lamports; + + // we deposited the expected amount + assert_eq!( + deposit_amount, + wallet_lamports_before - wallet_lamports_after, + ); + + let (_, onramp_stake_after, onramp_lamports_after) = + get_stake_account(&mut context.banks_client, &accounts.onramp_account).await; + + // pool duly recieved said deposit + assert_eq!( + deposit_amount, + onramp_lamports_after - onramp_lamports_before, + ); + + // onramp was successfully replenished if we met the minimum delegation + if (deposit_amount + additional_pool_value) >= minimum_delegation { + assert_eq!( + deposit_amount + additional_pool_value, + onramp_stake_after.unwrap().delegation.stake + ); + } + + let user_tokens_after = + get_token_balance(&mut context.banks_client, &accounts.alice_token).await; + + // depositing n stake yields n tokens for an initial pool with 1b locked stake to 1b phantom tokens + // so if we deposit n lamports we expect to cleanly have 1% fewer tokens than that + // if the pool has more stake per token we have to scale our expectations by token supply + // branch here so the simple case gets tested wth simple math + if additional_pool_value == 0 { + assert_eq!(deposit_amount - deposit_amount / 100, user_tokens_after); + } else { + let raw_tokens = + deposit_amount * token_supply_before / (minimum_pool_balance + additional_pool_value); + + assert_eq!(raw_tokens - raw_tokens / 100, user_tokens_after); + } +} + +#[test_matrix( + [StakeProgramVersion::Stable, StakeProgramVersion::Beta, StakeProgramVersion::Edge] +)] +#[tokio::test] +async fn fail_bad_pool(stake_version: StakeProgramVersion) { + let Some(program_test) = program_test(stake_version) else { + return; + }; + let mut context = program_test.start_with_context().await; + + let accounts = SinglePoolAccounts::default(); + accounts.initialize(&mut context).await; + + let e = deposit_sol(&mut context, &accounts, TEST_STAKE_AMOUNT) + .await + .unwrap_err(); + + // fail: pool activating + check_error(e, SinglePoolError::WrongStakeState); + + force_deactivate_stake_account(&mut context, &accounts.stake_account).await; + refresh_blockhash(&mut context).await; + + let e = deposit_sol(&mut context, &accounts, TEST_STAKE_AMOUNT) + .await + .unwrap_err(); + + // fail: pool inactive + check_error(e, SinglePoolError::WrongStakeState); + + replenish(&mut context, &accounts.vote_account.pubkey()).await; + advance_epoch(&mut context).await; + context.set_account(&accounts.onramp_account, &AccountSharedData::default()); + + let e = deposit_sol(&mut context, &accounts, TEST_STAKE_AMOUNT) + .await + .unwrap_err(); + + // fail: no onramp + check_error(e, SinglePoolError::OnRampDoesntExist); +} + +#[test_matrix( + [StakeProgramVersion::Stable, StakeProgramVersion::Beta, StakeProgramVersion::Edge] +)] +#[tokio::test] +async fn fail_bad_deposit(stake_version: StakeProgramVersion) { + let Some(program_test) = program_test(stake_version) else { + return; + }; + let mut context = program_test.start_with_context().await; + + let accounts = SinglePoolAccounts::default(); + accounts.initialize(&mut context).await; + + advance_epoch(&mut context).await; + + let e = deposit_sol(&mut context, &accounts, 0).await.unwrap_err(); + + // fail: zero deposit + check_error(e, SinglePoolError::DepositTooSmall); + + let e = deposit_sol(&mut context, &accounts, 99).await.unwrap_err(); + + // fail: deposit rounds to no fee + check_error(e, SinglePoolError::DepositTooSmall); + + let proxy_keypair = Keypair::new(); + transfer( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &proxy_keypair.pubkey(), + TEST_STAKE_AMOUNT, + ) + .await; + + let instruction = instruction::deposit_sol( + &id(), + &accounts.vote_account.pubkey(), + &proxy_keypair.pubkey(), + &accounts.alice_token, + TEST_STAKE_AMOUNT + 1, + ); + let transaction = Transaction::new_signed_with_payer( + &[instruction], + Some(&context.payer.pubkey()), + &[&context.payer, &proxy_keypair], + context.last_blockhash, + ); + + let e = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err(); + + // fail: not enough for deposit + check_error(e, SinglePoolError::InvalidDepositSolSource); + + let mut instruction = instruction::deposit_sol( + &id(), + &accounts.vote_account.pubkey(), + &proxy_keypair.pubkey(), + &accounts.alice_token, + TEST_STAKE_AMOUNT, + ); + instruction.accounts[7].is_signer = false; + + let transaction = Transaction::new_signed_with_payer( + &[instruction], + Some(&context.payer.pubkey()), + &[&context.payer], + context.last_blockhash, + ); + + let e = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err(); + + // fail: missing signer + check_error(e, SinglePoolError::InvalidDepositSolSource); + + let instruction = instruction::deposit_sol( + &id(), + &accounts.vote_account.pubkey(), + &proxy_keypair.pubkey(), + &accounts.alice_token, + TEST_STAKE_AMOUNT, + ); + let transaction = Transaction::new_signed_with_payer( + &[instruction], + Some(&context.payer.pubkey()), + &[&context.payer, &proxy_keypair], + context.last_blockhash, + ); + + context.set_account( + &proxy_keypair.pubkey(), + &AccountSharedData::new(TEST_STAKE_AMOUNT, 0, &Pubkey::new_unique()), + ); + + let e = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err(); + + // fail: bad owner + check_error(e, SinglePoolError::InvalidDepositSolSource); +} diff --git a/program/tests/deposit.rs b/program/tests/deposit_stake.rs similarity index 100% rename from program/tests/deposit.rs rename to program/tests/deposit_stake.rs diff --git a/program/tests/withdraw.rs b/program/tests/withdraw_stake.rs similarity index 100% rename from program/tests/withdraw.rs rename to program/tests/withdraw_stake.rs