Skip to content

Commit 389a7d8

Browse files
authoredMar 14, 2025··
[Liquidity Mining] Reserve packing & unpacking (4) (#202)
1 parent 2587270 commit 389a7d8

20 files changed

+1988
-178
lines changed
 

‎.editorconfig

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
root = true
2+
3+
[*]
4+
end_of_line = lf
5+
insert_final_newline = true
6+
7+
[*.ts]
8+
indent_size = 2

‎.github/workflows/pull-request-token-lending.yml

+7-7
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@ name: Token Lending Pull Request
33
on:
44
pull_request:
55
paths:
6-
- 'token-lending/**'
7-
- 'token/**'
6+
- "token-lending/**"
7+
- "token/**"
88
push:
99
branches: [master]
1010
paths:
11-
- 'token-lending/**'
12-
- 'token/**'
11+
- "token-lending/**"
12+
- "token/**"
1313

1414
jobs:
1515
cargo-test-bpf:
@@ -30,20 +30,20 @@ jobs:
3030
override: true
3131
profile: minimal
3232

33-
- uses: actions/cache@v2
33+
- uses: actions/cache@v4
3434
with:
3535
path: |
3636
~/.cargo/registry
3737
~/.cargo/git
3838
key: cargo-build-${{ hashFiles('**/Cargo.lock') }}-${{ env.RUST_STABLE}}
3939

40-
- uses: actions/cache@v2
40+
- uses: actions/cache@v4
4141
with:
4242
path: |
4343
~/.cargo/bin/rustfilt
4444
key: cargo-bpf-bins-${{ runner.os }}
4545

46-
- uses: actions/cache@v2
46+
- uses: actions/cache@v4
4747
with:
4848
path: |
4949
~/.cache

‎.github/workflows/pull-request.yml

+7-7
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@ name: Pull Request
33
on:
44
pull_request:
55
paths-ignore:
6-
- 'docs/**'
6+
- "docs/**"
77
push:
88
branches: [master, upcoming]
99
paths-ignore:
10-
- 'docs/**'
10+
- "docs/**"
1111

1212
jobs:
1313
all_github_action_checks:
@@ -59,7 +59,7 @@ jobs:
5959
profile: minimal
6060
components: clippy
6161

62-
- uses: actions/cache@v2
62+
- uses: actions/cache@v4
6363
with:
6464
path: |
6565
~/.cargo/registry
@@ -96,21 +96,21 @@ jobs:
9696
override: true
9797
profile: minimal
9898

99-
- uses: actions/cache@v2
99+
- uses: actions/cache@v4
100100
with:
101101
path: |
102102
~/.cargo/registry
103103
~/.cargo/git
104104
# target # Removed due to build dependency caching conflicts
105105
key: cargo-build-${{ hashFiles('**/Cargo.lock') }}-${{ env.RUST_STABLE}}
106106

107-
- uses: actions/cache@v2
107+
- uses: actions/cache@v4
108108
with:
109109
path: |
110110
~/.cargo/bin/rustfilt
111111
key: cargo-bpf-bins-${{ runner.os }}
112112

113-
- uses: actions/cache@v2
113+
- uses: actions/cache@v4
114114
with:
115115
path: |
116116
~/.cache
@@ -143,7 +143,7 @@ jobs:
143143
override: true
144144
profile: minimal
145145

146-
- uses: actions/cache@v2
146+
- uses: actions/cache@v4
147147
with:
148148
path: |
149149
~/.cargo/registry

‎.mocharc.yml

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
bail: true

‎Anchor.toml

+22-5
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,31 @@
1-
anchor_version = "0.13.2"
1+
[toolchain]
2+
package_manager = "yarn"
3+
anchor_version = "0.28.0"
4+
5+
[features]
6+
resolution = true
7+
skip-lint = false
28

39
[workspace]
4-
members = [
5-
"token-lending/program",
6-
"token-lending/brick",
7-
]
10+
members = ["token-lending/program", "token-lending/brick"]
811

912
[provider]
1013
cluster = "mainnet"
1114
wallet = "~/.config/solana/id.json"
1215

16+
[scripts]
17+
test = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 token-lending/tests/**/*.ts"
18+
1319
[programs.mainnet]
1420
spl_token_lending = "So1endDq2YkqhipRh3WViPa8hdiSpxWy6z3Z6tMCpAo"
21+
22+
[programs.localnet]
23+
solend_program = "So1endDq2YkqhipRh3WViPa8hdiSpxWy6z3Z6tMCpAo"
24+
25+
[test.validator]
26+
# we use some mainnet accounts for tests
27+
url = "https://api.mainnet-beta.solana.com"
28+
29+
[[test.validator.clone]]
30+
# Solend Main Pool - (USDC) Reserve State
31+
address = "BgxfHJDzm44T7XG68MYKx7YisTjZu73tVovyZSjJMpmw"

‎Cargo.lock

+9-16
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml

+6-2
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,12 @@ members = [
33
"token-lending/cli",
44
"token-lending/program",
55
"token-lending/sdk",
6-
"token-lending/brick"
7-
, "token-lending/oracles"]
6+
"token-lending/brick",
7+
"token-lending/oracles",
8+
]
9+
10+
[workspace.package]
11+
version = "2.1.0"
812

