diff --git a/Anchor.toml b/Anchor.toml index bd1b269975a..99fe81ddd78 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -29,3 +29,104 @@ url = "https://api.mainnet-beta.solana.com" [[test.validator.clone]] # Solend Main Pool - (USDC) Reserve State address = "BgxfHJDzm44T7XG68MYKx7YisTjZu73tVovyZSjJMpmw" +# What follows is a list of some more reserves to clone to test batch upgrade +[[test.validator.clone]] +address = "46t9bCbiBwiVsjQPz2CLYcMULCtsZTBbwqLdAz7s2xXy" +[[test.validator.clone]] +address = "6RLEnWjEUR8MTTn8MtFXmw1Fz1JWjUVcC1bQFPqDXbgy" +[[test.validator.clone]] +address = "4o8bqVMVrjwbUEU69axoQB7LFDHHt58d2XMtPXFJ8tK1" +[[test.validator.clone]] +address = "3xLmLkoKSKddqg7ejPNq679ApQNnm2dn3VVvtV7isSDo" +[[test.validator.clone]] +address = "44JpnzauMCjmwHBN1VJe4rFVjidozsAeGMR5srzuWp55" +[[test.validator.clone]] +address = "4YA39gkfuskkp3ir1NZ9jySkHYocSnZoPY2oT64BRmb5" +[[test.validator.clone]] +address = "3NdKfP3qLSzxqQTkJpYL2gGBza1esdyakRrAyMLt64R2" +[[test.validator.clone]] +address = "59KjpUPKXsoYZUNdc8g2Yi32uAw9Brm6g8bQCfFVomyJ" +[[test.validator.clone]] +address = "5noExw6LxoDaoAbcaFj5YZbBhpXoMqazzZuBrAUeFiUj" +[[test.validator.clone]] +address = "5eLCVf61tRmv4V6WASMfR7X4skqXpEakqyaLMFrQETSB" +[[test.validator.clone]] +address = "rKBpHeyPyn9YU4VNtxaX6Tu618Y2R4sWybxUpea5ph4" +[[test.validator.clone]] +address = "5yq5AJTJMoRQvGFbA2h6wKRQMV7Z6CjfbZCa3cAJS2r5" +[[test.validator.clone]] +address = "gY2sQUkxEQPDcn2s72KBKmw9q343QpVhxU5WLu2sxjr" +[[test.validator.clone]] +address = "6TsJpaAbwJMTFZdAEHcVb86zQaLoNmbabYX1kkW1NAj7" +[[test.validator.clone]] +address = "6JLJ3eq8sHDjUBLy4zjvLjvyMjrqnUDizQEdcZqgaYrG" +[[test.validator.clone]] +address = "4kgzahtogzibeopWQBrKcCYPiavEAaV9sJjuZy9dUuic" +[[test.validator.clone]] +address = "6uhoHHFPQbRpssDphCjxU6hMmXp3GLS2qAZmCExZkwap" +[[test.validator.clone]] +address = "13Ts1ERfwAM11MVQAU3zCz49fGkWmdZbfXuTGyKz6ENy" +[[test.validator.clone]] +address = "14aRZgQAQtGRES3mNTFvEVEfEqtnKJa73ryENDAvJFaQ" +[[test.validator.clone]] +address = "6e8zg2Y9skA5AMm7J62GJQ8Ui4reuzEytBS74zHV8S7A" +[[test.validator.clone]] +address = "6QNF4ovs4vWqjjvNUNoJLhXCW34iEm2QGipRKJdk725n" +[[test.validator.clone]] +address = "yjuWAA6XXhEwxWuzbPfDPnYiohCLFqAfgCDA4EdHHDm" +[[test.validator.clone]] +address = "XK8FEMEziMX9W46ivFmjddjvW3aY8dkVvucEGLmrt5D" +[[test.validator.clone]] +address = "2WBEmjZbUMXZbs9ucG3B7254y2C34d72uv2qHjvR1T4n" +[[test.validator.clone]] +address = "yxW7QwpJzKxfNo2QkbcmgjpgFxEL4UGbUivkFWtkmd3" +[[test.validator.clone]] +address = "66RtjhW1bMXTJ2ZL8TMowUTAtraHiksKGGQSArGRZKAZ" +[[test.validator.clone]] +address = "3Cv8evqFV1MWirL1ohv2VsdTAmpDvNWy4veGjYDFrWn2" +[[test.validator.clone]] +address = "5rDqwn1GMMrSkjvZS1G7BZ2tR5Q4JS13wnSHn11La9a9" +[[test.validator.clone]] +address = "4ERjFetPd5DQDK8N9wL4i36ozs4zeW1MeGbPbKw9QMsy" +[[test.validator.clone]] +address = "5hevPuvhqmXdQcuiB2mqekxQcqL9kVix8D5ckRGyA8yk" +[[test.validator.clone]] +address = "62M3oYeJ2agvuHsgHfzJuiWsTbEewZCFJbMHXKDkKMqL" +[[test.validator.clone]] +address = "6DF8vRKZKdAK2rdirJTwG3TWogjgByqcEi5WbnwXb3YZ" +[[test.validator.clone]] +address = "3mSMHPvNewL8RTwAcA6GTCLjS19J3NK2huJxgA553oHy" +[[test.validator.clone]] +address = "75EgKN1rrVssMQr6KjvR5w6Gnth8FkqECgZ6Q4mDDCx6" +[[test.validator.clone]] +address = "2tSNdecgEHEidEemg9vCbgFPzg6nyok97xJc1Lse3mG8" +[[test.validator.clone]] +address = "3Hr5qshXDQgbL1za6Sayug61Hwt5rjnV7dbyE9NUQDaZ" +[[test.validator.clone]] +address = "2v3Y19ahC3dtV6CnrmT5vZfpJy7HkyFoxkRgTeuk6cC4" +[[test.validator.clone]] +address = "53oro4QCCqqtDgfs1qfeH5LyvfdSexjXXaT14drTJ2Xj" +[[test.validator.clone]] +address = "7kL41rV8tgRBticXUyp3LfCV7eBGKUrAwL2e7GJB5ooP" +[[test.validator.clone]] +address = "7t3QnGAqse8zvAohJJX7robsBviyP5Bg7xNBxi7HSPNy" +[[test.validator.clone]] +address = "7vcWs9Gut1HE8o24cfuYkjuFCdBMEkBALqqvLDnQsBQT" +[[test.validator.clone]] +address = "2j8XVnUFk6Hxm75QdJn6MDyxVE9QCbCe5BauGg82ANZU" +[[test.validator.clone]] +address = "81EGVb5RD8yft1N3SGxitn2QnvJg7Pbq5k4CiXkf3f5A" +[[test.validator.clone]] +address = "7UgZy6RzhfSG66qrdD7Q5LHrVJRm7bUqhRHPh9siBqQQ" +[[test.validator.clone]] +address = "3kWqRMVepJ5HSmXF16bBWQYQ5C7YNGvJJqCPt3A7zKWH" +[[test.validator.clone]] +address = "8NVfgFqWPiy7B4o4yQQ8XSTwSihidtdftA1wzeePnCeJ" +[[test.validator.clone]] +address = "3738W1f4ygKayow8TrFGuDbhFovQ3QhM2MPY8AF375ki" +[[test.validator.clone]] +address = "7ssc2gVnucKKwsh6DS4HYHUWAigJomW6v37T65SXJVEr" +[[test.validator.clone]] +address = "2wDArvF5bAdLmmm4QZNVFJDrML6CCBbfbCeCLEb7c6QN" +[[test.validator.clone]] +address = "41SrrxMb1yzivSbUNjLShRV5yZf7S7YdQ1Emg2AxCviu" diff --git a/Cargo.lock b/Cargo.lock index afa6411fcfd..1f770e26255 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1143,7 +1143,7 @@ dependencies = [ "bitflags 1.3.2", "strsim 0.8.0", "textwrap 0.11.0", - "unicode-width", + "unicode-width 0.1.13", "vec_map", ] @@ -1203,7 +1203,7 @@ dependencies = [ "encode_unicode", "lazy_static", "libc", - "unicode-width", + "unicode-width 0.1.13", "windows-sys 0.52.0", ] @@ -2355,24 +2355,15 @@ dependencies = [ [[package]] name = "indicatif" -version = "0.17.8" +version = "0.17.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "763a5a8f45087d6bcea4222e7b72c291a054edf80e4ef6efd2a4979878c7bea3" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" dependencies = [ "console", - "instant", "number_prefix", "portable-atomic", - "unicode-width", -] - -[[package]] -name = "instant" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" -dependencies = [ - "cfg-if", + "unicode-width 0.2.0", + "web-time", ] [[package]] @@ -5463,6 +5454,7 @@ version = "2.1.0" dependencies = [ "bincode", "clap 2.34.0", + "indicatif", "reqwest 0.12.4", "serde_json", "solana-account-decoder", @@ -6166,7 +6158,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" dependencies = [ - "unicode-width", + "unicode-width 0.1.13", ] [[package]] @@ -6614,6 +6606,12 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d" +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "unicode-xid" version = "0.1.0" @@ -6872,6 +6870,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki" version = "0.22.4" diff --git a/token-lending/LIQUIDITY_MINING.md b/token-lending/LIQUIDITY_MINING.md index bc2af6f25f4..1f676658952 100644 --- a/token-lending/LIQUIDITY_MINING.md +++ b/token-lending/LIQUIDITY_MINING.md @@ -39,6 +39,10 @@ We keep adding `(total_rewards * time_passed) / (total_time)` every time someone This value is used to transfer the unallocated rewards to the admin. However, this can be calculated dynamically which avoids storing an extra packed decimal (16 bytes) on each reserve's pool reward (30). +In Suilend, we disable looped rewards. +For example, if an obligation has reserve $USDC and $USDT, this obligation cannot claim rewards. +This is not done in Save. + ## New ixs There's a common concept of reward vault and reward vault authority across the ixs. @@ -73,7 +77,7 @@ Users will still be able to claim rewards they accrued until this point. #### Cancel Cancelling a pool reward can be done by setting the end time to 0. -Note that only rewards longer than [solend_sdk::MIN_REWARD_PERIOD_SECS] can be cancelled. +Note that only rewards longer than `solend_sdk::MIN_REWARD_PERIOD_SECS` can be cancelled. In this case we transfer tokens from the reward vault to the lending market reward token account. #### Shorten diff --git a/token-lending/cli/Cargo.toml b/token-lending/cli/Cargo.toml index a59aa534750..0c024029373 100644 --- a/token-lending/cli/Cargo.toml +++ b/token-lending/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "solend-program-cli" -version.workspace = true +version = "2.1.0" authors = ["Solend Maintainers "] description = "Solend Program CLI" edition = "2018" @@ -9,21 +9,22 @@ license = "Apache-2.0" repository = "https://github.com/solendprotocol/solana-program-library" [dependencies] +bincode = "1.3.3" clap = "=2.34.0" +indicatif = "0.17.11" +reqwest = { version = "0.12.2", features = ["blocking", "json"] } +serde_json = "1.0.120" +solana-account-decoder = "1.14.10" solana-clap-utils = "1.14.10" solana-cli-config = "1.14.10" solana-client = "1.14.10" solana-logger = "1.14.10" -solana-sdk = "1.14.10" solana-program = "1.14.10" -solend-sdk = { path = "../sdk" } +solana-sdk = "1.14.10" solend-program = { path = "../program", features = ["no-entrypoint"] } -spl-token = { version = "3.3.0", features = ["no-entrypoint"] } +solend-sdk = { path = "../sdk" } spl-associated-token-account = "1.0" -solana-account-decoder = "1.14.10" -reqwest = { version = "0.12.2", features = ["blocking", "json"] } -bincode = "1.3.3" -serde_json = "1.0.120" +spl-token = { version = "3.3.0", features = ["no-entrypoint"] } [[bin]] name = "solend-cli" diff --git a/token-lending/cli/src/liquidity_mining.rs b/token-lending/cli/src/liquidity_mining.rs new file mode 100644 index 00000000000..e1cb75c819d --- /dev/null +++ b/token-lending/cli/src/liquidity_mining.rs @@ -0,0 +1,19 @@ +//! CLI commands related to liquidity mining. + +mod add_pool_reward; +mod close_pool_reward; +mod crank_pool_rewards; +mod edit_pool_reward; +mod find_obligations_to_fund; +mod migrate_all_reserves; +mod view_obligation_rewards; +mod view_reserve_rewards; + +pub(crate) use add_pool_reward::command as command_add_pool_reward; +pub(crate) use close_pool_reward::command as command_close_pool_reward; +pub(crate) use crank_pool_rewards::command as command_crank_pool_rewards; +pub(crate) use edit_pool_reward::command as command_edit_pool_reward; +pub(crate) use find_obligations_to_fund::command as command_find_obligations_to_fund_for_liquidity_mining; +pub(crate) use migrate_all_reserves::command as command_migrate_all_reserves_for_liquidity_mining; +pub(crate) use view_obligation_rewards::command as command_view_obligation_rewards; +pub(crate) use view_reserve_rewards::command as command_view_reserve_rewards; diff --git a/token-lending/cli/src/liquidity_mining/add_pool_reward.rs b/token-lending/cli/src/liquidity_mining/add_pool_reward.rs new file mode 100644 index 00000000000..1d337cab55d --- /dev/null +++ b/token-lending/cli/src/liquidity_mining/add_pool_reward.rs @@ -0,0 +1,114 @@ +//! Adds a pool reward to a reserve. +//! +//! The signer must be the owner of the lending market, and there must be a free slot in the reserve. + +use std::{borrow::Borrow, str::FromStr}; + +use solana_program::program_pack::Pack; +use solana_sdk::{pubkey::Pubkey, signature::Keypair, signer::Signer, system_instruction}; +use solend_sdk::{ + instruction::{add_pool_reward, find_reward_vault_authority}, + state::{LendingMarket, PoolRewardEntry, PositionKind, Reserve}, +}; + +use crate::{send_transaction, CommandResult, Config}; + +pub(crate) fn command( + config: &mut Config, + reserve_pubkey: Pubkey, + position_kind: PositionKind, + source_reward_token_account_pubkey: Pubkey, + start_time_secs: u64, + duration_secs: u32, + token_amount: u64, +) -> CommandResult { + let reserve_info = config.rpc_client.get_account(&reserve_pubkey)?; + let reserve = Reserve::unpack_from_slice(reserve_info.data.borrow())?; + let lending_market_info = config.rpc_client.get_account(&reserve.lending_market)?; + let lending_market = LendingMarket::unpack(lending_market_info.data.borrow())?; + + if config.fee_payer.pubkey() != lending_market.owner { + return Err(format!( + "The fee payer must be the owner of the lending market '{}'", + reserve.lending_market + ) + .into()); + } + + let has_free_slot = reserve + .pool_reward_manager(position_kind) + .pool_rewards + .iter() + .any(|pr| matches!(pr, PoolRewardEntry::Vacant { .. })); + + if !has_free_slot { + return Err( + "There are no vacant slots to add the pool reward. Please crank it first".into(), + ); + } + + let Some(source_reward_token_account) = config + .rpc_client + .get_token_account(&source_reward_token_account_pubkey)? + else { + return Err(format!( + "Failed to fetch source token account '{}'", + source_reward_token_account_pubkey + ) + .into()); + }; + + let reward_mint = Pubkey::from_str(&source_reward_token_account.mint)?; + + let reward_vault_keypair = Keypair::new(); + + let create_account_ix = system_instruction::create_account( + &config.fee_payer.pubkey(), + &reward_vault_keypair.pubkey(), + config + .rpc_client + .get_minimum_balance_for_rent_exemption(spl_token::state::Account::LEN)?, + spl_token::state::Account::LEN as _, + &spl_token::id(), + ); + + let (reward_vault_authority, reward_authority_bump) = find_reward_vault_authority( + &config.lending_program_id, + &reserve.lending_market, + &reward_vault_keypair.pubkey(), + ); + + let add_reward_ix = add_pool_reward( + config.lending_program_id, + reward_authority_bump, + position_kind, + start_time_secs, + start_time_secs + duration_secs as u64, + token_amount, + reserve_pubkey, + reward_mint, + source_reward_token_account_pubkey, + reward_vault_authority, + reward_vault_keypair.pubkey(), + reserve.lending_market, + lending_market.owner, + ); + + let recent_blockhash = config.rpc_client.get_latest_blockhash()?; + + let message = solana_sdk::message::Message::new_with_blockhash( + &[create_account_ix, add_reward_ix], + Some(&config.fee_payer.pubkey()), + &recent_blockhash, + ); + + let transaction = solana_sdk::transaction::Transaction::new( + &vec![config.fee_payer.as_ref()], + message, + recent_blockhash, + ); + + send_transaction(config, transaction)?; + + Ok(()) +} diff --git a/token-lending/cli/src/liquidity_mining/close_pool_reward.rs b/token-lending/cli/src/liquidity_mining/close_pool_reward.rs new file mode 100644 index 00000000000..109be55f268 --- /dev/null +++ b/token-lending/cli/src/liquidity_mining/close_pool_reward.rs @@ -0,0 +1,104 @@ +//! A pool reward can only be closed if it has no more active user reward managers. + +use std::{borrow::Borrow, str::FromStr}; + +use solana_program::program_pack::Pack; +use solana_sdk::pubkey::Pubkey; +use solend_sdk::{ + instruction::{close_pool_reward, find_reward_vault_authority}, + state::{LendingMarket, PoolRewardEntry, PositionKind, Reserve}, +}; + +use crate::{send_transaction, CommandResult, Config}; + +pub(crate) fn command( + config: &mut Config, + reserve_pubkey: Pubkey, + position_kind: PositionKind, + pool_reward_index: usize, + destination_reward_token_account_pubkey: Pubkey, +) -> CommandResult { + let reserve_info = config.rpc_client.get_account(&reserve_pubkey)?; + let reserve = Reserve::unpack_from_slice(reserve_info.data.borrow())?; + let lending_market_info = config.rpc_client.get_account(&reserve.lending_market)?; + let lending_market = LendingMarket::unpack(lending_market_info.data.borrow())?; + + if config.fee_payer.pubkey() != lending_market.owner { + return Err(format!( + "The fee payer must be the owner of the lending market '{}'", + reserve.lending_market + ) + .into()); + } + + let PoolRewardEntry::Occupied(pool_reward) = reserve + .pool_reward_manager(position_kind) + .pool_rewards + .get(pool_reward_index) + .ok_or_else(|| { + format!( + "Pool reward index {} does not exist for position kind {:?}", + pool_reward_index, position_kind + ) + })? + else { + return Err("Pool reward index is not occupied".into()); + }; + + if pool_reward.num_user_reward_managers > 0 { + return Err(format!( + "Pool reward still has {} user reward managers. Crank it first.", + pool_reward.num_user_reward_managers + ) + .into()); + } + + let Some(reward_vault_token_account) = + config.rpc_client.get_token_account(&pool_reward.vault)? + else { + return Err(format!( + "Failed to fetch pool reward vault '{}'", + pool_reward.vault + ))?; + }; + + let reward_mint = Pubkey::from_str(&reward_vault_token_account.mint)?; + + let (reward_vault_authority, reward_authority_bump) = find_reward_vault_authority( + &config.lending_program_id, + &reserve.lending_market, + &pool_reward.vault, + ); + + let close_reward_ix = close_pool_reward( + config.lending_program_id, + reward_authority_bump, + position_kind, + pool_reward_index as _, + reserve_pubkey, + reward_mint, + destination_reward_token_account_pubkey, + reward_vault_authority, + pool_reward.vault, + reserve.lending_market, + lending_market.owner, + ); + + let recent_blockhash = config.rpc_client.get_latest_blockhash()?; + + let message = solana_sdk::message::Message::new_with_blockhash( + &[close_reward_ix], + Some(&config.fee_payer.pubkey()), + &recent_blockhash, + ); + + let transaction = solana_sdk::transaction::Transaction::new( + &vec![config.fee_payer.as_ref()], + message, + recent_blockhash, + ); + + send_transaction(config, transaction)?; + + Ok(()) +} diff --git a/token-lending/cli/src/liquidity_mining/crank_pool_rewards.rs b/token-lending/cli/src/liquidity_mining/crank_pool_rewards.rs new file mode 100644 index 00000000000..72a56f7e17a --- /dev/null +++ b/token-lending/cli/src/liquidity_mining/crank_pool_rewards.rs @@ -0,0 +1,179 @@ +//! Each reserve has a limited number of entries that are used to declare pool rewards. +//! When all entries are occupied, the admin can no longer start new pool rewards. +//! +//! This is where cranking comes in. +//! Given a reserve, this command estimates the cheapest pool reward to crank out. +//! It loads each obligation and checks if it's tracking the pool reward. +//! Then it performs a claim on behalf of those obligation. + +use indicatif::ProgressIterator; +use solana_client::{ + rpc_config::RpcProgramAccountsConfig, + rpc_filter::{Memcmp, RpcFilterType}, +}; +use solana_program::program_pack::Pack; +use solana_sdk::pubkey::Pubkey; +use solend_sdk::{ + instruction::{claim_pool_reward, find_reward_vault_authority}, + state::{ + discriminator::AccountDiscriminator, Obligation, PoolRewardEntry, PositionKind, Reserve, + }, +}; +use spl_associated_token_account::{ + get_associated_token_address, instruction::create_associated_token_account_idempotent, +}; +use std::{borrow::Borrow, str::FromStr, time::SystemTime}; + +use crate::{send_transaction, CommandResult, Config}; + +/// How many claim ixs to send in a single transaction. +/// +/// Will be determined empirically. +const CLAIM_IXS_BATCH_SIZE: usize = 4; + +pub(crate) fn command( + config: &mut Config, + reserve_pubkey: Pubkey, + position_kind: PositionKind, +) -> CommandResult { + let reserve_info = config.rpc_client.get_account(&reserve_pubkey)?; + let reserve = Reserve::unpack_from_slice(reserve_info.data.borrow())?; + + // since the time onchain is approximate, pick only those pool rewards that are over for sure + // to avoid cranking for nothing + let now_minus_an_hour_secs = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH)? + .as_secs() + - 3_600; + + // first find a pool reward with the least number of user reward managers + + let Some((pool_reward_index, pool_reward)) = reserve + .pool_reward_manager(position_kind) + .pool_rewards + .iter() + .enumerate() + .filter_map(|(index, pr)| { + let PoolRewardEntry::Occupied(pr) = pr else { + return None; + }; + Some((index, pr)) + }) + .filter(|(_, pr)| now_minus_an_hour_secs >= pr.start_time_secs + pr.duration_secs as u64) + .min_by_key(|(_, pr)| pr.num_user_reward_managers) + else { + println!("No pool rewards found for reserve '{reserve_pubkey}' ({position_kind:?})"); + return Ok(()); + }; + + // now let's find the reward mint and other info about the vault + + let Some(reward_vault_token_account) = + config.rpc_client.get_token_account(&pool_reward.vault)? + else { + return Err(format!( + "Failed to fetch pool reward vault '{}'", + pool_reward.vault + ))?; + }; + + let reward_mint = Pubkey::from_str(&reward_vault_token_account.mint)?; + + let (reward_vault_authority, reward_authority_bump) = find_reward_vault_authority( + &config.lending_program_id, + &reserve.lending_market, + &pool_reward.vault, + ); + + // let's get all obligations + + let filter = RpcProgramAccountsConfig { + filters: Some(vec![RpcFilterType::Memcmp(Memcmp::new_raw_bytes( + 0, + vec![AccountDiscriminator::Obligation as u8], + ))]), + with_context: Some(false), + ..Default::default() + }; + let all_obligations = config + .rpc_client + .get_program_accounts_with_config(&config.lending_program_id, filter)?; + + // and filter only those that are still tracking the pool reward + + let ixs: Vec<_> = all_obligations + .into_iter() + .filter_map(|(pubkey, info)| { + // get only those that can be unpacked + Some(( + pubkey, + Obligation::unpack(&info.data) + .inspect_err(|e| { + eprintln!("Failed to unpack obligation account '{pubkey}': {e:?}") + }) + .ok()?, + )) + }) + .filter(|(_, obligation)| { + // get only those that are tracking the pool reward + obligation + .user_reward_managers + .iter() + .filter(|m| m.reserve == reserve_pubkey) + .filter(|m| m.position_kind == position_kind) + .any(|m| { + m.rewards + .iter() + .find(|r| r.pool_reward_index == pool_reward_index) + .map(|r| r.pool_reward_id) + == Some(pool_reward.id) + }) + }) + .map(|(obligation_pubkey, obligation)| (obligation_pubkey, obligation.owner)) + .flat_map(|(obligation_pubkey, obligation_owner)| { + let ata = get_associated_token_address(&obligation_owner, &reward_mint); + + let create_ata_ix = create_associated_token_account_idempotent( + &config.fee_payer.as_ref().pubkey(), + &obligation_owner, + &reward_mint, + &spl_token::id(), + ); + + let claim_ix = claim_pool_reward( + config.lending_program_id, + reward_authority_bump, + position_kind, + obligation_pubkey, + ata, + reserve_pubkey, + reward_mint, + reward_vault_authority, + pool_reward.vault, + reserve.lending_market, + ); + + std::iter::once(create_ata_ix).chain(std::iter::once(claim_ix)) + }) + .collect(); + + for ixs in ixs.chunks(CLAIM_IXS_BATCH_SIZE).progress() { + let recent_blockhash = config.rpc_client.get_latest_blockhash()?; + + let message = solana_sdk::message::Message::new_with_blockhash( + ixs, + Some(&config.fee_payer.pubkey()), + &recent_blockhash, + ); + + let transaction = solana_sdk::transaction::Transaction::new( + &vec![config.fee_payer.as_ref()], + message, + recent_blockhash, + ); + + send_transaction(config, transaction)?; + } + + Ok(()) +} diff --git a/token-lending/cli/src/liquidity_mining/edit_pool_reward.rs b/token-lending/cli/src/liquidity_mining/edit_pool_reward.rs new file mode 100644 index 00000000000..1e13acd35e3 --- /dev/null +++ b/token-lending/cli/src/liquidity_mining/edit_pool_reward.rs @@ -0,0 +1,96 @@ +use std::{borrow::Borrow, str::FromStr}; + +use solana_program::program_pack::Pack; +use solana_sdk::pubkey::Pubkey; +use solend_sdk::{ + instruction::{edit_pool_reward, find_reward_vault_authority}, + state::{LendingMarket, PoolRewardEntry, PositionKind, Reserve}, +}; + +use crate::{send_transaction, CommandResult, Config}; + +pub(crate) fn command( + config: &mut Config, + reserve_pubkey: Pubkey, + position_kind: PositionKind, + pool_reward_index: usize, + new_end_time_secs: u64, + reward_token_account_pubkey: Pubkey, +) -> CommandResult { + let reserve_info = config.rpc_client.get_account(&reserve_pubkey)?; + let reserve = Reserve::unpack_from_slice(reserve_info.data.borrow())?; + let lending_market_info = config.rpc_client.get_account(&reserve.lending_market)?; + let lending_market = LendingMarket::unpack(lending_market_info.data.borrow())?; + + if config.fee_payer.pubkey() != lending_market.owner { + return Err(format!( + "The fee payer must be the owner of the lending market '{}'", + reserve.lending_market + ) + .into()); + } + + let PoolRewardEntry::Occupied(pool_reward) = reserve + .pool_reward_manager(position_kind) + .pool_rewards + .get(pool_reward_index) + .ok_or_else(|| { + format!( + "Pool reward index {} does not exist for position kind {:?}", + pool_reward_index, position_kind + ) + })? + else { + return Err("Pool reward index is not occupied".into()); + }; + + let Some(reward_vault_token_account) = + config.rpc_client.get_token_account(&pool_reward.vault)? + else { + return Err(format!( + "Failed to fetch pool reward vault '{}'", + pool_reward.vault + ))?; + }; + + let reward_mint = Pubkey::from_str(&reward_vault_token_account.mint)?; + + let (reward_vault_authority, reward_authority_bump) = find_reward_vault_authority( + &config.lending_program_id, + &reserve.lending_market, + &pool_reward.vault, + ); + + let edit_reward_ix = edit_pool_reward( + config.lending_program_id, + reward_authority_bump, + position_kind, + pool_reward_index as _, + new_end_time_secs, + reserve_pubkey, + reward_mint, + reward_token_account_pubkey, + reward_vault_authority, + pool_reward.vault, + reserve.lending_market, + lending_market.owner, + ); + + let recent_blockhash = config.rpc_client.get_latest_blockhash()?; + + let message = solana_sdk::message::Message::new_with_blockhash( + &[edit_reward_ix], + Some(&config.fee_payer.pubkey()), + &recent_blockhash, + ); + + let transaction = solana_sdk::transaction::Transaction::new( + &vec![config.fee_payer.as_ref()], + message, + recent_blockhash, + ); + + send_transaction(config, transaction)?; + + Ok(()) +} diff --git a/token-lending/cli/src/liquidity_mining/find_obligations_to_fund.rs b/token-lending/cli/src/liquidity_mining/find_obligations_to_fund.rs new file mode 100644 index 00000000000..d481fa85c46 --- /dev/null +++ b/token-lending/cli/src/liquidity_mining/find_obligations_to_fund.rs @@ -0,0 +1,113 @@ +use std::fs::File; +use std::io::Write; +use std::path::Path; + +use solana_account_decoder::UiAccountEncoding; +use solana_account_decoder::UiDataSliceConfig; +use solana_client::rpc_config::RpcAccountInfoConfig; +use solana_client::rpc_config::RpcProgramAccountsConfig; +use solana_client::rpc_filter::RpcFilterType; +use solana_sdk::native_token::lamports_to_sol; +use solana_sdk::native_token::LAMPORTS_PER_SOL; +use solend_sdk::state::Obligation; + +use crate::CommandResult; +use crate::Config; + +pub(crate) fn command(config: &mut Config, output_csv: impl AsRef) -> CommandResult { + let rent_for_2_0_2 = config + .rpc_client + .get_minimum_balance_for_rent_exemption(Obligation::MIN_LEN)?; + let rent_for_overhead = config + .rpc_client + .get_minimum_balance_for_rent_exemption(1)?; + let rent_per_reserve = config + .rpc_client + .get_minimum_balance_for_rent_exemption(50)?; + + // obligations before migration were sized to Obligation::MIN_LEN and we're only interested in those + let filter = RpcProgramAccountsConfig { + filters: Some(vec![RpcFilterType::DataSize(Obligation::MIN_LEN as _)]), + with_context: Some(false), + account_config: RpcAccountInfoConfig { + data_slice: Some(UiDataSliceConfig { + offset: 10 + 32 * 2 + 16 * 7 + 1 + 1 + 14, + length: 2, // first byte for deposits len, second for borrows len + }), + encoding: Some(UiAccountEncoding::Base64), + ..Default::default() + }, + }; + let all_obligations = config + .rpc_client + .get_program_accounts_with_config(&config.lending_program_id, filter)?; + + println!("Found {} obligations in total", all_obligations.len()); + + let obligations_that_need_rent: Vec<_> = all_obligations + .into_iter() + .filter_map(|(pubkey, account)| { + assert_eq!(account.data.len(), 2); + let deposits_count = account.data[0] as usize; + let borrows_count = account.data[1] as usize; + assert!(deposits_count + borrows_count <= 10); + let positions_count = deposits_count + borrows_count; + + if positions_count == 0 { + None + } else { + Some((pubkey, positions_count, account.lamports)) + } + }) + .map(|(pubkey, positions_count, current_rent)| { + let extra_rent = current_rent - rent_for_2_0_2; + let required_extra_rent = rent_for_overhead + rent_per_reserve * positions_count as u64; + + let extra_rent_to_add = required_extra_rent.saturating_sub(extra_rent); + + (pubkey, extra_rent_to_add) + }) + .filter(|(_, extra_rent_to_add)| *extra_rent_to_add > 0) + .collect(); + + println!( + "Found {} obligations that need rent", + obligations_that_need_rent.len() + ); + + let missing_rent: u64 = obligations_that_need_rent + .iter() + .map(|(_, extra_rent_to_add)| *extra_rent_to_add) + .sum(); + + println!( + "We'll spend ~{:.2} $SOL on rent", + missing_rent as f64 / LAMPORTS_PER_SOL as f64 + ); + println!( + "Writing the amounts to CSV file at '{}'", + output_csv.as_ref().display() + ); + let mut file = File::create(output_csv.as_ref())?; + + // write the header used by the tokens CLI + writeln!(file, "recipient,amount,lockup_date")?; + + for (recipient, lamports) in obligations_that_need_rent { + writeln!(file, "{},{},", recipient, lamports_to_sol(lamports))?; + } + + println!("Done!"); + println!("Use to distribute the rent"); + println!(); + println!( + "$ solana-tokens distribute-tokens --input-csv {} --from --fee-payer ", + output_csv + .as_ref() + .canonicalize() + .expect("canonicalize") + .display() + ); + + Ok(()) +} diff --git a/token-lending/cli/src/liquidity_mining/migrate_all_reserves.rs b/token-lending/cli/src/liquidity_mining/migrate_all_reserves.rs new file mode 100644 index 00000000000..ac8adcffe6f --- /dev/null +++ b/token-lending/cli/src/liquidity_mining/migrate_all_reserves.rs @@ -0,0 +1,94 @@ +//! Temporary command that migrates all reserves to their new version. +//! +//! Delete once @v2.1.0 is fully deployed. +//! +//! Running this command before the upgrade: +//! > Found 1621 reserves to upgrade +//! > We'll spend ~54.46 $SOL on rent +//! > There are 87 reserves that were not used in the last 7 days as of 2025-05-08. + +use solana_account_decoder::UiAccountEncoding; +use solana_account_decoder::UiDataSliceConfig; +use solana_client::rpc_config::RpcAccountInfoConfig; +use solana_client::rpc_config::RpcProgramAccountsConfig; +use solana_client::rpc_filter::RpcFilterType; +use solana_sdk::compute_budget::ComputeBudgetInstruction; +use solana_sdk::message::Message; +use solana_sdk::native_token::LAMPORTS_PER_SOL; +use solana_sdk::program_pack::Pack; +use solana_sdk::transaction::Transaction; +use solend_sdk::instruction::upgrade_reserve_to_v2_1_0; +use solend_sdk::state::Reserve; +use solend_sdk::state::RESERVE_LEN_V2_0_2; + +use crate::send_transaction; +use crate::CommandResult; +use crate::Config; + +/// How many reserves to upgrade in a single transaction. +/// +/// We found the right value empirically. +const BATCH_SIZE: usize = 25; +/// How much to pay for compute units. +/// Helps lending txs. +const CU_PRICE: u64 = 3000; + +/// Upgrades all reserves to the new version. +pub(crate) fn command(config: &mut Config) -> CommandResult { + let reserve_new_rent = config + .rpc_client + .get_minimum_balance_for_rent_exemption(Reserve::LEN)?; + + // reserves before migration were sized to RESERVE_LEN_V2_0_2 and we're only interested in those + let filter = RpcProgramAccountsConfig { + filters: Some(vec![RpcFilterType::DataSize(RESERVE_LEN_V2_0_2 as _)]), + // with_context: Some(false), + account_config: RpcAccountInfoConfig { + data_slice: Some(UiDataSliceConfig { + offset: 1, + length: 8, // we don't need the data + }), + encoding: Some(UiAccountEncoding::Base64), + ..Default::default() + }, + ..Default::default() + }; + let reserves_to_upgrade = config + .rpc_client + .get_program_accounts_with_config(&config.lending_program_id, filter)?; + + println!("Found {} reserves to upgrade", reserves_to_upgrade.len()); + + let missing_rent: u64 = reserves_to_upgrade + .iter() + .map(|(_, acc)| reserve_new_rent.saturating_sub(acc.lamports)) + .sum(); + + println!( + "We'll spend ~{:.2} $SOL on rent", + missing_rent as f64 / LAMPORTS_PER_SOL as f64 + ); + + for reserves in reserves_to_upgrade.chunks(BATCH_SIZE) { + let mut ixs = vec![ComputeBudgetInstruction::set_compute_unit_price(CU_PRICE)]; + ixs.extend(reserves.iter().map(|(reserve_pubkey, _)| { + upgrade_reserve_to_v2_1_0( + config.lending_program_id, + *reserve_pubkey, + config.fee_payer.pubkey(), + ) + })); + + let recent_blockhash = config.rpc_client.get_latest_blockhash()?; + + let message = + Message::new_with_blockhash(&ixs, Some(&config.fee_payer.pubkey()), &recent_blockhash); + + let transaction = + Transaction::new(&vec![config.fee_payer.as_ref()], message, recent_blockhash); + + send_transaction(config, transaction)?; + } + + Ok(()) +} diff --git a/token-lending/cli/src/liquidity_mining/view_obligation_rewards.rs b/token-lending/cli/src/liquidity_mining/view_obligation_rewards.rs new file mode 100644 index 00000000000..f8c35558545 --- /dev/null +++ b/token-lending/cli/src/liquidity_mining/view_obligation_rewards.rs @@ -0,0 +1,38 @@ +use std::{borrow::Borrow, time::SystemTime}; + +use solana_program::program_pack::Pack; +use solana_sdk::pubkey::Pubkey; +use solend_sdk::state::{Obligation, Reserve}; + +use crate::{CommandResult, Config}; + +pub(crate) fn command(config: &mut Config, obligation_pubkey: Pubkey) -> CommandResult { + let obligation_info = config.rpc_client.get_account(&obligation_pubkey)?; + let obligation = Obligation::unpack_from_slice(obligation_info.data.borrow())?; + + let now_secs = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH)? + .as_secs(); + + for user_manager in obligation.user_reward_managers.iter() { + let reserve_info = config.rpc_client.get_account(&user_manager.reserve)?; + let reserve = Reserve::unpack_from_slice(reserve_info.data.borrow())?; + println!( + "Rewards for reserve {} {:?} last updated {}s ago", + user_manager.reserve, + user_manager.position_kind, + now_secs.saturating_sub(user_manager.last_update_time_secs) + ); + + let pool_reward_manager = reserve.pool_reward_manager(user_manager.position_kind); + + let share = user_manager.share as f64 / pool_reward_manager.total_shares as f64; + println!( + " Mines {}% in {} rewards", + share * 100.0, + user_manager.rewards.len() + ); + } + + Ok(()) +} diff --git a/token-lending/cli/src/liquidity_mining/view_reserve_rewards.rs b/token-lending/cli/src/liquidity_mining/view_reserve_rewards.rs new file mode 100644 index 00000000000..a2e0eb4dd31 --- /dev/null +++ b/token-lending/cli/src/liquidity_mining/view_reserve_rewards.rs @@ -0,0 +1,86 @@ +use std::{borrow::Borrow, time::SystemTime}; + +use solana_program::program_pack::Pack; +use solana_sdk::pubkey::Pubkey; +use solend_sdk::state::{PoolReward, PoolRewardEntry, PoolRewardManager, Reserve, MAX_REWARDS}; + +use crate::{CommandResult, Config}; + +pub(crate) fn command(config: &mut Config, reserve_pubkey: Pubkey) -> CommandResult { + let reserve_info = config.rpc_client.get_account(&reserve_pubkey)?; + let reserve = Reserve::unpack_from_slice(reserve_info.data.borrow())?; + + println!(); + println!("=== Borrow Rewards ==="); + print_pool_rewards(&reserve.borrows_pool_reward_manager)?; + + println!(); + println!("=== Deposit Rewards ==="); + print_pool_rewards(&reserve.deposits_pool_reward_manager)?; + + Ok(()) +} + +fn print_pool_rewards(manager: &PoolRewardManager) -> CommandResult { + // since the time onchain is approximate, pick only those pool rewards that are over for sure + // to avoid cranking for nothing + let now_secs = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH)? + .as_secs(); + + let open_count = manager + .pool_rewards + .iter() + .filter_map(|pr| { + let PoolRewardEntry::Occupied(pr) = pr else { + return None; + }; + Some(pr) + }) + .filter(|pr| now_secs < pr.start_time_secs + pr.duration_secs as u64) + .count(); + + println!("Total shares amount to {}.", manager.total_shares); + println!("There are {open_count}/{MAX_REWARDS} pool rewards running."); + manager + .pool_rewards + .iter() + .enumerate() + .filter_map(|(index, pr)| { + let PoolRewardEntry::Occupied(pr) = pr else { + return None; + }; + Some((index, *pr.clone())) + }) + .for_each( + |( + index, + PoolReward { + id, + vault, + start_time_secs, + duration_secs, + total_rewards, + cumulative_rewards_per_share, + num_user_reward_managers, + }, + )| { + println!("{index}) Pool reward {id:?}:"); + println!(" Vault: {vault}"); + println!(" Start time: {start_time_secs}"); + println!(" Duration: {duration_secs}"); + let ends_in = + duration_secs.saturating_sub(now_secs.saturating_sub(start_time_secs) as _); + if ends_in > 0 { + println!(" Ends in {ends_in}s"); + } else { + println!(" Ended"); + } + println!(" Total rewards: {total_rewards}"); + println!(" Cumulative rewards per share: {cumulative_rewards_per_share}"); + println!(" Number of user reward managers: {num_user_reward_managers}"); + }, + ); + + Ok(()) +} diff --git a/token-lending/cli/src/main.rs b/token-lending/cli/src/main.rs index afa365e7325..de9ae5d7345 100644 --- a/token-lending/cli/src/main.rs +++ b/token-lending/cli/src/main.rs @@ -1,7 +1,15 @@ +mod lending_state; +mod liquidity_mining; + +use std::path::PathBuf; +use std::time::SystemTime; + use lending_state::SolendState; +use liquidity_mining::*; use serde_json::Value; use solana_account_decoder::UiAccountEncoding; +use solana_clap_utils::input_validators::is_amount_or_all; use solana_client::rpc_config::{RpcProgramAccountsConfig, RpcSendTransactionConfig}; use solana_client::{rpc_config::RpcAccountInfoConfig, rpc_filter::RpcFilterType}; use solana_sdk::bs58; @@ -11,7 +19,7 @@ use solend_program::{ instruction::set_lending_market_owner_and_config, state::{validate_reserve_config, RateLimiterConfig}, }; -use solend_sdk::instruction::upgrade_reserve_to_v2_1_0; +use solend_sdk::state::PositionKind; use solend_sdk::{ instruction::{ liquidate_obligation_and_redeem_reserve_collateral, redeem_reserve_collateral, @@ -21,8 +29,6 @@ use solend_sdk::{ state::ReserveType, }; -mod lending_state; - use { clap::{ crate_description, crate_name, crate_version, value_t, App, AppSettings, Arg, ArgMatches, @@ -770,8 +776,138 @@ fn main() { ) ) .subcommand( - SubCommand::with_name("upgrade-reserve") - .about("Migrate reserve to version 2.1.0") + SubCommand::with_name("migrate-all-reserves-for-liquidity-mining") + .about("Upgrade all reserves to version v2.1.0") + ) + .subcommand( + SubCommand::with_name("find-obligations-to-fund-for-liquidity-mining") + .about("Finds obligations which need funding for migration to v2.1.0 and writes them to a CSV file") + .arg(Arg::with_name("output_csv") + .long("output-csv") + .validator(|s| PathBuf::from_str(&s).map(drop).map_err(|_| "Invalid output CSV path".to_string())) + .value_name("PATH") + .takes_value(true) + .required(true) + .help("Output CSV file to write obligations to")) + ) + .subcommand( + SubCommand::with_name("crank-rewards") + .about("Cranks liquidity mining rewards for a given reserve") + .arg( + Arg::with_name("reserve") + .long("reserve") + .validator(is_pubkey) + .value_name("PUBKEY") + .takes_value(true) + .required(true) + .help("Reserve address"), + ) + .arg(Arg::with_name("position_kind") + .long("position-kind") + .validator(is_parsable::) + .value_name("POSITION_KIND") + .takes_value(true) + .required(true) + .help("Either 'deposit' or 'borrow'")) + ) + .subcommand( + SubCommand::with_name("add-pool-reward") + .about("Adds a new liquidity mining reward to a reserve") + .arg( + Arg::with_name("reserve") + .long("reserve") + .validator(is_pubkey) + .value_name("PUBKEY") + .takes_value(true) + .required(true) + .help("Reserve address"), + ) + .arg(Arg::with_name("position_kind") + .long("position-kind") + .validator(is_parsable::) + .value_name("POSITION_KIND") + .takes_value(true) + .required(true) + .help("Either 'deposit' or 'borrow'")) + .arg( + Arg::with_name("source") + .long("source") + .validator(is_pubkey) + .value_name("PUBKEY") + .takes_value(true) + .required(true) + .help("SPL Token account to deposit rewards from"), + ) + .arg( + Arg::with_name("amount") + .long("amount") + .validator(is_amount_or_all) + .value_name("INTEGER_AMOUNT") + .takes_value(true) + .required(true) + .help("Amount of rewards to distribute (can be ALL)"), + ) + .arg( + Arg::with_name("start_time_secs") + .long("start-time-secs") + .validator(is_parsable::) + .value_name("INTEGER") + .takes_value(true) + .help("Start time in seconds since epoch, defaults to now"), + ) + .arg( + Arg::with_name("duration_secs") + .long("duration-secs") + .validator(is_parsable::) + .value_name("INTEGER") + .takes_value(true) + .required(true) + .help("Duration in seconds"), + ) + ) + .subcommand( + SubCommand::with_name("close-pool-reward") + .about("Closes a liquidity mining reward for a reserve") + .arg( + Arg::with_name("reserve") + .long("reserve") + .validator(is_pubkey) + .value_name("PUBKEY") + .takes_value(true) + .required(true) + .help("Reserve address"), + ) + .arg( + Arg::with_name("position_kind") + .long("position-kind") + .validator(is_parsable::) + .value_name("POSITION_KIND") + .takes_value(true) + .required(true) + .help("Either 'deposit' or 'borrow'") + ) + .arg( + Arg::with_name("pool_reward_index") + .long("pool-reward-index") + .validator(is_parsable::) + .value_name("INTEGER") + .takes_value(true) + .required(true) + .help("Index of the pool reward to close"), + ) + .arg( + Arg::with_name("destination") + .long("destination") + .validator(is_pubkey) + .value_name("PUBKEY") + .takes_value(true) + .required(true) + .help("SPL Token account to withdraw rewards to"), + ) + ) + .subcommand( + SubCommand::with_name("edit-pool-reward") + .about("Changes a liquidity mining reward for a reserve") .arg( Arg::with_name("reserve") .long("reserve") @@ -781,6 +917,68 @@ fn main() { .required(true) .help("Reserve address"), ) + .arg( + Arg::with_name("position_kind") + .long("position-kind") + .validator(is_parsable::) + .value_name("POSITION_KIND") + .takes_value(true) + .required(true) + .help("Either 'deposit' or 'borrow'") + ) + .arg( + Arg::with_name("pool_reward_index") + .long("pool-reward-index") + .validator(is_parsable::) + .value_name("INTEGER") + .takes_value(true) + .required(true) + .help("Index of the pool reward to close"), + ) + .arg( + Arg::with_name("new_end_time_secs") + .long("new-end-time-secs") + .validator(is_parsable::) + .value_name("INTEGER") + .takes_value(true) + .required(true) + .help("New end time in seconds since epoch"), + ) + .arg( + Arg::with_name("token_account") + .long("token-account") + .validator(is_pubkey) + .value_name("PUBKEY") + .takes_value(true) + .required(true) + .help("SPL Token account to either credit or debit rewards from"), + ) + ) + .subcommand( + SubCommand::with_name("view-reserve-rewards") + .about("View liquidity mining rewards for a reserve") + .arg( + Arg::with_name("reserve") + .long("reserve") + .validator(is_pubkey) + .value_name("PUBKEY") + .takes_value(true) + .required(true) + .help("Reserve address"), + ) + ) + .subcommand( + SubCommand::with_name("view-obligation-rewards") + .about("View liquidity mining rewards for an obligation") + .arg( + Arg::with_name("obligation") + .long("obligation") + .validator(is_pubkey) + .value_name("PUBKEY") + .takes_value(true) + .required(true) + .help("Obligation address"), + ) ) .subcommand( SubCommand::with_name("update-reserve") @@ -1338,10 +1536,96 @@ fn main() { risk_authority_pubkey, ) } - ("upgrade-reserve", Some(arg_matches)) => { - let reserve_pubkey = pubkey_of(arg_matches, "reserve").unwrap(); - - command_upgrade_reserve_to_v2_1_0(&mut config, reserve_pubkey) + ("migrate-all-reserves-for-liquidity-mining", _) => { + command_migrate_all_reserves_for_liquidity_mining(&mut config) + } + ("find-obligations-to-fund-for-liquidity-mining", Some(arg_matches)) => { + let output_csv: PathBuf = + value_of(arg_matches, "output_csv").expect("Should include --output-csv file path"); + command_find_obligations_to_fund_for_liquidity_mining(&mut config, &output_csv) + } + ("crank-rewards", Some(arg_matches)) => { + let reserve_pubkey = + pubkey_of(arg_matches, "reserve").expect("Should include --reserve"); + let position_kind = value_of::(arg_matches, "position-_ind") + .expect("Should include --position-kind"); + command_crank_pool_rewards(&mut config, reserve_pubkey, position_kind) + } + ("add-pool-reward", Some(arg_matches)) => { + let reserve_pubkey = + pubkey_of(arg_matches, "reserve").expect("Should include --reserve"); + let position_kind = value_of::(arg_matches, "position_kind") + .expect("Should include --position-kind"); + let source_reward_token_account_pubkey = + pubkey_of(arg_matches, "source").expect("Should include --source"); + let start_time_secs = value_of(arg_matches, "start_time_secs").unwrap_or( + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .expect("System time before UNIX EPOCH") + .as_secs(), + ); + let duration_secs = + value_of(arg_matches, "duration_secs").expect("Should include --duration-secs"); + let token_amount = value_of(arg_matches, "amount").expect("Should include --amount"); + + command_add_pool_reward( + &mut config, + reserve_pubkey, + position_kind, + source_reward_token_account_pubkey, + start_time_secs, + duration_secs, + token_amount, + ) + } + ("close-pool-reward", Some(arg_matches)) => { + let reserve_pubkey = + pubkey_of(arg_matches, "reserve").expect("Should include --reserve"); + let position_kind = value_of::(arg_matches, "position_kind") + .expect("Should include --position-kind"); + let pool_reward_index = value_of::(arg_matches, "pool_reward_index") + .expect("Should include --pool-reward-index"); + let destination_reward_token_account_pubkey = + pubkey_of(arg_matches, "destination").expect("Should include --destination"); + + command_close_pool_reward( + &mut config, + reserve_pubkey, + position_kind, + pool_reward_index as _, + destination_reward_token_account_pubkey, + ) + } + ("edit-pool-reward", Some(arg_matches)) => { + let reserve_pubkey = + pubkey_of(arg_matches, "reserve").expect("Should include --reserve"); + let position_kind = value_of::(arg_matches, "position_kind") + .expect("Should include --position-kind"); + let pool_reward_index = value_of::(arg_matches, "pool_reward_index") + .expect("Should include --pool-reward-index"); + let new_end_time_secs = value_of(arg_matches, "new_end_time_secs") + .expect("Should include --new-end-time-secs"); + let reward_token_account_pubkey = + pubkey_of(arg_matches, "token_account").expect("Should include --token-account"); + + command_edit_pool_reward( + &mut config, + reserve_pubkey, + position_kind, + pool_reward_index as _, + new_end_time_secs, + reward_token_account_pubkey, + ) + } + ("view-obligation-rewards", Some(arg_matches)) => { + let obligation_pubkey = + pubkey_of(arg_matches, "obligation").expect("Should include --obligation"); + command_view_obligation_rewards(&mut config, obligation_pubkey) + } + ("view-reserve-rewards", Some(arg_matches)) => { + let reserve_pubkey = + pubkey_of(arg_matches, "reserve").expect("Should include --reserve"); + command_view_reserve_rewards(&mut config, reserve_pubkey) } ("update-reserve", Some(arg_matches)) => { let reserve_pubkey = pubkey_of(arg_matches, "reserve").unwrap(); @@ -1992,29 +2276,6 @@ fn command_set_lending_market_owner_and_config( Ok(()) } -fn command_upgrade_reserve_to_v2_1_0(config: &mut Config, reserve_pubkey: Pubkey) -> CommandResult { - let recent_blockhash = config.rpc_client.get_latest_blockhash()?; - - let message = Message::new_with_blockhash( - &[ - ComputeBudgetInstruction::set_compute_unit_price(30101), - upgrade_reserve_to_v2_1_0( - config.lending_program_id, - reserve_pubkey, - config.fee_payer.pubkey(), - ), - ], - Some(&config.fee_payer.pubkey()), - &recent_blockhash, - ); - - let transaction = Transaction::new(&vec![config.fee_payer.as_ref()], message, recent_blockhash); - - send_transaction(config, transaction)?; - - Ok(()) -} - #[allow(clippy::too_many_arguments, clippy::unnecessary_unwrap)] fn command_update_reserve( config: &mut Config, diff --git a/token-lending/program/Cargo.toml b/token-lending/program/Cargo.toml index 535d35b1d77..5d2fb9dfb2a 100644 --- a/token-lending/program/Cargo.toml +++ b/token-lending/program/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "solend-program" -version.workspace = true +version = "2.1.0" description = "Solend Program" authors = ["Solend Maintainers "] repository = "https://github.com/solendprotocol/solana-program-library" diff --git a/token-lending/program/src/processor.rs b/token-lending/program/src/processor.rs index 80f06ac8ce7..64c5b22b98b 100644 --- a/token-lending/program/src/processor.rs +++ b/token-lending/program/src/processor.rs @@ -260,7 +260,7 @@ pub fn process_instruction( position_kind, } => { msg!("Instruction: Claim Reward"); - liquidity_mining::claim_user_reward::process( + liquidity_mining::claim_pool_reward::process( program_id, reward_authority_bump, position_kind, diff --git a/token-lending/program/src/processor/liquidity_mining.rs b/token-lending/program/src/processor/liquidity_mining.rs index 14c395f79be..69f2d4ce829 100644 --- a/token-lending/program/src/processor/liquidity_mining.rs +++ b/token-lending/program/src/processor/liquidity_mining.rs @@ -20,7 +20,7 @@ //! [suilend-lm]: https://github.com/solendprotocol/suilend/blob/dc53150416f352053ac3acbb320ee143409c4a5d/contracts/suilend/sources/liquidity_mining.move#L2 pub(crate) mod add_pool_reward; -pub(crate) mod claim_user_reward; +pub(crate) mod claim_pool_reward; pub(crate) mod close_pool_reward; pub(crate) mod edit_pool_reward; pub(crate) mod upgrade_reserve; diff --git a/token-lending/program/src/processor/liquidity_mining/claim_user_reward.rs b/token-lending/program/src/processor/liquidity_mining/claim_pool_reward.rs similarity index 100% rename from token-lending/program/src/processor/liquidity_mining/claim_user_reward.rs rename to token-lending/program/src/processor/liquidity_mining/claim_pool_reward.rs diff --git a/token-lending/sdk/Cargo.toml b/token-lending/sdk/Cargo.toml index 26ddb0536e7..4002f16f5a1 100644 --- a/token-lending/sdk/Cargo.toml +++ b/token-lending/sdk/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "solend-sdk" -version.workspace = true +version = "2.1.0" description = "Solend Sdk" authors = ["Solend Maintainers "] repository = "https://github.com/solendprotocol/solana-program-library" diff --git a/token-lending/sdk/src/state/obligation.rs b/token-lending/sdk/src/state/obligation.rs index c88a95ccb59..313b3aed9c5 100644 --- a/token-lending/sdk/src/state/obligation.rs +++ b/token-lending/sdk/src/state/obligation.rs @@ -15,6 +15,7 @@ use solana_program::{ use std::{ cmp::{min, Ordering}, convert::{TryFrom, TryInto}, + str::FromStr, }; /// Max number of collateral and liquidity reserve accounts combined for an obligation @@ -840,6 +841,18 @@ impl TryFrom for PositionKind { } } +impl FromStr for PositionKind { + type Err = LendingError; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "deposit" => Ok(PositionKind::Deposit), + "borrow" => Ok(PositionKind::Borrow), + _ => Err(LendingError::InstructionUnpackError), + } + } +} + #[cfg(test)] mod test { use super::*; diff --git a/token-lending/tests/liquidity-mining.ts b/token-lending/tests/liquidity-mining.ts index d8cd3aa2c8c..c9e029aa14e 100644 --- a/token-lending/tests/liquidity-mining.ts +++ b/token-lending/tests/liquidity-mining.ts @@ -14,20 +14,21 @@ describe("liquidity mining", () => { // Configure the client to use the local cluster. anchor.setProvider(anchor.AnchorProvider.env()); - const TEST_RESERVE_FOR_UPGRADE = - "BgxfHJDzm44T7XG68MYKx7YisTjZu73tVovyZSjJMpmw"; - - it("Upgrades reserve to 2.1.0 via CLI", async () => { - // There's an ix that upgrades a reserve to 2.1.0. + it("Upgrades reserves to 2.1.0 via CLI", async () => { + // There's an ix that upgrades all program reserves to 2.1.0. // This ix is invocable via our CLI. // In this test case for comfort and more test coverage we invoke the CLI // command rather than crafting the ix ourselves. + // We check this reserve before & after the upgrade. + const SOME_TEST_RESERVE_TO_CHECK = + "BgxfHJDzm44T7XG68MYKx7YisTjZu73tVovyZSjJMpmw"; + const rpcUrl = anchor.getProvider().connection.rpcEndpoint; const reserveBefore = await anchor .getProvider() - .connection.getAccountInfo(new PublicKey(TEST_RESERVE_FOR_UPGRADE)); + .connection.getAccountInfo(new PublicKey(SOME_TEST_RESERVE_TO_CHECK)); expect(reserveBefore.data.length).to.eq(619); // old version data length const expectedRentBefore = await anchor @@ -36,7 +37,7 @@ describe("liquidity mining", () => { // some reserves have more rent expect(reserveBefore.lamports).to.be.greaterThanOrEqual(expectedRentBefore); - const command = `cargo run --quiet --bin solend-cli -- --url ${rpcUrl} upgrade-reserve --reserve ${TEST_RESERVE_FOR_UPGRADE}`; + const command = `cargo run --quiet --bin solend-cli -- --url ${rpcUrl} migrate-all-reserves-for-liquidity-mining`; console.log(`\$ ${command}`); const cliProcess = exec(command); @@ -58,7 +59,7 @@ describe("liquidity mining", () => { const reserveAfter = await anchor .getProvider() - .connection.getAccountInfo(new PublicKey(TEST_RESERVE_FOR_UPGRADE)); + .connection.getAccountInfo(new PublicKey(SOME_TEST_RESERVE_TO_CHECK)); expect(reserveAfter.data.length).to.eq(5451); // new version data length const expectedRentAfter = await anchor