913
[profile.dev]
1014
split-debuginfo = "unpacked"

‎package.json

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"license": "ISC",
3+
"scripts": {
4+
"lint:fix": "prettier */*.js \"*/**/*{.js,.ts}\" -w",
5+
"lint": "prettier */*.js \"*/**/*{.js,.ts}\" --check"
6+
},
7+
"dependencies": {
8+
"@coral-xyz/anchor": "^0.28.0"
9+
},
10+
"devDependencies": {
11+
"chai": "^4.3.4",
12+
"mocha": "^9.0.3",
13+
"ts-mocha": "^10.0.0",
14+
"@types/bn.js": "^5.1.0",
15+
"@types/chai": "^4.3.0",
16+
"@types/mocha": "^9.0.0",
17+
"typescript": "^5.7.3",
18+
"prettier": "^2.6.2"
19+
}
20+
}

‎token-lending/cli/Cargo.toml

+5-5
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
[package]
2+
name = "solend-program-cli"
3+
version.workspace = true
24
authors = ["Solend Maintainers <maintainers@solend.fi>"]
35
description = "Solend Program CLI"
46
edition = "2018"
57
homepage = "https://solend.fi"
68
license = "Apache-2.0"
7-
name = "solend-program-cli"
89
repository = "https://github.com/solendprotocol/solana-program-library"
9-
version = "2.0.2"
1010

1111
[dependencies]
1212
clap = "=2.34.0"
@@ -16,9 +16,9 @@ solana-client = "1.14.10"
1616
solana-logger = "1.14.10"
1717
solana-sdk = "1.14.10"
1818
solana-program = "1.14.10"
19-
solend-sdk = { path="../sdk" }
20-
solend-program = { path="../program", features = [ "no-entrypoint" ] }
21-
spl-token = { version = "3.3.0", features=["no-entrypoint"] }
19+
solend-sdk = { path = "../sdk" }
20+
solend-program = { path = "../program", features = ["no-entrypoint"] }
21+
spl-token = { version = "3.3.0", features = ["no-entrypoint"] }
2222
spl-associated-token-account = "1.0"
2323
solana-account-decoder = "1.14.10"
2424
reqwest = { version = "0.12.2", features = ["blocking", "json"] }

‎token-lending/cli/src/main.rs

+43
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use solend_program::{
1111
instruction::set_lending_market_owner_and_config,
1212
state::{validate_reserve_config, RateLimiterConfig},
1313
};
14+
use solend_sdk::instruction::upgrade_reserve_to_v2_1_0;
1415
use solend_sdk::{
1516
instruction::{
1617
liquidate_obligation_and_redeem_reserve_collateral, redeem_reserve_collateral,
@@ -768,6 +769,20 @@ fn main() {
768769
.help("Risk authority address"),
769770
)
770771
)
772+
.subcommand(
773+
SubCommand::with_name("upgrade-reserve")
774+
.about("Migrate reserve to version 2.1.0")
775+
.arg(
776+
Arg::with_name("reserve")
777+
.long("reserve")
778+
.validator(is_pubkey)
779+
.value_name("PUBKEY")
780+
.takes_value(true)
781+
.required(true)
782+
.help("Reserve address"),
783+
)
784+
785+
)
771786
.subcommand(
772787
SubCommand::with_name("update-reserve")
773788
.about("Update a reserve config")
@@ -1324,6 +1339,11 @@ fn main() {
13241339
risk_authority_pubkey,
13251340
)
13261341
}
1342+
("upgrade-reserve", Some(arg_matches)) => {
1343+
let reserve_pubkey = pubkey_of(arg_matches, "reserve").unwrap();
1344+
1345+
command_upgrade_reserve_to_v2_1_0(&mut config, reserve_pubkey)
1346+
}
13271347
("update-reserve", Some(arg_matches)) => {
13281348
let reserve_pubkey = pubkey_of(arg_matches, "reserve").unwrap();
13291349
let lending_market_owner_keypair =
@@ -1973,6 +1993,29 @@ fn command_set_lending_market_owner_and_config(
19731993
Ok(())
19741994
}
19751995

1996+
fn command_upgrade_reserve_to_v2_1_0(config: &mut Config, reserve_pubkey: Pubkey) -> CommandResult {
1997+
let recent_blockhash = config.rpc_client.get_latest_blockhash()?;
1998+
1999+
let message = Message::new_with_blockhash(
2000+
&[
2001+
ComputeBudgetInstruction::set_compute_unit_price(30101),
2002+
upgrade_reserve_to_v2_1_0(
2003+
config.lending_program_id,
2004+
reserve_pubkey,
2005+
config.fee_payer.pubkey(),
2006+
),
2007+
],
2008+
Some(&config.fee_payer.pubkey()),
2009+
&recent_blockhash,
2010+
);
2011+
2012+
let transaction = Transaction::new(&vec![config.fee_payer.as_ref()], message, recent_blockhash);
2013+
2014+
send_transaction(config, transaction)?;
2015+
2016+
Ok(())
2017+
}
2018+
19762019
#[allow(clippy::too_many_arguments, clippy::unnecessary_unwrap)]
19772020
fn command_update_reserve(
19782021
config: &mut Config,

‎token-lending/program/Cargo.toml

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "solend-program"
3-
version = "2.0.2"
3+
version.workspace = true
44
description = "Solend Program"
55
authors = ["Solend Maintainers <maintainers@solend.fi>"]
66
repository = "https://github.com/solendprotocol/solana-program-library"
@@ -18,7 +18,7 @@ bytemuck = "1.5.1"
1818
solana-program = "=1.16.20"
1919
solend-sdk = { path = "../sdk" }
2020
oracles = { path = "../oracles" }
21-
spl-token = { version = "3.3.0", features=["no-entrypoint"] }
21+
spl-token = { version = "3.3.0", features = ["no-entrypoint"] }
2222
static_assertions = "1.1.0"
2323

2424
[dev-dependencies]

‎token-lending/program/src/processor.rs

+6
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,12 @@ pub fn process_instruction(
244244
accounts,
245245
)
246246
}
247+
248+
// temporary ix for upgrade
249+
LendingInstruction::UpgradeReserveToV2_1_0 => {
250+
msg!("Instruction: Upgrade Reserve to v2.1.0");
251+
liquidity_mining::upgrade_reserve(program_id, accounts)
252+
}
247253
}
248254
}
249255

‎token-lending/program/src/processor/liquidity_mining.rs

+134
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,11 @@ use solana_program::{
2626
clock::Clock,
2727
entrypoint::ProgramResult,
2828
msg,
29+
program::invoke,
2930
program_error::ProgramError,
3031
pubkey::Pubkey,
3132
rent::Rent,
33+
system_instruction,
3234
sysvar::Sysvar,
3335
};
3436
use solend_sdk::{
@@ -37,6 +39,7 @@ use solend_sdk::{
3739
};
3840
use spl_token::state::Account as TokenAccount;
3941
use std::convert::TryInto;
42+
use upgrade_reserve::UpgradeReserveAccounts;
4043

4144
/// # Accounts
4245
///
@@ -217,6 +220,75 @@ pub(crate) fn process_close_pool_reward(
217220
Ok(())
218221
}
219222

223+
/// Temporary ix to upgrade a reserve to LM feature added in @v2.0.2.
224+
/// Fails if reserve was not sized as @v2.0.2.
225+
///
226+
/// Until this ix is called for a [Reserve] account, all other ixs that try to
227+
/// unpack the [Reserve] will fail due to size mismatch.
228+
///
229+
/// # Accounts
230+
///
231+
/// See [upgrade_reserve::UpgradeReserveAccounts::from_unchecked_iter] for a list
232+
/// of accounts and their constraints.
233+
///
234+
/// # Effects
235+
///
236+
/// 1. Takes payer's lamports and pays for the rent increase.
237+
/// 2. Reallocates the reserve account to the latest size.
238+
/// 3. Repacks the reserve account.
239+
pub(crate) fn upgrade_reserve(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramResult {
240+
let accounts = UpgradeReserveAccounts::from_unchecked_iter(program_id, &mut accounts.iter())?;
241+
242+
//
243+
// 1.
244+
//
245+
246+
let current_rent = accounts.reserve_info.lamports();
247+
let new_rent = Rent::get()?.minimum_balance(Reserve::LEN);
248+
249+
if let Some(extra_rent) = new_rent.checked_sub(current_rent) {
250+
// some reserves have more rent than necessary, let's not assume that
251+
// the payer always needs to add more rent
252+
253+
invoke(
254+
&system_instruction::transfer(
255+
accounts.payer.key,
256+
accounts.reserve_info.key,
257+
extra_rent,
258+
),
259+
&[
260+
accounts.payer.clone(),
261+
accounts.reserve_info.clone(),
262+
accounts.system_program.clone(),
263+
],
264+
)?;
265+
}
266+
267+
//
268+
// 2.
269+
//
270+
271+
// From the [AccountInfo::realloc] docs:
272+
//
273+
// > Memory used to grow is already zero-initialized upon program entrypoint
274+
// > and re-zeroing it wastes compute units. If within the same call a program
275+
// > reallocs from larger to smaller and back to larger again the new space
276+
// > could contain stale data. Pass true for zero_init in this case,
277+
// > otherwise compute units will be wasted re-zero-initializing.
278+
let zero_init = false;
279+
accounts.reserve_info.realloc(Reserve::LEN, zero_init)?;
280+
281+
//
282+
// 3.
283+
//
284+
285+
// sanity checks pack and unpack reserves is ok
286+
let reserve = Reserve::unpack(&accounts.reserve_info.data.borrow())?;
287+
Reserve::pack(reserve, &mut accounts.reserve_info.data.borrow_mut())?;
288+
289+
Ok(())
290+
}
291+
220292
/// Unpacks a spl_token [TokenAccount].
221293
fn unpack_token_account(data: &[u8]) -> Result<TokenAccount, LendingError> {
222294
TokenAccount::unpack(data).map_err(|_| LendingError::InvalidTokenAccount)
@@ -645,6 +717,68 @@ mod close_pool_reward {
645717
}
646718
}
647719

720+
mod upgrade_reserve {
721+
use solend_sdk::state::RESERVE_LEN_V2_0_2;
722+
723+
use super::*;
724+
725+
pub(super) struct UpgradeReserveAccounts<'a, 'info> {
726+
/// Reserve sized as v2.0.2.
727+
///
728+
/// ✅ belongs to this program
729+
/// ✅ is sized [RESERVE_LEN_V2_0_2], ie. for sure [Reserve] account
730+
pub(super) reserve_info: &'a AccountInfo<'info>,
731+
/// The pool fella who pays for this.
732+
///
733+
/// ✅ is a signer
734+
pub(super) payer: &'a AccountInfo<'info>,
735+
/// The system program.
736+
///
737+
/// ✅ is the system program
738+
pub(super) system_program: &'a AccountInfo<'info>,
739+
740+
_priv: (),
741+
}
742+
743+
impl<'a, 'info> UpgradeReserveAccounts<'a, 'info> {
744+
pub(super) fn from_unchecked_iter(
745+
program_id: &Pubkey,
746+
iter: &mut impl Iterator<Item = &'a AccountInfo<'info>>,
747+
) -> Result<UpgradeReserveAccounts<'a, 'info>, ProgramError> {
748+
let reserve_info = next_account_info(iter)?;
749+
let payer = next_account_info(iter)?;
750+
let system_program = next_account_info(iter)?;
751+
752+
if !payer.is_signer {
753+
msg!("Payer provided must be a signer");
754+
return Err(LendingError::InvalidSigner.into());
755+
}
756+
757+
if reserve_info.owner != program_id {
758+
msg!("Reserve provided must be owned by the lending program");
759+
return Err(LendingError::InvalidAccountOwner.into());
760+
}
761+
762+
if reserve_info.data_len() != RESERVE_LEN_V2_0_2 {
763+
msg!("Reserve provided must be sized as v2.0.2");
764+
return Err(LendingError::InvalidAccountInput.into());
765+
}
766+
767+
if system_program.key != &solana_program::system_program::id() {
768+
msg!("System program provided must be the system program");
769+
return Err(LendingError::InvalidAccountInput.into());
770+
}
771+
772+
Ok(Self {
773+
payer,
774+
reserve_info,
775+
system_program,
776+
_priv: (),
777+
})
778+
}
779+
}
780+
}
781+
648782
/// Common checks within the admin ixs are:
649783
///
650784
/// * ✅ `reserve_info` belongs to this program

‎token-lending/sdk/Cargo.toml

+5-5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "solend-sdk"
3-
version = "2.0.2"
3+
version.workspace = true
44
description = "Solend Sdk"
55
authors = ["Solend Maintainers <maintainers@solend.fi>"]
66
repository = "https://github.com/solendprotocol/solana-program-library"
@@ -14,7 +14,7 @@ bytemuck = "1.5.1"
1414
num-derive = "0.3"
1515
num-traits = "0.2"
1616
solana-program = ">=1.9"
17-
spl-token = { version = "3.2.0", features=["no-entrypoint"] }
17+
spl-token = { version = "3.2.0", features = ["no-entrypoint"] }
1818
static_assertions = "1.1.0"
1919
thiserror = "1.0"
2020
uint = "=0.9.1"
@@ -23,11 +23,11 @@ uint = "=0.9.1"
2323
assert_matches = "1.5.0"
2424
base64 = "0.13"
2525
log = "0.4.14"
26-
proptest = "1.0"
27-
solana-sdk = ">=1.9"
26+
proptest = "1.6"
27+
rand = "0.8.5"
2828
serde = ">=1.0.140"
2929
serde_yaml = "0.8"
30-
rand = "0.8.5"
30+
solana-sdk = ">=1.9"
3131

3232
[lib]
3333
crate-type = ["cdylib", "lib"]

‎token-lending/sdk/src/instruction.rs

+33
Original file line numberDiff line numberDiff line change
@@ -613,6 +613,18 @@ pub enum LendingInstruction {
613613
/// Identifies a reward within a reserve's deposits/borrows rewards.
614614
pool_reward_index: u64,
615615
},
616+
617+
// 255
618+
/// UpgradeReserveToV2_1_0
619+
///
620+
/// Temporary ix which upgrades reserves from @2.0.2 to @2.1.0 with
621+
/// liquidity mining feature.
622+
/// Once all reserves are upgraded this ix is not necessary any more.
623+
///
624+
/// `[writable]` Reserve account.
625+
/// `[writable, signer]` Fee payer.
626+
/// `[]` System program.
627+
UpgradeReserveToV2_1_0,
616628
}
617629

618630
impl LendingInstruction {
@@ -899,6 +911,7 @@ impl LendingInstruction {
899911
pool_reward_index,
900912
}
901913
}
914+
255 => Self::UpgradeReserveToV2_1_0,
902915
_ => {
903916
msg!("Instruction cannot be unpacked");
904917
return Err(LendingError::InstructionUnpackError.into());
@@ -1235,6 +1248,9 @@ impl LendingInstruction {
12351248
buf.extend_from_slice(&(position_kind as u8).to_le_bytes());
12361249
buf.extend_from_slice(&pool_reward_index.to_le_bytes());
12371250
}
1251+
Self::UpgradeReserveToV2_1_0 => {
1252+
buf.push(255);
1253+
}
12381254
}
12391255
buf
12401256
}
@@ -2047,6 +2063,23 @@ pub fn donate_to_reserve(
20472063
}
20482064
}
20492065

2066+
/// Creates a `UpgradeReserveToV2_1_0` instruction.
2067+
pub fn upgrade_reserve_to_v2_1_0(
2068+
program_id: Pubkey,
2069+
reserve_pubkey: Pubkey,
2070+
fee_payer: Pubkey,
2071+
) -> Instruction {
2072+
Instruction {
2073+
program_id,
2074+
accounts: vec![
2075+
AccountMeta::new(reserve_pubkey, false),
2076+
AccountMeta::new(fee_payer, true),
2077+
AccountMeta::new_readonly(system_program::id(), false),
2078+
],
2079+
data: LendingInstruction::UpgradeReserveToV2_1_0.pack(),
2080+
}
2081+
}
2082+
20502083
#[cfg(test)]
20512084
mod test {
20522085
use super::*;

‎token-lending/sdk/src/state/liquidity_mining.rs

+264-21
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,28 @@
11
use crate::{
22
error::LendingError,
33
math::{Decimal, TryAdd, TryDiv, TryMul, TrySub},
4+
state::unpack_decimal,
5+
};
6+
use arrayref::{array_mut_ref, array_ref, array_refs, mut_array_refs};
7+
use solana_program::program_pack::{Pack, Sealed};
8+
use solana_program::{
9+
clock::Clock,
10+
program_error::ProgramError,
11+
pubkey::{Pubkey, PUBKEY_BYTES},
412
};
5-
use solana_program::{clock::Clock, program_error::ProgramError, pubkey::Pubkey};
613

7-
/// Cannot create a reward shorter than this.
8-
pub const MIN_REWARD_PERIOD_SECS: u64 = 3_600;
14+
use super::pack_decimal;
915

1016
/// Determines the size of [PoolRewardManager]
11-
/// TODO: This should be configured when we're dealing with migrations later but we should aim for 50.
12-
const MAX_REWARDS: usize = 44;
17+
const MAX_REWARDS: usize = 50;
18+
19+
/// Cannot create a reward shorter than this.
20+
pub const MIN_REWARD_PERIOD_SECS: u64 = 3_600;
1321

1422
/// Each reserve has two managers:
1523
/// - one for deposits
1624
/// - one for borrows
25+
#[derive(Clone, Debug, PartialEq)]
1726
pub struct PoolRewardManager {
1827
/// Is updated when we change user shares in the reserve.
1928
pub total_shares: u64,
@@ -46,17 +55,30 @@
4655
/// reward is vacant or not to save space.
4756
///
4857
/// If the pubkey is eq to default pubkey then slot is vacant.
58+
#[derive(Clone, Debug, PartialEq)]
4959
pub enum PoolRewardSlot {
5060
/// New reward can be added to this slot.
5161
Vacant {
5262
/// Increment this ID when adding new [PoolReward].
5363
last_pool_reward_id: PoolRewardId,
5464
},
5565
/// Reward has not been closed yet.
56-
Occupied(PoolReward),
66+
///
67+
/// We box the [PoolReward] to avoid stack overflow.
68+
Occupied(Box<PoolReward>),
5769
}
5870

5971
/// Tracks rewards in a specific mint over some period of time.
72+
///
73+
/// # Reward cancellation
74+
/// In Suilend we also store the amount of rewards that have been made available
75+
/// to users already.
76+
/// We keep adding `(total_rewards * time_passed) / (total_time)` every
77+
/// time someone interacts with the manager.
78+
/// This value is used to transfer the unallocated rewards to the admin.
79+
/// However, this can be calculated dynamically which avoids storing extra
80+
/// [Decimal] on each [PoolReward].
81+
#[derive(Clone, Debug, Default, PartialEq)]
6082
pub struct PoolReward {
6183
/// Unique ID for this slot that has never been used before, and will never
6284
/// be used again.
@@ -77,16 +99,13 @@
7799
/// There's a permission-less ix with which user rewards can be distributed
78100
/// that's used for cranking remaining rewards.
79101
pub num_user_reward_managers: u64,
80-
/// Amount of rewards that have been made available to users.
81-
///
82-
/// We keep adding `(total_rewards * time_passed) / (total_time)` every
83-
/// time someone interacts with the manager
84-
/// ([update_pool_reward_manager]).
85-
pub allocated_rewards: Decimal,
86102
/// We keep adding `(unlocked_rewards) / (total_shares)` every time
87103
/// someone interacts with the manager ([update_pool_reward_manager])
88104
/// where
89105
/// `unlocked_rewards = (total_rewards * time_passed) / (total_time)`
106+
///
107+
/// # (Un)Packing
108+
/// We only store 16 most significant digits.
90109
pub cumulative_rewards_per_share: Decimal,
91110
}
92111

@@ -135,7 +154,7 @@
135154

136155
impl PoolRewardManager {
137156
/// Should be updated before any interaction with rewards.
138157
fn update(&mut self, clock: &Clock) -> Result<(), ProgramError> {
139158
let curr_unix_timestamp_secs = clock.unix_timestamp as u64;
140159

141160
if self.last_update_time_secs >= curr_unix_timestamp_secs {
@@ -173,8 +192,6 @@
173192
.try_mul(Decimal::from(time_passed_secs))?
174193
.try_div(Decimal::from(end_time_secs - reward.start_time_secs))?;
175194

176-
reward.allocated_rewards = reward.allocated_rewards.try_add(unlocked_rewards)?;
177-
178195
reward.cumulative_rewards_per_share = reward
179196
.cumulative_rewards_per_share
180197
.try_add(unlocked_rewards.try_div(Decimal::from(self.total_shares))?)?;
@@ -186,7 +203,7 @@
186203
}
187204
}
188205

189206
enum CreatingNewUserRewardManager {
190207
/// If we are creating a [UserRewardManager] then we want to populate it.
191208
Yes,
192209
No,
@@ -198,7 +215,7 @@
198215
/// # Assumption
199216
/// Invoker has checked that this [PoolRewardManager] matches the
200217
/// [UserRewardManager].
201218
fn update(
202219
&mut self,
203220
pool_reward_manager: &mut PoolRewardManager,
204221
clock: &Clock,
@@ -280,24 +297,222 @@
280297
}
281298
}
282299

300+
impl PoolReward {
301+
const LEN: usize = PoolRewardId::LEN + PUBKEY_BYTES + 8 + 4 + 8 + 8 + 16;
302+
}
303+
304+
impl PoolRewardId {
305+
const LEN: usize = std::mem::size_of::<Self>();
306+
}
307+
308+
impl Default for PoolRewardManager {
309+
fn default() -> Self {
310+
Self {
311+
total_shares: 0,
312+
last_update_time_secs: 0,
313+
pool_rewards: std::array::from_fn(|_| PoolRewardSlot::default()),
314+
}
315+
}
316+
}
317+
318+
impl Default for PoolRewardSlot {
319+
fn default() -> Self {
320+
Self::Vacant {
321+
last_pool_reward_id: PoolRewardId(0),
322+
}
323+
}
324+
}
325+
326+
impl PoolRewardManager {
327+
#[inline(never)]
328+
pub(crate) fn unpack_to_box(input: &[u8]) -> Result<Box<Self>, ProgramError> {
329+
Ok(Box::new(PoolRewardManager::unpack_from_slice(input)?))
330+
}
331+
}
332+
333+
impl Sealed for PoolRewardManager {}
334+
335+
impl Pack for PoolRewardManager {
336+
/// total_shares + last_update_time_secs + pool_rewards.
337+
const LEN: usize = 8 + 8 + MAX_REWARDS * PoolReward::LEN;
338+
339+
fn pack_into_slice(&self, output: &mut [u8]) {
340+
output[0..8].copy_from_slice(&self.total_shares.to_le_bytes());
341+
output[8..16].copy_from_slice(&self.last_update_time_secs.to_le_bytes());
342+
343+
for (index, pool_reward_slot) in self.pool_rewards.iter().enumerate() {
344+
let offset = 16 + index * PoolReward::LEN;
345+
let raw_pool_reward = array_mut_ref![output, offset, PoolReward::LEN];
346+
347+
let (
348+
dst_id,
349+
dst_vault,
350+
dst_start_time_secs,
351+
dst_duration_secs,
352+
dst_total_rewards,
353+
dst_num_user_reward_managers,
354+
dst_cumulative_rewards_per_share_wads,
355+
) = mut_array_refs![
356+
raw_pool_reward,
357+
PoolRewardId::LEN,
358+
PUBKEY_BYTES,
359+
8, // start_time_secs
360+
4, // duration_secs
361+
8, // total_rewards
362+
8, // num_user_reward_managers
363+
16 // cumulative_rewards_per_share
364+
];
365+
366+
let (
367+
id,
368+
vault,
369+
start_time_secs,
370+
duration_secs,
371+
total_rewards,
372+
num_user_reward_managers,
373+
cumulative_rewards_per_share,
374+
) = match pool_reward_slot {
375+
PoolRewardSlot::Vacant {
376+
last_pool_reward_id,
377+
} => (
378+
*last_pool_reward_id,
379+
Pubkey::default(),
380+
0u64,
381+
0u32,
382+
0u64,
383+
0u64,
384+
Decimal::zero(),
385+
),
386+
PoolRewardSlot::Occupied(pool_reward) => (
387+
pool_reward.id,
388+
pool_reward.vault,
389+
pool_reward.start_time_secs,
390+
pool_reward.duration_secs,
391+
pool_reward.total_rewards,
392+
pool_reward.num_user_reward_managers,
393+
pool_reward.cumulative_rewards_per_share,
394+
),
395+
};
396+
397+
dst_id.copy_from_slice(&id.0.to_le_bytes());
398+
dst_vault.copy_from_slice(vault.as_ref());
399+
*dst_start_time_secs = start_time_secs.to_le_bytes();
400+
*dst_duration_secs = duration_secs.to_le_bytes();
401+
*dst_total_rewards = total_rewards.to_le_bytes();
402+
*dst_num_user_reward_managers = num_user_reward_managers.to_le_bytes();
403+
pack_decimal(
404+
cumulative_rewards_per_share,
405+
dst_cumulative_rewards_per_share_wads,
406+
);
407+
}
408+
}
409+
410+
#[inline(never)]
411+
fn unpack_from_slice(input: &[u8]) -> Result<Self, ProgramError> {
412+
let mut pool_reward_manager = PoolRewardManager {
413+
total_shares: u64::from_le_bytes(*array_ref![input, 0, 8]),
414+
last_update_time_secs: u64::from_le_bytes(*array_ref![input, 8, 8]),
415+
..Default::default()
416+
};
417+
418+
for index in 0..MAX_REWARDS {
419+
let offset = 8 + 8 + index * PoolReward::LEN;
420+
let raw_pool_reward = array_ref![input, offset, PoolReward::LEN];
421+
422+
let (
423+
src_id,
424+
src_vault,
425+
src_start_time_secs,
426+
src_duration_secs,
427+
src_total_rewards,
428+
src_num_user_reward_managers,
429+
src_cumulative_rewards_per_share_wads,
430+
) = array_refs![
431+
raw_pool_reward,
432+
PoolRewardId::LEN,
433+
PUBKEY_BYTES,
434+
8, // start_time_secs
435+
4, // duration_secs
436+
8, // total_rewards
437+
8, // num_user_reward_managers
438+
16 // cumulative_rewards_per_share
439+
];
440+
441+
let vault = Pubkey::new_from_array(*src_vault);
442+
let pool_reward_id = PoolRewardId(u32::from_le_bytes(*src_id));
443+
444+
// SAFETY: ok to assign because we know the index is less than length
445+
pool_reward_manager.pool_rewards[index] = if vault == Pubkey::default() {
446+
PoolRewardSlot::Vacant {
447+
last_pool_reward_id: pool_reward_id,
448+
}
449+
} else {
450+
PoolRewardSlot::Occupied(Box::new(PoolReward {
451+
id: pool_reward_id,
452+
vault,
453+
start_time_secs: u64::from_le_bytes(*src_start_time_secs),
454+
duration_secs: u32::from_le_bytes(*src_duration_secs),
455+
total_rewards: u64::from_le_bytes(*src_total_rewards),
456+
num_user_reward_managers: u64::from_le_bytes(*src_num_user_reward_managers),
457+
cumulative_rewards_per_share: unpack_decimal(
458+
src_cumulative_rewards_per_share_wads,
459+
),
460+
}))
461+
};
462+
}
463+
464+
Ok(pool_reward_manager)
465+
}
466+
}
467+
283468
#[cfg(test)]
284469
mod tests {
285470
//! TODO: Rewrite these tests from their Suilend counterparts.
286471
//! TODO: Calculate test coverage and add tests for missing branches.
287472
288473
use super::*;
474+
use proptest::prelude::*;
475+
use rand::Rng;
476+
477+
fn pool_reward_manager_strategy() -> impl Strategy<Value = PoolRewardManager> {
478+
(0..100u32).prop_perturb(|_, mut rng| PoolRewardManager::new_rand(&mut rng))
479+
}
480+
481+
proptest! {
482+
#[test]
483+
fn it_packs_and_unpacks(pool_reward_manager in pool_reward_manager_strategy()) {
484+
let mut packed = vec![0u8; PoolRewardManager::LEN];
485+
Pack::pack_into_slice(&pool_reward_manager, &mut packed);
486+
let unpacked = PoolRewardManager::unpack_from_slice(&packed).unwrap();
487+
prop_assert_eq!(pool_reward_manager, unpacked);
488+
}
489+
}
490+
491+
#[test]
492+
fn it_unpacks_empty_bytes_as_default() {
493+
let packed = vec![0u8; PoolRewardManager::LEN];
494+
let unpacked = PoolRewardManager::unpack_from_slice(&packed).unwrap();
495+
assert_eq!(unpacked, PoolRewardManager::default());
496+
497+
// sanity check that everything starts at 0
498+
let all_rewards_are_empty = unpacked.pool_rewards.iter().all(|pool_reward| {
499+
matches!(
500+
pool_reward,
501+
PoolRewardSlot::Vacant {
502+
last_pool_reward_id: PoolRewardId(0)
503+
}
504+
)
505+
});
506+
507+
assert!(all_rewards_are_empty);
508+
}
289509

290510
#[test]
291511
fn it_fits_reserve_realloc_into_single_ix() {
292-
const MAX_REALLOC: usize = 10 * 1024;
512+
const MAX_REALLOC: usize = solana_program::entrypoint::MAX_PERMITTED_DATA_INCREASE;
293513

294514
let size_of_discriminant = 1;
295-
let const_size_of_pool_manager = 8 + 8;
296-
let required_realloc = size_of_discriminant
297-
+ const_size_of_pool_manager
298-
+ 2 * MAX_REWARDS * std::mem::size_of::<PoolReward>();
299-
300-
println!("assert {required_realloc} <= {MAX_REALLOC}");
515+
let required_realloc = size_of_discriminant * PoolRewardManager::LEN;
301516
assert!(required_realloc <= MAX_REALLOC);
302517
}
303518

@@ -330,4 +545,32 @@
330545
fn it_tests_pool_reward_manager_cancel_and_close_regression() {
331546
// TODO: rewrite Suilend "test_pool_reward_manager_cancel_and_close_regression"
332547
}
548+
549+
impl PoolRewardManager {
550+
pub(crate) fn new_rand(rng: &mut impl Rng) -> Self {
551+
Self {
552+
total_shares: rng.gen(),
553+
last_update_time_secs: rng.gen(),
554+
pool_rewards: std::array::from_fn(|_| {
555+
let is_vacant = rng.gen_bool(0.5);
556+
557+
if is_vacant {
558+
PoolRewardSlot::Vacant {
559+
last_pool_reward_id: PoolRewardId(rng.gen()),
560+
}
561+
} else {
562+
PoolRewardSlot::Occupied(Box::new(PoolReward {
563+
id: PoolRewardId(rng.gen()),
564+
vault: Pubkey::new_unique(),
565+
start_time_secs: rng.gen(),
566+
duration_secs: rng.gen(),
567+
total_rewards: rng.gen(),
568+
cumulative_rewards_per_share: Decimal::from_scaled_val(rng.gen()),
569+
num_user_reward_managers: rng.gen(),
570+
}))
571+
}
572+
}),
573+
}
574+
}
575+
}
333576
}

‎token-lending/sdk/src/state/reserve.rs

+159-108
Large diffs are not rendered by default.
+66
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/**
2+
* $ anchor test --provider.cluster localnet --detach
3+
*/
4+
5+
import * as anchor from "@coral-xyz/anchor";
6+
import { PublicKey } from "@solana/web3.js";
7+
import { expect } from "chai";
8+
import { exec } from "node:child_process";
9+
10+
describe("liquidity mining", () => {
11+
// Configure the client to use the local cluster.
12+
anchor.setProvider(anchor.AnchorProvider.env());
13+
14+
const TEST_RESERVE_FOR_UPGRADE =
15+
"BgxfHJDzm44T7XG68MYKx7YisTjZu73tVovyZSjJMpmw";
16+
17+
it("Upgrades reserve to 2.1.0 via CLI", async () => {
18+
// There's an ix that upgrades a reserve to 2.1.0.
19+
// This ix is invocable via our CLI.
20+
// In this test case for comfort and more test coverage we invoke the CLI
21+
// command rather than crafting the ix ourselves.
22+
23+
const rpcUrl = anchor.getProvider().connection.rpcEndpoint;
24+
25+
const reserveBefore = await anchor
26+
.getProvider()
27+
.connection.getAccountInfo(new PublicKey(TEST_RESERVE_FOR_UPGRADE));
28+
29+
expect(reserveBefore.data.length).to.eq(619); // old version data length
30+
const expectedRentBefore = await anchor
31+
.getProvider()
32+
.connection.getMinimumBalanceForRentExemption(reserveBefore.data.length);
33+
// some reserves have more rent
34+
expect(reserveBefore.lamports).to.be.greaterThanOrEqual(expectedRentBefore);
35+
36+
const command = `cargo run --quiet --bin solend-cli -- --url ${rpcUrl} upgrade-reserve --reserve ${TEST_RESERVE_FOR_UPGRADE}`;
37+
console.log(`\$ ${command}`);
38+
const cliProcess = exec(command);
39+
40+
// let us observe progress
41+
cliProcess.stderr.setEncoding("utf8");
42+
cliProcess.stderr.pipe(process.stderr);
43+
44+
console.log("Waiting for command to finish...");
45+
const exitCode = await new Promise<number>((resolve) =>
46+
cliProcess.on("exit", (code) => resolve(code))
47+
);
48+
49+
if (exitCode !== 0) {
50+
cliProcess.stdout.setEncoding("utf8");
51+
console.log("CLI stdout", cliProcess.stdout.read());
52+
53+
throw new Error(`Command failed with exit code ${exitCode}`);
54+
}
55+
56+
const reserveAfter = await anchor
57+
.getProvider()
58+
.connection.getAccountInfo(new PublicKey(TEST_RESERVE_FOR_UPGRADE));
59+
60+
expect(reserveAfter.data.length).to.eq(8651); // new version data length
61+
const expectedRentAfter = await anchor
62+
.getProvider()
63+
.connection.getMinimumBalanceForRentExemption(reserveAfter.data.length);
64+
expect(reserveAfter.lamports).to.be.greaterThanOrEqual(expectedRentAfter);
65+
});
66+
});

‎tsconfig.json

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"compilerOptions": {
3+
"types": ["mocha", "chai"],
4+
"typeRoots": ["./node_modules/@types"],
5+
"lib": ["es2015"],
6+
"module": "commonjs",
7+
"target": "es6",
8+
"esModuleInterop": true
9+
}
10+
}

‎yarn.lock

+1,181
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)
Please sign in to comment.