diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000000..ccafd388660 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,8 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true + +[*.ts] +indent_size = 2 diff --git a/.gitignore b/.gitignore index 7a244d9bf0d..b7e8b8e693f 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ hfuzz_workspace **/*.so **/.DS_Store test-ledger +cargo-test-*.profraw diff --git a/.mocharc.yml b/.mocharc.yml new file mode 100644 index 00000000000..5c2191843de --- /dev/null +++ b/.mocharc.yml @@ -0,0 +1 @@ +bail: true diff --git a/Anchor.toml b/Anchor.toml index c3690eaf173..99fe81ddd78 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -1,14 +1,132 @@ -anchor_version = "0.13.2" +[toolchain] +package_manager = "yarn" +anchor_version = "0.28.0" + +[features] +resolution = true +skip-lint = false [workspace] -members = [ - "token-lending/program", - "token-lending/brick", -] +members = ["token-lending/program", "token-lending/brick"] [provider] cluster = "mainnet" wallet = "~/.config/solana/id.json" +[scripts] +test = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 token-lending/tests/**/*.ts" + [programs.mainnet] spl_token_lending = "So1endDq2YkqhipRh3WViPa8hdiSpxWy6z3Z6tMCpAo" + +[programs.localnet] +solend_program = "So1endDq2YkqhipRh3WViPa8hdiSpxWy6z3Z6tMCpAo" + +[test.validator] +# we use some mainnet accounts for tests +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 63624df360b..1f770e26255 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -780,18 +780,18 @@ dependencies = [ [[package]] name = "bit-set" -version = "0.5.3" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ "bit-vec", ] [[package]] name = "bit-vec" -version = "0.6.3" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] name = "bitflags" @@ -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", ] @@ -1469,6 +1469,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.9.0" @@ -2349,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]] @@ -2444,12 +2441,6 @@ version = "0.2.155" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" -[[package]] -name = "libm" -version = "0.2.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" - [[package]] name = "libredox" version = "0.1.3" @@ -2900,7 +2891,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", - "libm", ] [[package]] @@ -3283,6 +3273,16 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi 1.0.1", +] + [[package]] name = "proc-macro-crate" version = "0.1.5" @@ -3363,14 +3363,14 @@ dependencies = [ "quote 1.0.36", "syn 1.0.109", "version_check", - "yansi", + "yansi 0.5.1", ] [[package]] name = "proptest" -version = "1.5.0" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4c2511913b88df1637da85cc8d96ec8e43a3f8bb8ccb71ee1ac240d6f3df58d" +checksum = "14cae93065090804185d3b75f0bf93b8eeda30c7a9b4a33d3bdb3988d6229e50" dependencies = [ "bit-set", "bit-vec", @@ -5419,7 +5419,7 @@ dependencies = [ [[package]] name = "solend-program" -version = "2.0.2" +version = "2.1.0" dependencies = [ "anchor-lang 0.28.0", "assert_matches", @@ -5429,6 +5429,7 @@ dependencies = [ "bytemuck", "log", "oracles", + "pretty_assertions", "proptest", "pyth-sdk-solana", "pyth-solana-receiver-sdk", @@ -5438,6 +5439,7 @@ dependencies = [ "solana-program-test", "solana-sdk", "solend-sdk", + "spl-associated-token-account 1.1.3", "spl-token 3.5.0", "static_assertions", "switchboard-on-demand", @@ -5448,10 +5450,11 @@ dependencies = [ [[package]] name = "solend-program-cli" -version = "2.0.2" +version = "2.1.0" dependencies = [ "bincode", "clap 2.34.0", + "indicatif", "reqwest 0.12.4", "serde_json", "solana-account-decoder", @@ -5469,7 +5472,7 @@ dependencies = [ [[package]] name = "solend-sdk" -version = "2.0.2" +version = "2.1.0" dependencies = [ "arrayref", "assert_matches", @@ -5479,8 +5482,10 @@ dependencies = [ "log", "num-derive 0.3.3", "num-traits", + "pretty_assertions", "proptest", "rand 0.8.5", + "rand_chacha 0.3.1", "serde", "serde_yaml 0.8.26", "solana-program", @@ -6153,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]] @@ -6601,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" @@ -6859,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" @@ -7193,6 +7214,12 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yasna" version = "0.5.2" diff --git a/Cargo.toml b/Cargo.toml index 7daa3e7968f..40d3135d61f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,15 @@ [workspace] +resolver = "2" members = [ "token-lending/cli", "token-lending/program", "token-lending/sdk", - "token-lending/brick" -, "token-lending/oracles"] + "token-lending/brick", + "token-lending/oracles", +] + +[workspace.package] +version = "2.1.0" [profile.dev] split-debuginfo = "unpacked" diff --git a/ci/cargo-test-bpf.sh b/ci/cargo-test-bpf.sh index ea30a02f18a..b40acbeca45 100755 --- a/ci/cargo-test-bpf.sh +++ b/ci/cargo-test-bpf.sh @@ -8,7 +8,7 @@ source ./ci/solana-version.sh export RUSTFLAGS="-D warnings" export RUSTBACKTRACE=1 - +export RUST_LOG="warn,tarpc=error,solana_runtime::message_processor=debug" usage() { exitcode=0 @@ -33,11 +33,11 @@ run_dir=$(pwd) if [[ -d $run_dir/program ]]; then # Build/test just one BPF program cd $run_dir/program - RUST_LOG="error" cargo +"$rust_stable" test-bpf --features test-bpf -j 1 -- --nocapture + cargo +"$rust_stable" test-bpf --features test-bpf -j 1 -- --nocapture else # Build/test all BPF programs for directory in $(ls -d $run_dir/*/); do cd $directory - RUST_LOG="error" cargo +"$rust_stable" test-bpf --features test-bpf -j 1 -- --nocapture + cargo +"$rust_stable" test-bpf --features test-bpf -j 1 -- --nocapture done fi diff --git a/coverage.sh b/coverage.sh index 3bef941b59a..30e23283fce 100755 --- a/coverage.sh +++ b/coverage.sh @@ -21,10 +21,11 @@ RUST_LOG="error" CARGO_INCREMENTAL=0 RUSTFLAGS='-Cinstrument-coverage' LLVM_PROF # generate report mkdir -p target/coverage/html -grcov . --binary-path ./target/debug/deps/ -s . -t html --branch --ignore-not-existing --ignore '../*' --ignore "/*" -o target/coverage/html +grcov . --branch --binary-path ./target/debug/deps/ -s . -t html --ignore-not-existing --ignore '../*' --ignore "/*" -o target/coverage/html -grcov . --binary-path ./target/debug/deps/ -s . -t lcov --branch --ignore-not-existing --ignore '../*' --ignore "/*" -o target/coverage/tests.lcov +grcov . --branch --binary-path ./target/debug/deps/ -s . -t lcov --ignore-not-existing --ignore '../*' --ignore "/*" -o target/coverage/tests.lcov # cleanup rm *.profraw || true rm **/**/*.profraw || true + diff --git a/package.json b/package.json new file mode 100644 index 00000000000..43611d9784c --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "license": "ISC", + "scripts": { + "lint:fix": "prettier */*.js \"*/**/*{.js,.ts}\" -w", + "lint": "prettier */*.js \"*/**/*{.js,.ts}\" --check" + }, + "dependencies": { + "@coral-xyz/anchor": "^0.28.0" + }, + "devDependencies": { + "chai": "^4.3.4", + "mocha": "^9.0.3", + "ts-mocha": "^10.0.0", + "@types/bn.js": "^5.1.0", + "@types/chai": "^4.3.0", + "@types/mocha": "^9.0.0", + "typescript": "^5.7.3", + "prettier": "^2.6.2" + } +} diff --git a/token-lending/LIQUIDITY_MINING.md b/token-lending/LIQUIDITY_MINING.md new file mode 100644 index 00000000000..1f676658952 --- /dev/null +++ b/token-lending/LIQUIDITY_MINING.md @@ -0,0 +1,237 @@ +# Liquidity Mining + +## Overview + +The liquidity mining feature models the same feature implemented in [Suilend][suilend-lm]. +In a gist we track deposits and borrows for each reserve in two structures: pool reward manager that exist on each reserve and user reward manager that exist on linked obligations. + +Deposits increase total pool shares and user shares by the exact amount of collateral token deposited into an obligation. +Collateral token that is _not_ deposited into any obligation does not count toward the total pool shares. + +Conversely, withdraws decrease total pool shares and user shares by the exact amount of collateral token withdrawn from an obligation. + +Similarly, borrows increase total pool shares and user shares. +However, the amount of shares is determined by "liability shares". +Liability shares are calculated for an obligation as `(borrow_amount / cumulative_borrow_rate)`. + +Conversely, repays decrease total pool shares and user shares. + +When a user deposits, withdraws, borrows or repays, we calculate their effective shares and then set them rather than incrementing/decrementing them. + +An obligation can also be liquidated which is a process of repaying and withdrawing. +This adequately updates the pool reward manager and user reward manager deposit shares for the withdraw reserve and liability shares for the repay reserve. + +An obligation's debt can also be forgiven. +This is an act of repaying and the liability shares are updated accordingly. + +## Differences to Suilend + +In Suilend a reserve can have at most 50 rewards. +However, Sui dynamic object model let's us store more data easily. +In Save we're storing the data on the reserve and this means packing and +unpacking it frequently which negatively impacts CU limits. +We lower the number of rewards to 30. +In Save, if we want to add new rewards we will crank old ones to make space +in the reserve if there isn't any. + +In Suilend we store the amount of rewards that have been made available to users already. +We keep adding `(total_rewards * time_passed) / (total_time)` every time someone interacts with the manager. +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. +A reward vault is a token account that stores reward tokens for a specific pool reward. +A reward vault authority is a PDA that is used to sign CPIs into the token program for the reward vault. + +```rust +// the seeds for the reward vault authority +[ + b"RewardVaultAuthority", + lending_market_key, + vault_token_account_key, +] +``` + +### `add_pool_reward` + +Admin only ix that adds a new pool reward to a reserve's reward manager, either a deposit or a borrow one. +This ix will fail if all slots are occupied. + +There's a minimum reward period of 1 hour, no short rewards are allowed. + +Each pool reward has a unique vault that holds the reward tokens. +This vault account must be created for the token program before calling this ix. +In this ix we initialize the account as token account and transfer the reward tokens to it from the admin's token account. + +### `edit_pool_reward` + +Both extending and shortening calculate the difference between total rewards linearly. +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. +In this case we transfer tokens from the reward vault to the lending market reward token account. + +#### Shorten + +If the new endtime is in the future, larger than start time and smaller than previous end time +then we shorten the reward period, refunding the unallocated rewards to the lending market +reward token account. + +#### Extend + +If the new endtime is in the future, larger than start time and larger than previous end time +then we extend the reward period, taking more tokens from the lending market reward token +account. + +### `claim_pool_reward` + +Permission-less way to claim allocated user liquidity mining rewards. + +It finds the UserRewardManager for the reserve and obligation and withdraws +all eligible rewards from it. +The eligible rewards are then transferred to the obligation owners's token account. + +Anyone can call this ix which is useful for cranking. + +Alternatively, if the obligation is not yet migrated, this does the migration for the obligation as well. +See [Migrations](#migrations) section for more details. + +### `close_pool_reward` + +Closes a pool reward, making its slot vacant and ready for a new reward. + +Before closing a pool reward that pool reward must first be cancelled and all rewards must be claimed by the users. + +### `upgrade_reserve` + +Temporary ix to upgrade a reserve to LM feature added in @v2.0.2. +Fails if reserve was not sized as @v2.0.2 (ie. has been upgraded or created with @v2.1.00). + +Until this ix is called for a Reserve account, all other ixs that try to unpack the Reserve will fail due to size mismatch. + +## Changes + +This section is partly relevant also to client implementations. +There are breaking changes introduced with this version. + +### First byte of each account is discriminator + +In @v2.0.2 the first byte of any _initialized_ account was set to the program version, ie. `0x01`. +Once any account is mutably packed in @v2.1.0, the first byte will be set to the account discriminator: + +```rust +/// Match the first byte of an account data against this enum to determine +/// the account type. +/// +/// # Note +/// +/// In versions before @v2.1.0 this byte represented program version. +/// That's why we skip value `1u8`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum AccountDiscriminator { + /// Account is not initialized yet. + #[default] + Uninitialized = 0, + /// [crate::state::LendingMarket] + LendingMarket = 2, + /// [crate::state::Reserve] + Reserve = 3, + /// [crate::state::Obligation] + Obligation = 4, +} +``` + +### Obligations need more rent + +Because obligations track user rewards that depend on the number of reserves and the rewards that those reserves have, we now dynamically reallocate size of obligation accounts. + +This means that sometimes obligations will need more rent than before and this rent must be (`system_program`) transferred to the obligation account before any interaction with the borrow-lending program. + +The calculation for an upper-bound of an obligation size from which rent-exempt balance is calculated: + +```math +\overline{s} = 1301 + \sum_{i=0}^{n} 50 + 37 * m_{i} +``` + +Where $`n`$ is the number of reserves in the obligation and $`m_{i}`$ is the number of pool rewards in obligation's reserve manager $`i`$. + +This is an upper-bound because some of those pool rewards might be over and therefore wouldn't be copied to the obligation. + +The particular obligation's reserve manager depends on whether the obligation is a borrow or deposit obligation. + +### Reserve size increased + +Migrated reserve accounts are sized at 5451 bytes. + +### CUs increased for all reserve/obligation related ixs + +We increase the reserve size and the obligation size which costs more compute when (un)packing. +Additionally, we now write to the reserve account on withdrawal to update the total shares. + +All this means more CUs are needed for ixs to succeed. +Additionally, the CUs increase linearly with the number of rewards in each involved reserve. + +> TBD: Let's review together the limits used in the present client implementation. + +### Reserve account in processor is protected by runtime borrow checker + +In @v2.0.2 access to reserve account followed a pattern of unpacking an immutable reference to a cloned memory location, working with it and then mutably packing it back to the original location. +This introduced extra up(pack)ing operations and was prone to double spend bugs. + +In this version we're leveraging the `solana_program` framework's usage of `Cell` container. +We keep a `Ref`/`RefMut` around in a wrapper struct along with the unpacked reserve struct and automatically pack it back to the original location when `RefMut` is dropped. +This way we guarantee at runtime that only one mutable reference to the reserve exists at any time. + +## Migrations + +### `Reserve` + +There's a CLI command for `UpgradeReserveToV2_1_0` ix to permission-lessly upgrade a reserve account. +Once upgraded any subsequent calls to this ix for the specific reserve will fail. +The upgrade requires 4832 extra bytes which amounts to ~0.035 $SOL. +Some reserves have extra rent and won't require the full amount. +The `UpgradeReserveToV2_1_0` ix can be delete as soon as all reserves are migrated. + +### `Obligation` + +To start tracking rewards for an obligation we need to set its shares to the appropriate amount. +They are at 0 before the obligation is fully migrated. + +We can call `claim_pool_reward` ix to do this, or any deposit/withdraw/repay/borrow ix. + +> Prior to version @2.1.0 there was no concept of liq. mining. +> That means user shares are going to be 0 even if they have a borrow or deposit. +> This ix can be used to start tracking obligation's rewards. + +The obligation will be reallocated if it needs more space to add extra rewards. +Client must ensure that the obligation has enough rent-exempt balance. +All obligations would benefit from a extra airdropped rent about `1 + 50 * obligation_reserves` lamports. + +### `LendingMarket` + +The lending market account is not changed in this version except for the first byte discriminator. + +A lending market will be automatically upgraded on the first mutable ix. + +## Outstanding work + +- [x] Review feature parity with Suilend + - Looped rewards are not implemented but that's ok +- [x] Consider changing the reward vault authority seed +- [ ] Consider having another admin account to manage the rewards +- [x] Consider spending some rent to the obligations from the reclaimed merkle-tree reward distributor + - We will fund the obligations to support some of the extra rent +- [x] Discuss CU limits with the Save client team + + + +[suilend-lm]: https://github.com/solendprotocol/suilend/blob/dc53150416f352053ac3acbb320ee143409c4a5d/contracts/suilend/sources/liquidity_mining.move#L2 diff --git a/token-lending/cli/Cargo.toml b/token-lending/cli/Cargo.toml index 8888352835b..0c024029373 100644 --- a/token-lending/cli/Cargo.toml +++ b/token-lending/cli/Cargo.toml @@ -1,29 +1,30 @@ [package] +name = "solend-program-cli" +version = "2.1.0" authors = ["Solend Maintainers "] description = "Solend Program CLI" edition = "2018" homepage = "https://solend.fi" license = "Apache-2.0" -name = "solend-program-cli" repository = "https://github.com/solendprotocol/solana-program-library" -version = "2.0.2" [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" } -solend-program = { path="../program", features = [ "no-entrypoint" ] } -spl-token = { version = "3.3.0", features=["no-entrypoint"] } +solana-sdk = "1.14.10" +solend-program = { path = "../program", 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..27a28136e47 --- /dev/null +++ b/token-lending/cli/src/liquidity_mining/crank_pool_rewards.rs @@ -0,0 +1,180 @@ +//! 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, + None, // no payer => permissionless claim + ); + + 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 db0658a0068..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,6 +19,7 @@ use solend_program::{ instruction::set_lending_market_owner_and_config, state::{validate_reserve_config, RateLimiterConfig}, }; +use solend_sdk::state::PositionKind; use solend_sdk::{ instruction::{ liquidate_obligation_and_redeem_reserve_collateral, redeem_reserve_collateral, @@ -20,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, @@ -768,6 +775,211 @@ fn main() { .help("Risk authority address"), ) ) + .subcommand( + 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") + .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("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") .about("Update a reserve config") @@ -1324,6 +1536,97 @@ fn main() { risk_authority_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(); let lending_market_owner_keypair = diff --git a/token-lending/program/Cargo.toml b/token-lending/program/Cargo.toml index a58c4d08c06..5d2fb9dfb2a 100644 --- a/token-lending/program/Cargo.toml +++ b/token-lending/program/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "solend-program" -version = "2.0.2" +version = "2.1.0" description = "Solend Program" authors = ["Solend Maintainers "] repository = "https://github.com/solendprotocol/solana-program-library" @@ -8,38 +8,40 @@ license = "Apache-2.0" edition = "2018" [features] +custom-heap = [] +custom-panic = [] no-entrypoint = [] test-bpf = [] [dependencies] bytemuck = "1.5.1" -# pyth-sdk-solana = "0.8.0" -# pyth-solana-receiver-sdk = "0.3.0" +oracles = { path = "../oracles" } solana-program = "=1.16.20" solend-sdk = { path = "../sdk" } -oracles = { path = "../oracles" } -spl-token = { version = "3.3.0", features=["no-entrypoint"] } +spl-associated-token-account = "1.0.5" # compatible with spl-token 3.3.0 +spl-token = { version = "3.3.0", features = ["no-entrypoint"] } static_assertions = "1.1.0" [dev-dependencies] anchor-lang = "0.28.0" assert_matches = "1.5.0" -bytemuck = "1.5.1" base64 = "0.13" -log = "0.4.14" -proptest = "1.0" -solana-program-test = "=1.16.20" -solana-sdk = "=1.16.20" -serde = ">=1.0.140" -serde_yaml = "0.8" -thiserror = "1.0" bincode = "1.3.3" borsh = "0.10.3" +bytemuck = "1.5.1" +log = "0.4.14" +pretty_assertions = "1.4.1" +proptest = "1.0" pyth-sdk-solana = "0.8.0" pyth-solana-receiver-sdk = "0.3.0" +serde = ">=1.0.140" +serde_yaml = "0.8" +solana-program-test = "=1.16.20" +solana-sdk = "=1.16.20" switchboard-on-demand = "0.1.12" switchboard-program = "0.2.0" switchboard-v2 = "0.1.3" +thiserror = "1.0" [lib] crate-type = ["cdylib", "lib"] diff --git a/token-lending/program/src/processor.rs b/token-lending/program/src/processor.rs index eda9beb686d..64c5b22b98b 100644 --- a/token-lending/program/src/processor.rs +++ b/token-lending/program/src/processor.rs @@ -1,5 +1,8 @@ //! Program state processor +mod account_borrow; +mod liquidity_mining; + use crate::state::Bonus; use crate::{ self as solend_program, @@ -13,6 +16,7 @@ use crate::{ ReserveCollateral, ReserveConfig, ReserveLiquidity, }, }; +use account_borrow::ReserveBorrow; use bytemuck::bytes_of; use oracles::get_single_price; use oracles::get_single_price_unchecked; @@ -35,6 +39,7 @@ use solana_program::{ sysvar::instructions::{load_current_index_checked, load_instruction_at_checked}, sysvar::{clock::Clock, rent::Rent, Sysvar}, }; +use solend_sdk::state::PositionKind; use solend_sdk::{ math::SaturatingSub, state::{LendingMarketMetadata, RateLimiter, RateLimiterConfig, ReserveType}, @@ -202,6 +207,72 @@ pub fn process_instruction( msg!("Instruction: Donate To Reserve"); process_donate_to_reserve(program_id, liquidity_amount, accounts) } + LendingInstruction::AddPoolReward { + reward_authority_bump, + position_kind, + start_time_secs, + end_time_secs, + token_amount, + } => { + msg!("Instruction: Add Pool Reward"); + liquidity_mining::add_pool_reward::process( + program_id, + reward_authority_bump, + position_kind, + start_time_secs, + end_time_secs, + token_amount, + accounts, + ) + } + LendingInstruction::EditPoolReward { + reward_authority_bump, + position_kind, + pool_reward_index, + new_end_time_secs, + } => { + msg!("Instruction: Edit Pool Reward"); + liquidity_mining::edit_pool_reward::process( + program_id, + reward_authority_bump, + position_kind, + pool_reward_index as _, + new_end_time_secs, + accounts, + ) + } + LendingInstruction::ClosePoolReward { + reward_authority_bump, + position_kind, + pool_reward_index, + } => { + msg!("Instruction: Close Pool Reward"); + liquidity_mining::close_pool_reward::process( + program_id, + reward_authority_bump, + position_kind, + pool_reward_index as _, + accounts, + ) + } + LendingInstruction::ClaimReward { + reward_authority_bump, + position_kind, + } => { + msg!("Instruction: Claim Reward"); + liquidity_mining::claim_pool_reward::process( + program_id, + reward_authority_bump, + position_kind, + accounts, + ) + } + + // temporary ix for upgrade + LendingInstruction::UpgradeReserveToV2_1_0 => { + msg!("Instruction: Upgrade Reserve to v2.1.0"); + liquidity_mining::upgrade_reserve::process(program_id, accounts) + } } } @@ -418,7 +489,6 @@ fn process_init_reserve( }); let collateral_amount = reserve.deposit_liquidity(liquidity_amount)?; - Reserve::pack(reserve, &mut reserve_info.data.borrow_mut())?; spl_token_init_account(TokenInitializeAccountParams { account: reserve_liquidity_supply_info.clone(), @@ -478,7 +548,7 @@ fn process_init_reserve( token_program: token_program_id.clone(), })?; - Ok(()) + Reserve::pack(reserve, &mut reserve_info.data.borrow_mut()) } fn validate_extra_oracle( @@ -521,29 +591,27 @@ fn process_refresh_reserve(program_id: &Pubkey, accounts: &[AccountInfo]) -> Pro let clock = &Clock::get()?; let extra_oracle_account_info = next_account_info(account_info_iter).ok(); + + let mut reserve = ReserveBorrow::new_mut(program_id, reserve_info)?; + _refresh_reserve( - program_id, - reserve_info, + &mut reserve, pyth_price_info, Some(switchboard_feed_info), clock, extra_oracle_account_info, - ) + )?; + + Ok(()) } fn _refresh_reserve<'a>( - program_id: &Pubkey, - reserve_info: &AccountInfo<'a>, + reserve: &mut ReserveBorrow, pyth_price_info: &AccountInfo<'a>, switchboard_feed_info: Option<&AccountInfo<'a>>, clock: &Clock, extra_oracle_account_info: Option<&AccountInfo<'a>>, ) -> ProgramResult { - let mut reserve = Box::new(Reserve::unpack(&reserve_info.data.borrow())?); - if reserve_info.owner != program_id { - msg!("Reserve provided is not owned by the lending program"); - return Err(LendingError::InvalidAccountOwner.into()); - } if &reserve.liquidity.pyth_oracle_pubkey != pyth_price_info.key { msg!("Reserve liquidity pyth oracle does not match the reserve liquidity pyth oracle provided"); return Err(LendingError::InvalidAccountInput.into()); @@ -595,27 +663,16 @@ fn _refresh_reserve<'a>( reserve.liquidity.smoothed_market_price = reserve.liquidity.market_price; } - Reserve::pack(*reserve, &mut reserve_info.data.borrow_mut())?; + _refresh_reserve_interest(reserve, clock)?; - _refresh_reserve_interest(program_id, reserve_info, clock) + Ok(()) } /// Lite version of refresh_reserve that should be used when the oracle price doesn't need to be updated /// BE CAREFUL WHEN USING THIS -fn _refresh_reserve_interest( - program_id: &Pubkey, - reserve_info: &AccountInfo<'_>, - clock: &Clock, -) -> ProgramResult { - let mut reserve = Box::new(Reserve::unpack(&reserve_info.data.borrow())?); - if reserve_info.owner != program_id { - msg!("Reserve provided is not owned by the lending program"); - return Err(LendingError::InvalidAccountOwner.into()); - } - +fn _refresh_reserve_interest(reserve: &mut ReserveBorrow, clock: &Clock) -> ProgramResult { reserve.accrue_interest(clock.slot)?; reserve.last_update.update_slot(clock.slot); - Reserve::pack(*reserve, &mut reserve_info.data.borrow_mut())?; Ok(()) } @@ -642,13 +699,15 @@ fn process_deposit_reserve_liquidity( let clock = &Clock::get()?; let token_program_id = next_account_info(account_info_iter)?; - _refresh_reserve_interest(program_id, reserve_info, clock)?; + let mut reserve = ReserveBorrow::new_mut(program_id, reserve_info)?; + + _refresh_reserve_interest(&mut reserve, clock)?; _deposit_reserve_liquidity( program_id, liquidity_amount, source_liquidity_info, destination_collateral_info, - reserve_info, + &mut reserve, reserve_liquidity_supply_info, reserve_collateral_mint_info, lending_market_info, @@ -667,7 +726,7 @@ fn _deposit_reserve_liquidity<'a>( liquidity_amount: u64, source_liquidity_info: &AccountInfo<'a>, destination_collateral_info: &AccountInfo<'a>, - reserve_info: &AccountInfo<'a>, + reserve: &mut ReserveBorrow, reserve_liquidity_supply_info: &AccountInfo<'a>, reserve_collateral_mint_info: &AccountInfo<'a>, lending_market_info: &AccountInfo<'a>, @@ -685,11 +744,6 @@ fn _deposit_reserve_liquidity<'a>( msg!("Lending market token program does not match the token program provided"); return Err(LendingError::InvalidTokenProgram.into()); } - let mut reserve = Box::new(Reserve::unpack(&reserve_info.data.borrow())?); - if reserve_info.owner != program_id { - msg!("Reserve provided is not owned by the lending program"); - return Err(LendingError::InvalidAccountOwner.into()); - } if &reserve.lending_market != lending_market_info.key { msg!("Reserve lending market does not match the lending market provided"); return Err(LendingError::InvalidAccountInput.into()); @@ -740,7 +794,6 @@ fn _deposit_reserve_liquidity<'a>( let collateral_amount = reserve.deposit_liquidity(liquidity_amount)?; reserve.last_update.mark_stale(); - Reserve::pack(*reserve, &mut reserve_info.data.borrow_mut())?; spl_token_transfer(TokenTransferParams { source: source_liquidity_info.clone(), @@ -785,13 +838,15 @@ fn process_redeem_reserve_collateral( let clock = &Clock::get()?; let token_program_id = next_account_info(account_info_iter)?; - _refresh_reserve_interest(program_id, reserve_info, clock)?; + let mut reserve = ReserveBorrow::new_mut(program_id, reserve_info)?; + + _refresh_reserve_interest(&mut reserve, clock)?; _redeem_reserve_collateral( program_id, collateral_amount, source_collateral_info, destination_liquidity_info, - reserve_info, + &mut reserve, reserve_collateral_mint_info, reserve_liquidity_supply_info, lending_market_info, @@ -801,10 +856,8 @@ fn process_redeem_reserve_collateral( token_program_id, true, )?; - let mut reserve = Box::new(Reserve::unpack(&reserve_info.data.borrow())?); - reserve.last_update.mark_stale(); - Reserve::pack(*reserve, &mut reserve_info.data.borrow_mut())?; + reserve.last_update.mark_stale(); Ok(()) } @@ -814,7 +867,7 @@ fn _redeem_reserve_collateral<'a>( collateral_amount: u64, source_collateral_info: &AccountInfo<'a>, destination_liquidity_info: &AccountInfo<'a>, - reserve_info: &AccountInfo<'a>, + reserve: &mut ReserveBorrow, reserve_collateral_mint_info: &AccountInfo<'a>, reserve_liquidity_supply_info: &AccountInfo<'a>, lending_market_info: &AccountInfo<'a>, @@ -834,11 +887,6 @@ fn _redeem_reserve_collateral<'a>( return Err(LendingError::InvalidTokenProgram.into()); } - let mut reserve = Box::new(Reserve::unpack(&reserve_info.data.borrow())?); - if reserve_info.owner != program_id { - msg!("Reserve provided is not owned by the lending program"); - return Err(LendingError::InvalidAccountOwner.into()); - } if &reserve.lending_market != lending_market_info.key { msg!("Reserve lending market does not match the lending market provided"); return Err(LendingError::InvalidAccountInput.into()); @@ -901,7 +949,6 @@ fn _redeem_reserve_collateral<'a>( } reserve.last_update.mark_stale(); - Reserve::pack(*reserve, &mut reserve_info.data.borrow_mut())?; LendingMarket::pack(lending_market, &mut lending_market_info.data.borrow_mut())?; spl_token_burn(TokenBurnParams { @@ -936,7 +983,7 @@ fn process_init_obligation(program_id: &Pubkey, accounts: &[AccountInfo]) -> Pro let token_program_id = next_account_info(account_info_iter)?; assert_rent_exempt(rent, obligation_info)?; - let mut obligation = assert_uninitialized::(obligation_info)?; + let mut obligation = Obligation::unpack_uninitialized(&obligation_info.data.borrow())?; if obligation_info.owner != program_id { msg!("Obligation provided is not owned by the lending program"); return Err(LendingError::InvalidAccountOwner.into()); @@ -991,14 +1038,9 @@ fn process_refresh_obligation(program_id: &Pubkey, accounts: &[AccountInfo]) -> for (index, collateral) in obligation.deposits.iter_mut().enumerate() { let deposit_reserve_info = next_account_info(account_info_iter)?; - if deposit_reserve_info.owner != program_id { - msg!( - "Deposit reserve provided for collateral {} is not owned by the lending program", - index - ); - return Err(LendingError::InvalidAccountOwner.into()); - } - if collateral.deposit_reserve != *deposit_reserve_info.key { + let deposit_reserve = ReserveBorrow::new(program_id, deposit_reserve_info)?; + + if collateral.deposit_reserve != deposit_reserve.key() { msg!( "Deposit reserve of collateral {} does not match the deposit reserve provided", index @@ -1006,7 +1048,6 @@ fn process_refresh_obligation(program_id: &Pubkey, accounts: &[AccountInfo]) -> return Err(LendingError::InvalidAccountInput.into()); } - let deposit_reserve = Box::new(Reserve::unpack(&deposit_reserve_info.data.borrow())?); if deposit_reserve.last_update.is_stale(clock.slot)? { msg!( "Deposit reserve provided for collateral {} is stale and must be refreshed in the current slot", @@ -1043,14 +1084,9 @@ fn process_refresh_obligation(program_id: &Pubkey, accounts: &[AccountInfo]) -> let mut max_borrow_weight = None; for (index, liquidity) in obligation.borrows.iter_mut().enumerate() { let borrow_reserve_info = next_account_info(account_info_iter)?; - if borrow_reserve_info.owner != program_id { - msg!( - "Borrow reserve provided for liquidity {} is not owned by the lending program", - index - ); - return Err(LendingError::InvalidAccountOwner.into()); - } - if liquidity.borrow_reserve != *borrow_reserve_info.key { + let borrow_reserve = ReserveBorrow::new(program_id, borrow_reserve_info)?; + + if liquidity.borrow_reserve != borrow_reserve.key() { msg!( "Borrow reserve of liquidity {} does not match the borrow reserve provided", index @@ -1058,7 +1094,6 @@ fn process_refresh_obligation(program_id: &Pubkey, accounts: &[AccountInfo]) -> return Err(LendingError::InvalidAccountInput.into()); } - let borrow_reserve = Box::new(Reserve::unpack(&borrow_reserve_info.data.borrow())?); if borrow_reserve.last_update.is_stale(clock.slot)? { msg!( "Borrow reserve provided for liquidity {} is stale and must be refreshed in the current slot", @@ -1129,7 +1164,8 @@ fn process_refresh_obligation(program_id: &Pubkey, accounts: &[AccountInfo]) -> obligation.last_update.update_slot(clock.slot); - let (_, close_exceeded) = update_borrow_attribution_values(&mut obligation, &accounts[1..])?; + let (_, close_exceeded) = + update_borrow_attribution_values(program_id, &mut obligation, &accounts[1..])?; if close_exceeded.is_none() { obligation.closeable = false; } @@ -1147,6 +1183,7 @@ fn process_refresh_obligation(program_id: &Pubkey, accounts: &[AccountInfo]) -> .borrows .retain(|liquidity| liquidity.borrowed_amount_wads > Decimal::zero()); + realloc_obligation_if_necessary(&obligation, obligation_info)?; Obligation::pack(obligation, &mut obligation_info.data.borrow_mut())?; Ok(()) @@ -1160,8 +1197,13 @@ fn process_refresh_obligation(program_id: &Pubkey, accounts: &[AccountInfo]) -> /// - the obligation's deposited_value must be refreshed /// - the obligation's true_borrowed_value must be refreshed /// -/// Note that this function packs and unpacks deposit reserves. +/// # Important +/// +/// This function packs and unpacks deposit reserves. +/// This means that any [ReserveBorrow] whose data might be processed in this +/// function needs to be released. fn update_borrow_attribution_values( + program_id: &Pubkey, obligation: &mut Obligation, deposit_reserve_infos: &[AccountInfo], ) -> Result<(Option, Option), ProgramError> { @@ -1172,7 +1214,7 @@ fn update_borrow_attribution_values( for collateral in obligation.deposits.iter_mut() { let deposit_reserve_info = next_account_info(deposit_infos)?; - let mut deposit_reserve = Reserve::unpack(&deposit_reserve_info.data.borrow())?; + let mut deposit_reserve = ReserveBorrow::new_mut(program_id, deposit_reserve_info)?; // sanity check if collateral.deposit_reserve != *deposit_reserve_info.key { @@ -1207,8 +1249,6 @@ fn update_borrow_attribution_values( { close_exceeded = Some(*deposit_reserve_info.key); } - - Reserve::pack(deposit_reserve, &mut deposit_reserve_info.data.borrow_mut())?; } Ok((open_exceeded, close_exceeded)) @@ -1235,13 +1275,16 @@ fn process_deposit_obligation_collateral( let user_transfer_authority_info = next_account_info(account_info_iter)?; let clock = &Clock::get()?; let token_program_id = next_account_info(account_info_iter)?; - _refresh_reserve_interest(program_id, deposit_reserve_info, clock)?; + + let mut deposit_reserve = ReserveBorrow::new_mut(program_id, deposit_reserve_info)?; + + _refresh_reserve_interest(&mut deposit_reserve, clock)?; _deposit_obligation_collateral( program_id, collateral_amount, source_collateral_info, destination_collateral_info, - deposit_reserve_info, + &mut deposit_reserve, obligation_info, lending_market_info, obligation_owner_info, @@ -1249,9 +1292,8 @@ fn process_deposit_obligation_collateral( clock, token_program_id, )?; - let mut reserve = Box::new(Reserve::unpack(&deposit_reserve_info.data.borrow())?); - reserve.last_update.mark_stale(); - Reserve::pack(*reserve, &mut deposit_reserve_info.data.borrow_mut())?; + + deposit_reserve.last_update.mark_stale(); Ok(()) } @@ -1261,7 +1303,7 @@ fn _deposit_obligation_collateral<'a>( collateral_amount: u64, source_collateral_info: &AccountInfo<'a>, destination_collateral_info: &AccountInfo<'a>, - deposit_reserve_info: &AccountInfo<'a>, + deposit_reserve: &mut ReserveBorrow, obligation_info: &AccountInfo<'a>, lending_market_info: &AccountInfo<'a>, obligation_owner_info: &AccountInfo<'a>, @@ -1279,11 +1321,6 @@ fn _deposit_obligation_collateral<'a>( return Err(LendingError::InvalidTokenProgram.into()); } - let deposit_reserve = Box::new(Reserve::unpack(&deposit_reserve_info.data.borrow())?); - if deposit_reserve_info.owner != program_id { - msg!("Deposit reserve provided is not owned by the lending program"); - return Err(LendingError::InvalidAccountOwner.into()); - } if &deposit_reserve.lending_market != lending_market_info.key { msg!("Deposit reserve lending market does not match the lending market provided"); return Err(LendingError::InvalidAccountInput.into()); @@ -1321,11 +1358,24 @@ fn _deposit_obligation_collateral<'a>( return Err(LendingError::InvalidSigner.into()); } - obligation - .find_or_add_collateral_to_deposits(*deposit_reserve_info.key)? - .deposit(collateral_amount)?; + let collateral = obligation.find_or_add_collateral_to_deposits(deposit_reserve.key())?; + collateral.deposit(collateral_amount)?; + + // liq. mining + let new_share = collateral.deposited_amount; + obligation.user_reward_managers.set_share( + deposit_reserve.key(), + PositionKind::Deposit, + &mut deposit_reserve.deposits_pool_reward_manager, + new_share, + clock, + )?; + obligation.last_update.mark_stale(); + + realloc_obligation_if_necessary(&obligation, obligation_info)?; Obligation::pack(obligation, &mut obligation_info.data.borrow_mut())?; + spl_token_transfer(TokenTransferParams { source: source_collateral_info.clone(), destination: destination_collateral_info.clone(), @@ -1365,13 +1415,15 @@ fn process_deposit_reserve_liquidity_and_obligation_collateral( let clock = &Clock::get()?; let token_program_id = next_account_info(account_info_iter)?; - _refresh_reserve_interest(program_id, reserve_info, clock)?; + let mut reserve = ReserveBorrow::new_mut(program_id, reserve_info)?; + + _refresh_reserve_interest(&mut reserve, clock)?; let collateral_amount = _deposit_reserve_liquidity( program_id, liquidity_amount, source_liquidity_info, user_collateral_info, - reserve_info, + &mut reserve, reserve_liquidity_supply_info, reserve_collateral_mint_info, lending_market_info, @@ -1380,13 +1432,13 @@ fn process_deposit_reserve_liquidity_and_obligation_collateral( clock, token_program_id, )?; - _refresh_reserve_interest(program_id, reserve_info, clock)?; + _refresh_reserve_interest(&mut reserve, clock)?; _deposit_obligation_collateral( program_id, collateral_amount, user_collateral_info, destination_collateral_info, - reserve_info, + &mut reserve, obligation_info, lending_market_info, obligation_owner_info, @@ -1394,11 +1446,9 @@ fn process_deposit_reserve_liquidity_and_obligation_collateral( clock, token_program_id, )?; + // mark the reserve as stale to make sure no weird bugs happen - let mut reserve = Box::new(Reserve::unpack(&reserve_info.data.borrow())?); reserve.last_update.mark_stale(); - Reserve::pack(*reserve, &mut reserve_info.data.borrow_mut())?; - Ok(()) } @@ -1423,12 +1473,15 @@ fn process_withdraw_obligation_collateral( let obligation_owner_info = next_account_info(account_info_iter)?; let clock = &Clock::get()?; let token_program_id = next_account_info(account_info_iter)?; + + let mut withdraw_reserve = ReserveBorrow::new_mut(program_id, withdraw_reserve_info)?; + _withdraw_obligation_collateral( program_id, collateral_amount, source_collateral_info, destination_collateral_info, - withdraw_reserve_info, + &mut withdraw_reserve, obligation_info, lending_market_info, lending_market_authority_info, @@ -1438,6 +1491,7 @@ fn process_withdraw_obligation_collateral( false, &accounts[8..], )?; + Ok(()) } @@ -1447,7 +1501,7 @@ fn _withdraw_obligation_collateral<'a>( collateral_amount: u64, source_collateral_info: &AccountInfo<'a>, destination_collateral_info: &AccountInfo<'a>, - withdraw_reserve_info: &AccountInfo<'a>, + withdraw_reserve: &mut ReserveBorrow, obligation_info: &AccountInfo<'a>, lending_market_info: &AccountInfo<'a>, lending_market_authority_info: &AccountInfo<'a>, @@ -1467,13 +1521,7 @@ fn _withdraw_obligation_collateral<'a>( return Err(LendingError::InvalidTokenProgram.into()); } - let withdraw_reserve = Box::new(Reserve::unpack(&withdraw_reserve_info.data.borrow())?); let mut obligation = Obligation::unpack(&obligation_info.data.borrow())?; - - if withdraw_reserve_info.owner != program_id { - msg!("Withdraw reserve provided is not owned by the lending program"); - return Err(LendingError::InvalidAccountOwner.into()); - } if &withdraw_reserve.lending_market != lending_market_info.key { msg!("Withdraw reserve lending market does not match the lending market provided"); return Err(LendingError::InvalidAccountInput.into()); @@ -1513,7 +1561,7 @@ fn _withdraw_obligation_collateral<'a>( } let (collateral, collateral_index) = - obligation.find_collateral_in_deposits(*withdraw_reserve_info.key)?; + obligation.find_collateral_in_deposits(withdraw_reserve.key())?; if collateral.deposited_amount == 0 { msg!("Collateral deposited amount is zero"); return Err(LendingError::ObligationCollateralEmpty.into()); @@ -1568,7 +1616,7 @@ fn _withdraw_obligation_collateral<'a>( u64::MAX }; - let max_withdraw_amount = obligation.max_withdraw_amount(collateral, &withdraw_reserve)?; + let max_withdraw_amount = obligation.max_withdraw_amount(collateral, withdraw_reserve)?; let withdraw_amount = min( collateral_amount, min(max_withdraw_amount, max_outflow_collateral_amount), @@ -1592,8 +1640,10 @@ fn _withdraw_obligation_collateral<'a>( .market_value .saturating_sub(withdraw_value); - let (open_exceeded, _) = - update_borrow_attribution_values(&mut obligation, deposit_reserve_infos)?; + let (open_exceeded, _) = withdraw_reserve.while_released(|| { + update_borrow_attribution_values(program_id, &mut obligation, deposit_reserve_infos) + })?; + if let Some(reserve_pubkey) = open_exceeded { msg!( "Open borrow attribution limit exceeded for reserve {:?}", @@ -1604,9 +1654,20 @@ fn _withdraw_obligation_collateral<'a>( // obligation.withdraw must be called after updating borrow attribution values, since we can // lose information if an entire deposit is removed, making the former calculation incorrect - obligation.withdraw(withdraw_amount, collateral_index)?; + let new_share = obligation.withdraw(withdraw_amount, collateral_index)?; + + // liq. mining + obligation.user_reward_managers.set_share( + withdraw_reserve.key(), + PositionKind::Deposit, + &mut withdraw_reserve.deposits_pool_reward_manager, + new_share, + clock, + )?; + obligation.last_update.mark_stale(); + realloc_obligation_if_necessary(&obligation, obligation_info)?; Obligation::pack(obligation, &mut obligation_info.data.borrow_mut())?; spl_token_transfer(TokenTransferParams { @@ -1654,11 +1715,8 @@ fn process_borrow_obligation_liquidity( return Err(LendingError::InvalidTokenProgram.into()); } - let mut borrow_reserve = Box::new(Reserve::unpack(&borrow_reserve_info.data.borrow())?); - if borrow_reserve_info.owner != program_id { - msg!("Borrow reserve provided is not owned by the lending program"); - return Err(LendingError::InvalidAccountOwner.into()); - } + let mut borrow_reserve = ReserveBorrow::new_mut(program_id, borrow_reserve_info)?; + if &borrow_reserve.lending_market != lending_market_info.key { msg!("Borrow reserve lending market does not match the lending market provided"); return Err(LendingError::InvalidAccountInput.into()); @@ -1844,15 +1902,29 @@ fn process_borrow_obligation_liquidity( .unweighted_borrowed_value .try_add(borrow_reserve.market_value(borrow_amount)?)?; - Reserve::pack(*borrow_reserve, &mut borrow_reserve_info.data.borrow_mut())?; - let obligation_liquidity = obligation .find_or_add_liquidity_to_borrows(*borrow_reserve_info.key, cumulative_borrow_rate_wads)?; obligation_liquidity.borrow(borrow_amount)?; + + // liq. mining + let new_share = obligation_liquidity.liability_shares()?; + obligation.user_reward_managers.set_share( + borrow_reserve.key(), + PositionKind::Borrow, + &mut borrow_reserve.borrows_pool_reward_manager, + new_share, + clock, + )?; + obligation.last_update.mark_stale(); - let (open_exceeded, _) = update_borrow_attribution_values(&mut obligation, &accounts[9..])?; + // because [update_borrow_attribution_values] takes reference to the data + // we need to drop our borrow + borrow_reserve.commit(); + + let (open_exceeded, _) = + update_borrow_attribution_values(program_id, &mut obligation, &accounts[9..])?; if let Some(reserve_pubkey) = open_exceeded { msg!( "Open borrow attribution limit exceeded for reserve {:?}", @@ -1866,6 +1938,7 @@ fn process_borrow_obligation_liquidity( next_account_info(account_info_iter)?; } + realloc_obligation_if_necessary(&obligation, obligation_info)?; Obligation::pack(obligation, &mut obligation_info.data.borrow_mut())?; let mut owner_fee = borrow_fee; @@ -1938,12 +2011,10 @@ fn process_repay_obligation_liquidity( return Err(LendingError::InvalidTokenProgram.into()); } - _refresh_reserve_interest(program_id, repay_reserve_info, clock)?; - let mut repay_reserve = Box::new(Reserve::unpack(&repay_reserve_info.data.borrow())?); - if repay_reserve_info.owner != program_id { - msg!("Repay reserve provided is not owned by the lending program"); - return Err(LendingError::InvalidAccountOwner.into()); - } + let mut repay_reserve = ReserveBorrow::new_mut(program_id, repay_reserve_info)?; + + _refresh_reserve_interest(&mut repay_reserve, clock)?; + if &repay_reserve.lending_market != lending_market_info.key { msg!("Repay reserve lending market does not match the lending market provided"); return Err(LendingError::InvalidAccountInput.into()); @@ -1993,10 +2064,20 @@ fn process_repay_obligation_liquidity( repay_reserve.liquidity.repay(repay_amount, settle_amount)?; repay_reserve.last_update.mark_stale(); - Reserve::pack(*repay_reserve, &mut repay_reserve_info.data.borrow_mut())?; - obligation.repay(settle_amount, liquidity_index)?; + let new_share = obligation.repay(settle_amount, liquidity_index)?; obligation.last_update.mark_stale(); + + // liq. mining + obligation.user_reward_managers.set_share( + repay_reserve.key(), + PositionKind::Borrow, + &mut repay_reserve.borrows_pool_reward_manager, + new_share, + clock, + )?; + + realloc_obligation_if_necessary(&obligation, obligation_info)?; Obligation::pack(obligation, &mut obligation_info.data.borrow_mut())?; spl_token_transfer(TokenTransferParams { @@ -2011,15 +2092,22 @@ fn process_repay_obligation_liquidity( Ok(()) } +/// Because repay and withdraw reserve can match we cannot have both of them +/// mutably borrowed at the same time. +/// +/// This function assumes that both reserves are given in read only state and +/// will mutably borrow them inside the function in a safe fashion. +/// +/// When the function returns the reserves are in a read only state again. #[allow(clippy::too_many_arguments)] fn _liquidate_obligation<'a>( program_id: &Pubkey, liquidity_amount: u64, source_liquidity_info: &AccountInfo<'a>, destination_collateral_info: &AccountInfo<'a>, - repay_reserve_info: &AccountInfo<'a>, + repay_reserve: &mut ReserveBorrow, repay_reserve_liquidity_supply_info: &AccountInfo<'a>, - withdraw_reserve_info: &AccountInfo<'a>, + withdraw_reserve: &mut ReserveBorrow, withdraw_reserve_collateral_supply_info: &AccountInfo<'a>, obligation_info: &AccountInfo<'a>, lending_market_info: &AccountInfo<'a>, @@ -2038,11 +2126,6 @@ fn _liquidate_obligation<'a>( return Err(LendingError::InvalidTokenProgram.into()); } - let mut repay_reserve = Box::new(Reserve::unpack(&repay_reserve_info.data.borrow())?); - if repay_reserve_info.owner != program_id { - msg!("Repay reserve provided is not owned by the lending program"); - return Err(LendingError::InvalidAccountOwner.into()); - } if &repay_reserve.lending_market != lending_market_info.key { msg!("Repay reserve lending market does not match the lending market provided"); return Err(LendingError::InvalidAccountInput.into()); @@ -2066,11 +2149,6 @@ fn _liquidate_obligation<'a>( return Err(LendingError::ReserveStale.into()); } - let mut withdraw_reserve = Box::new(Reserve::unpack(&withdraw_reserve_info.data.borrow())?); - if withdraw_reserve_info.owner != program_id { - msg!("Withdraw reserve provided is not owned by the lending program"); - return Err(LendingError::InvalidAccountOwner.into()); - } if &withdraw_reserve.lending_market != lending_market_info.key { msg!("Withdraw reserve lending market does not match the lending market provided"); return Err(LendingError::InvalidAccountInput.into()); @@ -2126,8 +2204,7 @@ fn _liquidate_obligation<'a>( } } - let (liquidity, liquidity_index) = - obligation.find_liquidity_in_borrows(*repay_reserve_info.key)?; + let (liquidity, liquidity_index) = obligation.find_liquidity_in_borrows(repay_reserve.key())?; if liquidity.market_value == Decimal::zero() { msg!("Obligation borrow value is zero"); return Err(LendingError::ObligationLiquidityEmpty.into()); @@ -2138,7 +2215,7 @@ fn _liquidate_obligation<'a>( } let (collateral, collateral_index) = - obligation.find_collateral_in_deposits(*withdraw_reserve_info.key)?; + obligation.find_collateral_in_deposits(withdraw_reserve.key())?; if collateral.market_value == Decimal::zero() { msg!("Obligation deposit value is zero"); return Err(LendingError::ObligationCollateralEmpty.into()); @@ -2179,28 +2256,69 @@ fn _liquidate_obligation<'a>( return Err(LendingError::LiquidationTooSmall.into()); } - repay_reserve.liquidity.repay(repay_amount, settle_amount)?; - repay_reserve.last_update.mark_stale(); - Reserve::pack(*repay_reserve, &mut repay_reserve_info.data.borrow_mut())?; - - // if there is a full withdraw here (which can happen on a full liquidation), then the borrow - // attribution value needs to be updated on the reserve. note that we can't depend on - // refresh_obligation to update this correctly because the ObligationCollateral object will be - // deleted after this call. - if withdraw_amount == collateral.deposited_amount { - withdraw_reserve.attributed_borrow_value = withdraw_reserve - .attributed_borrow_value - .saturating_sub(collateral.market_value); + let collateral_deposited_amount = collateral.deposited_amount; + let collateral_market_value = collateral.market_value; + + { + // we need to update the repay reserve but in order to do that we need to + // release the withdraw reserve first + withdraw_reserve.release()?; + repay_reserve.acquire_reload_mut()?; + + repay_reserve.liquidity.repay(repay_amount, settle_amount)?; + repay_reserve.last_update.mark_stale(); + let new_share = obligation.repay(settle_amount, liquidity_index)?; + + // liq. mining + obligation.user_reward_managers.set_share( + repay_reserve.key(), + PositionKind::Borrow, + &mut repay_reserve.borrows_pool_reward_manager, + new_share, + clock, + )?; - Reserve::pack( - *withdraw_reserve, - &mut withdraw_reserve_info.data.borrow_mut(), + repay_reserve.release()?; + + // both reserves released now + } + + { + // after releasing repay we write into the withdraw reserve + withdraw_reserve.acquire_reload_mut()?; + if withdraw_amount == collateral_deposited_amount { + // if there is a full withdraw here (which can happen on a full liquidation), then the borrow + // attribution value needs to be updated on the reserve. note that we can't depend on + // refresh_obligation to update this correctly because the ObligationCollateral object will be + // deleted after this call. + + withdraw_reserve.attributed_borrow_value = withdraw_reserve + .attributed_borrow_value + .saturating_sub(collateral_market_value); + } + + let new_share = obligation.withdraw(withdraw_amount, collateral_index)?; + + // liq. mining + obligation.user_reward_managers.set_share( + withdraw_reserve.key(), + PositionKind::Deposit, + &mut withdraw_reserve.deposits_pool_reward_manager, + new_share, + clock, )?; + withdraw_reserve.release()?; + + // both reserves released now } - obligation.repay(settle_amount, liquidity_index)?; - obligation.withdraw(withdraw_amount, collateral_index)?; + // and both reserves are again read only + withdraw_reserve.acquire_reload()?; + repay_reserve.acquire_reload()?; + obligation.last_update.mark_stale(); + + realloc_obligation_if_necessary(&obligation, obligation_info)?; Obligation::pack(obligation, &mut obligation_info.data.borrow_mut())?; spl_token_transfer(TokenTransferParams { @@ -2253,14 +2371,17 @@ fn process_liquidate_obligation_and_redeem_reserve_collateral( let token_program_id = next_account_info(account_info_iter)?; let clock = &Clock::get()?; + let mut repay_reserve = ReserveBorrow::new(program_id, repay_reserve_info)?; + let mut withdraw_reserve = ReserveBorrow::new(program_id, withdraw_reserve_info)?; + let (withdrawn_collateral_amount, bonus) = _liquidate_obligation( program_id, liquidity_amount, source_liquidity_info, destination_collateral_info, - repay_reserve_info, + &mut repay_reserve, repay_reserve_liquidity_supply_info, - withdraw_reserve_info, + &mut withdraw_reserve, withdraw_reserve_collateral_supply_info, obligation_info, lending_market_info, @@ -2269,9 +2390,10 @@ fn process_liquidate_obligation_and_redeem_reserve_collateral( clock, token_program_id, )?; + drop(repay_reserve); - _refresh_reserve_interest(program_id, withdraw_reserve_info, clock)?; - let withdraw_reserve = Box::new(Reserve::unpack(&withdraw_reserve_info.data.borrow())?); + withdraw_reserve.acquire_reload_mut()?; + _refresh_reserve_interest(&mut withdraw_reserve, clock)?; let collateral_exchange_rate = withdraw_reserve.collateral_exchange_rate()?; let max_redeemable_collateral = collateral_exchange_rate .liquidity_to_collateral(withdraw_reserve.liquidity.available_amount)?; @@ -2283,7 +2405,7 @@ fn process_liquidate_obligation_and_redeem_reserve_collateral( withdraw_collateral_amount, destination_collateral_info, destination_liquidity_info, - withdraw_reserve_info, + &mut withdraw_reserve, withdraw_reserve_collateral_mint_info, withdraw_reserve_liquidity_supply_info, lending_market_info, @@ -2293,7 +2415,6 @@ fn process_liquidate_obligation_and_redeem_reserve_collateral( token_program_id, false, )?; - let withdraw_reserve = Box::new(Reserve::unpack(&withdraw_reserve_info.data.borrow())?); if &withdraw_reserve.config.fee_receiver != withdraw_reserve_liquidity_fee_receiver_info.key { msg!("Withdraw reserve liquidity fee receiver does not match the reserve liquidity fee receiver provided"); @@ -2336,12 +2457,14 @@ fn process_withdraw_obligation_collateral_and_redeem_reserve_liquidity( let clock = &Clock::get()?; let token_program_id = next_account_info(account_info_iter)?; + let mut reserve = ReserveBorrow::new_mut(program_id, reserve_info)?; + let liquidity_amount = _withdraw_obligation_collateral( program_id, collateral_amount, reserve_collateral_info, user_collateral_info, - reserve_info, + &mut reserve, obligation_info, lending_market_info, lending_market_authority_info, @@ -2355,13 +2478,13 @@ fn process_withdraw_obligation_collateral_and_redeem_reserve_liquidity( // Needed in the case where the obligation has no borrows => user doesn't refresh anything // if the obligation has borrows, then withdraw_obligation_collateral ensures that the // obligation (and as a result, the reserves) were refreshed - _refresh_reserve_interest(program_id, reserve_info, clock)?; + _refresh_reserve_interest(&mut reserve, clock)?; _redeem_reserve_collateral( program_id, liquidity_amount, user_collateral_info, user_liquidity_info, - reserve_info, + &mut reserve, reserve_collateral_mint_info, reserve_liquidity_supply_info, lending_market_info, @@ -2371,6 +2494,7 @@ fn process_withdraw_obligation_collateral_and_redeem_reserve_liquidity( token_program_id, true, )?; + Ok(()) } @@ -2391,15 +2515,8 @@ fn process_update_reserve_config( let pyth_price_info = next_account_info(account_info_iter)?; let switchboard_feed_info = next_account_info(account_info_iter)?; - let mut reserve = Box::new(Reserve::unpack(&reserve_info.data.borrow())?); - if reserve_info.owner != program_id { - msg!( - "Reserve provided is not owned by the lending program {} != {}", - &reserve_info.owner.to_string(), - &program_id.to_string(), - ); - return Err(LendingError::InvalidAccountOwner.into()); - } + let mut reserve = ReserveBorrow::new_mut(program_id, reserve_info)?; + if &reserve.lending_market != lending_market_info.key { msg!("Reserve lending market does not match the lending market provided"); return Err(LendingError::InvalidAccountInput.into()); @@ -2511,7 +2628,6 @@ fn process_update_reserve_config( } reserve.last_update.mark_stale(); - Reserve::pack(*reserve, &mut reserve_info.data.borrow_mut())?; Ok(()) } @@ -2526,15 +2642,7 @@ fn process_redeem_fees(program_id: &Pubkey, accounts: &[AccountInfo]) -> Program let token_program_id = next_account_info(account_info_iter)?; let clock = &Clock::get()?; - let mut reserve = Box::new(Reserve::unpack(&reserve_info.data.borrow())?); - if reserve_info.owner != program_id { - msg!( - "Reserve provided is not owned by the lending program {} != {}", - &reserve_info.owner.to_string(), - &program_id.to_string(), - ); - return Err(LendingError::InvalidAccountOwner.into()); - } + let mut reserve = ReserveBorrow::new_mut(program_id, reserve_info)?; if &reserve.config.fee_receiver != reserve_liquidity_fee_receiver_info.key { msg!("Reserve liquidity fee receiver does not match the reserve liquidity fee receiver provided"); @@ -2582,7 +2690,6 @@ fn process_redeem_fees(program_id: &Pubkey, accounts: &[AccountInfo]) -> Program reserve.liquidity.redeem_fees(withdraw_amount)?; reserve.last_update.mark_stale(); - Reserve::pack(*reserve, &mut reserve_info.data.borrow_mut())?; spl_token_transfer(TokenTransferParams { source: reserve_supply_liquidity_info.clone(), @@ -2611,18 +2718,21 @@ fn process_flash_borrow_reserve_liquidity( let token_program_id = next_account_info(account_info_iter)?; let clock = Clock::get()?; - _refresh_reserve_interest(program_id, reserve_info, &clock)?; + let mut reserve = ReserveBorrow::new_mut(program_id, reserve_info)?; + + _refresh_reserve_interest(&mut reserve, &clock)?; _flash_borrow_reserve_liquidity( program_id, liquidity_amount, source_liquidity_info, destination_liquidity_info, - reserve_info, + &mut reserve, lending_market_info, lending_market_authority_info, sysvar_info, token_program_id, )?; + Ok(()) } @@ -2632,7 +2742,7 @@ fn _flash_borrow_reserve_liquidity<'a>( liquidity_amount: u64, source_liquidity_info: &AccountInfo<'a>, destination_liquidity_info: &AccountInfo<'a>, - reserve_info: &AccountInfo<'a>, + reserve: &mut ReserveBorrow, lending_market_info: &AccountInfo<'a>, lending_market_authority_info: &AccountInfo<'a>, sysvar_info: &AccountInfo<'a>, @@ -2647,11 +2757,7 @@ fn _flash_borrow_reserve_liquidity<'a>( msg!("Lending market token program does not match the token program provided"); return Err(LendingError::InvalidTokenProgram.into()); } - let mut reserve = Box::new(Reserve::unpack(&reserve_info.data.borrow())?); - if reserve_info.owner != program_id { - msg!("Reserve provided is not owned by the lending program"); - return Err(LendingError::InvalidAccountOwner.into()); - } + if &reserve.lending_market != lending_market_info.key { msg!("Reserve lending market does not match the lending market provided"); return Err(LendingError::InvalidAccountInput.into()); @@ -2730,7 +2836,7 @@ fn _flash_borrow_reserve_liquidity<'a>( msg!("Multiple flash repays not allowed"); return Err(LendingError::MultipleFlashBorrows.into()); } - if ixn.accounts[4].pubkey != *reserve_info.key { + if ixn.accounts[4].pubkey != reserve.key() { msg!("Invalid reserve account on flash repay"); return Err(LendingError::InvalidFlashRepay.into()); } @@ -2760,7 +2866,6 @@ fn _flash_borrow_reserve_liquidity<'a>( reserve.liquidity.borrow(Decimal::from(liquidity_amount))?; reserve.last_update.mark_stale(); - Reserve::pack(*reserve, &mut reserve_info.data.borrow_mut())?; spl_token_transfer(TokenTransferParams { source: source_liquidity_info.clone(), @@ -2791,6 +2896,8 @@ fn process_flash_repay_reserve_liquidity( let sysvar_info = next_account_info(account_info_iter)?; let token_program_id = next_account_info(account_info_iter)?; + let mut reserve = ReserveBorrow::new_mut(program_id, reserve_info)?; + _flash_repay_reserve_liquidity( program_id, liquidity_amount, @@ -2799,12 +2906,13 @@ fn process_flash_repay_reserve_liquidity( destination_liquidity_info, reserve_liquidity_fee_receiver_info, host_fee_receiver_info, - reserve_info, + &mut reserve, lending_market_info, user_transfer_authority_info, sysvar_info, token_program_id, )?; + Ok(()) } @@ -2817,7 +2925,7 @@ fn _flash_repay_reserve_liquidity<'a>( destination_liquidity_info: &AccountInfo<'a>, reserve_liquidity_fee_receiver_info: &AccountInfo<'a>, host_fee_receiver_info: &AccountInfo<'a>, - reserve_info: &AccountInfo<'a>, + reserve: &mut ReserveBorrow, lending_market_info: &AccountInfo<'a>, user_transfer_authority_info: &AccountInfo<'a>, sysvar_info: &AccountInfo<'a>, @@ -2832,11 +2940,7 @@ fn _flash_repay_reserve_liquidity<'a>( msg!("Lending market token program does not match the token program provided"); return Err(LendingError::InvalidTokenProgram.into()); } - let mut reserve = Box::new(Reserve::unpack(&reserve_info.data.borrow())?); - if reserve_info.owner != program_id { - msg!("Reserve provided is not owned by the lending program"); - return Err(LendingError::InvalidAccountOwner.into()); - } + if &reserve.lending_market != lending_market_info.key { msg!("Reserve lending market does not match the lending market provided"); return Err(LendingError::InvalidAccountInput.into()); @@ -2895,7 +2999,7 @@ fn _flash_repay_reserve_liquidity<'a>( liquidity_amount: borrow_liquidity_amount, } => { // re-check everything here out of paranoia - if ixn.accounts[2].pubkey != *reserve_info.key { + if ixn.accounts[2].pubkey != reserve.key() { msg!("Invalid reserve account on flash repay"); return Err(LendingError::InvalidFlashRepay.into()); } @@ -2915,7 +3019,6 @@ fn _flash_repay_reserve_liquidity<'a>( .liquidity .repay(flash_loan_amount, flash_loan_amount_decimal)?; reserve.last_update.mark_stale(); - Reserve::pack(*reserve, &mut reserve_info.data.borrow_mut())?; spl_token_transfer(TokenTransferParams { source: source_liquidity_info.clone(), @@ -2980,11 +3083,8 @@ fn process_forgive_debt( return Err(LendingError::InvalidSigner.into()); } - let mut reserve = Box::new(Reserve::unpack(&reserve_info.data.borrow())?); - if reserve_info.owner != program_id { - msg!("Reserve provided is not owned by the lending program"); - return Err(LendingError::InvalidAccountOwner.into()); - } + let mut reserve = ReserveBorrow::new_mut(program_id, reserve_info)?; + if &reserve.lending_market != lending_market_info.key { msg!("Reserve lending market does not match the lending market provided"); return Err(LendingError::InvalidAccountInput.into()); @@ -3033,10 +3133,20 @@ fn process_forgive_debt( reserve.liquidity.forgive_debt(forgive_amount)?; reserve.last_update.mark_stale(); - Reserve::pack(*reserve, &mut reserve_info.data.borrow_mut())?; - obligation.repay(forgive_amount, liquidity_index)?; + let new_share = obligation.repay(forgive_amount, liquidity_index)?; obligation.last_update.mark_stale(); + + // liq. mining + obligation.user_reward_managers.set_share( + reserve.key(), + PositionKind::Borrow, + &mut reserve.borrows_pool_reward_manager, + new_share, + &Clock::get()?, + )?; + + realloc_obligation_if_necessary(&obligation, obligation_info)?; Obligation::pack(obligation, &mut obligation_info.data.borrow_mut())?; Ok(()) @@ -3140,11 +3250,7 @@ pub fn process_set_obligation_closeability_status( return Err(LendingError::InvalidAccountOwner.into()); } - let reserve = Reserve::unpack(&reserve_info.data.borrow())?; - if reserve_info.owner != program_id { - msg!("Reserve provided is not owned by the lending program"); - return Err(LendingError::InvalidAccountOwner.into()); - } + let reserve = ReserveBorrow::new(program_id, reserve_info)?; if &reserve.lending_market != lending_market_info.key { msg!("Reserve lending market does not match the lending market provided"); return Err(LendingError::InvalidAccountInput.into()); @@ -3196,6 +3302,7 @@ pub fn process_set_obligation_closeability_status( obligation.closeable = closeable; + realloc_obligation_if_necessary(&obligation, obligation_info)?; Obligation::pack(obligation, &mut obligation_info.data.borrow_mut())?; Ok(()) @@ -3226,12 +3333,8 @@ pub fn process_donate_to_reserve( return Err(LendingError::InvalidTokenProgram.into()); } - if reserve_info.owner != program_id { - msg!("Lending market provided is not owned by the lending program"); - return Err(LendingError::InvalidAccountOwner.into()); - } + let mut reserve = ReserveBorrow::new_mut(program_id, reserve_info)?; - let mut reserve = Box::new(Reserve::unpack(&reserve_info.data.borrow())?); if &reserve.lending_market != lending_market_info.key { msg!("Reserve lending market does not match the lending market provided"); return Err(LendingError::InvalidAccountInput.into()); @@ -3253,7 +3356,7 @@ pub fn process_donate_to_reserve( return Err(LendingError::InvalidAccountInput.into()); } - _refresh_reserve_interest(program_id, reserve_info, clock)?; + _refresh_reserve_interest(&mut reserve, clock)?; reserve.liquidity.donate(liquidity_amount)?; spl_token_transfer(TokenTransferParams { @@ -3266,8 +3369,6 @@ pub fn process_donate_to_reserve( })?; reserve.last_update.mark_stale(); - Reserve::pack(*reserve, &mut reserve_info.data.borrow_mut())?; - Ok(()) } @@ -3444,6 +3545,31 @@ fn spl_token_burn(params: TokenBurnParams<'_, '_>) -> ProgramResult { result.map_err(|_| LendingError::TokenBurnFailed.into()) } +/// Issue a spl_token `CloseAccount` instruction. +#[inline(always)] +fn spl_token_close_account(params: TokenCloseAccountParams<'_, '_>) -> ProgramResult { + let TokenCloseAccountParams { + account, + destination, + authority, + token_program, + authority_signer_seeds, + } = params; + let result = invoke_optionally_signed( + &spl_token::instruction::close_account( + token_program.key, + account.key, + destination.key, + authority.key, + &[], + )?, + &[account, destination, authority, token_program], + authority_signer_seeds, + ); + + result.map_err(|_| LendingError::CloseTokenAccountFailed.into()) +} + fn is_cpi_call( program_id: &Pubkey, current_index: usize, @@ -3473,6 +3599,44 @@ fn is_cpi_call( Ok(false) } +/// Calls realloc on the obligation if the packed size is larger than the +/// underlying buffer. +/// +/// # Important +/// +/// The off-chain client is responsible for making sure the obligation has +/// enough rent to be still rent-exempt. +fn realloc_obligation_if_necessary( + obligation: &Obligation, + obligation_info: &AccountInfo<'_>, +) -> ProgramResult { + let expected_size = obligation.get_packed_len(); + + if expected_size <= obligation_info.data_len() { + return Ok(()); + } + + let current_rent = obligation_info.lamports(); + let new_rent = Rent::get()?.minimum_balance(expected_size); + + if let Some(extra_rent) = new_rent.checked_sub(current_rent) { + msg!("Obligation is missing {} lamports in rent", extra_rent); + return Err(ProgramError::AccountNotRentExempt); + } + + // From the [AccountInfo::realloc] docs: + // + // > Memory used to grow is already zero-initialized upon program entrypoint + // > and re-zeroing it wastes compute units. If within the same call a program + // > reallocs from larger to smaller and back to larger again the new space + // > could contain stale data. Pass true for zero_init in this case, + // > otherwise compute units will be wasted re-zero-initializing. + let zero_init = false; + obligation_info.realloc(expected_size, zero_init)?; + + Ok(()) +} + struct TokenInitializeMintParams<'a: 'b, 'b> { mint: AccountInfo<'a>, rent: AccountInfo<'a>, @@ -3515,3 +3679,11 @@ struct TokenBurnParams<'a: 'b, 'b> { authority_signer_seeds: &'b [&'b [u8]], token_program: AccountInfo<'a>, } + +struct TokenCloseAccountParams<'a: 'b, 'b> { + account: AccountInfo<'a>, + destination: AccountInfo<'a>, + authority: AccountInfo<'a>, + authority_signer_seeds: &'b [&'b [u8]], + token_program: AccountInfo<'a>, +} diff --git a/token-lending/program/src/processor/account_borrow.rs b/token-lending/program/src/processor/account_borrow.rs new file mode 100644 index 00000000000..2d812af5250 --- /dev/null +++ b/token-lending/program/src/processor/account_borrow.rs @@ -0,0 +1,233 @@ +//! # Why do we wrap account data? +//! +//! Previous version of borrow-lending implementation unpacked and then packed +//! the account data several times in a single ix to avoid errors of overwriting +//! data written by other functions. +//! However, this was still fragile as all function calls between unpack and +//! pack would have to be checked to ensure they do not write to the same data. +//! +//! Instead we now have a convention that data access is created in the +//! `process_*` functions and are passed as a reference to other functions. +//! +//! This structure guarantees at runtime that the double write error does not +//! occur while avoiding the cost of unpacking and packing the data. + +use crate::{error::LendingError, state::Reserve}; +use solana_program::{ + account_info::AccountInfo, entrypoint::ProgramResult, msg, program_error::ProgramError, + program_pack::Pack, pubkey::Pubkey, +}; + +use std::fmt::Debug; +use std::ops::{Deref, DerefMut}; +use std::result::Result; + +/// Wraps around a [Reserve] data and provides runtime borrow semantics. +/// +/// Is either in state of +/// - `release`. The underlying data is not borrowed at all and can be read and +/// written to by other holders of the account info. +/// - `Ref`. The underlying data is borrowed as immutable and can be read by +/// other holders of the account info but not written to. +/// - `RefMut`. The underlying data is borrowed as mutable and can be read and +/// written only via this borrow. +/// +/// # Persistence +/// +/// The data is written to the underlying account buffer when the borrow is +/// done mutably with either [Self::new_mut] or [Self::acquire_reload_mut]. +/// The write happens on [Self::release] or in any function that calls it and on +/// [drop]. +pub(crate) struct ReserveBorrow<'a, 'info> { + info: &'a AccountInfo<'info>, + guard: ReserveDataGuard<'a, 'info>, +} + +enum ReserveDataGuard<'a, 'info> { + Released, + Ref( + #[allow(dead_code)] std::cell::Ref<'a, &'info mut [u8]>, + Box, + ), + RefMut(std::cell::RefMut<'a, &'info mut [u8]>, Box), +} + +enum ReserveDataGuardKind { + Release, + Ref, + RefMut, +} + +impl Drop for ReserveBorrow<'_, '_> { + fn drop(&mut self) { + if let Err(e) = self.release() { + msg!("Failed to release reserve data"); + panic!("{}", e); + } + } +} + +impl Deref for ReserveBorrow<'_, '_> { + type Target = Box; + + fn deref(&self) -> &Self::Target { + match &self.guard { + ReserveDataGuard::Ref(_, inner) => inner, + ReserveDataGuard::RefMut(_, inner) => inner, + ReserveDataGuard::Released => panic!("Reserve data has been released"), + } + } +} + +impl DerefMut for ReserveBorrow<'_, '_> { + fn deref_mut(&mut self) -> &mut Self::Target { + match &mut self.guard { + ReserveDataGuard::RefMut(_, inner) => inner, + ReserveDataGuard::Ref(_, _) => panic!("Reserve data is not mutable"), + ReserveDataGuard::Released => panic!("Reserve data has been released"), + } + } +} + +impl<'a, 'info> ReserveBorrow<'a, 'info> { + /// Creates a new `Ref` guard over the data. + /// + /// Many readers can exist at the same time if no writer is present, + /// otherwise panics. + pub(crate) fn new( + program_id: &Pubkey, + info: &'a AccountInfo<'info>, + ) -> Result { + if info.owner != program_id { + msg!("Reserve provided is not owned by the lending program"); + return Err(LendingError::InvalidAccountOwner.into()); + } + + let data = info.data.borrow(); + let reserve = Box::new(Reserve::unpack(&data)?); + let guard = ReserveDataGuard::Ref(data, reserve); + + Ok(Self { guard, info }) + } + + /// Creates a new `RefMut` guard over the data. + /// + /// Only one writer can exist at a time and no readers, otherwise panics. + pub(crate) fn new_mut( + program_id: &Pubkey, + info: &'a AccountInfo<'info>, + ) -> Result { + if info.owner != program_id { + msg!("Reserve provided is not owned by the lending program"); + return Err(LendingError::InvalidAccountOwner.into()); + } + + let data = info.data.borrow_mut(); + let reserve = Box::new(Reserve::unpack(&data)?); + let guard = ReserveDataGuard::RefMut(data, reserve); + + Ok(Self { guard, info }) + } + + pub(crate) fn key(&self) -> Pubkey { + *self.info.key + } + + /// Explicit version of [drop]ping that panics if the data is not guarded + /// as `RefMut`. + pub(crate) fn commit(self) { + if let ReserveDataGuard::RefMut(_, _) = self.guard { + // drop self + } else { + panic!("Cannot commit a non mutable borrow"); + } + } + + /// Releases the guard over the data. + /// + /// If the data was guarded as `RefMut`, it will be packed back to the + /// account. + pub(crate) fn release(&mut self) -> ProgramResult { + let prev_guard = std::mem::replace(&mut self.guard, ReserveDataGuard::Released); + + if let ReserveDataGuard::RefMut(mut data, inner) = prev_guard { + Reserve::pack(*inner, &mut data)?; + } + + Ok(()) + } + + /// Calls [Self::release] and then creates a new `RefMut` guard. + pub(crate) fn acquire_reload_mut(&mut self) -> ProgramResult { + self.release()?; + + let data_ref = self.info.data.borrow_mut(); + let inner = Reserve::unpack(&data_ref)?; + self.guard = ReserveDataGuard::RefMut(data_ref, Box::new(inner)); + + Ok(()) + } + + /// Calls [Self::release] and then creates a new `RefMut` guard. + pub(crate) fn acquire_reload(&mut self) -> ProgramResult { + self.release()?; + + let data_ref = self.info.data.borrow(); + let inner = Reserve::unpack(&data_ref)?; + self.guard = ReserveDataGuard::Ref(data_ref, Box::new(inner)); + + Ok(()) + } + + /// Releases the guard, calls the given function and returns the guard to + /// the same state it was before the call. + pub(crate) fn while_released( + &mut self, + f: impl FnOnce() -> Result, + ) -> Result { + let prev_guard = ReserveDataGuardKind::from(&self.guard); + self.release()?; + + let res = f(); + + match prev_guard { + ReserveDataGuardKind::Ref => { + self.acquire_reload()?; + } + ReserveDataGuardKind::RefMut => { + self.acquire_reload_mut()?; + } + ReserveDataGuardKind::Release => { + // already released + } + } + + res + } +} + +impl From<&'_ ReserveDataGuard<'_, '_>> for ReserveDataGuardKind { + fn from(guard: &'_ ReserveDataGuard) -> Self { + match guard { + ReserveDataGuard::Released => ReserveDataGuardKind::Release, + ReserveDataGuard::Ref(_, _) => ReserveDataGuardKind::Ref, + ReserveDataGuard::RefMut(_, _) => ReserveDataGuardKind::RefMut, + } + } +} + +impl Debug for ReserveBorrow<'_, '_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut dbg = f.debug_struct("ReserveBorrow"); + match &self.guard { + ReserveDataGuard::Released => dbg.field("variant", &"Released"), + ReserveDataGuard::Ref(_, reserve) => { + dbg.field("variant", &"Ref").field("reserve", &reserve) + } + ReserveDataGuard::RefMut(_, reserve) => { + dbg.field("variant", &"RefMut").field("reserve", &reserve) + } + } + .finish() + } +} diff --git a/token-lending/program/src/processor/liquidity_mining.rs b/token-lending/program/src/processor/liquidity_mining.rs new file mode 100644 index 00000000000..69f2d4ce829 --- /dev/null +++ b/token-lending/program/src/processor/liquidity_mining.rs @@ -0,0 +1,591 @@ +//! Liquidity mining is a feature where depositors and borrowers are rewarded +//! for using the protocol. +//! The rewards are in the form of tokens that a lending market owner can attach +//! to each reserve. +//! +//! The feature is built with reference to the [Suilend][suilend-lm] +//! implementation of the same feature. +//! +//! There are three admin-only ixs: +//! - [add_pool_reward] +//! - [edit_pool_reward] +//! - [close_pool_reward] +//! +//! There is an ix related to migration: +//! - [upgrade_reserve] (has anchor integration test) +//! +//! There is one user ix: +//! - [claim_user_reward] +//! +//! [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_pool_reward; +pub(crate) mod close_pool_reward; +pub(crate) mod edit_pool_reward; +pub(crate) mod upgrade_reserve; + +use solana_program::program_pack::Pack; +use solana_program::{account_info::AccountInfo, msg, program_error::ProgramError, pubkey::Pubkey}; +use solend_sdk::instruction::create_reward_vault_authority; +use solend_sdk::{error::LendingError, state::LendingMarket}; +use spl_token::state::Account as TokenAccount; + +use super::ReserveBorrow; +struct Bumps { + reward_authority: u8, +} + +/// Unpacks a spl_token [TokenAccount]. +fn unpack_token_account(data: &[u8]) -> Result { + TokenAccount::unpack(data).map_err(|_| LendingError::InvalidTokenAccount) +} + +/// Named args for [check_and_unpack_pool_reward_accounts] +struct CheckAndUnpackPoolRewardAccounts<'a, 'info> { + reserve_info: &'a AccountInfo<'info>, + reward_mint_info: &'a AccountInfo<'info>, + reward_authority_info: &'a AccountInfo<'info>, + lending_market_info: &'a AccountInfo<'info>, + token_program_info: &'a AccountInfo<'info>, + reward_token_vault_info: &'a AccountInfo<'info>, +} + +/// Does all the checks of [check_and_unpack_pool_reward_accounts] and additionally: +/// +/// * ✅ `lending_market_owner_info` is a signer +/// * ✅ `lending_market_owner_info` matches `lending_market_info` +fn check_and_unpack_pool_reward_accounts_for_admin_ixs<'a, 'info>( + program_id: &Pubkey, + bumps: Bumps, + accs: CheckAndUnpackPoolRewardAccounts<'a, 'info>, + lending_market_owner_info: &AccountInfo<'info>, +) -> Result<(LendingMarket, ReserveBorrow<'a, 'info>), ProgramError> { + let (lending_market, reserve) = check_and_unpack_pool_reward_accounts(program_id, bumps, accs)?; + + if lending_market.owner != *lending_market_owner_info.key { + msg!("Lending market owner does not match the lending market owner provided"); + return Err(LendingError::InvalidMarketOwner.into()); + } + if !lending_market_owner_info.is_signer { + msg!("Lending market owner provided must be a signer"); + return Err(LendingError::InvalidSigner.into()); + } + + Ok((lending_market, reserve)) +} + +/// Checks that: +/// +/// * ✅ `reserve_info` belongs to this program +/// * ✅ `reserve_info` unpacks +/// * ✅ `reserve_info` belongs to `lending_market_info` +/// * ✅ `lending_market_info` belongs to this program +/// * ✅ `lending_market_info` unpacks +/// * ✅ `token_program_info` matches `lending_market_info` +/// * ✅ `reward_mint_info` belongs to the token program +/// * ✅ `reward_authority_info` is seed of `lending_market_info`, `reward_token_vault_info` +fn check_and_unpack_pool_reward_accounts<'a, 'info>( + program_id: &Pubkey, + bumps: Bumps, + CheckAndUnpackPoolRewardAccounts { + reserve_info, + reward_mint_info, + reward_authority_info, + lending_market_info, + token_program_info, + reward_token_vault_info, + }: CheckAndUnpackPoolRewardAccounts<'a, 'info>, +) -> Result<(LendingMarket, ReserveBorrow<'a, 'info>), ProgramError> { + let reserve = ReserveBorrow::new_mut(program_id, reserve_info)?; + + if lending_market_info.owner != program_id { + msg!("Lending market provided is not owned by the lending program"); + return Err(LendingError::InvalidAccountOwner.into()); + } + let lending_market = LendingMarket::unpack(&lending_market_info.data.borrow())?; + + if reserve.lending_market != *lending_market_info.key { + msg!("Reserve lending market does not match the lending market provided"); + return Err(LendingError::InvalidAccountInput.into()); + } + + if lending_market.token_program_id != *token_program_info.key { + msg!("Lending market token program does not match the token program provided"); + return Err(LendingError::InvalidTokenProgram.into()); + } + + if reward_mint_info.owner != token_program_info.key { + msg!("Reward mint provided must be owned by the token program"); + return Err(LendingError::InvalidTokenOwner.into()); + } + + let expected_reward_vault_authority = create_reward_vault_authority( + program_id, + lending_market_info.key, + reward_token_vault_info.key, + bumps.reward_authority, + )?; + if expected_reward_vault_authority != *reward_authority_info.key { + msg!("Reward vault authority does not match the expected value"); + return Err(LendingError::InvalidAccountInput.into()); + } + + Ok((lending_market, reserve)) +} + +#[cfg(test)] +mod tests { + //! For each ✅ in [check_and_unpack_pool_reward_accounts] and + //! [check_and_unpack_pool_reward_accounts_for_admin_ixs] there is a test + //! that expects a failure if that conditions is not met. + + use solana_program::system_program; + use solend_sdk::{ + instruction::find_reward_vault_authority, + state::{discriminator::AccountDiscriminator, Reserve}, + }; + use spl_token::state::Mint; + + use super::*; + + #[test] + fn test_check_and_unpack_pool_reward_accounts_ok() { + let (account_info_builders, bumps) = CheckAndUnpackPoolRewardAccountInfoBuilders::new(); + + account_info_builders + .check_and_unpack_pool_reward_accounts(crate::id(), bumps) + .expect("Should succeed"); + } + + /// ❌ `reserve_info` belongs to this program + #[test] + fn test_fails_if_reserve_info_does_not_belong_to_program() { + let (mut account_info_builders, bumps) = CheckAndUnpackPoolRewardAccountInfoBuilders::new(); + account_info_builders.reserve.owner = Pubkey::new_unique(); + + account_info_builders + .check_and_unpack_pool_reward_accounts(crate::id(), bumps) + .expect_err("Should fail"); + } + + /// ❌ `reserve_info` unpacks + #[test] + fn test_fails_if_reserve_info_does_not_unpack() { + let (mut account_info_builders, bumps) = CheckAndUnpackPoolRewardAccountInfoBuilders::new(); + account_info_builders.reserve.data = vec![0; Reserve::get_packed_len() - 1]; + + account_info_builders + .check_and_unpack_pool_reward_accounts(crate::id(), bumps) + .expect_err("Should fail"); + } + + /// ❌ `reserve_info` belongs to `lending_market_info` + #[test] + fn test_fails_if_reserve_info_does_not_belong_to_lending_market() { + let (mut account_info_builders, bumps) = CheckAndUnpackPoolRewardAccountInfoBuilders::new(); + + account_info_builders.reserve = AccountInfoBuilder::from(Reserve { + discriminator: AccountDiscriminator::Reserve, + lending_market: Pubkey::new_unique(), + ..Default::default() + }); + + account_info_builders + .check_and_unpack_pool_reward_accounts(crate::id(), bumps) + .expect_err("Should fail"); + } + + /// ❌ `lending_market_info` belongs to this program + #[test] + fn test_fails_if_lending_market_info_does_not_belong_to_program() { + let (mut account_info_builders, bumps) = CheckAndUnpackPoolRewardAccountInfoBuilders::new(); + account_info_builders.lending_market.owner = Pubkey::new_unique(); + + account_info_builders + .check_and_unpack_pool_reward_accounts(crate::id(), bumps) + .expect_err("Should fail"); + } + + /// ❌ `lending_market_info` unpacks + #[test] + fn test_fails_if_lending_market_info_does_not_unpack() { + let (mut account_info_builders, bumps) = CheckAndUnpackPoolRewardAccountInfoBuilders::new(); + account_info_builders.lending_market.data = vec![0; LendingMarket::get_packed_len() - 1]; + + account_info_builders + .check_and_unpack_pool_reward_accounts(crate::id(), bumps) + .expect_err("Should fail"); + } + + /// ❌ `token_program_info` matches `lending_market_info` + #[test] + fn test_fails_if_token_program_info_does_not_match_lending_market() { + let (mut account_info_builders, bumps) = CheckAndUnpackPoolRewardAccountInfoBuilders::new(); + + account_info_builders.lending_market = AccountInfoBuilder::from(LendingMarket { + discriminator: AccountDiscriminator::LendingMarket, + token_program_id: Pubkey::new_unique(), + ..Default::default() + }); + + account_info_builders + .check_and_unpack_pool_reward_accounts(crate::id(), bumps) + .expect_err("Should fail"); + } + + /// ❌ `reward_mint_info` belongs to the token program + #[test] + fn test_fails_if_reward_mint_info_does_not_belong_to_token_program() { + let (mut account_info_builders, bumps) = CheckAndUnpackPoolRewardAccountInfoBuilders::new(); + account_info_builders.mint.owner = Pubkey::new_unique(); + + account_info_builders + .check_and_unpack_pool_reward_accounts(crate::id(), bumps) + .expect_err("Should fail"); + } + + /// ❌ `reward_authority_info` is seed of `lending_market_info`, `reward_token_vault_info` + #[test] + fn test_fails_if_reward_authority_info_is_not_seed() { + let (mut account_info_builders, og_bumps) = + CheckAndUnpackPoolRewardAccountInfoBuilders::new(); + let og_reward_authority = account_info_builders.reward_authority.key; + + // wrong lending market + + let (new_reward_authority, new_reward_authority_bump) = find_reward_vault_authority( + &crate::id(), + &Pubkey::new_unique(), + &account_info_builders.reward_token_vault.key, + ); + account_info_builders.reward_authority.key = new_reward_authority; + account_info_builders + .clone() + .check_and_unpack_pool_reward_accounts( + crate::id(), + Bumps { + reward_authority: new_reward_authority_bump, + }, + ) + .expect_err("Should fail"); + + // wrong vault + + let (new_reward_authority, new_reward_authority_bump) = find_reward_vault_authority( + &crate::id(), + &account_info_builders.lending_market.key, + &Pubkey::new_unique(), + ); + account_info_builders.reward_authority.key = new_reward_authority; + account_info_builders + .clone() + .check_and_unpack_pool_reward_accounts( + crate::id(), + Bumps { + reward_authority: new_reward_authority_bump, + }, + ) + .expect_err("Should fail"); + + // wrong bump + + account_info_builders.reward_authority.key = og_reward_authority; + account_info_builders + .clone() + .check_and_unpack_pool_reward_accounts( + crate::id(), + Bumps { + reward_authority: og_bumps.reward_authority.wrapping_add(1), + }, + ) + .expect_err("Should fail"); + } + + #[test] + fn test_check_and_unpack_pool_reward_accounts_for_admin_ixs_ok() { + let (account_info_builders, bumps) = CheckAndUnpackPoolRewardAccountInfoBuilders::new(); + + account_info_builders + .check_and_unpack_pool_reward_accounts_for_admin_ixs(crate::id(), bumps) + .expect("Should succeed"); + } + + /// ❌ `lending_market_owner_info` is a signer + #[test] + fn test_fails_if_lending_market_owner_info_is_not_signer() { + let (mut account_info_builders, bumps) = CheckAndUnpackPoolRewardAccountInfoBuilders::new(); + account_info_builders.lending_market_owner.is_signer = false; + + account_info_builders + .check_and_unpack_pool_reward_accounts_for_admin_ixs(crate::id(), bumps) + .expect_err("Should fail"); + } + + /// ❌ `lending_market_owner_info` matches `lending_market_info` + #[test] + fn test_fails_if_lending_market_owner_info_does_not_match_lending_market() { + let (mut account_info_builders, bumps) = CheckAndUnpackPoolRewardAccountInfoBuilders::new(); + account_info_builders.lending_market_owner.key = Pubkey::new_unique(); + + account_info_builders + .check_and_unpack_pool_reward_accounts_for_admin_ixs(crate::id(), bumps) + .expect_err("Should fail"); + } + + #[derive(Clone)] + struct CheckAndUnpackPoolRewardAccountInfoBuilders { + lending_market: AccountInfoBuilder, + lending_market_owner: AccountInfoBuilder, + mint: AccountInfoBuilder, + reserve: AccountInfoBuilder, + reward_authority: AccountInfoBuilder, + token_program: AccountInfoBuilder, + reward_token_vault: AccountInfoBuilder, + } + + #[derive(Clone)] + struct AccountInfoBuilder { + key: Pubkey, + lamports: u64, + data: Vec, + owner: Pubkey, + rent_epoch: u64, + is_signer: bool, + is_writable: bool, + is_executable: bool, + } + + impl CheckAndUnpackPoolRewardAccountInfoBuilders { + fn new() -> (Self, Bumps) { + let token_program = AccountInfoBuilder::new_token_program(); + let lending_market_owner = AccountInfoBuilder::new_lending_market_owner(); + let lending_market: AccountInfoBuilder = AccountInfoBuilder::from(LendingMarket { + discriminator: AccountDiscriminator::LendingMarket, + token_program_id: token_program.key, + owner: lending_market_owner.key, + ..Default::default() + }); + let mint = AccountInfoBuilder::from(Mint { + is_initialized: true, + ..Default::default() + }); + let reserve = AccountInfoBuilder::from(Reserve { + discriminator: AccountDiscriminator::Reserve, + lending_market: lending_market.key, + ..Default::default() + }); + let reward_token_vault = AccountInfoBuilder::new_reward_token_vault(); + let (reward_authority, bumps) = AccountInfoBuilder::new_reward_authority( + &lending_market.key, + &reward_token_vault.key, + ); + + ( + Self { + lending_market_owner, + lending_market, + mint, + reserve, + reward_authority, + token_program, + reward_token_vault, + }, + bumps, + ) + } + + fn check_and_unpack_pool_reward_accounts( + mut self, + program_id: Pubkey, + bumps: Bumps, + ) -> Result<(), ProgramError> { + let lending_market_info = self.lending_market.as_account_info(); + let mint_info = self.mint.as_account_info(); + let reserve_info = self.reserve.as_account_info(); + let reward_authority_info = self.reward_authority.as_account_info(); + let token_program_info = self.token_program.as_account_info(); + let reward_token_vault_info = self.reward_token_vault.as_account_info(); + + check_and_unpack_pool_reward_accounts( + &program_id, + bumps, + CheckAndUnpackPoolRewardAccounts { + lending_market_info: &lending_market_info, + reserve_info: &reserve_info, + reward_authority_info: &reward_authority_info, + reward_mint_info: &mint_info, + token_program_info: &token_program_info, + reward_token_vault_info: &reward_token_vault_info, + }, + ) + .map(drop) + } + + fn check_and_unpack_pool_reward_accounts_for_admin_ixs( + mut self, + program_id: Pubkey, + bumps: Bumps, + ) -> Result<(), ProgramError> { + let lending_market_info = self.lending_market.as_account_info(); + let mint_info = self.mint.as_account_info(); + let reserve_info = self.reserve.as_account_info(); + let reward_authority_info = self.reward_authority.as_account_info(); + let token_program_info = self.token_program.as_account_info(); + let lending_market_owner_info = self.lending_market_owner.as_account_info(); + let reward_token_vault_info = self.reward_token_vault.as_account_info(); + + check_and_unpack_pool_reward_accounts_for_admin_ixs( + &program_id, + bumps, + CheckAndUnpackPoolRewardAccounts { + lending_market_info: &lending_market_info, + reserve_info: &reserve_info, + reward_authority_info: &reward_authority_info, + reward_mint_info: &mint_info, + token_program_info: &token_program_info, + reward_token_vault_info: &reward_token_vault_info, + }, + &lending_market_owner_info, + ) + .map(drop) + } + } + + impl From for AccountInfoBuilder { + fn from(lending_market: LendingMarket) -> Self { + let mut data = vec![0; LendingMarket::get_packed_len()]; + LendingMarket::pack(lending_market, &mut data).unwrap(); + + Self { + key: Pubkey::new_unique(), + lamports: 1, + data, + owner: crate::id(), + rent_epoch: 0, + is_signer: false, + is_writable: false, + is_executable: false, + } + } + } + + impl From for AccountInfoBuilder { + fn from(mint: Mint) -> Self { + let mut data = vec![0; Mint::get_packed_len()]; + Mint::pack(mint, &mut data).unwrap(); + + Self { + key: Pubkey::new_unique(), + lamports: 1, + data, + owner: spl_token::id(), + rent_epoch: 0, + is_signer: false, + is_writable: false, + is_executable: false, + } + } + } + + impl From for AccountInfoBuilder { + fn from(reserve: Reserve) -> Self { + let mut data = vec![0; Reserve::get_packed_len()]; + Reserve::pack(reserve, &mut data).unwrap(); + + Self { + key: Pubkey::new_unique(), + lamports: 1, + data, + owner: crate::id(), + rent_epoch: 0, + is_signer: false, + is_writable: false, + is_executable: false, + } + } + } + + impl AccountInfoBuilder { + fn as_account_info(&mut self) -> AccountInfo { + AccountInfo::new( + &self.key, + self.is_signer, + self.is_writable, + &mut self.lamports, + &mut self.data, + &self.owner, + self.is_executable, + self.rent_epoch, + ) + } + + fn new_token_program() -> Self { + Self { + key: spl_token::id(), + lamports: 0, + data: vec![], + owner: system_program::id(), + rent_epoch: 0, + is_signer: false, + is_writable: false, + is_executable: true, + } + } + + fn new_reward_authority( + lending_market_key: &Pubkey, + reward_token_vault_key: &Pubkey, + ) -> (Self, Bumps) { + let (key, bump) = find_reward_vault_authority( + &crate::id(), + lending_market_key, + reward_token_vault_key, + ); + + let s = Self { + key, + lamports: 0, + data: vec![], + owner: system_program::id(), + rent_epoch: 0, + is_signer: false, + is_writable: false, + is_executable: false, + }; + + ( + s, + Bumps { + reward_authority: bump, + }, + ) + } + + fn new_lending_market_owner() -> Self { + Self { + key: Pubkey::new_unique(), + lamports: 0, + data: vec![], + owner: system_program::id(), + rent_epoch: 0, + is_signer: true, + is_writable: false, + is_executable: false, + } + } + + fn new_reward_token_vault() -> Self { + Self { + key: Pubkey::new_unique(), + lamports: 0, + data: vec![], + owner: spl_token::id(), + rent_epoch: 0, + is_signer: false, + is_writable: true, + is_executable: false, + } + } + } +} diff --git a/token-lending/program/src/processor/liquidity_mining/add_pool_reward.rs b/token-lending/program/src/processor/liquidity_mining/add_pool_reward.rs new file mode 100644 index 00000000000..cd63db66c65 --- /dev/null +++ b/token-lending/program/src/processor/liquidity_mining/add_pool_reward.rs @@ -0,0 +1,213 @@ +//! Adds a new pool reward to a reserve. +//! +//! Each pool reward has a unique vault that holds the reward tokens. +//! This vault account must be created for the token program before calling this +//! ix. +//! In this ix we initialize the account as token account and transfer the +//! reward tokens to it. + +use crate::processor::{ + assert_rent_exempt, spl_token_init_account, spl_token_transfer, TokenInitializeAccountParams, + TokenTransferParams, +}; +use solana_program::{ + account_info::{next_account_info, AccountInfo}, + clock::Clock, + entrypoint::ProgramResult, + msg, + program_error::ProgramError, + pubkey::Pubkey, + rent::Rent, + sysvar::Sysvar, +}; +use solend_sdk::{error::LendingError, state::PositionKind}; + +use super::{ + check_and_unpack_pool_reward_accounts_for_admin_ixs, unpack_token_account, Bumps, + CheckAndUnpackPoolRewardAccounts, ReserveBorrow, +}; + +/// Use [Self::from_unchecked_iter] to validate the accounts except for +/// * `reward_token_vault_info` +/// * `rent_info` +struct AddPoolRewardAccounts<'a, 'info> { + /// ✅ belongs to this program + /// ✅ unpacks + /// ✅ belongs to `lending_market_info` + /// ✅ is writable + _reserve_info: &'a AccountInfo<'info>, + /// ✅ belongs to the token program + reward_mint_info: &'a AccountInfo<'info>, + /// ✅ belongs to the token program + /// ✅ owned by `lending_market_owner_info` + /// ❓ we don't know yet whether it has enough tokens + /// ✅ matches `reward_mint_info` + /// ✅ is writable + reward_token_source_info: &'a AccountInfo<'info>, + /// ✅ seed of `lending_market_info`, `reward_token_vault_info` + reward_authority_info: &'a AccountInfo<'info>, + /// ✅ belongs to the token program + /// ✅ has no data + /// ✅ is writable + /// ❓ we don't yet know whether it's rent exempt + reward_token_vault_info: &'a AccountInfo<'info>, + /// ✅ belongs to this program + /// ✅ unpacks + _lending_market_info: &'a AccountInfo<'info>, + /// ✅ is a signer + /// ✅ matches `lending_market_info` + /// + /// TBD: do we want to create another signer authority to be able to + /// delegate reward management to a softer multisig? + lending_market_owner_info: &'a AccountInfo<'info>, + /// ❓ we don't yet know whether this is rent info + rent_info: &'a AccountInfo<'info>, + /// ✅ matches `lending_market_info` + token_program_info: &'a AccountInfo<'info>, + + reserve: ReserveBorrow<'a, 'info>, +} + +/// # Effects +/// +/// 1. Initializes a new reward vault account and transfers +/// `reward_token_amount` tokens from the `reward_token_source` account to +/// the new reward vault account. +/// 2. Finds an empty slot in the [Reserve]'s LM reward vector and adds it there. +pub(crate) fn process( + program_id: &Pubkey, + reward_authority_bump: u8, + position_kind: PositionKind, + start_time_secs: u64, + end_time_secs: u64, + reward_token_amount: u64, + accounts: &[AccountInfo], +) -> ProgramResult { + msg!("Adding {position_kind:?} pool reward from {start_time_secs}s to {end_time_secs}s",); + + let clock = &Clock::get()?; + + let mut accounts = AddPoolRewardAccounts::from_unchecked_iter( + program_id, + Bumps { + reward_authority: reward_authority_bump, + }, + &mut accounts.iter(), + )?; + + // 1. + + spl_token_init_account(TokenInitializeAccountParams { + account: accounts.reward_token_vault_info.clone(), + mint: accounts.reward_mint_info.clone(), + owner: accounts.reward_authority_info.clone(), + rent: accounts.rent_info.clone(), + token_program: accounts.token_program_info.clone(), + })?; + let rent = &Rent::from_account_info(accounts.rent_info)?; + assert_rent_exempt(rent, accounts.reward_token_vault_info)?; + + spl_token_transfer(TokenTransferParams { + source: accounts.reward_token_source_info.clone(), + destination: accounts.reward_token_vault_info.clone(), + amount: reward_token_amount, + authority: accounts.lending_market_owner_info.clone(), + authority_signer_seeds: &[], + token_program: accounts.token_program_info.clone(), + })?; + + // 2. + + accounts + .reserve + .pool_reward_manager_mut(position_kind) + .add_pool_reward( + *accounts.reward_token_vault_info.key, + start_time_secs, + end_time_secs, + reward_token_amount, + clock, + )?; + + Ok(()) +} + +impl<'a, 'info> AddPoolRewardAccounts<'a, 'info> { + fn from_unchecked_iter( + program_id: &Pubkey, + bumps: Bumps, + iter: &mut impl Iterator>, + ) -> Result, ProgramError> { + let reserve_info = next_account_info(iter)?; + let reward_mint_info = next_account_info(iter)?; + let reward_token_source_info = next_account_info(iter)?; + let reward_authority_info = next_account_info(iter)?; + let reward_token_vault_info = next_account_info(iter)?; + let lending_market_info = next_account_info(iter)?; + let lending_market_owner_info = next_account_info(iter)?; + let rent_info = next_account_info(iter)?; + let token_program_info = next_account_info(iter)?; + + let (_, reserve) = check_and_unpack_pool_reward_accounts_for_admin_ixs( + program_id, + bumps, + CheckAndUnpackPoolRewardAccounts { + reserve_info, + reward_mint_info, + reward_authority_info, + lending_market_info, + token_program_info, + reward_token_vault_info, + }, + lending_market_owner_info, + )?; + + if reward_token_source_info.owner != token_program_info.key { + msg!("Reward token source provided must be owned by the token program"); + return Err(LendingError::InvalidTokenOwner.into()); + } + let reward_token_source = unpack_token_account(&reward_token_source_info.data.borrow())?; + if reward_token_source.owner != *lending_market_owner_info.key { + msg!("Reward token source owner does not match the lending market owner provided"); + return Err(LendingError::InvalidAccountInput.into()); + } + if reward_token_source.mint != *reward_mint_info.key { + msg!("Reward token source mint does not match the reward mint provided"); + return Err(LendingError::InvalidAccountInput.into()); + } + + if reward_token_vault_info.owner != token_program_info.key { + msg!("Reward token vault provided must be owned by the token program"); + return Err(LendingError::InvalidTokenOwner.into()); + } + + // check that accounts that should be writable are writable + + if !reward_token_vault_info.is_writable { + msg!("Reward token vault provided must be writable"); + return Err(ProgramError::InvalidAccountData); + } + if !reserve_info.is_writable { + msg!("Reserve provided must be writable"); + return Err(ProgramError::InvalidAccountData); + } + if !reward_token_source_info.is_writable { + msg!("Reward token source provided must be writable"); + return Err(ProgramError::InvalidAccountData); + } + + Ok(Self { + _reserve_info: reserve_info, + reward_mint_info, + reward_token_source_info, + reward_authority_info, + reward_token_vault_info, + _lending_market_info: lending_market_info, + lending_market_owner_info, + rent_info, + token_program_info, + + reserve, + }) + } +} diff --git a/token-lending/program/src/processor/liquidity_mining/claim_pool_reward.rs b/token-lending/program/src/processor/liquidity_mining/claim_pool_reward.rs new file mode 100644 index 00000000000..e8ff96b97d0 --- /dev/null +++ b/token-lending/program/src/processor/liquidity_mining/claim_pool_reward.rs @@ -0,0 +1,341 @@ +//! Permission-less way to claim allocated user liquidity mining rewards. +//! +//! # Migration +//! +//! Prior to version @2.1.0 there was no concept of liq. mining. +//! That means user shares are going to be 0 even if they have a borrow or +//! deposit. +//! This ix can be used to start tracking obligation's rewards. + +use crate::processor::{ + realloc_obligation_if_necessary, spl_token_transfer, ReserveBorrow, TokenTransferParams, +}; +use solana_program::{ + account_info::{next_account_info, AccountInfo}, + clock::Clock, + entrypoint::ProgramResult, + msg, + program_error::ProgramError, + pubkey::Pubkey, + sysvar::Sysvar, +}; +use solend_sdk::state::{HasRewardEnded, Obligation, PositionKind}; +use solend_sdk::{error::LendingError, instruction::reward_vault_authority_seeds}; +use spl_associated_token_account::get_associated_token_address_with_program_id; + +use super::{ + check_and_unpack_pool_reward_accounts, unpack_token_account, Bumps, + CheckAndUnpackPoolRewardAccounts, +}; + +/// Use [Self::from_unchecked_iter] to validate the accounts. +struct ClaimUserReward<'a, 'info> { + /// ✅ is_signer + perhaps_payer_info: Option<&'a AccountInfo<'info>>, + /// ✅ belongs to this program + /// ✅ unpacks + /// ✅ matches `lending_market_info` + /// ✅ is writable + obligation_info: &'a AccountInfo<'info>, + /// ✅ belongs to the token program + /// ✅ is writable + /// ✅ matches `reward_mint_info` + /// ✅ is obligation owner's ATA for the reward mint + obligation_owner_token_account_info: &'a AccountInfo<'info>, + /// ✅ belongs to this program + /// ✅ unpacks + /// ✅ belongs to `lending_market_info` + /// ✅ is writable + _reserve_info: &'a AccountInfo<'info>, + /// ✅ belongs to the token program + _reward_mint_info: &'a AccountInfo<'info>, + /// ✅ seed of `lending_market_info`, `reward_token_vault_info` + reward_authority_info: &'a AccountInfo<'info>, + /// ✅ belongs to the token program + /// ✅ unpacks to a [TokenAccount] + /// ✅ owned by `reward_authority_info` + /// ✅ matches `reward_mint_info` + /// ✅ is writable + reward_token_vault_info: &'a AccountInfo<'info>, + /// ✅ belongs to this program + /// ✅ unpacks + lending_market_info: &'a AccountInfo<'info>, + /// ✅ matches `lending_market_info` + token_program_info: &'a AccountInfo<'info>, + + obligation: Box, + reserve: ReserveBorrow<'a, 'info>, +} + +/// # Effects +/// +/// 1. Finds the [UserRewardManager] for the reserve and obligation. +/// 2. Withdraws all eligible rewards from [UserRewardManager]. +/// Eligible rewards are those that match the vault and user has earned any. +/// 3. Transfers the withdrawn rewards to the user's token account. +/// 4. Packs all changes into account buffers for [Obligation] and [Reserve]. +pub(crate) fn process( + program_id: &Pubkey, + reward_authority_bump: u8, + position_kind: PositionKind, + accounts: &[AccountInfo], +) -> ProgramResult { + let clock = &Clock::get()?; + + let mut accounts = ClaimUserReward::from_unchecked_iter( + program_id, + Bumps { + reward_authority: reward_authority_bump, + }, + &mut accounts.iter(), + )?; + let reserve_key = accounts.reserve.key(); + + // AUDIT: + // > ClaimUserReward doesn’t check if the Obligation is stale. + // > This can cause problems for borrow rewards, because the obligation's liability_shares will + // > be stale. + if matches!(position_kind, PositionKind::Borrow) + && accounts.obligation.last_update.is_stale(clock.slot)? + { + msg!("obligation is stale and must be refreshed in the current slot"); + return Err(LendingError::ObligationStale.into()); + } + + // 1. + + let pool_reward_manager = accounts.reserve.pool_reward_manager_mut(position_kind); + + if let Some((_, user_reward_manager)) = accounts + .obligation + .user_reward_managers + .find_mut(reserve_key, position_kind) + { + msg!( + "Found user reward manager that was last updated at {} and has {}/{} shares", + user_reward_manager.last_update_time_secs, + user_reward_manager.share, + pool_reward_manager.total_shares + ); + + // 2. + + let (has_ended, total_reward_amount) = user_reward_manager.claim_rewards( + pool_reward_manager, + *accounts.reward_token_vault_info.key, + clock, + )?; + + // AUDIT: + // > ClaimUserReward on Suilend can only be called permissionlessly if the reward period is + // > fully elapsed. + let payer_matches_obligation_owner = accounts + .perhaps_payer_info + .map_or(false, |payer| payer.key == &accounts.obligation.owner); + if !matches!(has_ended, HasRewardEnded::Yes) && !payer_matches_obligation_owner { + msg!("User reward manager has not ended, but payer does not match obligation owner"); + return Err(LendingError::InvalidSigner.into()); + } + + // 3. + + if total_reward_amount > 0 { + spl_token_transfer(TokenTransferParams { + source: accounts.reward_token_vault_info.clone(), + destination: accounts.obligation_owner_token_account_info.clone(), + amount: total_reward_amount, + authority: accounts.reward_authority_info.clone(), + authority_signer_seeds: &[ + reward_vault_authority_seeds( + accounts.lending_market_info.key, + accounts.reward_token_vault_info.key, + ) + .as_slice(), + &[&[reward_authority_bump]], + ] + .concat(), + token_program: accounts.token_program_info.clone(), + })?; + } + } else { + let expected_position_kind = accounts.obligation.find_position_kind(reserve_key)?; + + if expected_position_kind != position_kind { + msg!("Obligation does not have {:?} for reserve", position_kind); + return Err(LendingError::InvalidAccountInput.into()); + } + + // We've checked that the obligation associates this reserve but it's + // not in the user reward managers yet. + // This means that the obligation hasn't been migrated to track the + // pool reward manager. + // + // We'll upgrade it here. + + let migrated_share = match position_kind { + PositionKind::Borrow => accounts + .obligation + .find_liquidity_in_borrows(reserve_key)? + .0 + .liability_shares()?, + PositionKind::Deposit => { + accounts + .obligation + .find_collateral_in_deposits(reserve_key)? + .0 + .deposited_amount + } + }; + + msg!( + "Migrating obligation to track pool reward manager with share of {}/{}", + migrated_share, + pool_reward_manager.total_shares + ); + + accounts.obligation.user_reward_managers.set_share( + reserve_key, + position_kind, + pool_reward_manager, + migrated_share, + clock, + )?; + }; + + // 4. + + realloc_obligation_if_necessary(&accounts.obligation, accounts.obligation_info)?; + Obligation::pack( + *accounts.obligation, + &mut accounts.obligation_info.data.borrow_mut(), + )?; + + // reserve is packed on drop + + Ok(()) +} + +impl<'a, 'info> ClaimUserReward<'a, 'info> { + fn from_unchecked_iter( + program_id: &Pubkey, + bumps: Bumps, + iter: &mut impl Iterator>, + ) -> Result, ProgramError> { + let obligation_info = next_account_info(iter)?; + let obligation_owner_token_account_info = next_account_info(iter)?; + let reserve_info = next_account_info(iter)?; + let reward_mint_info = next_account_info(iter)?; + let reward_authority_info = next_account_info(iter)?; + let reward_token_vault_info = next_account_info(iter)?; + let lending_market_info = next_account_info(iter)?; + let token_program_info = next_account_info(iter)?; + let perhaps_payer_info = next_account_info(iter).ok(); + + let (_, reserve) = check_and_unpack_pool_reward_accounts( + program_id, + bumps, + CheckAndUnpackPoolRewardAccounts { + reserve_info, + reward_mint_info, + reward_authority_info, + lending_market_info, + token_program_info, + reward_token_vault_info, + }, + )?; + + if perhaps_payer_info.map(|a| !a.is_signer).unwrap_or(false) { + msg!("Payer account must be a signer"); + return Err(LendingError::InvalidSigner.into()); + } + + if obligation_info.owner != program_id { + msg!("Obligation provided is not owned by the lending program"); + return Err(LendingError::InvalidAccountOwner.into()); + } + + let obligation = Box::new(Obligation::unpack(&obligation_info.data.borrow())?); + + if obligation.lending_market != *lending_market_info.key { + msg!("Obligation lending market does not match the lending market provided"); + return Err(LendingError::InvalidAccountInput.into()); + } + + // AUDIT: + // > In ClaimUserReward, because this is a permissionless instruction, we recommend + // > validating that obligation_owner_token_account_info is an associated token account + // > (ATA), rather than only a token account owned by the obligation owner. + // > Allowing arbitrary token accounts would require indexing each one, adding unnecessary + // > complexity and risk. + let expected_ata = get_associated_token_address_with_program_id( + &obligation.owner, + reward_mint_info.key, + token_program_info.key, + ); + if expected_ata != *obligation_owner_token_account_info.key { + msg!("Token account for collecting rewards must be ATA"); + return Err(LendingError::InvalidAccountInput.into()); + } + + if obligation_owner_token_account_info.owner != token_program_info.key { + msg!("Obligation owner token account provided must be owned by the token program"); + return Err(LendingError::InvalidTokenOwner.into()); + } + let obligation_owner_token_account = + unpack_token_account(&obligation_owner_token_account_info.data.borrow())?; + + if obligation_owner_token_account.mint != *reward_mint_info.key { + msg!("Obligation owner token account mint does not match the reward mint provided"); + return Err(LendingError::InvalidAccountInput.into()); + } + + if reward_token_vault_info.owner != token_program_info.key { + msg!("Reward token vault provided must be owned by the token program"); + return Err(LendingError::InvalidTokenOwner.into()); + } + let reward_token_vault = unpack_token_account(&reward_token_vault_info.data.borrow())?; + + if reward_token_vault.owner != *reward_authority_info.key { + msg!("Reward token vault owner does not match the reward authority provided"); + return Err(LendingError::InvalidAccountInput.into()); + } + if reward_token_vault.mint != *reward_mint_info.key { + msg!("Reward token vault mint does not match the reward mint provided"); + return Err(LendingError::InvalidAccountInput.into()); + } + + // check that accounts that should be writable are writable + + if !obligation_info.is_writable { + msg!("Obligation provided must be writable"); + return Err(ProgramError::InvalidAccountData); + } + if !obligation_owner_token_account_info.is_writable { + msg!("Obligation owner token account provided must be writable"); + return Err(ProgramError::InvalidAccountData); + } + if !reward_token_vault_info.is_writable { + msg!("Reward token vault provided must be writable"); + return Err(ProgramError::InvalidAccountData); + } + if !reserve_info.is_writable { + msg!("Reserve provided must be writable"); + return Err(ProgramError::InvalidAccountData); + } + + Ok(Self { + perhaps_payer_info, + obligation_info, + obligation_owner_token_account_info, + _reserve_info: reserve_info, + _reward_mint_info: reward_mint_info, + reward_authority_info, + reward_token_vault_info, + lending_market_info, + token_program_info, + + reserve, + obligation, + }) + } +} diff --git a/token-lending/program/src/processor/liquidity_mining/close_pool_reward.rs b/token-lending/program/src/processor/liquidity_mining/close_pool_reward.rs new file mode 100644 index 00000000000..31a445a3d91 --- /dev/null +++ b/token-lending/program/src/processor/liquidity_mining/close_pool_reward.rs @@ -0,0 +1,212 @@ +//! Closes a pool reward, making its slot vacant and ready for a new reward. +//! +//! Before closing a pool reward that pool reward must first be cancelled +//! and all rewards must be claimed by the users. +//! +//! The claim ix is permission-less and therefore it can be cranked. + +use solana_program::{ + account_info::{next_account_info, AccountInfo}, + entrypoint::ProgramResult, + msg, + program_error::ProgramError, + pubkey::Pubkey, +}; +use solend_sdk::{ + error::LendingError, instruction::reward_vault_authority_seeds, state::PositionKind, +}; +use spl_token::state::Account as TokenAccount; + +use crate::processor::{ + spl_token_close_account, spl_token_transfer, TokenCloseAccountParams, TokenTransferParams, +}; + +use super::{ + check_and_unpack_pool_reward_accounts_for_admin_ixs, unpack_token_account, Bumps, + CheckAndUnpackPoolRewardAccounts, ReserveBorrow, +}; + +/// Use [Self::from_unchecked_iter] to validate the accounts. +struct ClosePoolRewardAccounts<'a, 'info> { + /// ✅ belongs to this program + /// ✅ unpacks + /// ✅ belongs to `lending_market_info` + /// ✅ is writable + _reserve_info: &'a AccountInfo<'info>, + /// ✅ belongs to the token program + _reward_mint_info: &'a AccountInfo<'info>, + /// ✅ belongs to the token program + /// ✅ owned by `lending_market_owner_info` + /// ✅ matches `reward_mint_info` + /// ✅ is writable + reward_token_destination_info: &'a AccountInfo<'info>, + /// ✅ seed of `lending_market_info`, `reward_token_vault_info` + reward_authority_info: &'a AccountInfo<'info>, + /// ❓ we don't know whether it matches vault in the [Reserve] + /// ✅ is writable + /// ✅ unpacks + reward_token_vault_info: &'a AccountInfo<'info>, + /// ✅ belongs to this program + /// ✅ unpacks + lending_market_info: &'a AccountInfo<'info>, + /// ✅ is a signer + /// ✅ matches `lending_market_info` + lending_market_owner_info: &'a AccountInfo<'info>, + /// ✅ matches `lending_market_info` + token_program_info: &'a AccountInfo<'info>, + + reserve: ReserveBorrow<'a, 'info>, + reward_token_vault: TokenAccount, +} + +/// # Effects +/// +/// 1. Closes reward in the [Reserve] account if all users have claimed. +/// 2. Transfers dust to the `reward_token_destination` account. +/// 3. Closes reward vault token account. +pub(crate) fn process( + program_id: &Pubkey, + reward_authority_bump: u8, + position_kind: PositionKind, + pool_reward_index: usize, + accounts: &[AccountInfo], +) -> ProgramResult { + let mut accounts = ClosePoolRewardAccounts::from_unchecked_iter( + program_id, + Bumps { + reward_authority: reward_authority_bump, + }, + &mut accounts.iter(), + )?; + + // 1. + + let expected_vault = accounts + .reserve + .pool_reward_manager_mut(position_kind) + .close_pool_reward(pool_reward_index)?; + if expected_vault != *accounts.reward_token_vault_info.key { + msg!("Reward token vault provided does not match the expected vault"); + return Err(LendingError::InvalidAccountInput.into()); + } + + // 2. + + let bump_seed = [reward_authority_bump]; + let signer_seeds = [ + reward_vault_authority_seeds( + accounts.lending_market_info.key, + accounts.reward_token_vault_info.key, + ) + .as_slice(), + &[&bump_seed], + ] + .concat(); + + msg!( + "Transferring {} reward tokens to {}", + accounts.reward_token_vault.amount, + accounts.reward_token_destination_info.key + ); + spl_token_transfer(TokenTransferParams { + source: accounts.reward_token_vault_info.clone(), + destination: accounts.reward_token_destination_info.clone(), + amount: accounts.reward_token_vault.amount, + authority: accounts.reward_authority_info.clone(), + authority_signer_seeds: &signer_seeds, + token_program: accounts.token_program_info.clone(), + })?; + + // 3. + + msg!( + "Closing reward token vault {}", + accounts.reward_token_vault_info.key + ); + spl_token_close_account(TokenCloseAccountParams { + account: accounts.reward_token_vault_info.clone(), + destination: accounts.lending_market_owner_info.clone(), + authority: accounts.reward_authority_info.clone(), + authority_signer_seeds: &signer_seeds, + token_program: accounts.token_program_info.clone(), + })?; + + Ok(()) +} + +impl<'a, 'info> ClosePoolRewardAccounts<'a, 'info> { + fn from_unchecked_iter( + program_id: &Pubkey, + bumps: Bumps, + iter: &mut impl Iterator>, + ) -> Result, ProgramError> { + let reserve_info = next_account_info(iter)?; + let reward_mint_info = next_account_info(iter)?; + let reward_token_destination_info = next_account_info(iter)?; + let reward_authority_info = next_account_info(iter)?; + let reward_token_vault_info = next_account_info(iter)?; + let lending_market_info = next_account_info(iter)?; + let lending_market_owner_info = next_account_info(iter)?; + let token_program_info = next_account_info(iter)?; + + let (_, reserve) = check_and_unpack_pool_reward_accounts_for_admin_ixs( + program_id, + bumps, + CheckAndUnpackPoolRewardAccounts { + reserve_info, + reward_mint_info, + reward_authority_info, + lending_market_info, + token_program_info, + reward_token_vault_info, + }, + lending_market_owner_info, + )?; + + if reward_token_destination_info.owner != token_program_info.key { + msg!("Reward token destination provided must be owned by the token program"); + return Err(LendingError::InvalidTokenOwner.into()); + } + let reward_token_destination = + unpack_token_account(&reward_token_destination_info.data.borrow())?; + if reward_token_destination.mint != *reward_mint_info.key { + msg!("Reward token destination mint does not match the reward mint provided"); + return Err(LendingError::InvalidAccountInput.into()); + } + + let reward_token_vault = unpack_token_account(&reward_token_vault_info.data.borrow())?; + if reward_token_vault.mint != *reward_mint_info.key { + msg!("Reward token vault mint does not match the reward mint provided"); + return Err(LendingError::InvalidAccountInput.into()); + } + + // check that accounts that should be writable are writable + + if !reserve_info.is_writable { + msg!("Reserve provided must be writable"); + return Err(ProgramError::InvalidAccountData); + } + if !reward_token_destination_info.is_writable { + msg!("Reward token destination provided must be writable"); + return Err(ProgramError::InvalidAccountData); + } + if !reward_token_vault_info.is_writable { + msg!("Reward token vault provided must be writable"); + return Err(ProgramError::InvalidAccountData); + } + + Ok(Self { + _reserve_info: reserve_info, + _reward_mint_info: reward_mint_info, + reward_token_destination_info, + reward_authority_info, + reward_token_vault_info, + lending_market_info, + lending_market_owner_info, + token_program_info, + + reserve, + reward_token_vault, + }) + } +} diff --git a/token-lending/program/src/processor/liquidity_mining/edit_pool_reward.rs b/token-lending/program/src/processor/liquidity_mining/edit_pool_reward.rs new file mode 100644 index 00000000000..c4bb8fb4e82 --- /dev/null +++ b/token-lending/program/src/processor/liquidity_mining/edit_pool_reward.rs @@ -0,0 +1,206 @@ +//! Edits a pool reward. +//! +//! # 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. +//! In this case we transfer tokens from the reward vault to the lending market reward token account. +//! +//! # Shorten +//! If the new endtime is in the future, larger than start time and smaller than previous end time +//! then we shorten the reward period, refunding the unallocated rewards to the lending market +//! reward token account. +//! +//! # Extend +//! If the new endtime is in the future, larger than start time and larger than previous end time +//! then we extend the reward period, taking more tokens from the lending market reward token +//! account. +//! +//! --- +//! +//! Both extending and shortening calculate the difference between total rewards linearly. + +use crate::processor::liquidity_mining::{ + check_and_unpack_pool_reward_accounts_for_admin_ixs, unpack_token_account, +}; +use crate::processor::{spl_token_transfer, TokenTransferParams}; +use solana_program::sysvar::Sysvar; +use solana_program::{ + account_info::{next_account_info, AccountInfo}, + clock::Clock, + entrypoint::ProgramResult, + msg, + program_error::ProgramError, + pubkey::Pubkey, +}; +use solend_sdk::instruction::reward_vault_authority_seeds; +use solend_sdk::{error::LendingError, state::PositionKind}; + +use super::{Bumps, CheckAndUnpackPoolRewardAccounts, ReserveBorrow}; + +/// Use [Self::from_unchecked_iter] to validate the accounts. +struct EditPoolRewardAccounts<'a, 'info> { + /// ✅ belongs to this program + /// ✅ unpacks + /// ✅ belongs to `lending_market_info` + /// ✅ is writable + _reserve_info: &'a AccountInfo<'info>, + /// ✅ belongs to the token program + _reward_mint_info: &'a AccountInfo<'info>, + /// ✅ belongs to the token program + /// ✅ matches `reward_mint_info` + /// ✅ is writable + lending_market_reward_token_account_info: &'a AccountInfo<'info>, + /// ✅ seed of `lending_market_info`, `reward_token_vault_info` + reward_authority_info: &'a AccountInfo<'info>, + /// ❓ we don't know whether it matches the reward vault pubkey stored in [Reserve] + /// ✅ is writable + reward_token_vault_info: &'a AccountInfo<'info>, + /// ✅ belongs to this program + /// ✅ unpacks + lending_market_info: &'a AccountInfo<'info>, + /// ✅ is a signer + /// ✅ matches `lending_market_info` + lending_market_owner_info: &'a AccountInfo<'info>, + /// ✅ matches `lending_market_info` + token_program_info: &'a AccountInfo<'info>, + + reserve: ReserveBorrow<'a, 'info>, +} + +/// # Effects +/// +/// 1. Sets the new time +/// 2. Either refunds the admin or takes more tokens from the admin, based on the new end time +/// relation to the old end time +pub(crate) fn process( + program_id: &Pubkey, + reward_authority_bump: u8, + position_kind: PositionKind, + pool_reward_index: usize, + new_end_time_secs: u64, + accounts: &[AccountInfo], +) -> ProgramResult { + let mut accounts = EditPoolRewardAccounts::from_unchecked_iter( + program_id, + Bumps { + reward_authority: reward_authority_bump, + }, + &mut accounts.iter(), + )?; + + // 1. + + let (expected_vault, change_to_vault_amount) = accounts + .reserve + .pool_reward_manager_mut(position_kind) + .edit_pool_reward(pool_reward_index, new_end_time_secs, &Clock::get()?)?; + + if expected_vault != *accounts.reward_token_vault_info.key { + msg!("Reward vault provided does not match the reward vault pubkey stored in [Reserve]"); + return Err(LendingError::InvalidAccountInput.into()); + } + + // 2. + + msg!("Change to vault amount: {}", change_to_vault_amount); + + match change_to_vault_amount { + 0 => Ok(()), + // transfer more tokens to the vault + 1.. => spl_token_transfer(TokenTransferParams { + source: accounts.lending_market_reward_token_account_info.clone(), + destination: accounts.reward_token_vault_info.clone(), + amount: change_to_vault_amount.unsigned_abs(), + authority: accounts.lending_market_owner_info.clone(), + authority_signer_seeds: &[], + token_program: accounts.token_program_info.clone(), + }), + // refund to lending market reward token account + ..=-1 => spl_token_transfer(TokenTransferParams { + source: accounts.reward_token_vault_info.clone(), + destination: accounts.lending_market_reward_token_account_info.clone(), + amount: change_to_vault_amount.unsigned_abs(), + authority: accounts.reward_authority_info.clone(), + authority_signer_seeds: &[ + reward_vault_authority_seeds( + accounts.lending_market_info.key, + accounts.reward_token_vault_info.key, + ) + .as_slice(), + &[&[reward_authority_bump]], + ] + .concat(), + token_program: accounts.token_program_info.clone(), + }), + } +} + +impl<'a, 'info> EditPoolRewardAccounts<'a, 'info> { + fn from_unchecked_iter( + program_id: &Pubkey, + bump: Bumps, + iter: &mut impl Iterator>, + ) -> Result, ProgramError> { + let reserve_info = next_account_info(iter)?; + let reward_mint_info = next_account_info(iter)?; + let reward_token_destination_info = next_account_info(iter)?; + let reward_authority_info = next_account_info(iter)?; + let reward_token_vault_info = next_account_info(iter)?; + let lending_market_info = next_account_info(iter)?; + let lending_market_owner_info = next_account_info(iter)?; + let token_program_info = next_account_info(iter)?; + + let (_, reserve) = check_and_unpack_pool_reward_accounts_for_admin_ixs( + program_id, + bump, + CheckAndUnpackPoolRewardAccounts { + reserve_info, + reward_mint_info, + reward_authority_info, + lending_market_info, + token_program_info, + reward_token_vault_info, + }, + lending_market_owner_info, + )?; + + if reward_token_destination_info.owner != token_program_info.key { + msg!("Reward token destination provided must be owned by the token program"); + return Err(LendingError::InvalidTokenOwner.into()); + } + let reward_token_destination = + unpack_token_account(&reward_token_destination_info.data.borrow())?; + if reward_token_destination.mint != *reward_mint_info.key { + msg!("Reward token destination mint does not match the reward mint provided"); + return Err(LendingError::InvalidAccountInput.into()); + } + + // check that accounts that should be writable are writable + + if !reward_token_vault_info.is_writable { + msg!("Reward token vault provided must be writable"); + return Err(ProgramError::InvalidAccountData); + } + if !reserve_info.is_writable { + msg!("Reserve provided must be writable"); + return Err(ProgramError::InvalidAccountData); + } + if !reward_token_destination_info.is_writable { + msg!("Reward token destination provided must be writable"); + return Err(ProgramError::InvalidAccountData); + } + + Ok(Self { + _reserve_info: reserve_info, + _reward_mint_info: reward_mint_info, + lending_market_reward_token_account_info: reward_token_destination_info, + reward_authority_info, + reward_token_vault_info, + lending_market_info, + lending_market_owner_info, + token_program_info, + + reserve, + }) + } +} diff --git a/token-lending/program/src/processor/liquidity_mining/upgrade_reserve.rs b/token-lending/program/src/processor/liquidity_mining/upgrade_reserve.rs new file mode 100644 index 00000000000..60d5508dc81 --- /dev/null +++ b/token-lending/program/src/processor/liquidity_mining/upgrade_reserve.rs @@ -0,0 +1,160 @@ +use solana_program::program_pack::Pack; +use solana_program::{ + account_info::{next_account_info, AccountInfo}, + entrypoint::ProgramResult, + msg, + program::invoke, + program_error::ProgramError, + pubkey::Pubkey, + rent::Rent, + system_instruction, + sysvar::Sysvar, +}; +use solend_sdk::state::discriminator::AccountDiscriminator; +use solend_sdk::state::{PROGRAM_VERSION_2_0_2, RESERVE_LEN_V2_0_2}; +use solend_sdk::{error::LendingError, state::Reserve}; + +struct UpgradeReserveAccounts<'a, 'info> { + /// Reserve sized as v2.0.2. + /// + /// ✅ belongs to this program + /// ✅ is sized [RESERVE_LEN_V2_0_2], ie. for sure [Reserve] account + /// ✅ is writable + reserve_info: &'a AccountInfo<'info>, + /// The pool fella who pays for this. + /// + /// ✅ is a signer + /// ✅ is writable + payer: &'a AccountInfo<'info>, + /// The system program. + /// + /// ✅ is the system program + system_program: &'a AccountInfo<'info>, +} + +/// Temporary ix to upgrade a reserve to LM feature added in @v2.0.2. +/// Fails if reserve was not sized as @v2.0.2. +/// +/// Until this ix is called for a [Reserve] account, all other ixs that try to +/// unpack the [Reserve] will fail due to size mismatch. +/// +/// # Effects +/// +/// 1. Takes payer's lamports and pays for the rent increase. +/// 2. Reallocates the reserve account to the latest size. +/// 3. Repacks the reserve account. +pub(crate) fn process(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramResult { + let accounts = UpgradeReserveAccounts::from_unchecked_iter(program_id, &mut accounts.iter())?; + + // 1. + + let current_rent = accounts.reserve_info.lamports(); + let new_rent = Rent::get()?.minimum_balance(Reserve::LEN); + + if let Some(extra_rent) = new_rent.checked_sub(current_rent) { + // some reserves have more rent than necessary, let's not assume that + // the payer always needs to add more rent + + invoke( + &system_instruction::transfer( + accounts.payer.key, + accounts.reserve_info.key, + extra_rent, + ), + &[ + accounts.payer.clone(), + accounts.reserve_info.clone(), + accounts.system_program.clone(), + ], + )?; + } + + // 2. + + // From the [AccountInfo::realloc] docs: + // + // > Memory used to grow is already zero-initialized upon program entrypoint + // > and re-zeroing it wastes compute units. If within the same call a program + // > reallocs from larger to smaller and back to larger again the new space + // > could contain stale data. Pass true for zero_init in this case, + // > otherwise compute units will be wasted re-zero-initializing. + let zero_init = false; + accounts.reserve_info.realloc(Reserve::LEN, zero_init)?; + + // 3. + + // we upgrade discriminator as we've checked that the account is indeed + // a reserve account in [UpgradeReserveAccounts::from_unchecked_iter] + let mut data = accounts.reserve_info.data.borrow_mut(); + data[0] = AccountDiscriminator::Reserve as u8; + // Now the reserve can unpack fine and doesn't have to worry about + // migrations. + // Instead it returns an error on an invalid discriminator. + // This way a reserve cannot be mistaken for an obligation. + let reserve = Reserve::unpack(&data)?; + Reserve::pack(reserve, &mut data)?; + + Ok(()) +} + +impl<'a, 'info> UpgradeReserveAccounts<'a, 'info> { + fn from_unchecked_iter( + program_id: &Pubkey, + iter: &mut impl Iterator>, + ) -> Result, ProgramError> { + let reserve_info = next_account_info(iter)?; + let payer = next_account_info(iter)?; + let system_program = next_account_info(iter)?; + + if !payer.is_signer { + msg!("Payer provided must be a signer"); + return Err(LendingError::InvalidSigner.into()); + } + + if reserve_info.owner != program_id { + msg!("Reserve provided must be owned by the lending program"); + return Err(LendingError::InvalidAccountOwner.into()); + } + + if reserve_info.data_len() != RESERVE_LEN_V2_0_2 { + msg!("Reserve provided must be sized as v2.0.2"); + return Err(LendingError::InvalidAccountInput.into()); + } + + // AUDIT: + // > UpgradeReserve should verify that a Reserve was previously stored in the account before + // > performing the upgrade. + // > This doesn’t appear to be exploitable—it would just allow an attacker to create + // > a valid-looking Reserve filled with zeros—but it’s worth addressing. + // > Anybody can create an account owned by solend with size == RESERVE_LEN_V2_0_2 by + // > calling system_instruction::create_account, so you can't only rely on checking the + // > account length && ownership. + // > Asserting that the first byte is equal to the expected old version would fix it. + if reserve_info.data.borrow()[0] != PROGRAM_VERSION_2_0_2 { + msg!("Reserve provided must be a v2.0.2 reserve"); + return Err(LendingError::InvalidAccountInput.into()); + } + + if system_program.key != &solana_program::system_program::id() { + msg!("System program provided must be the system program"); + return Err(LendingError::InvalidAccountInput.into()); + } + + // check that accounts that should be writable are writable + + if !payer.is_writable { + msg!("Payer provided must be writable"); + return Err(ProgramError::InvalidAccountData); + } + if !reserve_info.is_writable { + msg!("Reserve provided must be writable"); + return Err(ProgramError::InvalidAccountData); + } + + Ok(Self { + payer, + reserve_info, + system_program, + }) + } +} diff --git a/token-lending/program/tests/add_pool_reward.rs b/token-lending/program/tests/add_pool_reward.rs new file mode 100644 index 00000000000..9392eb785b8 --- /dev/null +++ b/token-lending/program/tests/add_pool_reward.rs @@ -0,0 +1,158 @@ +#![cfg(feature = "test-bpf")] + +mod helpers; + +use helpers::solend_program_test::{setup_world, LiqMiningReward}; +use helpers::test_reserve_config; + +use pretty_assertions::assert_eq; +use solana_program_test::*; +use solana_sdk::signature::{Keypair, Signer}; +use solend_program::{ + math::Decimal, + state::{PoolRewardId, PoolRewardManager, PositionKind, Reserve, UserRewardManager}, +}; +use solend_sdk::state::{PoolReward, PoolRewardEntry, UserReward}; + +#[tokio::test] +async fn test_add_pool_reward_for_deposit() { + test_(PositionKind::Deposit).await; +} + +#[tokio::test] +async fn test_add_pool_reward_for_borrow() { + test_(PositionKind::Borrow).await; +} + +async fn test_(position_kind: PositionKind) { + let (mut test, lending_market, usdc_reserve, wsol_reserve, mut lending_market_owner, user) = + setup_world(&test_reserve_config(), &test_reserve_config()).await; + + let reward_mint = test.create_mint_as_test_authority().await; + let reward_vault = Keypair::new(); + let duration_secs = 3_600; + let total_rewards = 1_000_000; + let current_time = test.get_clock().await.unix_timestamp as u64; + + lending_market + .add_pool_reward( + &mut test, + &usdc_reserve, + &mut lending_market_owner, + &LiqMiningReward { + mint: reward_mint, + vault: reward_vault.insecure_clone(), + }, + position_kind, + current_time, + current_time + duration_secs as u64, + total_rewards, + ) + .await + .expect("Should add pool reward"); + + let obligation = lending_market + .init_obligation(&mut test, Keypair::new(), &user) + .await + .expect("Should init obligation"); + + let usdc_reserve_post = test.load_account::(usdc_reserve.pubkey).await; + + let expected_reward_manager = Box::new(PoolRewardManager { + total_shares: 0, + last_update_time_secs: current_time as _, + pool_rewards: { + let mut og = PoolRewardManager::default().pool_rewards; + + og[0] = PoolRewardEntry::Occupied(Box::new(PoolReward { + id: PoolRewardId(1), + vault: reward_vault.pubkey(), + start_time_secs: current_time, + duration_secs, + total_rewards, + num_user_reward_managers: 0, + cumulative_rewards_per_share: Decimal::zero(), + })); + + og + }, + }); + + let expected_share = match position_kind { + PositionKind::Deposit => { + assert_eq!( + usdc_reserve_post.account, + Reserve { + deposits_pool_reward_manager: expected_reward_manager, + ..usdc_reserve.clone().account + } + ); + + let deposit_amount = 1_000_000; + lending_market + .deposit_reserve_liquidity_and_obligation_collateral( + &mut test, + &usdc_reserve, + &obligation, + &user, + deposit_amount, + ) + .await + .expect("Should deposit $USDC"); + + deposit_amount + } + PositionKind::Borrow => { + assert_eq!( + usdc_reserve_post.account, + Reserve { + borrows_pool_reward_manager: expected_reward_manager, + ..usdc_reserve.clone().account + } + ); + + lending_market + .deposit_reserve_liquidity_and_obligation_collateral( + &mut test, + &wsol_reserve, + &obligation, + &user, + 420_000_000, + ) + .await + .expect("Should borrow $wSOL"); + + lending_market + .borrow_obligation_liquidity( + &mut test, + &usdc_reserve, + &obligation, + &user, + None, + 690, + ) + .await + .expect("Should borrow $USDC"); + + 690 + } + }; + + let obligation_post = test.load_obligation(obligation.pubkey).await; + + assert_eq!( + obligation_post.account.user_reward_managers.last().unwrap(), + &UserRewardManager { + reserve: usdc_reserve.pubkey, + position_kind, + share: expected_share, + last_update_time_secs: current_time as _, + rewards: vec![UserReward { + pool_reward_index: 0, + pool_reward_id: PoolRewardId(1), + earned_rewards: Decimal::zero(), + cumulative_rewards_per_share: Decimal::zero(), + }], + } + ); +} diff --git a/token-lending/program/tests/attributed_borrows.rs b/token-lending/program/tests/attributed_borrows.rs index 049694882e9..742bc901adb 100644 --- a/token-lending/program/tests/attributed_borrows.rs +++ b/token-lending/program/tests/attributed_borrows.rs @@ -4,6 +4,7 @@ use crate::solend_program_test::custom_scenario; use crate::solend_program_test::User; +use pretty_assertions::assert_eq; use solend_program::math::TryDiv; use solana_sdk::instruction::InstructionError; @@ -12,6 +13,7 @@ use solend_program::math::TryAdd; use solend_program::state::LastUpdate; use solend_program::state::Reserve; use solend_sdk::error::LendingError; +use solend_sdk::state::PoolRewardManager; use solend_sdk::state::ReserveLiquidity; use crate::solend_program_test::ObligationArgs; @@ -22,7 +24,7 @@ use solana_program::native_token::LAMPORTS_PER_SOL; use solend_sdk::math::Decimal; -use solend_program::state::{Obligation, ReserveConfig}; +use solend_program::state::ReserveConfig; use solend_sdk::state::ReserveFees; mod helpers; @@ -318,7 +320,7 @@ async fn test_calculations() { assert_eq!( err, TransactionError::InstructionError( - 1, + 2, // ix 0 is CU budget, ix 1 is transfer to obligation for realloc, ix 2 is borrow InstructionError::Custom(LendingError::BorrowAttributionLimitExceeded as u32) ) ); @@ -359,6 +361,7 @@ async fn test_calculations() { { let usdc_reserve = reserves[0].account.clone(); let usdc_reserve_post = test.load_account::(reserves[0].pubkey).await; + let expected_usdc_reserve_post = Reserve { last_update: LastUpdate { slot: 1001, @@ -386,6 +389,20 @@ async fn test_calculations() { attributed_borrow_limit_open: 120, ..usdc_reserve.config }, + borrows_pool_reward_manager: Box::new(PoolRewardManager { + total_shares: { + assert!( + usdc_reserve.borrows_pool_reward_manager.total_shares + < usdc_reserve_post + .account + .borrows_pool_reward_manager + .total_shares + ); + + 120_000_000 + }, + ..*usdc_reserve.borrows_pool_reward_manager + }), ..usdc_reserve }; assert_eq!(usdc_reserve_post.account, expected_usdc_reserve_post); @@ -402,7 +419,7 @@ async fn test_calculations() { .await .unwrap(); - let obligation_post = test.load_account::(obligations[0].pubkey).await; + let obligation_post = test.load_obligation(obligations[0].pubkey).await; // obligation 0 after borrowing 10 usd // usdc.borrow_attribution = 80 / 100 * 30 = 24 @@ -693,7 +710,7 @@ async fn test_withdraw() { Decimal::from_percent(250) ); - let obligation_post = test.load_account::(obligations[0].pubkey).await; + let obligation_post = test.load_obligation(obligations[0].pubkey).await; assert_eq!( obligation_post.account.deposits[0].attributed_borrow_value, Decimal::from(7500u64) @@ -738,7 +755,7 @@ async fn test_withdraw() { Decimal::zero() ); - let obligation_post = test.load_account::(obligations[0].pubkey).await; + let obligation_post = test.load_obligation(obligations[0].pubkey).await; assert_eq!( obligation_post.account.deposits[0].attributed_borrow_value, Decimal::from(10u64) diff --git a/token-lending/program/tests/borrow_obligation_liquidity.rs b/token-lending/program/tests/borrow_obligation_liquidity.rs index f8284f627df..0c8cebe989d 100644 --- a/token-lending/program/tests/borrow_obligation_liquidity.rs +++ b/token-lending/program/tests/borrow_obligation_liquidity.rs @@ -1,6 +1,7 @@ #![cfg(feature = "test-bpf")] use crate::helpers::solend_program_test::*; +use pretty_assertions::assert_eq; use solana_program::pubkey::Pubkey; use solana_sdk::signature::Signer; @@ -77,7 +78,7 @@ async fn setup( .unwrap(); // populate deposit value correctly. - let obligation = test.load_account::(obligation.pubkey).await; + let obligation = test.load_obligation(obligation.pubkey).await; lending_market .refresh_obligation(&mut test, &obligation) .await @@ -86,7 +87,7 @@ async fn setup( let lending_market = test.load_account(lending_market.pubkey).await; let usdc_reserve = test.load_account(usdc_reserve.pubkey).await; let wsol_reserve = test.load_account(wsol_reserve.pubkey).await; - let obligation = test.load_account::(obligation.pubkey).await; + let obligation = test.load_obligation(obligation.pubkey).await; let host_fee_receiver = User::new_with_balances(&mut test, &[(&wsol_mint::id(), 0)]).await; ( @@ -216,7 +217,20 @@ async fn test_success() { usdc_reserve_post, ); - let wsol_reserve_post = test.load_account::(wsol_reserve.pubkey).await; + let mut wsol_reserve_post = test.load_account::(wsol_reserve.pubkey).await; + + { + // let's test liq. mining separately bcs of clock time + + let borrows_manager = &wsol_reserve_post.account.borrows_pool_reward_manager; + + assert_eq!(borrows_manager.total_shares, 4000000400); + assert_ne!(borrows_manager.last_update_time_secs, 0); + + wsol_reserve_post.account.borrows_pool_reward_manager = + wsol_reserve.account.borrows_pool_reward_manager.clone(); + } + let expected_wsol_reserve_post = Reserve { last_update: LastUpdate { slot: 1000, @@ -238,13 +252,9 @@ async fn test_success() { ..wsol_reserve.account }; - assert_eq!( - wsol_reserve_post.account, expected_wsol_reserve_post, - "{:#?} {:#?}", - wsol_reserve_post, expected_wsol_reserve_post - ); + assert_eq!(wsol_reserve_post.account, expected_wsol_reserve_post); - let obligation_post = test.load_account::(obligation.pubkey).await; + let obligation_post = test.load_obligation(obligation.pubkey).await; assert_eq!( obligation_post.account, Obligation { @@ -271,10 +281,31 @@ async fn test_success() { unweighted_borrowed_value: borrow_value, allowed_borrow_value: Decimal::from(50u64), unhealthy_borrow_value: Decimal::from(55u64), + user_reward_managers: { + // clock value remains the same + let last_update_time_secs = + obligation.account.user_reward_managers[0].last_update_time_secs; + + vec![ + UserRewardManager { + reserve: usdc_reserve.pubkey, + position_kind: PositionKind::Deposit, + share: 100000000, + last_update_time_secs, + rewards: Vec::new(), + }, + UserRewardManager { + reserve: wsol_reserve.pubkey, + position_kind: PositionKind::Borrow, + share: 4000000400, + last_update_time_secs, + rewards: Vec::new(), + }, + ] + .into() + }, ..obligation.account }, - "{:#?}", - obligation_post.account ); } @@ -379,7 +410,7 @@ async fn test_fail_borrow_over_reserve_borrow_limit() { assert_eq!( res, TransactionError::InstructionError( - 1, + 2, // ix 0 is CU budget, ix 1 is obligation realloc, ix 2 is borrow InstructionError::Custom(LendingError::InvalidAmount as u32) ) ); @@ -450,7 +481,7 @@ async fn test_fail_reserve_borrow_rate_limit_exceeded() { assert_eq!( res, TransactionError::InstructionError( - 1, + 2, // ix 0 is CU budget, ix 1 is obligation realloc, ix 2 is borrow InstructionError::Custom(LendingError::OutflowRateLimitExceeded as u32) ) ); @@ -476,7 +507,7 @@ async fn test_fail_reserve_borrow_rate_limit_exceeded() { assert_eq!( res, TransactionError::InstructionError( - 1, + 2, // ix 0 is CU budget, ix 1 is obligation realloc, ix 2 is borrow InstructionError::Custom(LendingError::OutflowRateLimitExceeded as u32) ) ); diff --git a/token-lending/program/tests/borrow_weight.rs b/token-lending/program/tests/borrow_weight.rs index 1f8c306a052..3e417caf628 100644 --- a/token-lending/program/tests/borrow_weight.rs +++ b/token-lending/program/tests/borrow_weight.rs @@ -37,7 +37,7 @@ async fn test_refresh_obligation() { .await .unwrap(); - let obligation_post = test.load_account::(obligation.pubkey).await; + let obligation_post = test.load_obligation(obligation.pubkey).await; // obligation has borrowed 10 sol and sol = $10 but since borrow weight == 2, the // borrowed_value is 200 instead of 100. @@ -134,7 +134,7 @@ async fn test_borrow() { .await .unwrap(); - let obligation_post = test.load_account::(obligation.pubkey).await; + let obligation_post = test.load_obligation(obligation.pubkey).await; // - usdc ltv is 0.5, // - sol borrow weight is 2 // max you can borrow is 100 * 0.5 / 2 = 2.5 SOL @@ -176,7 +176,7 @@ async fn test_borrow() { test.advance_clock_by_slots(1).await; - let obligation = test.load_account::(obligation.pubkey).await; + let obligation = test.load_obligation(obligation.pubkey).await; // max withdraw { @@ -302,7 +302,7 @@ async fn test_liquidation() { .await .unwrap(); - let obligation_post = test.load_account::(obligation.pubkey).await; + let obligation_post = test.load_obligation(obligation.pubkey).await; // - usdc ltv is 0.5, // - sol borrow weight is 1 // max you can borrow is 100 * 0.5 = 5 SOL @@ -340,7 +340,7 @@ async fn test_liquidation() { assert_eq!( res, TransactionError::InstructionError( - 1, + 2, InstructionError::Custom(LendingError::ObligationHealthy as u32) ) ); diff --git a/token-lending/program/tests/claim_pool_reward.rs b/token-lending/program/tests/claim_pool_reward.rs new file mode 100644 index 00000000000..092786ca8d8 --- /dev/null +++ b/token-lending/program/tests/claim_pool_reward.rs @@ -0,0 +1,553 @@ +#![cfg(feature = "test-bpf")] + +mod helpers; + +use std::collections::HashSet; + +use helpers::solend_program_test::{ + setup_world, BalanceChecker, LiqMiningReward, TokenAccount, TokenBalanceChange, +}; +use helpers::test_reserve_config; + +use pretty_assertions::assert_eq; +use solana_program_test::*; +use solana_sdk::account::Account; +use solana_sdk::instruction::InstructionError; +use solana_sdk::signature::{Keypair, Signer}; +use solana_sdk::transaction::TransactionError; +use solend_program::{ + math::Decimal, + state::{PoolRewardId, PoolRewardManager, PositionKind, Reserve, UserRewardManager}, +}; +use solend_sdk::error::LendingError; +use solend_sdk::math::TryMul; +use solend_sdk::state::{Obligation, PoolReward, PoolRewardEntry, UserReward}; + +#[tokio::test] +async fn test_claim_pool_reward_for_deposit() { + test_(PositionKind::Deposit).await; +} + +#[tokio::test] +async fn test_claim_pool_reward_for_borrow() { + test_(PositionKind::Borrow).await; +} + +async fn test_(position_kind: PositionKind) { + let (mut test, lending_market, usdc_reserve, wsol_reserve, mut lending_market_owner, mut user) = + setup_world(&test_reserve_config(), &test_reserve_config()).await; + + let reward_mint = test.create_mint_as_test_authority().await; + let reward_vault = Keypair::new(); + let duration_secs = 3_600; + let total_rewards = 1_000_000; + let current_time = test.get_clock().await.unix_timestamp as u64; + let initial_time = current_time; + let reward = LiqMiningReward { + mint: reward_mint, + vault: reward_vault.insecure_clone(), + }; + + lending_market + .add_pool_reward( + &mut test, + &usdc_reserve, + &mut lending_market_owner, + &reward, + position_kind, + current_time, + current_time + duration_secs as u64, + total_rewards, + ) + .await + .expect("Should add pool reward"); + + let obligation = lending_market + .init_obligation(&mut test, Keypair::new(), &user) + .await + .expect("Should init obligation"); + + let expected_share = match position_kind { + PositionKind::Deposit => { + let deposit_amount = 1_000_000; + lending_market + .deposit_reserve_liquidity_and_obligation_collateral( + &mut test, + &usdc_reserve, + &obligation, + &user, + deposit_amount, + ) + .await + .expect("Should deposit $USDC"); + + deposit_amount + } + PositionKind::Borrow => { + lending_market + .deposit_reserve_liquidity_and_obligation_collateral( + &mut test, + &wsol_reserve, + &obligation, + &user, + 420_000_000, + ) + .await + .expect("Should deposit $wSOL"); + + lending_market + .borrow_obligation_liquidity( + &mut test, + &usdc_reserve, + &obligation, + &user, + None, + 690, + ) + .await + .expect("Should borrow $USDC"); + + 690 + } + }; + + let current_time = test + .advance_clock_by_slots_and_secs(1, duration_secs as u64 / 2) + .await; + + // user must have a token account to deposit rewards into ahead of time + user.create_associated_token_account(&reward.mint, &mut test) + .await; + + let balance_checker = + BalanceChecker::start(&mut test, &[&TokenAccount(reward.vault.pubkey()), &user]).await; + + let err = lending_market + .claim_pool_reward( + &mut test, + &obligation, + &usdc_reserve, + &user, + &reward, + position_kind, + None, + ) + .await + .expect_err("Cannot claim reward before it ends unless owner"); + + match err.unwrap() { + TransactionError::InstructionError(_, InstructionError::Custom(err_code)) => { + assert_eq!(err_code, LendingError::InvalidSigner as u32); + } + _ => panic!("Expected LendingError::InvalidSigner, got: {:?}", err), + }; + + lending_market + .claim_pool_reward( + &mut test, + &obligation, + &usdc_reserve, + &user, + &reward, + position_kind, + Some(&user), + ) + .await + .expect("Should claim reward"); + + let (balance_changes, _) = balance_checker.find_balance_changes(&mut test).await; + + let diff = (total_rewards as i128) / 2 + - match position_kind { + PositionKind::Deposit => 0, + PositionKind::Borrow => 1, // integer division rounds down + }; + let expected_balance_changes = HashSet::from([ + TokenBalanceChange { + token_account: user.get_account(&reward.mint).unwrap(), + mint: reward.mint, + diff, + }, + TokenBalanceChange { + token_account: reward.vault.pubkey(), + mint: reward.mint, + diff: -diff, + }, + ]); + assert_eq!(balance_changes, expected_balance_changes); + + let usdc_reserve_post = test.load_account::(usdc_reserve.pubkey).await; + + let cumulative_rewards_per_share = match position_kind { + PositionKind::Deposit => Decimal::from_scaled_val(500000000000000000), + PositionKind::Borrow => Decimal::from_scaled_val(724637681159420289855), + }; + + let expected_reward_manager = PoolRewardManager { + total_shares: expected_share, + last_update_time_secs: current_time, + pool_rewards: { + let mut og = PoolRewardManager::default().pool_rewards; + + og[0] = PoolRewardEntry::Occupied(Box::new(PoolReward { + id: PoolRewardId(1), + vault: reward_vault.pubkey(), + start_time_secs: initial_time, + duration_secs, + total_rewards, + num_user_reward_managers: 1, + cumulative_rewards_per_share, + })); + + og + }, + }; + + assert_eq!( + usdc_reserve_post.account.pool_reward_manager(position_kind), + &expected_reward_manager + ); + + let obligation_post = test.load_obligation(obligation.pubkey).await; + + let earned_rewards = match position_kind { + PositionKind::Deposit => { + // on deposit there's no division involved and so it ends up being + // nice whole number + Decimal::zero() + } + PositionKind::Borrow => { + // on borrow we have some precision loss and so the one extra + // _almost_ token stays in the user's account + Decimal::from_scaled_val(999999999999999950) + } + }; + // we don't withdraw fractions of a token but keep them around for future claims + assert_eq!(earned_rewards.try_floor_u64().unwrap(), 0); + + assert_eq!( + obligation_post.account.user_reward_managers.last().unwrap(), + &UserRewardManager { + reserve: usdc_reserve.pubkey, + position_kind, + share: expected_share, + last_update_time_secs: current_time, + rewards: vec![UserReward { + pool_reward_index: 0, + pool_reward_id: PoolRewardId(1), + earned_rewards, + cumulative_rewards_per_share + }], + } + ); + + // move time forward so that all rewards can be claimed + + let current_time = test + .advance_clock_by_slots_and_secs(1, duration_secs as _) + .await; + + lending_market + .claim_pool_reward( + &mut test, + &obligation, + &usdc_reserve, + &user, + &reward, + position_kind, + None, + ) + .await + .expect("Should claim reward"); + + // reserve should have no user reward managers + + let usdc_reserve_final = test.load_account::(usdc_reserve.pubkey).await; + let pool_reward_manager = usdc_reserve_final + .account + .pool_reward_manager(position_kind); + + assert_eq!(pool_reward_manager.last_update_time_secs, current_time); + + assert_eq!( + pool_reward_manager.pool_rewards[0], + PoolRewardEntry::Occupied(Box::new(PoolReward { + id: PoolRewardId(1), + vault: reward_vault.pubkey(), + start_time_secs: initial_time, + duration_secs, + total_rewards, + num_user_reward_managers: 0, + cumulative_rewards_per_share: cumulative_rewards_per_share + .try_mul(Decimal::from(2u64)) + .unwrap() + })) + ); + + // obligation should no longer track this reward + + let obligation_final = test.load_obligation(obligation.pubkey).await; + + assert_eq!( + obligation_final + .account + .user_reward_managers + .last() + .unwrap() + .rewards, + vec![], + ); +} + +#[tokio::test] +async fn test_cannot_claim_into_wrong_destination() { + let (mut test, lending_market, usdc_reserve, _, mut lending_market_owner, user) = + setup_world(&test_reserve_config(), &test_reserve_config()).await; + + let reward_mint = test.create_mint_as_test_authority().await; + let reward_vault = Keypair::new(); + let duration_secs = 3_600; + let total_rewards = 1_000_000; + let initial_time = test.get_clock().await.unix_timestamp as u64; + let reward = LiqMiningReward { + mint: reward_mint, + vault: reward_vault.insecure_clone(), + }; + + lending_market + .add_pool_reward( + &mut test, + &usdc_reserve, + &mut lending_market_owner, + &reward, + PositionKind::Deposit, + initial_time, + initial_time + duration_secs as u64, + total_rewards, + ) + .await + .expect("Should add pool reward"); + + let obligation = lending_market + .init_obligation(&mut test, Keypair::new(), &user) + .await + .expect("Should init obligation"); + + lending_market + .deposit_reserve_liquidity_and_obligation_collateral( + &mut test, + &usdc_reserve, + &obligation, + &user, + 1, + ) + .await + .expect("Should deposit $USDC"); + + // let's use a token account of a wrong user + lending_market_owner + .create_token_account(&reward.mint, &mut test) + .await; + + let err = lending_market + .claim_pool_reward( + &mut test, + &obligation, + &usdc_reserve, + &lending_market_owner, // ! wrong + &reward, + PositionKind::Deposit, + None, + ) + .await + .expect_err("Cannot steal user reward"); + + assert_eq!( + err.unwrap(), + TransactionError::InstructionError( + 1, + InstructionError::Custom(LendingError::InvalidAccountInput as _) + ) + ); +} + +#[tokio::test] +async fn test_migrate_obligation() { + let (mut test, lending_market, usdc_reserve, _, mut lending_market_owner, mut user) = + setup_world(&test_reserve_config(), &test_reserve_config()).await; + + let obligation = lending_market + .init_obligation(&mut test, Keypair::new(), &user) + .await + .expect("Should init obligation"); + + lending_market + .deposit_reserve_liquidity_and_obligation_collateral( + &mut test, + &usdc_reserve, + &obligation, + &user, + 1, + ) + .await + .expect("Should deposit $USDC"); + + { + // The call above set up the obligation with a user reward manager. + // We'll now truncate the liq. mining data to simulate an obligation in + // the old format. + // However, that will leave the reserve in an invalid state as it will + // have already the user shares set up. + // That's ok, let's just ignore that in this test. + + let mut new_raw_obligation = Account { + data: vec![0; Obligation::MIN_LEN], + ..test + .context + .banks_client + .get_account(obligation.pubkey) + .await + .expect("Should access obligation account") + .expect("Obligation account should exist") + }; + + Obligation::pack( + { + let mut obligation = test.load_obligation(obligation.pubkey).await; + obligation.account.user_reward_managers.clear(); + obligation.account + }, + &mut new_raw_obligation.data, + ) + .expect("Should pack obligation"); + + test.context + .set_account(&obligation.pubkey, &new_raw_obligation.into()); + } + + let reward_mint = test.create_mint_as_test_authority().await; + let reward_vault = Keypair::new(); + let duration_secs = 3_600; + let total_rewards = 1_000_000; + let initial_time = test.get_clock().await.unix_timestamp as u64; + let reward = LiqMiningReward { + mint: reward_mint, + vault: reward_vault.insecure_clone(), + }; + + lending_market + .add_pool_reward( + &mut test, + &usdc_reserve, + &mut lending_market_owner, + &reward, + PositionKind::Deposit, + initial_time, + initial_time + duration_secs as u64, + total_rewards, + ) + .await + .expect("Should add pool reward"); + + let current_time = test + .advance_clock_by_slots_and_secs(1, duration_secs as u64 / 2) + .await; + + // user must have a token account to deposit rewards into ahead of time + user.create_associated_token_account(&reward.mint, &mut test) + .await; + + let balance_checker = BalanceChecker::start(&mut test, &[&user]).await; + + // At this point the user did not have any shares in the obligation and so + // they cannot claim anything. + // However, we migrate the obligation so that next time they claim they do + // get something. + + lending_market + .claim_pool_reward( + &mut test, + &obligation, + &usdc_reserve, + &user, + &reward, + PositionKind::Deposit, + None, + ) + .await + .expect("Should claim reward"); + + let usdc_reserve_post = test.load_account::(usdc_reserve.pubkey).await; + + assert_eq!( + usdc_reserve_post + .account + .deposits_pool_reward_manager + .total_shares, + 2 // 1 from the old obligation and 1 from the new one + ); + + let obligation_post = test.load_obligation(obligation.pubkey).await; + + assert_eq!( + obligation_post.account.user_reward_managers[0], + UserRewardManager { + reserve: usdc_reserve.pubkey, + position_kind: PositionKind::Deposit, + share: 1, + last_update_time_secs: current_time, + rewards: vec![UserReward { + pool_reward_index: 0, + pool_reward_id: PoolRewardId(1), + earned_rewards: Decimal::zero(), + cumulative_rewards_per_share: Decimal::from_scaled_val(500000_000000000000000000), + }], + } + ); + + let (balance_changes, _) = balance_checker.find_balance_changes(&mut test).await; + assert_eq!(balance_changes, HashSet::new()); + + let current_time = test.advance_clock_by_slots_and_secs(1, duration_secs).await; + + // now they should be able to claim rewards + + lending_market + .claim_pool_reward( + &mut test, + &obligation, + &usdc_reserve, + &user, + &reward, + PositionKind::Deposit, + None, + ) + .await + .expect("Should claim reward"); + + let obligation_final = test.load_obligation(obligation.pubkey).await; + + assert_eq!( + obligation_final.account.user_reward_managers[0], + UserRewardManager { + reserve: usdc_reserve.pubkey, + position_kind: PositionKind::Deposit, + share: 1, + last_update_time_secs: current_time, + rewards: vec![], + } + ); + + let (balance_changes, _) = balance_checker.find_balance_changes(&mut test).await; + assert_eq!( + balance_changes, + HashSet::from([TokenBalanceChange { + token_account: user.get_account(&reward.mint).unwrap(), + mint: reward.mint, + // There are 2 shares and we're accruing rewards for half the time. + // There are 2 shares bcs we reset the obligation and "register" it + // a second time in this test. + diff: (total_rewards as i128) / 4, + }]) + ); +} diff --git a/token-lending/program/tests/close_pool_reward.rs b/token-lending/program/tests/close_pool_reward.rs new file mode 100644 index 00000000000..9003a4e2c77 --- /dev/null +++ b/token-lending/program/tests/close_pool_reward.rs @@ -0,0 +1,120 @@ +#![cfg(feature = "test-bpf")] + +mod helpers; + +use std::collections::HashSet; + +use helpers::solend_program_test::{ + setup_world, BalanceChecker, LiqMiningReward, TokenBalanceChange, +}; +use helpers::test_reserve_config; + +use pretty_assertions::assert_eq; +use solana_program_test::*; +use solana_sdk::signature::Keypair; +use solend_program::state::{PoolRewardId, PoolRewardManager, PositionKind, Reserve}; +use solend_sdk::state::PoolRewardEntry; + +#[tokio::test] +async fn test_close_pool_reward_for_deposit() { + test_(PositionKind::Deposit).await; +} + +#[tokio::test] +async fn test_close_pool_reward_for_borrow() { + test_(PositionKind::Borrow).await; +} + +async fn test_(position_kind: PositionKind) { + let (mut test, lending_market, usdc_reserve, _, mut lending_market_owner, _) = + setup_world(&test_reserve_config(), &test_reserve_config()).await; + + let reward_mint = test.create_mint_as_test_authority().await; + let reward_vault = Keypair::new(); + let duration_secs = 3_600; + let total_rewards = 1_000_000; + let initial_time = test.get_clock().await.unix_timestamp as u64; + let reward = LiqMiningReward { + mint: reward_mint, + vault: reward_vault.insecure_clone(), + }; + + lending_market + .add_pool_reward( + &mut test, + &usdc_reserve, + &mut lending_market_owner, + &reward, + position_kind, + initial_time, + initial_time + duration_secs as u64, + total_rewards, + ) + .await + .expect("Should add pool reward"); + + let balance_checker = BalanceChecker::start(&mut test, &[&lending_market_owner]).await; + + // doesn't matter when we close as long as there are no obligations + test.advance_clock_by_slots_and_secs(1, 1).await; + + let pool_reward_index = 0; + lending_market + .close_pool_reward( + &mut test, + &usdc_reserve, + &mut lending_market_owner, + &reward, + position_kind, + pool_reward_index, + ) + .await + .expect("Should close pool reward"); + + let (balance_changes, _) = balance_checker.find_balance_changes(&mut test).await; + + let expected_balance_changes = HashSet::from([TokenBalanceChange { + token_account: lending_market_owner.get_account(&reward.mint).unwrap(), + mint: reward.mint, + diff: total_rewards as _, + }]); + assert_eq!(balance_changes, expected_balance_changes); + + let usdc_reserve_post = test.load_account::(usdc_reserve.pubkey).await; + + let expected_reward_manager = Box::new(PoolRewardManager { + total_shares: 0, + last_update_time_secs: initial_time as _, + pool_rewards: { + let mut og = PoolRewardManager::default().pool_rewards; + + og[0] = PoolRewardEntry::Vacant { + last_pool_reward_id: PoolRewardId(1), + has_been_just_vacated: false, + }; + + og + }, + }); + + match position_kind { + PositionKind::Deposit => { + assert_eq!( + usdc_reserve_post.account, + Reserve { + deposits_pool_reward_manager: expected_reward_manager, + ..usdc_reserve.clone().account + } + ); + } + PositionKind::Borrow => { + assert_eq!( + usdc_reserve_post.account, + Reserve { + borrows_pool_reward_manager: expected_reward_manager, + ..usdc_reserve.clone().account + } + ); + } + } +} diff --git a/token-lending/program/tests/deposit_obligation_collateral.rs b/token-lending/program/tests/deposit_obligation_collateral.rs index c0879d9abe2..7e1d0cb5c6e 100644 --- a/token-lending/program/tests/deposit_obligation_collateral.rs +++ b/token-lending/program/tests/deposit_obligation_collateral.rs @@ -9,12 +9,16 @@ use helpers::solend_program_test::{ }; use helpers::test_reserve_config; +use pretty_assertions::assert_eq; use solana_program::instruction::InstructionError; use solana_program_test::*; use solana_sdk::signature::Keypair; use solana_sdk::transaction::TransactionError; use solend_program::math::Decimal; -use solend_program::state::{LastUpdate, LendingMarket, Obligation, ObligationCollateral, Reserve}; +use solend_program::state::{ + LastUpdate, LendingMarket, Obligation, ObligationCollateral, PoolRewardManager, PositionKind, + Reserve, UserRewardManager, +}; async fn setup() -> ( SolendProgramTest, @@ -47,8 +51,10 @@ async fn test_success() { let balance_checker = BalanceChecker::start(&mut test, &[&usdc_reserve, &user]).await; + let deposit_amount = 1_000_000u64; + lending_market - .deposit_obligation_collateral(&mut test, &usdc_reserve, &obligation, &user, 1_000_000) + .deposit_obligation_collateral(&mut test, &usdc_reserve, &obligation, &user, deposit_amount) .await .expect("This should succeed"); @@ -61,12 +67,12 @@ async fn test_success() { .get_account(&usdc_reserve.account.collateral.mint_pubkey) .unwrap(), mint: usdc_reserve.account.collateral.mint_pubkey, - diff: -1_000_000, + diff: -(deposit_amount as i128), }, TokenBalanceChange { token_account: usdc_reserve.account.collateral.supply_pubkey, mint: usdc_reserve.account.collateral.mint_pubkey, - diff: 1_000_000, + diff: deposit_amount as _, }, ]); @@ -77,10 +83,26 @@ async fn test_success() { let lending_market_post = test.load_account(lending_market.pubkey).await; assert_eq!(lending_market, lending_market_post); - let usdc_reserve_post = test.load_account(usdc_reserve.pubkey).await; - assert_eq!(usdc_reserve, usdc_reserve_post); + let usdc_reserve_post = test.load_account::(usdc_reserve.pubkey).await; + let last_update_time_secs = usdc_reserve_post + .account + .deposits_pool_reward_manager + .last_update_time_secs; + assert_ne!(last_update_time_secs, 0); + + assert_eq!( + usdc_reserve_post.account, + Reserve { + deposits_pool_reward_manager: Box::new(PoolRewardManager { + total_shares: deposit_amount, + last_update_time_secs, + ..*usdc_reserve.account.deposits_pool_reward_manager + }), + ..usdc_reserve.account + } + ); - let obligation_post = test.load_account::(obligation.pubkey).await; + let obligation_post = test.load_obligation(obligation.pubkey).await; assert_eq!( obligation_post.account, Obligation { @@ -90,10 +112,18 @@ async fn test_success() { }, deposits: vec![ObligationCollateral { deposit_reserve: usdc_reserve.pubkey, - deposited_amount: 1_000_000, + deposited_amount: deposit_amount, market_value: Decimal::zero(), // this field only gets updated on a refresh attributed_borrow_value: Decimal::zero() }], + user_reward_managers: vec![UserRewardManager { + reserve: usdc_reserve.pubkey, + position_kind: PositionKind::Deposit, + share: deposit_amount, + last_update_time_secs, + rewards: Vec::new(), + }] + .into(), ..obligation.account } ); @@ -110,11 +140,13 @@ async fn test_fail_deposit_too_much() { .unwrap() .unwrap(); + // ix 0 is CU budget, ix 1 is transfer to obligation for realloc, ix 2 is deposit + const EXPECTED_IX: u8 = 2; match res { // InsufficientFunds - TransactionError::InstructionError(1, InstructionError::Custom(1)) => (), + TransactionError::InstructionError(EXPECTED_IX, InstructionError::Custom(1)) => (), // LendingError::TokenTransferFailed - TransactionError::InstructionError(1, InstructionError::Custom(17)) => (), + TransactionError::InstructionError(EXPECTED_IX, InstructionError::Custom(17)) => (), e => panic!("unexpected error: {:#?}", e), }; } diff --git a/token-lending/program/tests/deposit_reserve_liquidity_and_obligation_collateral.rs b/token-lending/program/tests/deposit_reserve_liquidity_and_obligation_collateral.rs index 579d80b3d56..16eb199fa96 100644 --- a/token-lending/program/tests/deposit_reserve_liquidity_and_obligation_collateral.rs +++ b/token-lending/program/tests/deposit_reserve_liquidity_and_obligation_collateral.rs @@ -3,6 +3,7 @@ mod helpers; use crate::solend_program_test::MintSupplyChange; +use pretty_assertions::assert_eq; use std::collections::HashSet; use helpers::solend_program_test::{ @@ -14,8 +15,8 @@ use solana_sdk::signature::Keypair; use solend_program::math::Decimal; use solend_program::state::{ - LastUpdate, LendingMarket, Obligation, ObligationCollateral, Reserve, ReserveCollateral, - ReserveLiquidity, + LastUpdate, LendingMarket, Obligation, ObligationCollateral, PoolRewardManager, PositionKind, + Reserve, ReserveCollateral, ReserveLiquidity, UserRewardManager, }; async fn setup() -> ( @@ -44,6 +45,8 @@ async fn test_success() { let balance_checker = BalanceChecker::start(&mut test, &[&usdc_reserve, &user]).await; + let deposit_amount = 1_000_000; + // deposit lending_market .deposit_reserve_liquidity_and_obligation_collateral( @@ -51,7 +54,7 @@ async fn test_success() { &usdc_reserve, &obligation, &user, - 1_000_000, + deposit_amount, ) .await .expect("this should succeed"); @@ -66,31 +69,27 @@ async fn test_success() { TokenBalanceChange { token_account: user.get_account(&usdc_mint::id()).unwrap(), mint: usdc_mint::id(), - diff: -1_000_000, + diff: -(deposit_amount as i128), }, TokenBalanceChange { token_account: usdc_reserve.account.collateral.supply_pubkey, mint: usdc_reserve.account.collateral.mint_pubkey, - diff: 1_000_000, + diff: deposit_amount as _, }, TokenBalanceChange { token_account: usdc_reserve.account.liquidity.supply_pubkey, mint: usdc_reserve.account.liquidity.mint_pubkey, - diff: 1_000_000, + diff: deposit_amount as _, }, ]), - "{:#?}", - token_balance_changes ); assert_eq!( mint_supply_changes, HashSet::from([MintSupplyChange { mint: usdc_reserve.account.collateral.mint_pubkey, - diff: 1_000_000, + diff: deposit_amount as _, },]), - "{:#?}", - mint_supply_changes ); // check program state @@ -100,26 +99,38 @@ async fn test_success() { assert_eq!(lending_market.account, lending_market_post.account); let usdc_reserve_post = test.load_account::(usdc_reserve.pubkey).await; + let last_update_time_secs = usdc_reserve_post + .account + .deposits_pool_reward_manager + .last_update_time_secs; + assert_ne!(last_update_time_secs, 0); + assert_eq!( usdc_reserve_post.account, Reserve { last_update: LastUpdate { slot: 1001, - stale: false, + stale: true, }, liquidity: ReserveLiquidity { - available_amount: usdc_reserve.account.liquidity.available_amount + 1_000_000, + available_amount: usdc_reserve.account.liquidity.available_amount + deposit_amount, ..usdc_reserve.account.liquidity }, collateral: ReserveCollateral { - mint_total_supply: usdc_reserve.account.collateral.mint_total_supply + 1_000_000, + mint_total_supply: usdc_reserve.account.collateral.mint_total_supply + + deposit_amount, ..usdc_reserve.account.collateral }, + deposits_pool_reward_manager: Box::new(PoolRewardManager { + total_shares: deposit_amount, + last_update_time_secs, + ..*usdc_reserve.account.deposits_pool_reward_manager + }), ..usdc_reserve.account } ); - let obligation_post = test.load_account::(obligation.pubkey).await; + let obligation_post = test.load_obligation(obligation.pubkey).await; assert_eq!( obligation_post.account, Obligation { @@ -134,6 +145,14 @@ async fn test_success() { attributed_borrow_value: Decimal::zero() }] .to_vec(), + user_reward_managers: vec![UserRewardManager { + reserve: usdc_reserve.pubkey, + position_kind: PositionKind::Deposit, + share: deposit_amount, + last_update_time_secs: last_update_time_secs, + rewards: Vec::new(), + }] + .into(), ..obligation.account } ); diff --git a/token-lending/program/tests/edit_pool_reward.rs b/token-lending/program/tests/edit_pool_reward.rs new file mode 100644 index 00000000000..91b26d76251 --- /dev/null +++ b/token-lending/program/tests/edit_pool_reward.rs @@ -0,0 +1,274 @@ +#![cfg(feature = "test-bpf")] + +mod helpers; + +use std::collections::HashSet; + +use helpers::solend_program_test::{ + setup_world, BalanceChecker, LiqMiningReward, TokenAccount, TokenBalanceChange, +}; +use helpers::test_reserve_config; + +use pretty_assertions::assert_eq; +use solana_program_test::*; +use solana_sdk::signature::{Keypair, Signer}; +use solend_program::{ + math::Decimal, + state::{PoolRewardId, PoolRewardManager, PositionKind, Reserve}, +}; +use solend_sdk::state::{PoolReward, PoolRewardEntry}; + +#[tokio::test] +async fn test_cancel_pool_reward_for_deposit() { + test_cancel_(PositionKind::Deposit).await; +} + +#[tokio::test] +async fn test_cancel_pool_reward_for_borrow() { + test_cancel_(PositionKind::Borrow).await; +} + +#[tokio::test] +async fn test_extend_pool_reward_for_deposit() { + test_extend_(PositionKind::Deposit).await; +} + +#[tokio::test] +async fn test_extend_pool_reward_for_borrow() { + test_extend_(PositionKind::Borrow).await; +} + +async fn test_cancel_(position_kind: PositionKind) { + let (mut test, lending_market, usdc_reserve, _, mut lending_market_owner, _) = + setup_world(&test_reserve_config(), &test_reserve_config()).await; + + let reward_mint = test.create_mint_as_test_authority().await; + let reward_vault = Keypair::new(); + let duration_secs = 10 * 3_600; + let total_rewards = 1_000_000; + let initial_time = test.get_clock().await.unix_timestamp as u64; + let reward = LiqMiningReward { + mint: reward_mint, + vault: reward_vault.insecure_clone(), + }; + + lending_market + .add_pool_reward( + &mut test, + &usdc_reserve, + &mut lending_market_owner, + &reward, + position_kind, + initial_time, + initial_time + duration_secs as u64, + total_rewards, + ) + .await + .expect("Should add pool reward"); + + let balance_checker = BalanceChecker::start( + &mut test, + &[&TokenAccount(reward.vault.pubkey()), &lending_market_owner], + ) + .await; + + let current_time = test + .advance_clock_by_slots_and_secs(1, duration_secs as u64 / 2) + .await; + + let pool_reward_index = 0; + lending_market + .edit_pool_reward( + &mut test, + &usdc_reserve, + &mut lending_market_owner, + &reward, + position_kind, + pool_reward_index, + 0, // cancel + ) + .await + .expect("Should cancel pool reward"); + + let (balance_changes, _) = balance_checker.find_balance_changes(&mut test).await; + + let diff = (total_rewards as i128) / 2 - 1; + let expected_balance_changes = HashSet::from([ + TokenBalanceChange { + token_account: reward.vault.pubkey(), + mint: reward.mint, + diff: -diff, + }, + TokenBalanceChange { + token_account: lending_market_owner.get_account(&reward.mint).unwrap(), + mint: reward.mint, + diff, + }, + ]); + assert_eq!(balance_changes, expected_balance_changes); + + let usdc_reserve_post = test.load_account::(usdc_reserve.pubkey).await; + + let expected_reward_manager = Box::new(PoolRewardManager { + total_shares: 0, + last_update_time_secs: current_time, + pool_rewards: { + let mut og = PoolRewardManager::default().pool_rewards; + + og[0] = PoolRewardEntry::Occupied(Box::new(PoolReward { + id: PoolRewardId(1), + vault: reward_vault.pubkey(), + start_time_secs: initial_time, + duration_secs: duration_secs / 2, + total_rewards: total_rewards - diff as u64, + num_user_reward_managers: 0, + cumulative_rewards_per_share: Decimal::zero(), + })); + + og + }, + }); + + match position_kind { + PositionKind::Deposit => { + assert_eq!( + usdc_reserve_post.account, + Reserve { + deposits_pool_reward_manager: expected_reward_manager, + ..usdc_reserve.clone().account + } + ); + } + PositionKind::Borrow => { + assert_eq!( + usdc_reserve_post.account, + Reserve { + borrows_pool_reward_manager: expected_reward_manager, + ..usdc_reserve.clone().account + } + ); + } + } +} + +async fn test_extend_(position_kind: PositionKind) { + let (mut test, lending_market, usdc_reserve, _, mut lending_market_owner, _) = + setup_world(&test_reserve_config(), &test_reserve_config()).await; + + let reward_mint = test.create_mint_as_test_authority().await; + let reward_vault = Keypair::new(); + let duration_secs = 10 * 3_600u32; + let total_rewards = 1_000_000; + let initial_time = test.get_clock().await.unix_timestamp as u64; + let reward = LiqMiningReward { + mint: reward_mint, + vault: reward_vault.insecure_clone(), + }; + + lending_market + .add_pool_reward( + &mut test, + &usdc_reserve, + &mut lending_market_owner, + &reward, + position_kind, + initial_time, + initial_time + duration_secs as u64, + total_rewards, + ) + .await + .expect("Should add pool reward"); + + let lending_market_owner_reward_token_account = + lending_market_owner.get_account(&reward.mint).unwrap(); + + let current_time = test + .advance_clock_by_slots_and_secs(1, duration_secs as u64 / 2) + .await; + + test.mint_to( + &reward.mint, + &lending_market_owner_reward_token_account, + total_rewards, + ) + .await; + + let balance_checker = BalanceChecker::start( + &mut test, + &[&TokenAccount(reward.vault.pubkey()), &lending_market_owner], + ) + .await; + + let pool_reward_index = 0; + lending_market + .edit_pool_reward( + &mut test, + &usdc_reserve, + &mut lending_market_owner, + &reward, + position_kind, + pool_reward_index, + initial_time + duration_secs as u64 * 2, // twice as long + ) + .await + .expect("Should extend pool reward"); + + let (balance_changes, _) = balance_checker.find_balance_changes(&mut test).await; + + let expected_balance_changes = HashSet::from([ + TokenBalanceChange { + token_account: reward.vault.pubkey(), + mint: reward.mint, + diff: total_rewards as i128, + }, + TokenBalanceChange { + token_account: lending_market_owner_reward_token_account, + mint: reward.mint, + diff: -(total_rewards as i128), + }, + ]); + assert_eq!(balance_changes, expected_balance_changes); + + let usdc_reserve_post = test.load_account::(usdc_reserve.pubkey).await; + + let expected_reward_manager = Box::new(PoolRewardManager { + total_shares: 0, + last_update_time_secs: current_time, + pool_rewards: { + let mut og = PoolRewardManager::default().pool_rewards; + + og[0] = PoolRewardEntry::Occupied(Box::new(PoolReward { + id: PoolRewardId(1), + vault: reward_vault.pubkey(), + start_time_secs: initial_time, + duration_secs: duration_secs * 2, + total_rewards: total_rewards * 2, + num_user_reward_managers: 0, + cumulative_rewards_per_share: Decimal::zero(), + })); + + og + }, + }); + + match position_kind { + PositionKind::Deposit => { + assert_eq!( + usdc_reserve_post.account, + Reserve { + deposits_pool_reward_manager: expected_reward_manager, + ..usdc_reserve.clone().account + } + ); + } + PositionKind::Borrow => { + assert_eq!( + usdc_reserve_post.account, + Reserve { + borrows_pool_reward_manager: expected_reward_manager, + ..usdc_reserve.clone().account + } + ); + } + } +} diff --git a/token-lending/program/tests/forgive_debt.rs b/token-lending/program/tests/forgive_debt.rs index e439cfbc6b8..022c113968e 100644 --- a/token-lending/program/tests/forgive_debt.rs +++ b/token-lending/program/tests/forgive_debt.rs @@ -10,6 +10,7 @@ use solana_sdk::instruction::Instruction; use solana_sdk::pubkey::Pubkey; use solana_sdk::signer::Signer; +use pretty_assertions::assert_eq; use std::collections::HashSet; use solend_sdk::instruction::LendingInstruction; @@ -123,7 +124,7 @@ async fn test_forgive_debt_success_easy() { assert_eq!( err, TransactionError::InstructionError( - 3, + 4, InstructionError::Custom(LendingError::InvalidAccountInput as u32) ) ); @@ -167,7 +168,7 @@ async fn test_forgive_debt_success_easy() { .await .unwrap(); - let obligation_post = test.load_account::(obligations[0].pubkey).await; + let obligation_post = test.load_obligation(obligations[0].pubkey).await; assert_eq!( obligation_post.account, Obligation { @@ -184,6 +185,7 @@ async fn test_forgive_debt_success_easy() { allowed_borrow_value: Decimal::zero(), unhealthy_borrow_value: Decimal::zero(), super_unhealthy_borrow_value: Decimal::zero(), + user_reward_managers: obligation_post.account.user_reward_managers.clone(), ..obligations[0].account } ); @@ -203,6 +205,10 @@ async fn test_forgive_debt_success_easy() { + wsol_reserve.account.liquidity.available_amount, ..wsol_reserve.account.liquidity }, + borrows_pool_reward_manager: Box::new(PoolRewardManager { + total_shares: 0, // liquidated everything + ..*wsol_reserve.account.borrows_pool_reward_manager.clone() + }), ..wsol_reserve.account.clone() } ); @@ -310,7 +316,7 @@ async fn test_forgive_debt_fail_invalid_signer() { assert_eq!( err, TransactionError::InstructionError( - 3, + 4, InstructionError::Custom(LendingError::InvalidMarketOwner as u32) ) ); diff --git a/token-lending/program/tests/helpers/mod.rs b/token-lending/program/tests/helpers/mod.rs index 0562b38d34f..9683c86ab74 100644 --- a/token-lending/program/tests/helpers/mod.rs +++ b/token-lending/program/tests/helpers/mod.rs @@ -117,25 +117,25 @@ pub mod bonk_mint { } pub trait AddPacked { + fn add_packed(&mut self, pubkey: Pubkey, amount: u64, data: &[u8], owner: &Pubkey); + fn add_packable_account( &mut self, pubkey: Pubkey, amount: u64, - data: &T, + unpacked: &T, owner: &Pubkey, - ); + ) { + let mut data = vec![0; T::get_packed_len()]; + unpacked.pack_into_slice(&mut data); + self.add_packed(pubkey, amount, &data, owner); + } } impl AddPacked for ProgramTest { - fn add_packable_account( - &mut self, - pubkey: Pubkey, - amount: u64, - data: &T, - owner: &Pubkey, - ) { - let mut account = Account::new(amount, T::get_packed_len(), owner); - data.pack_into_slice(&mut account.data); + fn add_packed(&mut self, pubkey: Pubkey, amount: u64, data: &[u8], owner: &Pubkey) { + let mut account = Account::new(amount, data.len(), owner); + account.data.copy_from_slice(data); self.add_account(pubkey, account); } } diff --git a/token-lending/program/tests/helpers/solend_program_test.rs b/token-lending/program/tests/helpers/solend_program_test.rs index 769695e6d06..b2a649835cf 100644 --- a/token-lending/program/tests/helpers/solend_program_test.rs +++ b/token-lending/program/tests/helpers/solend_program_test.rs @@ -57,6 +57,36 @@ use std::{ use super::mock_pyth::{init, set_price}; use super::mock_pyth_pull::{init as init_pull, set_price as set_price_pull}; +mod cu_budgets { + pub(super) const INIT_OBLIGATION: u32 = 10_001; + pub(super) const DEPOSIT_OBLIGATION_COLLATERAL: u32 = 70_002; + pub(super) const REFRESH_RESERVE: u32 = 2_000_003; + pub(super) const REFRESH_OBLIGATION: u32 = 1_000_004; + pub(super) const BORROW_OBLIGATION_LIQUIDITY: u32 = 180_005; + pub(super) const REPAY_OBLIGATION_LIQUIDITY: u32 = 70_006; + pub(super) const REDEEM_FEES: u32 = 80_007; + pub(super) const LIQUIDATE_OBLIGATION_AND_REDEEM_RESERVE_COLLATERAL: u32 = 250_008; + pub(super) const WITHDRAW_OBLIGATION_COLLATERAL_AND_REDEEM_RESERVE_COLLATERAL: u32 = 200_009; + pub(super) const WITHDRAW_OBLIGATION_COLLATERAL: u32 = 130_010; + pub(super) const INIT_RESERVE: u32 = 90_011; + pub(super) const DEPOSIT: u32 = 70_012; + pub(super) const DONATE_TO_RESERVE: u32 = 50_013; + pub(super) const UPDATE_RESERVE_CONFIG: u32 = 30_014; + pub(super) const DEPOSIT_RESERVE_LIQUIDITY_AND_OBLIGATION_COLLATERAL: u32 = 130_015; + pub(super) const REDEEM: u32 = 90_016; + pub(super) const ADD_POOL_REWARD: u32 = 80_017; + pub(super) const EDIT_POOL_REWARD: u32 = 80_018; + pub(super) const CLOSE_POOL_REWARD: u32 = 80_019; + pub(super) const CLAIM_POOL_REWARD: u32 = 200_020; +} + +/// This is at most how many bytes can an obligation grow. +/// An obligation grows dynamically as needed when new rewards are being tracked. +/// These tests don't need to care about correctly transferring just the amount +/// needed, we'll just transfer lamports to cover the rent of the largest +/// possible obligation there can be. +const OBLIGATION_EXTRA_SIZE: usize = Obligation::MAX_LEN - Obligation::MIN_LEN; + pub struct SolendProgramTest { pub context: ProgramTestContext, rent: Rent, @@ -80,6 +110,11 @@ pub struct Info { pub account: T, } +pub struct LiqMiningReward { + pub mint: Pubkey, + pub vault: Keypair, +} + impl SolendProgramTest { pub async fn start_with_test(mut test: ProgramTest) -> Self { test.prefer_bpf(false); @@ -254,6 +289,21 @@ impl SolendProgramTest { } } + pub async fn load_obligation(&mut self, acc_pk: Pubkey) -> Info { + let acc = self + .context + .banks_client + .get_account(acc_pk) + .await + .unwrap() + .unwrap(); + + Info { + pubkey: acc_pk, + account: Obligation::unpack(&acc.data).unwrap(), + } + } + pub async fn load_zeroable_account(&mut self, acc_pk: Pubkey) -> Info { let acc = self .context @@ -288,6 +338,16 @@ impl SolendProgramTest { .await } + /// Returns the new clock unix timestamp + pub async fn advance_clock_by_slots_and_secs(&mut self, slots: u64, secs: u64) -> u64 { + self.advance_clock_by_slots(slots).await; + let mut clock = self.get_clock().await; + clock.unix_timestamp += secs as i64; + self.context.set_sysvar(&clock); + + clock.unix_timestamp as u64 + } + /// Advances clock by x slots. note that transactions don't automatically increment the slot /// value in Clock, so this function must be explicitly called whenever you want time to move /// forward. @@ -325,6 +385,12 @@ impl SolendProgramTest { keypair.pubkey() } + pub async fn create_mint_as_test_authority(&mut self) -> Pubkey { + let mint = self.create_mint(&self.authority.pubkey()).await; + self.mints.insert(mint, None); + mint + } + pub async fn create_mint(&mut self, mint_authority: &Pubkey) -> Pubkey { let keypair = Keypair::new(); let rent = self.rent.minimum_balance(Mint::LEN); @@ -380,6 +446,29 @@ impl SolendProgramTest { keypair.pubkey() } + pub async fn create_associated_token_account( + &mut self, + owner: &Pubkey, + mint: &Pubkey, + ) -> Pubkey { + let instructions = [ + spl_associated_token_account::instruction::create_associated_token_account( + &self.context.payer.pubkey(), + owner, + mint, + &spl_token::id(), + ), + ]; + + self.process_transaction(&instructions, None).await.unwrap(); + + spl_associated_token_account::get_associated_token_address_with_program_id( + owner, + mint, + &spl_token::id(), + ) + } + pub async fn mint_to(&mut self, mint: &Pubkey, dst: &Pubkey, amount: u64) { assert!(self.mints.contains_key(mint)); @@ -656,7 +745,7 @@ impl SolendProgramTest { let res = self .process_transaction( &[ - ComputeBudgetInstruction::set_compute_unit_limit(80_000), + ComputeBudgetInstruction::set_compute_unit_limit(cu_budgets::INIT_RESERVE), init_reserve( solend_program::id(), liquidity_amount, @@ -770,6 +859,30 @@ impl User { } } + pub async fn create_associated_token_account( + &mut self, + mint: &Pubkey, + test: &mut SolendProgramTest, + ) -> Info { + match self + .token_accounts + .iter() + .find(|ta| ta.account.mint == *mint) + { + None => { + let pubkey = test + .create_associated_token_account(&self.keypair.pubkey(), mint) + .await; + let account = test.load_account::(pubkey).await; + + self.token_accounts.push(account.clone()); + + account + } + Some(t) => t.clone(), + } + } + pub async fn transfer( &self, mint: &Pubkey, @@ -842,7 +955,7 @@ impl Info { liquidity_amount: u64, ) -> Result<(), BanksClientError> { let instructions = [ - ComputeBudgetInstruction::set_compute_unit_limit(50_000), + ComputeBudgetInstruction::set_compute_unit_limit(cu_budgets::DEPOSIT), deposit_reserve_liquidity( solend_program::id(), liquidity_amount, @@ -862,6 +975,188 @@ impl Info { .await } + pub async fn add_pool_reward( + &self, + test: &mut SolendProgramTest, + reserve: &Info, + lending_market_owner: &mut User, + reward: &LiqMiningReward, + position_kind: PositionKind, + start_time_secs: u64, + end_time_secs: u64, + reward_amount: u64, + ) -> Result<(), BanksClientError> { + let token_account = lending_market_owner + .create_token_account(&reward.mint, test) + .await; + test.mint_to(&reward.mint, &token_account.pubkey, reward_amount) + .await; + + let (reward_authority_pda, reward_authority_bump) = find_reward_vault_authority( + &solend_program::id(), + &self.pubkey, + &reward.vault.pubkey(), + ); + + let instructions = [ + ComputeBudgetInstruction::set_compute_unit_limit(cu_budgets::ADD_POOL_REWARD), + system_instruction::create_account( + &test.context.payer.pubkey(), + &reward.vault.pubkey(), + test.rent.minimum_balance(Token::LEN), + spl_token::state::Account::LEN as u64, + &spl_token::id(), + ), + add_pool_reward( + solend_program::id(), + reward_authority_bump, + position_kind, + start_time_secs, + end_time_secs, + reward_amount, + reserve.pubkey, + reward.mint, + token_account.pubkey, + reward_authority_pda, + reward.vault.pubkey(), + self.pubkey, + lending_market_owner.keypair.pubkey(), + ), + ]; + + test.process_transaction( + &instructions, + Some(&[&lending_market_owner.keypair, &reward.vault]), + ) + .await + } + + pub async fn edit_pool_reward( + &self, + test: &mut SolendProgramTest, + reserve: &Info, + lending_market_owner: &mut User, + reward: &LiqMiningReward, + position_kind: PositionKind, + pool_reward_index: u64, + new_end_time_secs: u64, + ) -> Result<(), BanksClientError> { + let (reward_authority_pda, reward_authority_bump) = find_reward_vault_authority( + &solend_program::id(), + &self.pubkey, + &reward.vault.pubkey(), + ); + + let instructions = [ + ComputeBudgetInstruction::set_compute_unit_limit(cu_budgets::EDIT_POOL_REWARD), + edit_pool_reward( + solend_program::id(), + reward_authority_bump, + position_kind, + pool_reward_index, + new_end_time_secs, + reserve.pubkey, + reward.mint, + lending_market_owner.get_account(&reward.mint).unwrap(), + reward_authority_pda, + reward.vault.pubkey(), + self.pubkey, + lending_market_owner.keypair.pubkey(), + ), + ]; + + test.process_transaction(&instructions, Some(&[&lending_market_owner.keypair])) + .await + } + + pub async fn close_pool_reward( + &self, + test: &mut SolendProgramTest, + reserve: &Info, + lending_market_owner: &mut User, + reward: &LiqMiningReward, + position_kind: PositionKind, + pool_reward_index: u64, + ) -> Result<(), BanksClientError> { + let (reward_authority_pda, reward_authority_bump) = find_reward_vault_authority( + &solend_program::id(), + &self.pubkey, + &reward.vault.pubkey(), + ); + + let instructions = [ + ComputeBudgetInstruction::set_compute_unit_limit(cu_budgets::CLOSE_POOL_REWARD), + close_pool_reward( + solend_program::id(), + reward_authority_bump, + position_kind, + pool_reward_index, + reserve.pubkey, + reward.mint, + lending_market_owner.get_account(&reward.mint).unwrap(), + reward_authority_pda, + reward.vault.pubkey(), + self.pubkey, + lending_market_owner.keypair.pubkey(), + ), + ]; + + test.process_transaction(&instructions, Some(&[&lending_market_owner.keypair])) + .await + } + + pub async fn claim_pool_reward( + &self, + test: &mut SolendProgramTest, + obligation: &Info, + reserve: &Info, + obligation_owner: &User, + reward: &LiqMiningReward, + position_kind: PositionKind, + signer: Option<&User>, + ) -> Result<(), BanksClientError> { + let (reward_authority_pda, reward_authority_bump) = find_reward_vault_authority( + &solend_program::id(), + &self.pubkey, + &reward.vault.pubkey(), + ); + + let mut instructions = if matches!(position_kind, PositionKind::Borrow) { + self.build_refresh_instructions(test, obligation, None) + .await + } else { + vec![] + }; + + instructions.extend_from_slice(&[ + ComputeBudgetInstruction::set_compute_unit_limit(cu_budgets::CLAIM_POOL_REWARD), + claim_pool_reward( + solend_program::id(), + reward_authority_bump, + position_kind, + obligation.pubkey, + spl_associated_token_account::get_associated_token_address_with_program_id( + &obligation_owner.keypair.pubkey(), + &reward.mint, + &spl_token::id(), + ), + reserve.pubkey, + reward.mint, + reward_authority_pda, + reward.vault.pubkey(), + self.pubkey, + signer.map(|s| s.keypair.pubkey()), + ), + ]); + + if let Some(signer) = signer { + test.process_transaction(&instructions, Some(&[&signer.keypair])) + .await + } else { + test.process_transaction(&instructions, None).await + } + } + pub async fn donate_to_reserve( &self, test: &mut SolendProgramTest, @@ -870,7 +1165,7 @@ impl Info { liquidity_amount: u64, ) -> Result<(), BanksClientError> { let instructions = [ - ComputeBudgetInstruction::set_compute_unit_limit(50_000), + ComputeBudgetInstruction::set_compute_unit_limit(cu_budgets::DONATE_TO_RESERVE), donate_to_reserve( solend_program::id(), liquidity_amount, @@ -904,7 +1199,7 @@ impl Info { let oracle = oracle.unwrap_or(&default_oracle); let instructions = [ - ComputeBudgetInstruction::set_compute_unit_limit(30_000), + ComputeBudgetInstruction::set_compute_unit_limit(cu_budgets::UPDATE_RESERVE_CONFIG), update_reserve_config( solend_program::id(), config, @@ -931,7 +1226,14 @@ impl Info { liquidity_amount: u64, ) -> Result<(), BanksClientError> { let instructions = [ - ComputeBudgetInstruction::set_compute_unit_limit(70_000), + ComputeBudgetInstruction::set_compute_unit_limit( + cu_budgets::DEPOSIT_RESERVE_LIQUIDITY_AND_OBLIGATION_COLLATERAL, + ), + system_instruction::transfer( + &test.context.payer.pubkey(), + &obligation.pubkey, + Rent::minimum_balance(&Rent::default(), OBLIGATION_EXTRA_SIZE), + ), deposit_reserve_liquidity_and_obligation_collateral( solend_program::id(), liquidity_amount, @@ -964,7 +1266,7 @@ impl Info { collateral_amount: u64, ) -> Result<(), BanksClientError> { let instructions = [ - ComputeBudgetInstruction::set_compute_unit_limit(60_000), + ComputeBudgetInstruction::set_compute_unit_limit(cu_budgets::REDEEM), refresh_reserve( solend_program::id(), reserve.pubkey, @@ -998,12 +1300,12 @@ impl Info { user: &User, ) -> Result, BanksClientError> { let instructions = [ - ComputeBudgetInstruction::set_compute_unit_limit(10_000), + ComputeBudgetInstruction::set_compute_unit_limit(cu_budgets::INIT_OBLIGATION), system_instruction::create_account( &test.context.payer.pubkey(), &obligation_keypair.pubkey(), - Rent::minimum_balance(&Rent::default(), Obligation::LEN), - Obligation::LEN as u64, + Rent::minimum_balance(&Rent::default(), Obligation::MIN_LEN), + Obligation::MIN_LEN as u64, &solend_program::id(), ), init_obligation( @@ -1018,9 +1320,7 @@ impl Info { .process_transaction(&instructions, Some(&[&obligation_keypair, &user.keypair])) .await { - Ok(()) => Ok(test - .load_account::(obligation_keypair.pubkey()) - .await), + Ok(()) => Ok(test.load_obligation(obligation_keypair.pubkey()).await), Err(e) => Err(e), } } @@ -1034,7 +1334,14 @@ impl Info { collateral_amount: u64, ) -> Result<(), BanksClientError> { let instructions = [ - ComputeBudgetInstruction::set_compute_unit_limit(38_000), + ComputeBudgetInstruction::set_compute_unit_limit( + cu_budgets::DEPOSIT_OBLIGATION_COLLATERAL, + ), + system_instruction::transfer( + &test.context.payer.pubkey(), + &obligation.pubkey, + Rent::minimum_balance(&Rent::default(), OBLIGATION_EXTRA_SIZE), + ), deposit_obligation_collateral( solend_program::id(), collateral_amount, @@ -1060,7 +1367,7 @@ impl Info { ) -> Result<(), BanksClientError> { test.process_transaction( &[ - ComputeBudgetInstruction::set_compute_unit_limit(2_000_000), + ComputeBudgetInstruction::set_compute_unit_limit(cu_budgets::REFRESH_RESERVE), refresh_reserve( solend_program::id(), reserve.pubkey, @@ -1080,7 +1387,7 @@ impl Info { obligation: &Info, extra_reserve: Option<&Info>, ) -> Vec { - let obligation = test.load_account::(obligation.pubkey).await; + let obligation = test.load_obligation(obligation.pubkey).await; let reserve_pubkeys: Vec = { let mut r = HashSet::new(); r.extend( @@ -1130,6 +1437,12 @@ impl Info { r }; + instructions.push(system_instruction::transfer( + &test.context.payer.pubkey(), + &obligation.pubkey, + Rent::minimum_balance(&Rent::default(), OBLIGATION_EXTRA_SIZE), + )); + instructions.push(refresh_obligation( solend_program::id(), obligation.pubkey, @@ -1159,7 +1472,9 @@ impl Info { Err(e) => return Err(e), }; - let mut instructions = vec![ComputeBudgetInstruction::set_compute_unit_limit(1_000_000)]; + let mut instructions = vec![ComputeBudgetInstruction::set_compute_unit_limit( + cu_budgets::REFRESH_OBLIGATION, + )]; instructions.push(refresh_reserve_instructions.last().unwrap().clone()); test.process_transaction(&instructions, None).await @@ -1174,14 +1489,23 @@ impl Info { host_fee_receiver_pubkey: Option, liquidity_amount: u64, ) -> Result<(), BanksClientError> { - let obligation = test.load_account::(obligation.pubkey).await; + let obligation = test.load_obligation(obligation.pubkey).await; let refresh_ixs = self .build_refresh_instructions(test, &obligation, Some(borrow_reserve)) .await; test.process_transaction(&refresh_ixs, None).await.unwrap(); - let mut instructions = vec![ComputeBudgetInstruction::set_compute_unit_limit(100_000)]; + let mut instructions = vec![ + ComputeBudgetInstruction::set_compute_unit_limit( + cu_budgets::BORROW_OBLIGATION_LIQUIDITY, + ), + system_instruction::transfer( + &test.context.payer.pubkey(), + &obligation.pubkey, + Rent::minimum_balance(&Rent::default(), OBLIGATION_EXTRA_SIZE), + ), + ]; instructions.push(borrow_obligation_liquidity( solend_program::id(), liquidity_amount, @@ -1215,7 +1539,9 @@ impl Info { liquidity_amount: u64, ) -> Result<(), BanksClientError> { let instructions = [ - ComputeBudgetInstruction::set_compute_unit_limit(35_000), + ComputeBudgetInstruction::set_compute_unit_limit( + cu_budgets::REPAY_OBLIGATION_LIQUIDITY, + ), repay_obligation_liquidity( solend_program::id(), liquidity_amount, @@ -1239,7 +1565,7 @@ impl Info { reserve: &Info, ) -> Result<(), BanksClientError> { let instructions = [ - ComputeBudgetInstruction::set_compute_unit_limit(50_000), + ComputeBudgetInstruction::set_compute_unit_limit(cu_budgets::REDEEM_FEES), refresh_reserve( solend_program::id(), reserve.pubkey, @@ -1275,7 +1601,14 @@ impl Info { test.process_transaction( &[ - ComputeBudgetInstruction::set_compute_unit_limit(110_000), + ComputeBudgetInstruction::set_compute_unit_limit( + cu_budgets::LIQUIDATE_OBLIGATION_AND_REDEEM_RESERVE_COLLATERAL, + ), + system_instruction::transfer( + &test.context.payer.pubkey(), + &obligation.pubkey, + Rent::minimum_balance(&Rent::default(), OBLIGATION_EXTRA_SIZE), + ), liquidate_obligation_and_redeem_reserve_collateral( solend_program::id(), liquidity_amount, @@ -1315,6 +1648,11 @@ impl Info { .build_refresh_instructions(test, obligation, None) .await; + instructions.push(system_instruction::transfer( + &test.context.payer.pubkey(), + &obligation.pubkey, + Rent::minimum_balance(&Rent::default(), OBLIGATION_EXTRA_SIZE), + )); instructions.push(liquidate_obligation( solend_program::id(), liquidity_amount, @@ -1343,7 +1681,7 @@ impl Info { user: &User, collateral_amount: u64, ) -> Result<(), BanksClientError> { - let obligation = test.load_account::(obligation.pubkey).await; + let obligation = test.load_obligation(obligation.pubkey).await; if !obligation.account.borrows.is_empty() { let refresh_ixs = self @@ -1354,7 +1692,9 @@ impl Info { test.process_transaction( &[ - ComputeBudgetInstruction::set_compute_unit_limit(120_000), + ComputeBudgetInstruction::set_compute_unit_limit( + cu_budgets::WITHDRAW_OBLIGATION_COLLATERAL_AND_REDEEM_RESERVE_COLLATERAL, + ), withdraw_obligation_collateral_and_redeem_reserve_collateral( solend_program::id(), collateral_amount, @@ -1398,7 +1738,9 @@ impl Info { test.process_transaction( &[ - ComputeBudgetInstruction::set_compute_unit_limit(100_000), + ComputeBudgetInstruction::set_compute_unit_limit( + cu_budgets::WITHDRAW_OBLIGATION_COLLATERAL, + ), withdraw_obligation_collateral( solend_program::id(), collateral_amount, @@ -1453,7 +1795,7 @@ impl Info { reserve: &Info, liquidity_amount: u64, ) -> Result<(), BanksClientError> { - let obligation = test.load_account::(obligation.pubkey).await; + let obligation = test.load_obligation(obligation.pubkey).await; let mut instructions = self .build_refresh_instructions(test, &obligation, None) @@ -1817,7 +2159,7 @@ pub async fn scenario_1( .unwrap(); // borrow 10 SOL against 100k cUSDC. - let obligation = test.load_account::(obligation.pubkey).await; + let obligation = test.load_obligation(obligation.pubkey).await; lending_market .borrow_obligation_liquidity( &mut test, @@ -1842,7 +2184,7 @@ pub async fn scenario_1( .unwrap(); // populate deposit value correctly. - let obligation = test.load_account::(obligation.pubkey).await; + let obligation = test.load_obligation(obligation.pubkey).await; lending_market .refresh_obligation(&mut test, &obligation) .await @@ -1851,7 +2193,7 @@ pub async fn scenario_1( let lending_market = test.load_account(lending_market.pubkey).await; let usdc_reserve = test.load_account(usdc_reserve.pubkey).await; let wsol_reserve = test.load_account(wsol_reserve.pubkey).await; - let obligation = test.load_account::(obligation.pubkey).await; + let obligation = test.load_obligation(obligation.pubkey).await; ( test, @@ -2040,7 +2382,7 @@ pub async fn custom_scenario( .await .unwrap(); - *obligation = test.load_account::(obligation.pubkey).await; + *obligation = test.load_obligation(obligation.pubkey).await; } // load accounts into reserve diff --git a/token-lending/program/tests/init_lending_market.rs b/token-lending/program/tests/init_lending_market.rs index 7549463dc9b..1443ccc01b3 100644 --- a/token-lending/program/tests/init_lending_market.rs +++ b/token-lending/program/tests/init_lending_market.rs @@ -12,7 +12,7 @@ use solana_sdk::signer::Signer; use solana_sdk::transaction::TransactionError; use solend_program::error::LendingError; use solend_program::instruction::init_lending_market; -use solend_program::state::{LendingMarket, RateLimiter, PROGRAM_VERSION}; +use solend_program::state::{discriminator::AccountDiscriminator, LendingMarket, RateLimiter}; #[tokio::test] async fn test_success() { @@ -28,7 +28,7 @@ async fn test_success() { assert_eq!( lending_market.account, LendingMarket { - version: PROGRAM_VERSION, + discriminator: AccountDiscriminator::LendingMarket, bump_seed: lending_market.account.bump_seed, // TODO test this field owner: lending_market_owner.keypair.pubkey(), quote_currency: QUOTE_CURRENCY, diff --git a/token-lending/program/tests/init_obligation.rs b/token-lending/program/tests/init_obligation.rs index 943f5768d6a..ecb32f4ac8e 100644 --- a/token-lending/program/tests/init_obligation.rs +++ b/token-lending/program/tests/init_obligation.rs @@ -13,7 +13,9 @@ use solana_sdk::transaction::TransactionError; use solend_program::error::LendingError; use solend_program::instruction::init_obligation; use solend_program::math::Decimal; -use solend_program::state::{LastUpdate, LendingMarket, Obligation, PROGRAM_VERSION}; +use solend_program::state::{ + discriminator::AccountDiscriminator, LastUpdate, LendingMarket, Obligation, +}; async fn setup() -> (SolendProgramTest, Info, User) { let (test, lending_market, _, _, _, user) = @@ -34,7 +36,7 @@ async fn test_success() { assert_eq!( obligation.account, Obligation { - version: PROGRAM_VERSION, + discriminator: AccountDiscriminator::Obligation, last_update: LastUpdate { slot: 1000, stale: true @@ -52,6 +54,7 @@ async fn test_success() { super_unhealthy_borrow_value: Decimal::zero(), borrowing_isolated_asset: false, closeable: false, + user_reward_managers: Default::default(), } ); } diff --git a/token-lending/program/tests/init_reserve.rs b/token-lending/program/tests/init_reserve.rs index a66f8d59209..62489668912 100644 --- a/token-lending/program/tests/init_reserve.rs +++ b/token-lending/program/tests/init_reserve.rs @@ -25,22 +25,19 @@ use solana_sdk::{ signature::{Keypair, Signer}, transaction::TransactionError, }; -use solend_program::state::LastUpdate; -use solend_program::state::RateLimiter; -use solend_program::state::Reserve; -use solend_program::state::ReserveCollateral; -use solend_program::state::ReserveLiquidity; -use solend_program::state::PROGRAM_VERSION; use solend_program::NULL_PUBKEY; +use solend_program::state::{ + discriminator::AccountDiscriminator, LastUpdate, LendingMarket, RateLimiter, Reserve, + ReserveCollateral, ReserveLiquidity, +}; use solend_program::{ error::LendingError, instruction::init_reserve, math::Decimal, state::{RateLimiterConfig, ReserveConfig, ReserveFees}, }; -use solend_sdk::state::LendingMarket; use spl_token::state::{Account as Token, Mint}; async fn setup() -> (SolendProgramTest, Info, User) { @@ -154,7 +151,7 @@ async fn test_success() { assert_eq!( wsol_reserve.account, Reserve { - version: PROGRAM_VERSION, + discriminator: AccountDiscriminator::Reserve, last_update: LastUpdate { slot: 1001, stale: true @@ -182,6 +179,8 @@ async fn test_success() { config: reserve_config, rate_limiter: RateLimiter::new(RateLimiterConfig::default(), 1001), attributed_borrow_value: Decimal::zero(), + borrows_pool_reward_manager: Default::default(), + deposits_pool_reward_manager: Default::default(), } ); } diff --git a/token-lending/program/tests/isolated_tier_assets.rs b/token-lending/program/tests/isolated_tier_assets.rs index 657b0821321..146a158108c 100644 --- a/token-lending/program/tests/isolated_tier_assets.rs +++ b/token-lending/program/tests/isolated_tier_assets.rs @@ -1,5 +1,7 @@ #![cfg(feature = "test-bpf")] +use pretty_assertions::assert_eq; + use crate::solend_program_test::custom_scenario; use solend_program::state::ObligationCollateral; @@ -15,7 +17,9 @@ use solend_sdk::math::Decimal; use solend_program::state::LastUpdate; use solend_program::state::ReserveType; -use solend_program::state::{Obligation, ObligationLiquidity, ReserveConfig}; +use solend_program::state::{ + Obligation, ObligationLiquidity, PositionKind, ReserveConfig, UserRewardManager, +}; use solend_sdk::state::ReserveFees; mod helpers; @@ -77,7 +81,7 @@ async fn test_refresh_obligation() { .await .unwrap(); - let obligation = test.load_account::(obligations[0].pubkey).await; + let obligation = test.load_obligation(obligations[0].pubkey).await; assert!(!obligation.account.borrowing_isolated_asset); test.advance_clock_by_slots(1).await; @@ -86,6 +90,10 @@ async fn test_refresh_obligation() { .iter() .find(|r| r.account.liquidity.mint_pubkey == wsol_mint::id()) .unwrap(); + let usdc_reserve = reserves + .iter() + .find(|r| r.account.liquidity.mint_pubkey == usdc_mint::id()) + .unwrap(); // borrow isolated tier asset lending_market @@ -105,7 +113,11 @@ async fn test_refresh_obligation() { .await .unwrap(); - let obligation_post = test.load_account::(obligations[0].pubkey).await; + let obligation_post = test.load_obligation(obligations[0].pubkey).await; + + let last_update_time_secs = + obligation_post.account.user_reward_managers[0].last_update_time_secs; + assert_ne!(last_update_time_secs, 0,); assert_eq!( obligation_post.account, @@ -128,6 +140,23 @@ async fn test_refresh_obligation() { unweighted_borrowed_value: Decimal::from(10u64), borrowed_value_upper_bound: Decimal::from(10u64), borrowing_isolated_asset: true, + user_reward_managers: vec![ + UserRewardManager { + reserve: usdc_reserve.pubkey, + position_kind: PositionKind::Deposit, + share: 100000000, + last_update_time_secs, + rewards: Vec::new(), + }, + UserRewardManager { + reserve: wsol_reserve.pubkey, + position_kind: PositionKind::Borrow, + share: 1000000000, + last_update_time_secs, + rewards: Vec::new(), + }, + ] + .into(), ..obligations[0].account.clone() } ); @@ -290,7 +319,7 @@ async fn borrow_isolated_asset_invalid() { assert_eq!( err, TransactionError::InstructionError( - 1, + 2, InstructionError::Custom(LendingError::IsolatedTierAssetViolation as u32) ) ); @@ -385,7 +414,7 @@ async fn borrow_regular_asset_invalid() { assert_eq!( err, TransactionError::InstructionError( - 1, + 2, InstructionError::Custom(LendingError::IsolatedTierAssetViolation as u32) ) ); @@ -490,7 +519,7 @@ async fn invalid_borrow_due_to_reserve_config_change() { assert_eq!( err, TransactionError::InstructionError( - 1, + 2, InstructionError::Custom(LendingError::IsolatedTierAssetViolation as u32) ) ); diff --git a/token-lending/program/tests/liquidate_obligation.rs b/token-lending/program/tests/liquidate_obligation.rs index e583f55b702..b58a1fb0384 100644 --- a/token-lending/program/tests/liquidate_obligation.rs +++ b/token-lending/program/tests/liquidate_obligation.rs @@ -33,7 +33,7 @@ async fn test_fail_deprecated() { assert_eq!( res, TransactionError::InstructionError( - 3, + 5, InstructionError::Custom(LendingError::DeprecatedInstruction as u32) ) ); diff --git a/token-lending/program/tests/liquidate_obligation_and_redeem_collateral.rs b/token-lending/program/tests/liquidate_obligation_and_redeem_collateral.rs index de6aa759d93..005c62a19b6 100644 --- a/token-lending/program/tests/liquidate_obligation_and_redeem_collateral.rs +++ b/token-lending/program/tests/liquidate_obligation_and_redeem_collateral.rs @@ -30,11 +30,13 @@ use solana_sdk::signature::Keypair; use solend_program::math::Decimal; use solend_program::state::LendingMarket; use solend_program::state::Obligation; +use solend_program::state::PoolRewardManager; use solend_program::state::Reserve; use solend_program::state::ReserveCollateral; use solend_program::state::ReserveLiquidity; use solend_program::state::LIQUIDATION_CLOSE_FACTOR; +use pretty_assertions::assert_eq; use std::collections::HashSet; #[tokio::test] @@ -166,9 +168,18 @@ async fn test_success_new() { assert_eq!(lending_market_post.account, lending_market.account); let usdc_reserve_post = test.load_account::(usdc_reserve.pubkey).await; + let expected_usdc_reserve_post_total_shares = usdc_reserve + .account + .deposits_pool_reward_manager + .total_shares + - expected_usdc_withdrawn * FRACTIONAL_TO_USDC; assert_eq!( usdc_reserve_post.account, Reserve { + last_update: LastUpdate { + stale: true, + ..usdc_reserve.account.last_update + }, liquidity: ReserveLiquidity { available_amount: usdc_reserve.account.liquidity.available_amount - expected_usdc_withdrawn * FRACTIONAL_TO_USDC, @@ -180,14 +191,23 @@ async fn test_success_new() { ..usdc_reserve.account.collateral }, attributed_borrow_value: Decimal::from(55000u64), + deposits_pool_reward_manager: Box::new(PoolRewardManager { + total_shares: expected_usdc_reserve_post_total_shares, + ..*usdc_reserve.account.deposits_pool_reward_manager.clone() + }), ..usdc_reserve.account } ); + let expected_wsol_reserve_post_total_shares = 8000000000; let wsol_reserve_post = test.load_account::(wsol_reserve.pubkey).await; assert_eq!( wsol_reserve_post.account, Reserve { + last_update: LastUpdate { + stale: true, + ..wsol_reserve.account.last_update + }, liquidity: ReserveLiquidity { available_amount: wsol_reserve.account.liquidity.available_amount + expected_borrow_repaid * LAMPORTS_TO_SOL, @@ -201,11 +221,27 @@ async fn test_success_new() { smoothed_market_price: Decimal::from(5500u64), ..wsol_reserve.account.liquidity }, + borrows_pool_reward_manager: Box::new(PoolRewardManager { + total_shares: { + assert_eq!( + wsol_reserve + .account + .borrows_pool_reward_manager + .total_shares, + 10000000000 + ); + + expected_wsol_reserve_post_total_shares + }, + ..*wsol_reserve.account.borrows_pool_reward_manager.clone() + }), ..wsol_reserve.account } ); - let obligation_post = test.load_account::(obligation.pubkey).await; + let deposit_reserve = usdc_reserve.pubkey; + let borrow_reserve = wsol_reserve.pubkey; + let obligation_post = test.load_obligation(obligation.pubkey).await; assert_eq!( obligation_post.account, Obligation { @@ -214,7 +250,7 @@ async fn test_success_new() { stale: true }, deposits: [ObligationCollateral { - deposit_reserve: usdc_reserve.pubkey, + deposit_reserve, deposited_amount: (100_000 - expected_usdc_withdrawn) * FRACTIONAL_TO_USDC, market_value: Decimal::from(100_000u64), // old value attributed_borrow_value: obligation_post.account.deposits[0] @@ -222,7 +258,7 @@ async fn test_success_new() { }] .to_vec(), borrows: [ObligationLiquidity { - borrow_reserve: wsol_reserve.pubkey, + borrow_reserve, cumulative_borrow_rate_wads: Decimal::one(), borrowed_amount_wads: Decimal::from(10 * LAMPORTS_TO_SOL) .try_sub(Decimal::from(expected_borrow_repaid * LAMPORTS_TO_SOL)) @@ -236,6 +272,21 @@ async fn test_success_new() { borrowed_value_upper_bound: Decimal::from(55_000u64), allowed_borrow_value: Decimal::from(50_000u64), unhealthy_borrow_value: Decimal::from(55_000u64), + user_reward_managers: { + let mut og = obligation.account.user_reward_managers.clone(); + + og.iter_mut() + .find(|m| m.reserve == deposit_reserve) + .unwrap() + .share = expected_usdc_reserve_post_total_shares; + + og.iter_mut() + .find(|m| m.reserve == borrow_reserve) + .unwrap() + .share = expected_wsol_reserve_post_total_shares; + + og + }, ..obligation.account } ); @@ -322,7 +373,7 @@ async fn test_whitelisting_liquidator() { assert_eq!( err, TransactionError::InstructionError( - 1, + 2, InstructionError::Custom(LendingError::NotWhitelistedLiquidator as u32) ) ); @@ -382,7 +433,7 @@ async fn test_success_insufficient_liquidity() { .await .unwrap(); - let obligation = test.load_account::(obligation.pubkey).await; + let obligation = test.load_obligation(obligation.pubkey).await; lending_market .borrow_obligation_liquidity( &mut test, @@ -649,7 +700,7 @@ async fn test_liquidity_ordering() { assert_eq!( err, TransactionError::InstructionError( - 1, + 2, InstructionError::Custom(LendingError::InvalidAccountInput as u32) ) ); diff --git a/token-lending/program/tests/mark_obligation_as_closeable.rs b/token-lending/program/tests/mark_obligation_as_closeable.rs index 23032dd4d8a..fa8a05afb07 100644 --- a/token-lending/program/tests/mark_obligation_as_closeable.rs +++ b/token-lending/program/tests/mark_obligation_as_closeable.rs @@ -130,7 +130,7 @@ async fn test_mark_obligation_as_closeable_success() { .await .unwrap(); - let obligation_post = test.load_account::(obligations[0].pubkey).await; + let obligation_post = test.load_obligation(obligations[0].pubkey).await; assert_eq!( obligation_post.account, Obligation { diff --git a/token-lending/program/tests/obligation_end_to_end.rs b/token-lending/program/tests/obligation_end_to_end.rs index d29bbe15b9e..575824a8e3d 100644 --- a/token-lending/program/tests/obligation_end_to_end.rs +++ b/token-lending/program/tests/obligation_end_to_end.rs @@ -71,7 +71,7 @@ async fn test_success() { .await .unwrap(); - let obligation = test.load_account(obligation.pubkey).await; + let obligation = test.load_obligation(obligation.pubkey).await; lending_market .borrow_obligation_liquidity( &mut test, @@ -89,7 +89,7 @@ async fn test_success() { .await .unwrap(); - let obligation = test.load_account(obligation.pubkey).await; + let obligation = test.load_obligation(obligation.pubkey).await; lending_market .withdraw_obligation_collateral_and_redeem_reserve_collateral( &mut test, diff --git a/token-lending/program/tests/outflow_rate_limits.rs b/token-lending/program/tests/outflow_rate_limits.rs index 35c953bf1de..66e47fc7d1d 100644 --- a/token-lending/program/tests/outflow_rate_limits.rs +++ b/token-lending/program/tests/outflow_rate_limits.rs @@ -78,7 +78,7 @@ async fn setup( .unwrap(); // populate deposit value correctly. - let obligation = test.load_account::(obligation.pubkey).await; + let obligation = test.load_obligation(obligation.pubkey).await; lending_market .refresh_obligation(&mut test, &obligation) .await @@ -87,7 +87,7 @@ async fn setup( let lending_market = test.load_account(lending_market.pubkey).await; let usdc_reserve = test.load_account(usdc_reserve.pubkey).await; let wsol_reserve = test.load_account(wsol_reserve.pubkey).await; - let obligation = test.load_account::(obligation.pubkey).await; + let obligation = test.load_obligation(obligation.pubkey).await; let host_fee_receiver = User::new_with_balances(&mut test, &[(&wsol_mint::id(), 0)]).await; ( @@ -169,7 +169,7 @@ async fn test_outflow_reserve() { assert_eq!( res, TransactionError::InstructionError( - 1, + 2, InstructionError::Custom(LendingError::OutflowRateLimitExceeded as u32) ) ); diff --git a/token-lending/program/tests/refresh_obligation.rs b/token-lending/program/tests/refresh_obligation.rs index 80258ef0cbb..e8d675d5dab 100644 --- a/token-lending/program/tests/refresh_obligation.rs +++ b/token-lending/program/tests/refresh_obligation.rs @@ -12,7 +12,6 @@ use solend_program::instruction::refresh_obligation; use solend_program::processor::process_instruction; use solend_program::state::ObligationCollateral; -use solend_sdk::state::PROGRAM_VERSION; use std::collections::HashSet; use helpers::solend_program_test::{setup_world, BalanceChecker, Info, SolendProgramTest, User}; @@ -21,7 +20,10 @@ use solana_program::native_token::LAMPORTS_PER_SOL; use solana_program_test::*; use solana_sdk::signature::Keypair; use solend_program::state::SLOTS_PER_YEAR; -use solend_program::state::{LastUpdate, ObligationLiquidity, ReserveFees, ReserveLiquidity}; +use solend_program::state::{ + discriminator::AccountDiscriminator, LastUpdate, ObligationLiquidity, ReserveFees, + ReserveLiquidity, +}; use solend_program::{ math::{Decimal, TryAdd, TryDiv, TryMul}, @@ -101,7 +103,7 @@ async fn setup() -> ( .unwrap(); // borrow 6 SOL against 100k cUSDC. - let obligation = test.load_account::(obligation.pubkey).await; + let obligation = test.load_obligation(obligation.pubkey).await; lending_market .borrow_obligation_liquidity( &mut test, @@ -121,7 +123,7 @@ async fn setup() -> ( .unwrap(); // populate deposit value correctly. - let obligation = test.load_account::(obligation.pubkey).await; + let obligation = test.load_obligation(obligation.pubkey).await; lending_market .refresh_obligation(&mut test, &obligation) .await @@ -130,7 +132,7 @@ async fn setup() -> ( let lending_market = test.load_account(lending_market.pubkey).await; let usdc_reserve = test.load_account(usdc_reserve.pubkey).await; let wsol_reserve = test.load_account(wsol_reserve.pubkey).await; - let obligation = test.load_account::(obligation.pubkey).await; + let obligation = test.load_obligation(obligation.pubkey).await; ( test, @@ -246,7 +248,7 @@ async fn test_success() { } ); - let obligation_post = test.load_account::(obligation.pubkey).await; + let obligation_post = test.load_obligation(obligation.pubkey).await; assert_eq!( obligation_post.account, @@ -416,7 +418,7 @@ async fn test_obligation_liquidity_ordering() { .await .unwrap(); - let obligation = test.load_account::(obligations[0].pubkey).await; + let obligation = test.load_obligation(obligations[0].pubkey).await; let max_reserve = reserves.iter().max_by_key(|r| r.pubkey).unwrap(); assert!(obligation.account.borrows[0].borrow_reserve == max_reserve.pubkey); @@ -442,7 +444,7 @@ async fn test_obligation_liquidity_ordering() { .await .unwrap(); - let obligation = test.load_account::(obligations[0].pubkey).await; + let obligation = test.load_obligation(obligations[0].pubkey).await; assert!(obligation.account.borrows[0].borrow_reserve == wsol_reserve.pubkey); lending_market @@ -467,7 +469,7 @@ async fn test_obligation_liquidity_ordering() { .await .unwrap(); - let obligation = test.load_account::(obligations[0].pubkey).await; + let obligation = test.load_obligation(obligations[0].pubkey).await; assert!(obligation.account.borrows[0].borrow_reserve == usdc_reserve.pubkey); } @@ -480,7 +482,7 @@ async fn test_normalize_obligation() { ); let reserve_1 = Reserve { - version: PROGRAM_VERSION, + discriminator: AccountDiscriminator::Reserve, last_update: LastUpdate { slot: 1, stale: false, @@ -497,7 +499,7 @@ async fn test_normalize_obligation() { ); let reserve_2 = Reserve { - version: PROGRAM_VERSION, + discriminator: AccountDiscriminator::Reserve, last_update: LastUpdate { slot: 1, stale: false, @@ -515,7 +517,7 @@ async fn test_normalize_obligation() { let obligation_pubkey = Pubkey::new_unique(); let obligation = Obligation { - version: PROGRAM_VERSION, + discriminator: AccountDiscriminator::Obligation, deposits: vec![ ObligationCollateral { deposit_reserve: reserve_1_pubkey, @@ -543,10 +545,12 @@ async fn test_normalize_obligation() { ..Obligation::default() }; - test.add_packable_account( + let mut packed_obligation = vec![0; obligation.get_packed_len()]; + obligation.pack_into_slice(&mut packed_obligation); + test.add_packed( obligation_pubkey, u32::MAX as u64, - &obligation, + &packed_obligation, &solend_program::id(), ); @@ -564,7 +568,7 @@ async fn test_normalize_obligation() { )]; test.process_transaction(&ix, None).await.unwrap(); - let o = test.load_account::(obligation_pubkey).await; + let o = test.load_obligation(obligation_pubkey).await; assert_eq!( o.account.deposits, vec![ObligationCollateral { diff --git a/token-lending/program/tests/refresh_reserve.rs b/token-lending/program/tests/refresh_reserve.rs index 7e84a2bf70b..a979fb7fe91 100644 --- a/token-lending/program/tests/refresh_reserve.rs +++ b/token-lending/program/tests/refresh_reserve.rs @@ -105,7 +105,7 @@ async fn setup() -> ( .unwrap(); // borrow 6 SOL against 100k cUSDC. All sol is borrowed, so the borrow rate should be at max. - let obligation = test.load_account::(obligation.pubkey).await; + let obligation = test.load_obligation(obligation.pubkey).await; lending_market .borrow_obligation_liquidity( &mut test, @@ -125,7 +125,7 @@ async fn setup() -> ( .unwrap(); // populate deposit value correctly. - let obligation = test.load_account::(obligation.pubkey).await; + let obligation = test.load_obligation(obligation.pubkey).await; lending_market .refresh_obligation(&mut test, &obligation) .await @@ -134,7 +134,7 @@ async fn setup() -> ( let lending_market = test.load_account(lending_market.pubkey).await; let usdc_reserve = test.load_account(usdc_reserve.pubkey).await; let wsol_reserve = test.load_account(wsol_reserve.pubkey).await; - let obligation = test.load_account::(obligation.pubkey).await; + let obligation = test.load_obligation(obligation.pubkey).await; ( test, diff --git a/token-lending/program/tests/repay_obligation_liquidity.rs b/token-lending/program/tests/repay_obligation_liquidity.rs index c3e9cd7a958..24686f4d4e5 100644 --- a/token-lending/program/tests/repay_obligation_liquidity.rs +++ b/token-lending/program/tests/repay_obligation_liquidity.rs @@ -3,6 +3,7 @@ mod helpers; use crate::solend_program_test::scenario_1; +use pretty_assertions::assert_eq; use std::collections::HashSet; use helpers::solend_program_test::{BalanceChecker, TokenBalanceChange}; @@ -14,7 +15,7 @@ use solend_program::math::TryDiv; use solend_program::state::{LastUpdate, ObligationLiquidity, ReserveLiquidity, SLOTS_PER_YEAR}; use solend_program::{ math::{Decimal, TryAdd, TryMul, TrySub}, - state::{Obligation, Reserve}, + state::{Obligation, PoolRewardManager, Reserve}, }; #[tokio::test] @@ -73,6 +74,7 @@ async fn test_success() { .try_sub(Decimal::from(10 * LAMPORTS_TO_SOL)) .unwrap(); + let expected_wsol_reserve_post_borrow_total_shares = 47; assert_eq!( wsol_reserve_post.account, Reserve { @@ -86,11 +88,26 @@ async fn test_success() { cumulative_borrow_rate_wads: new_cumulative_borrow_rate, ..wsol_reserve.account.liquidity }, + borrows_pool_reward_manager: Box::new(PoolRewardManager { + total_shares: { + assert_eq!( + wsol_reserve + .account + .borrows_pool_reward_manager + .total_shares, + 10 * LAMPORTS_PER_SOL, + ); + + expected_wsol_reserve_post_borrow_total_shares + }, + ..*wsol_reserve.account.borrows_pool_reward_manager + }), ..wsol_reserve.account } ); - let obligation_post = test.load_account::(obligation.pubkey).await; + let obligation_post = test.load_obligation(obligation.pubkey).await; + let borrow_reserve = wsol_reserve.pubkey; assert_eq!( obligation_post.account, Obligation { @@ -100,12 +117,22 @@ async fn test_success() { stale: true }, borrows: [ObligationLiquidity { - borrow_reserve: wsol_reserve.pubkey, + borrow_reserve, cumulative_borrow_rate_wads: new_cumulative_borrow_rate, borrowed_amount_wads: new_borrowed_amount_wads, ..obligation.account.borrows[0] }] .to_vec(), + user_reward_managers: { + let mut og = obligation.account.user_reward_managers.clone(); + + og.iter_mut() + .find(|m| m.reserve == borrow_reserve) + .unwrap() + .share = expected_wsol_reserve_post_borrow_total_shares; + + og + }, ..obligation.account } ); diff --git a/token-lending/program/tests/two_prices.rs b/token-lending/program/tests/two_prices.rs index 2334807d2ff..e673b59180d 100644 --- a/token-lending/program/tests/two_prices.rs +++ b/token-lending/program/tests/two_prices.rs @@ -145,7 +145,7 @@ async fn test_borrow() { assert_eq!( err, TransactionError::InstructionError( - 1, + 2, InstructionError::Custom(LendingError::BorrowTooLarge as u32) ) ); @@ -379,7 +379,7 @@ async fn test_liquidation_doesnt_use_smoothed_price() { assert_eq!( err, TransactionError::InstructionError( - 1, + 2, InstructionError::Custom(LendingError::ObligationHealthy as u32) ) ); @@ -415,7 +415,7 @@ async fn test_liquidation_doesnt_use_smoothed_price() { assert_eq!( err, TransactionError::InstructionError( - 1, + 2, InstructionError::Custom(LendingError::ObligationHealthy as u32) ) ); diff --git a/token-lending/program/tests/withdraw_obligation_collateral.rs b/token-lending/program/tests/withdraw_obligation_collateral.rs index 6cd535dec27..9109d4ea4b3 100644 --- a/token-lending/program/tests/withdraw_obligation_collateral.rs +++ b/token-lending/program/tests/withdraw_obligation_collateral.rs @@ -9,7 +9,9 @@ use solend_sdk::math::Decimal; use solana_program_test::*; +use pretty_assertions::assert_eq; use solend_program::state::{LastUpdate, Obligation, ObligationCollateral, Reserve}; +use solend_sdk::state::PoolRewardManager; use std::collections::HashSet; use std::u64; @@ -21,8 +23,16 @@ async fn test_success_withdraw_fixed_amount() { let balance_checker = BalanceChecker::start(&mut test, &[&usdc_reserve, &user, &wsol_reserve]).await; + let withdraw_amount = 1_000_000; + lending_market - .withdraw_obligation_collateral(&mut test, &usdc_reserve, &obligation, &user, 1_000_000) + .withdraw_obligation_collateral( + &mut test, + &usdc_reserve, + &obligation, + &user, + withdraw_amount, + ) .await .unwrap(); @@ -34,21 +44,35 @@ async fn test_success_withdraw_fixed_amount() { .get_account(&usdc_reserve.account.collateral.mint_pubkey) .unwrap(), mint: usdc_reserve.account.collateral.mint_pubkey, - diff: 1_000_000, + diff: withdraw_amount as _, }, TokenBalanceChange { token_account: usdc_reserve.account.collateral.supply_pubkey, mint: usdc_reserve.account.collateral.mint_pubkey, - diff: -1_000_000, + diff: -(withdraw_amount as i128), }, ]); assert_eq!(balance_changes, expected_balance_changes); assert_eq!(mint_supply_changes, HashSet::new()); let usdc_reserve_post = test.load_account::(usdc_reserve.pubkey).await; - assert_eq!(usdc_reserve_post.account, usdc_reserve.account); + assert_eq!( + usdc_reserve_post.account, + Reserve { + deposits_pool_reward_manager: Box::new(PoolRewardManager { + total_shares: usdc_reserve + .account + .deposits_pool_reward_manager + .total_shares + - withdraw_amount, + ..*usdc_reserve.account.deposits_pool_reward_manager + }), + ..usdc_reserve.account + } + ); - let obligation_post = test.load_account::(obligation.pubkey).await; + let obligation_post = test.load_obligation(obligation.pubkey).await; + let deposit_reserve = usdc_reserve.pubkey; assert_eq!( obligation_post.account, Obligation { @@ -57,13 +81,27 @@ async fn test_success_withdraw_fixed_amount() { stale: true }, deposits: [ObligationCollateral { - deposit_reserve: usdc_reserve.pubkey, - deposited_amount: 100_000_000_000 - 1_000_000, + deposit_reserve, + deposited_amount: 100_000_000_000 - withdraw_amount, market_value: Decimal::from(99_999u64), ..obligation.account.deposits[0] }] .to_vec(), deposited_value: Decimal::from(99_999u64), + user_reward_managers: { + let mut og = obligation.account.user_reward_managers.clone(); + + og.iter_mut() + .find(|m| m.reserve == deposit_reserve) + .unwrap() + .share = usdc_reserve + .account + .deposits_pool_reward_manager + .total_shares + - withdraw_amount; + + og + }, ..obligation.account } ); @@ -111,9 +149,19 @@ async fn test_success_withdraw_max() { assert_eq!(mint_supply_changes, HashSet::new()); let usdc_reserve_post = test.load_account::(usdc_reserve.pubkey).await; - assert_eq!(usdc_reserve_post.account, usdc_reserve.account); + assert_eq!( + usdc_reserve_post.account, + Reserve { + deposits_pool_reward_manager: Box::new(PoolRewardManager { + total_shares: expected_remaining_collateral, + ..*usdc_reserve.account.deposits_pool_reward_manager + }), + ..usdc_reserve.account + } + ); - let obligation_post = test.load_account::(obligation.pubkey).await; + let obligation_post = test.load_obligation(obligation.pubkey).await; + let deposit_reserve = usdc_reserve.pubkey; assert_eq!( obligation_post.account, Obligation { @@ -122,13 +170,23 @@ async fn test_success_withdraw_max() { stale: true }, deposits: [ObligationCollateral { - deposit_reserve: usdc_reserve.pubkey, + deposit_reserve, deposited_amount: expected_remaining_collateral, market_value: Decimal::from(200u64), ..obligation.account.deposits[0] }] .to_vec(), deposited_value: Decimal::from(200u64), + user_reward_managers: { + let mut og = obligation.account.user_reward_managers.clone(); + + og.iter_mut() + .find(|m| m.reserve == deposit_reserve) + .unwrap() + .share = expected_remaining_collateral; + + og + }, ..obligation.account } ); diff --git a/token-lending/program/tests/withdraw_obligation_collateral_and_redeem_reserve_collateral.rs b/token-lending/program/tests/withdraw_obligation_collateral_and_redeem_reserve_collateral.rs index 7b516104953..50f58b98bcd 100644 --- a/token-lending/program/tests/withdraw_obligation_collateral_and_redeem_reserve_collateral.rs +++ b/token-lending/program/tests/withdraw_obligation_collateral_and_redeem_reserve_collateral.rs @@ -7,6 +7,7 @@ use solend_program::math::TryDiv; mod helpers; use crate::solend_program_test::*; +use pretty_assertions::assert_eq; use solend_sdk::math::Decimal; use solend_sdk::state::ObligationCollateral; use solend_sdk::state::ReserveCollateral; @@ -126,11 +127,20 @@ async fn test_success() { rate_limiter }, + deposits_pool_reward_manager: Box::new(PoolRewardManager { + total_shares: usdc_reserve + .account + .deposits_pool_reward_manager + .total_shares + - withdraw_amount as u64, + ..*usdc_reserve.account.deposits_pool_reward_manager + }), ..usdc_reserve.account } ); - let obligation_post = test.load_account::(obligation.pubkey).await; + let obligation_post = test.load_obligation(obligation.pubkey).await; + let deposit_reserve = usdc_reserve.pubkey; assert_eq!( obligation_post.account, Obligation { @@ -139,13 +149,23 @@ async fn test_success() { stale: true }, deposits: [ObligationCollateral { - deposit_reserve: usdc_reserve.pubkey, + deposit_reserve, deposited_amount: 200 * FRACTIONAL_TO_USDC, market_value: Decimal::from(200u64), ..obligation.account.deposits[0] }] .to_vec(), deposited_value: Decimal::from(200u64), + user_reward_managers: { + let mut og = obligation.account.user_reward_managers.clone(); + + og.iter_mut() + .find(|m| m.reserve == deposit_reserve) + .unwrap() + .share = 200 * FRACTIONAL_TO_USDC; + + og + }, ..obligation.account } ); diff --git a/token-lending/sdk/Cargo.toml b/token-lending/sdk/Cargo.toml index f969a318766..4002f16f5a1 100644 --- a/token-lending/sdk/Cargo.toml +++ b/token-lending/sdk/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "solend-sdk" -version = "2.0.2" +version = "2.1.0" description = "Solend Sdk" authors = ["Solend Maintainers "] repository = "https://github.com/solendprotocol/solana-program-library" @@ -14,7 +14,7 @@ bytemuck = "1.5.1" num-derive = "0.3" num-traits = "0.2" solana-program = ">=1.9" -spl-token = { version = "3.2.0", features=["no-entrypoint"] } +spl-token = { version = "3.2.0", features = ["no-entrypoint"] } static_assertions = "1.1.0" thiserror = "1.0" uint = "=0.9.1" @@ -23,11 +23,13 @@ uint = "=0.9.1" assert_matches = "1.5.0" base64 = "0.13" log = "0.4.14" -proptest = "1.0" -solana-sdk = ">=1.9" +pretty_assertions = "1.4.1" +proptest = "1.6" +rand = "0.8.5" +rand_chacha = "0.3.1" serde = ">=1.0.140" serde_yaml = "0.8" -rand = "0.8.5" +solana-sdk = ">=1.9" [lib] crate-type = ["cdylib", "lib"] diff --git a/token-lending/sdk/proptest-regressions/state/liquidity_mining.txt b/token-lending/sdk/proptest-regressions/state/liquidity_mining.txt new file mode 100644 index 00000000000..85c05d3ebdc --- /dev/null +++ b/token-lending/sdk/proptest-regressions/state/liquidity_mining.txt @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 6a1836cf0547a44279bf151c5fb919eb5549fa1f5b07782ac9da2a3ddbdd73ec # shrinks to rng_seed = 1807223968525307359, user_count = 2, reward_period = 19190, total_rewards = 1000000 diff --git a/token-lending/sdk/src/error.rs b/token-lending/sdk/src/error.rs index 597521cd91c..fb2a6fa6761 100644 --- a/token-lending/sdk/src/error.rs +++ b/token-lending/sdk/src/error.rs @@ -209,6 +209,26 @@ pub enum LendingError { /// Borrow Attribution Limit Not Exceeded #[error("Borrow Attribution Limit Not Exceeded")] BorrowAttributionLimitNotExceeded, + /// Pool rewards have a hard coded minimum length in seconds. + #[error("Pool reward too short")] + PoolRewardPeriodTooShort, + + // 60 + /// Cannot close token account + #[error("Cannot close token account")] + CloseTokenAccountFailed, + /// Not an account discriminator + #[error("Given leading byte does not match any account discriminator")] + InvalidAccountDiscriminator, + /// Trying to use an account that hasn't been migrated + #[error("Trying to use an account that hasn't been migrated")] + AccountNotMigrated, + /// There's no pool reward that matches the given parameters + #[error("There's no pool reward that matches the given parameters")] + NoPoolRewardMatches, + /// There's no vacant slot for a pool reward + #[error("There's no vacant slot for a pool reward")] + NoVacantEntryForPoolReward, } impl From for ProgramError { diff --git a/token-lending/sdk/src/instruction.rs b/token-lending/sdk/src/instruction.rs index 4340458ad0f..a7f7e2c6edd 100644 --- a/token-lending/sdk/src/instruction.rs +++ b/token-lending/sdk/src/instruction.rs @@ -1,6 +1,6 @@ //! Instruction types -use crate::state::{LendingMarketMetadata, ReserveType}; +use crate::state::{LendingMarketMetadata, PositionKind, ReserveType}; use crate::{ error::LendingError, state::{RateLimiterConfig, ReserveConfig, ReserveFees}, @@ -528,6 +528,136 @@ pub enum LendingInstruction { /// amount to donate liquidity_amount: u64, }, + + // 25 + /// AddPoolReward + /// + /// * Admin only instruction. + /// * Duration is ceiled to granularity of 1 second. + /// * Can last at most 49,710 days. + /// + /// `[writable]` Reserve account. + /// `[]` Reward mint. + /// `[writable]` Reward token account owned by signer + /// `[]` Derived reserve pool reward authority. Seed: + /// * b"RewardVaultAuthority" + /// * Lending market account pubkey + /// * Vault token account pubkey + /// `[writable]` Uninitialized rent-exempt account that will hold reward tokens. + /// `[]` Lending market account. + /// `[signer]` Lending market owner. + /// `[]` Rent sysvar. + /// `[]` Token program. + AddPoolReward { + /// The bump seed of the reward authority. + reward_authority_bump: u8, + /// Whether this reward applies to deposits or borrows + position_kind: PositionKind, + /// If in the past according to the Clock sysvar then started immediately. + start_time_secs: u64, + /// Must be larger than start. + end_time_secs: u64, + /// Must have at least this many tokens in the source account. + token_amount: u64, + }, + + // 26 + /// ClosePoolReward + /// + /// * Admin only instruction. + /// * Can only be called if reward period is over. + /// * Can only be called if all users claimed rewards. + /// + /// `[writable]` Reserve account. + /// `[]` Reward mint. + /// `[writable]` Reward token account owned by signer + /// `[]` Derived reserve pool reward authority. Seed: + /// * b"RewardVaultAuthority" + /// * Lending market account pubkey + /// * Vault token account pubkey + /// `[writable]` Reward vault token account. + /// `[]` Lending market account. + /// `[signer]` Lending market owner. + /// `[]` Token program. + ClosePoolReward { + /// The bump seed of the reward authority. + reward_authority_bump: u8, + /// Whether this reward applies to deposits or borrows + position_kind: PositionKind, + /// Identifies a reward within a reserve's deposits/borrows rewards. + pool_reward_index: u64, + }, + + // 27 + /// EditPoolReward + /// + /// * Admin only instruction. + /// * Either extends or shortens the reward time. + /// * Provide `now` or less to changed the endtime of the reward to the current time, + /// effectively cancelling the reward. + /// * Claims unallocated rewards to the admin signer if shortening the time, takes extra rewards + /// from the admin signer if extending the time. + /// + /// `[writable]` Reserve account. + /// `[]` Reward mint. + /// `[writable]` Reward token account owned by signer + /// `[]` Derived reserve pool reward authority. Seed: + /// * b"RewardVaultAuthority" + /// * Lending market account pubkey + /// * Vault token account pubkey + /// `[writable]` Reward vault token account. + /// `[]` Lending market account. + /// `[signer]` Lending market owner. + /// `[]` Token program. + EditPoolReward { + /// The bump seed of the reward authority. + reward_authority_bump: u8, + /// Whether this reward applies to deposits or borrows + position_kind: PositionKind, + /// Identifies a reward within a reserve's deposits/borrows rewards. + pool_reward_index: u64, + /// Will be truncated such that the duration in secs is not longer than [u32::MAX] and not + /// shorter than [crate::MIN_REWARD_PERIOD_SECS]. + /// Also, it must be at least current time and the reward start time. + new_end_time_secs: u64, + }, + + /// 28 + /// ClaimReward + /// + /// * Permissionless claim of rewards from an obligation. + /// + /// `[writable]` Obligation account. + /// `[writable]` Obligation owner's token account that receives reward. + /// `[writable]` Reserve account. + /// `[]` Reward mint. + /// `[]` Derived reserve pool reward authority. Seed: + /// * b"RewardVaultAuthority" + /// * Lending market account pubkey + /// * Vault token account pubkey + /// `[writable]` Reward vault token account. + /// `[]` Lending market account. + /// `[]` Token program. + ClaimReward { + /// The bump seed of the reward authority. + reward_authority_bump: u8, + /// Even though an obligation can either deposit or borrow the same + /// reserve, the obligation's rewards can hold rewards for both. + /// It's therefore necessary to specify which kind of reward to claim. + position_kind: PositionKind, + }, + + // 255 + /// UpgradeReserveToV2_1_0 + /// + /// Temporary ix which upgrades reserves from @2.0.2 to @2.1.0 with + /// liquidity mining feature. + /// Once all reserves are upgraded this ix is not necessary any more. + /// + /// `[writable]` Reserve account. + /// `[writable, signer]` Fee payer. + /// `[]` System program. + UpgradeReserveToV2_1_0, } impl LendingInstruction { @@ -786,6 +916,52 @@ impl LendingInstruction { let (liquidity_amount, _rest) = Self::unpack_u64(rest)?; Self::DonateToReserve { liquidity_amount } } + 25 => { + let (reward_authority_bump, rest) = Self::unpack_u8(rest)?; + let (position_kind, rest) = Self::unpack_try_from_u8(rest)?; + let (start_time_secs, rest) = Self::unpack_u64(rest)?; + let (end_time_secs, rest) = Self::unpack_u64(rest)?; + let (token_amount, _rest) = Self::unpack_u64(rest)?; + Self::AddPoolReward { + reward_authority_bump, + position_kind, + start_time_secs, + end_time_secs, + token_amount, + } + } + 26 => { + let (reward_authority_bump, rest) = Self::unpack_u8(rest)?; + let (position_kind, rest) = Self::unpack_try_from_u8(rest)?; + let (pool_reward_index, _rest) = Self::unpack_u64(rest)?; + Self::ClosePoolReward { + reward_authority_bump, + position_kind, + pool_reward_index: pool_reward_index as _, + } + } + 27 => { + let (reward_authority_bump, rest) = Self::unpack_u8(rest)?; + let (position_kind, rest) = Self::unpack_try_from_u8(rest)?; + let (pool_reward_index, rest) = Self::unpack_u64(rest)?; + let (new_end_time_secs, _rest) = Self::unpack_u64(rest)?; + + Self::EditPoolReward { + reward_authority_bump, + position_kind, + pool_reward_index: pool_reward_index as _, + new_end_time_secs, + } + } + 28 => { + let (reward_authority_bump, rest) = Self::unpack_u8(rest)?; + let (position_kind, _rest) = Self::unpack_try_from_u8(rest)?; + Self::ClaimReward { + reward_authority_bump, + position_kind, + } + } + 255 => Self::UpgradeReserveToV2_1_0, _ => { msg!("Instruction cannot be unpacked"); return Err(LendingError::InstructionUnpackError.into()); @@ -835,6 +1011,15 @@ impl LendingInstruction { Ok((value, rest)) } + fn unpack_try_from_u8(input: &[u8]) -> Result<(T, &[u8]), ProgramError> + where + T: TryFrom, + ProgramError: From<>::Error>, + { + let (byte, rest) = Self::unpack_u8(input)?; + Ok((T::try_from(byte)?, rest)) + } + fn unpack_bytes32(input: &[u8]) -> Result<(&[u8; 32], &[u8]), ProgramError> { if input.len() < 32 { msg!("32 bytes cannot be unpacked"); @@ -1085,6 +1270,53 @@ impl LendingInstruction { buf.push(24); buf.extend_from_slice(&liquidity_amount.to_le_bytes()); } + Self::AddPoolReward { + reward_authority_bump, + position_kind, + start_time_secs, + end_time_secs, + token_amount, + } => { + buf.push(25); + buf.extend_from_slice(&reward_authority_bump.to_le_bytes()); + buf.extend_from_slice(&(position_kind as u8).to_le_bytes()); + buf.extend_from_slice(&start_time_secs.to_le_bytes()); + buf.extend_from_slice(&end_time_secs.to_le_bytes()); + buf.extend_from_slice(&token_amount.to_le_bytes()); + } + Self::ClosePoolReward { + reward_authority_bump, + position_kind, + pool_reward_index, + } => { + buf.push(26); + buf.extend_from_slice(&reward_authority_bump.to_le_bytes()); + buf.extend_from_slice(&(position_kind as u8).to_le_bytes()); + buf.extend_from_slice(&pool_reward_index.to_le_bytes()); + } + Self::EditPoolReward { + reward_authority_bump, + position_kind, + pool_reward_index, + new_end_time_secs, + } => { + buf.push(27); + buf.extend_from_slice(&reward_authority_bump.to_le_bytes()); + buf.extend_from_slice(&(position_kind as u8).to_le_bytes()); + buf.extend_from_slice(&pool_reward_index.to_le_bytes()); + buf.extend_from_slice(&new_end_time_secs.to_le_bytes()); + } + Self::ClaimReward { + reward_authority_bump, + position_kind, + } => { + buf.push(28); + buf.extend_from_slice(&reward_authority_bump.to_le_bytes()); + buf.extend_from_slice(&(position_kind as u8).to_le_bytes()); + } + Self::UpgradeReserveToV2_1_0 => { + buf.push(255); + } } buf } @@ -1443,7 +1675,7 @@ pub fn withdraw_obligation_collateral_and_redeem_reserve_collateral( AccountMeta::new(destination_liquidity_pubkey, false), AccountMeta::new(reserve_collateral_mint_pubkey, false), AccountMeta::new(reserve_liquidity_supply_pubkey, false), - AccountMeta::new_readonly(obligation_owner_pubkey, true), + AccountMeta::new(obligation_owner_pubkey, true), AccountMeta::new_readonly(user_transfer_authority_pubkey, true), AccountMeta::new_readonly(spl_token::id(), false), ]; @@ -1485,7 +1717,7 @@ pub fn withdraw_obligation_collateral( let mut accounts = vec![ AccountMeta::new(source_collateral_pubkey, false), AccountMeta::new(destination_collateral_pubkey, false), - AccountMeta::new_readonly(withdraw_reserve_pubkey, false), + AccountMeta::new(withdraw_reserve_pubkey, false), AccountMeta::new(obligation_pubkey, false), AccountMeta::new_readonly(lending_market_pubkey, false), AccountMeta::new_readonly(lending_market_authority_pubkey, false), @@ -1603,7 +1835,7 @@ pub fn liquidate_obligation( AccountMeta::new(destination_collateral_pubkey, false), AccountMeta::new(repay_reserve_pubkey, false), AccountMeta::new(repay_reserve_liquidity_supply_pubkey, false), - AccountMeta::new_readonly(withdraw_reserve_pubkey, false), + AccountMeta::new(withdraw_reserve_pubkey, false), AccountMeta::new(withdraw_reserve_collateral_supply_pubkey, false), AccountMeta::new(obligation_pubkey, false), AccountMeta::new_readonly(lending_market_pubkey, false), @@ -1897,6 +2129,233 @@ pub fn donate_to_reserve( } } +/// Creates a `UpgradeReserveToV2_1_0` instruction. +pub fn upgrade_reserve_to_v2_1_0( + program_id: Pubkey, + reserve_pubkey: Pubkey, + fee_payer: Pubkey, +) -> Instruction { + Instruction { + program_id, + accounts: vec![ + AccountMeta::new(reserve_pubkey, false), + AccountMeta::new(fee_payer, true), + AccountMeta::new_readonly(system_program::id(), false), + ], + data: LendingInstruction::UpgradeReserveToV2_1_0.pack(), + } +} + +/// Creates a `AddPoolReward` instruction +#[allow(clippy::too_many_arguments)] +pub fn add_pool_reward( + program_id: Pubkey, + reward_authority_bump: u8, + position_kind: PositionKind, + start_time_secs: u64, + end_time_secs: u64, + token_amount: u64, + reserve: Pubkey, + reward_mint: Pubkey, + source_reward_token_account: Pubkey, + reward_vault_authority: Pubkey, + reward_vault: Pubkey, + lending_market: Pubkey, + lending_market_owner: Pubkey, +) -> Instruction { + Instruction { + program_id, + accounts: vec![ + AccountMeta::new(reserve, false), + AccountMeta::new_readonly(reward_mint, false), + AccountMeta::new(source_reward_token_account, false), + AccountMeta::new_readonly(reward_vault_authority, false), + AccountMeta::new(reward_vault, false), + AccountMeta::new_readonly(lending_market, false), + AccountMeta::new_readonly(lending_market_owner, true), + AccountMeta::new_readonly(sysvar::rent::id(), false), + AccountMeta::new_readonly(spl_token::id(), false), + ], + data: LendingInstruction::AddPoolReward { + reward_authority_bump, + position_kind, + start_time_secs, + end_time_secs, + token_amount, + } + .pack(), + } +} + +/// Creates an `EditPoolReward` instruction +#[allow(clippy::too_many_arguments)] +pub fn edit_pool_reward( + program_id: Pubkey, + reward_authority_bump: u8, + position_kind: PositionKind, + pool_reward_index: u64, + new_end_time_secs: u64, + reserve: Pubkey, + reward_mint: Pubkey, + lending_market_owner_reward_token_account: Pubkey, + reward_vault_authority: Pubkey, + reward_vault: Pubkey, + lending_market: Pubkey, + lending_market_owner: Pubkey, +) -> Instruction { + Instruction { + program_id, + accounts: vec![ + AccountMeta::new(reserve, false), + AccountMeta::new_readonly(reward_mint, false), + AccountMeta::new(lending_market_owner_reward_token_account, false), + AccountMeta::new_readonly(reward_vault_authority, false), + AccountMeta::new(reward_vault, false), + AccountMeta::new_readonly(lending_market, false), + AccountMeta::new_readonly(lending_market_owner, true), + AccountMeta::new_readonly(spl_token::id(), false), + ], + data: LendingInstruction::EditPoolReward { + reward_authority_bump, + position_kind, + pool_reward_index, + new_end_time_secs, + } + .pack(), + } +} + +/// Creates a `ClosePoolReward` instruction +#[allow(clippy::too_many_arguments)] +pub fn close_pool_reward( + program_id: Pubkey, + reward_authority_bump: u8, + position_kind: PositionKind, + pool_reward_index: u64, + reserve: Pubkey, + reward_mint: Pubkey, + destination_reward_token_account: Pubkey, + reward_vault_authority: Pubkey, + reward_vault: Pubkey, + lending_market: Pubkey, + lending_market_owner: Pubkey, +) -> Instruction { + Instruction { + program_id, + accounts: vec![ + AccountMeta::new(reserve, false), + AccountMeta::new_readonly(reward_mint, false), + AccountMeta::new(destination_reward_token_account, false), + AccountMeta::new_readonly(reward_vault_authority, false), + AccountMeta::new(reward_vault, false), + AccountMeta::new_readonly(lending_market, false), + AccountMeta::new(lending_market_owner, true), + AccountMeta::new_readonly(spl_token::id(), false), + ], + data: LendingInstruction::ClosePoolReward { + reward_authority_bump, + position_kind, + pool_reward_index, + } + .pack(), + } +} + +/// `[writable]` Obligation account. +/// `[writable]` Obligation owner's token account that receives reward. +/// `[writable]` Reserve account. +/// `[]` Reward mint. +/// `[]` Derived reserve pool reward authority. Seed: +/// * b"RewardVaultAuthority" +/// * Lending market account pubkey +/// * Vault token account pubkey +/// `[writable]` Reward vault token account. +/// `[]` Lending market account. +/// `[]` Token program. +/// +/// If payer is not provided then this is a permission-less claim. +/// The ix will fail if the reward has not ended yet. +#[allow(clippy::too_many_arguments)] +pub fn claim_pool_reward( + program_id: Pubkey, + reward_authority_bump: u8, + position_kind: PositionKind, + obligation: Pubkey, + obligation_owner_token_account_for_reward: Pubkey, + reserve: Pubkey, + reward_mint: Pubkey, + reward_vault_authority: Pubkey, + reward_vault: Pubkey, + lending_market: Pubkey, + payer: Option, +) -> Instruction { + let mut accounts = vec![ + AccountMeta::new(obligation, false), + AccountMeta::new(obligation_owner_token_account_for_reward, false), + AccountMeta::new(reserve, false), + AccountMeta::new_readonly(reward_mint, false), + AccountMeta::new_readonly(reward_vault_authority, false), + AccountMeta::new(reward_vault, false), + AccountMeta::new_readonly(lending_market, false), + AccountMeta::new_readonly(spl_token::id(), false), + ]; + + if let Some(payer) = payer { + accounts.push(AccountMeta::new(payer, true)); + } + + Instruction { + program_id, + accounts, + data: LendingInstruction::ClaimReward { + reward_authority_bump, + position_kind, + } + .pack(), + } +} + +/// Derives the reward vault authority PDA address. +pub fn find_reward_vault_authority( + program_id: &Pubkey, + lending_market_key: &Pubkey, + reward_token_vault_key: &Pubkey, +) -> (Pubkey, u8) { + Pubkey::find_program_address( + &reward_vault_authority_seeds(lending_market_key, reward_token_vault_key), + program_id, + ) +} + +/// Creates a reward vault authority PDA address. +pub fn create_reward_vault_authority( + program_id: &Pubkey, + lending_market_key: &Pubkey, + reward_token_vault_key: &Pubkey, + bump: u8, +) -> Result { + Pubkey::create_program_address( + &[ + reward_vault_authority_seeds(lending_market_key, reward_token_vault_key).as_slice(), + &[&[bump]], + ] + .concat(), + program_id, + ) +} + +/// Returns seeds to derive the reward vault authority PDA address. +pub fn reward_vault_authority_seeds<'keys>( + lending_market_key: &'keys Pubkey, + reward_token_vault_key: &'keys Pubkey, +) -> [&'keys [u8]; 3] { + [ + b"RewardVaultAuthority", + lending_market_key.as_ref(), + reward_token_vault_key.as_ref(), + ] +} + #[cfg(test)] mod test { use super::*; diff --git a/token-lending/sdk/src/state/lending_market.rs b/token-lending/sdk/src/state/lending_market.rs index 1836dc76e2d..2d699eaa39a 100644 --- a/token-lending/sdk/src/state/lending_market.rs +++ b/token-lending/sdk/src/state/lending_market.rs @@ -1,3 +1,7 @@ +use std::convert::TryFrom; + +use crate::error::LendingError; + use super::*; use arrayref::{array_mut_ref, array_ref, array_refs, mut_array_refs}; use solana_program::{ @@ -10,8 +14,13 @@ use solana_program::{ /// Lending market state #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct LendingMarket { - /// Version of lending market - pub version: u8, + /// For uninitialized accounts, this will be equal to [AccountDiscriminator::Uninitialized]. + /// Otherwise this is [AccountDiscriminator::LendingMarket]. + /// + /// # Note + /// For accounts last used with version prior to @v2.1.0 this will be equal + /// to [PROGRAM_VERSION_2_0_2]. + pub discriminator: AccountDiscriminator, /// Bump seed for derived authority address pub bump_seed: u8, /// Owner authority which can add new reserves @@ -43,7 +52,7 @@ impl LendingMarket { /// Initialize a lending market pub fn init(&mut self, params: InitLendingMarketParams) { - self.version = PROGRAM_VERSION; + self.discriminator = AccountDiscriminator::LendingMarket; self.bump_seed = params.bump_seed; self.owner = params.owner; self.quote_currency = params.quote_currency; @@ -76,7 +85,7 @@ pub struct InitLendingMarketParams { impl Sealed for LendingMarket {} impl IsInitialized for LendingMarket { fn is_initialized(&self) -> bool { - self.version != UNINITIALIZED_VERSION + !matches!(self.discriminator, AccountDiscriminator::Uninitialized) } } @@ -88,7 +97,7 @@ impl Pack for LendingMarket { let output = array_mut_ref![output, 0, LENDING_MARKET_LEN]; #[allow(clippy::ptr_offset_with_cast)] let ( - version, + discriminator, bump_seed, owner, quote_currency, @@ -114,7 +123,7 @@ impl Pack for LendingMarket { 8 ]; - *version = self.version.to_le_bytes(); + discriminator[0] = self.discriminator as _; *bump_seed = self.bump_seed.to_le_bytes(); owner.copy_from_slice(self.owner.as_ref()); quote_currency.copy_from_slice(self.quote_currency.as_ref()); @@ -138,7 +147,7 @@ impl Pack for LendingMarket { let input = array_ref![input, 0, LENDING_MARKET_LEN]; #[allow(clippy::ptr_offset_with_cast)] let ( - version, + discriminator, bump_seed, owner, quote_currency, @@ -164,15 +173,31 @@ impl Pack for LendingMarket { 8 ]; - let version = u8::from_le_bytes(*version); - if version > PROGRAM_VERSION { - msg!("Lending market version does not match lending program version"); - return Err(ProgramError::InvalidAccountData); - } + let discriminator = match AccountDiscriminator::try_from(discriminator) { + Ok(d @ AccountDiscriminator::Uninitialized) => d, // yet to be set + Ok(d @ AccountDiscriminator::LendingMarket) => d, // migrated to v2.1.0 + Ok(_) => { + msg!("Lending market discriminator does not match"); + return Err(LendingError::InvalidAccountDiscriminator.into()); + } + #[allow(clippy::assertions_on_constants)] + Err(LendingError::AccountNotMigrated) => { + // We're migrating the account from v2.0.2 to v2.1.0. + // The reason this is safe to do is conveyed in these asserts: + debug_assert_eq!(Self::LEN, input.len()); + debug_assert!(Self::LEN < Reserve::LEN); + debug_assert!(Self::LEN < RESERVE_LEN_V2_0_2); + debug_assert!(Self::LEN < Obligation::MIN_LEN); + // Ie. there's no confusion with other account types. + + AccountDiscriminator::LendingMarket + } + Err(e) => return Err(e.into()), + }; let owner_pubkey = Pubkey::new_from_array(*owner); Ok(Self { - version, + discriminator, bump_seed: u8::from_le_bytes(*bump_seed), owner: owner_pubkey, quote_currency: *quote_currency, @@ -198,33 +223,54 @@ impl Pack for LendingMarket { } #[cfg(test)] -mod test { +pub(crate) mod test { use super::*; use rand::Rng; + impl LendingMarket { + pub(crate) fn new_rand(rng: &mut impl Rng) -> Self { + Self { + discriminator: AccountDiscriminator::LendingMarket, + bump_seed: rng.gen(), + owner: Pubkey::new_unique(), + quote_currency: [rng.gen(); 32], + token_program_id: Pubkey::new_unique(), + oracle_program_id: Pubkey::new_unique(), + switchboard_oracle_program_id: Pubkey::new_unique(), + rate_limiter: rand_rate_limiter(), + whitelisted_liquidator: if rng.gen_bool(0.5) { + None + } else { + Some(Pubkey::new_unique()) + }, + risk_authority: Pubkey::new_unique(), + } + } + } + #[test] - fn pack_and_unpack_lending_market() { + fn pack_and_unpack_lending_market_v2_1_0() { let mut rng = rand::thread_rng(); - let lending_market = LendingMarket { - version: PROGRAM_VERSION, - bump_seed: rng.gen(), - owner: Pubkey::new_unique(), - quote_currency: [rng.gen(); 32], - token_program_id: Pubkey::new_unique(), - oracle_program_id: Pubkey::new_unique(), - switchboard_oracle_program_id: Pubkey::new_unique(), - rate_limiter: rand_rate_limiter(), - whitelisted_liquidator: if rng.gen_bool(0.5) { - None - } else { - Some(Pubkey::new_unique()) - }, - risk_authority: Pubkey::new_unique(), - }; + let lending_market = LendingMarket::new_rand(&mut rng); + + let mut packed = vec![0u8; LendingMarket::LEN]; + LendingMarket::pack(lending_market.clone(), &mut packed).unwrap(); + let unpacked = LendingMarket::unpack_from_slice(&packed).unwrap(); + assert_eq!(unpacked, lending_market); + } + + #[test] + fn pack_and_unpack_lending_market_v2_0_2() { + let mut rng = rand::thread_rng(); + let lending_market = LendingMarket::new_rand(&mut rng); let mut packed = vec![0u8; LendingMarket::LEN]; LendingMarket::pack(lending_market.clone(), &mut packed).unwrap(); + // this is what version looked like before the upgrade to v2.1.0 + packed[0] = PROGRAM_VERSION_2_0_2; + let unpacked = LendingMarket::unpack_from_slice(&packed).unwrap(); + // upgraded assert_eq!(unpacked, lending_market); } } diff --git a/token-lending/sdk/src/state/liquidity_mining.rs b/token-lending/sdk/src/state/liquidity_mining.rs new file mode 100644 index 00000000000..439439a3b16 --- /dev/null +++ b/token-lending/sdk/src/state/liquidity_mining.rs @@ -0,0 +1,828 @@ +//! Liquidity mining feature built analogous to Suilend's implementation. + +pub mod pool_reward_manager; +pub mod user_reward_manager; + +pub use pool_reward_manager::*; +pub use user_reward_manager::*; + +/// Determines the size of [PoolRewardManager]. +/// +/// On Suilend this is 50. +/// However, Sui dynamic object model let's us store more data easily. +/// In Save we're storing the data on the reserve and this means packing and +/// unpacking it frequently which negatively impacts CU limits. +/// +/// In Save, if we want to add new rewards we will crank old ones to make space +/// in the reserve. +pub const MAX_REWARDS: usize = 30; + +/// Cannot create a reward shorter than this. +pub const MIN_REWARD_PERIOD_SECS: u32 = 3_600; + +#[cfg(test)] +mod tests { + use super::*; + use crate::math::{TryDiv, TryMul}; + use crate::{ + error::LendingError, + math::Decimal, + state::{PoolRewardManager, PositionKind, UserRewardManager}, + }; + use pretty_assertions::assert_eq; + use proptest::prelude::*; + use rand::prelude::*; + use rand_chacha::ChaCha8Rng; + use solana_program::{clock::Clock, msg, program_error::ProgramError, pubkey::Pubkey}; + use std::convert::TryFrom; + + /// This test asserts that cancelling a reward does not change the amount of rewards that are + /// emitted to a user. + #[test] + fn it_cancels_reward_without_changing_user_eligible_amount() { + // This is an implementation that was superseded by the edit function. + // We show that cancelling is a special case of editing. + impl PoolRewardManager { + fn cancel_pool_reward( + &mut self, + pool_reward_index: usize, + clock: &Clock, + ) -> Result<(Pubkey, u64), ProgramError> { + self.update(clock)?; + + let Some(PoolRewardEntry::Occupied(pool_reward)) = + self.pool_rewards.get_mut(pool_reward_index) + else { + msg!("Cannot cancel a non-existent pool reward"); + return Err(ProgramError::InvalidArgument); + }; + + if pool_reward.has_ended(clock) { + msg!("Cannot cancel a pool reward that has already ended"); + return Err(LendingError::InvalidAccountInput.into()); + } + + let since_start_secs = clock.unix_timestamp as u64 - pool_reward.start_time_secs; + let unlocked_rewards = Decimal::from(pool_reward.total_rewards) + .try_mul(Decimal::from(since_start_secs))? + .try_div(Decimal::from(pool_reward.duration_secs as u64))? + .try_floor_u64()?; + let remaining_rewards = pool_reward.total_rewards - unlocked_rewards; + + pool_reward.duration_secs = + u32::try_from(since_start_secs).expect("New duration to be strictly shorter"); + + Ok((pool_reward.vault, remaining_rewards)) + } + } + + let usdc = Pubkey::new_unique(); // reserve pubkey + let slnd_vault = Pubkey::new_unique(); // where rewards are stored + let reward_period = 10 * MIN_REWARD_PERIOD_SECS as u64; + let total_rewards = 100 * 1_000_000; + + let mut clock = Clock { + unix_timestamp: 0, + ..Default::default() + }; + + let mut pool_reward_manager = PoolRewardManager::default(); + + let mut user_reward_manager = UserRewardManager::new(usdc, PositionKind::Deposit, &clock); + user_reward_manager + .populate(&mut pool_reward_manager, &clock) + .expect("It populates user reward manager"); + user_reward_manager.set_share(&mut pool_reward_manager, 1); + + pool_reward_manager + .add_pool_reward(slnd_vault, 0, reward_period, total_rewards, &clock) + .expect("It adds pool reward"); + + clock.unix_timestamp = reward_period as i64 / 2; + + let (_, not_cancelled_claimed_slnd) = { + let mut pool_reward_manager = pool_reward_manager.clone(); + let mut user_reward_manager = user_reward_manager.clone(); + + user_reward_manager + .claim_rewards(&mut pool_reward_manager, slnd_vault, &clock) + .expect("It claims rewards") + }; + + let (_, edited_claimed_slnd) = { + let mut pool_reward_manager = pool_reward_manager.clone(); + let mut user_reward_manager = user_reward_manager.clone(); + + let pool_reward_index = 0; + let end_now = 0; // should be same as cancel really + pool_reward_manager + .edit_pool_reward(pool_reward_index, end_now, &clock) + .expect("It edits pool reward"); + + user_reward_manager + .claim_rewards(&mut pool_reward_manager, slnd_vault, &clock) + .expect("It claims rewards") + }; + + let (_, canceled_claimed_slnd) = { + let pool_reward_index = 0; + pool_reward_manager + .cancel_pool_reward(pool_reward_index, &clock) + .expect("It cancels pool reward"); + + user_reward_manager + .claim_rewards(&mut pool_reward_manager, slnd_vault, &clock) + .expect("It claims rewards") + }; + + assert_eq!(not_cancelled_claimed_slnd, canceled_claimed_slnd); + assert_eq!(edited_claimed_slnd, canceled_claimed_slnd); + } + + #[test] + fn it_extends_reward() { + let slnd_vault = Pubkey::new_unique(); // where rewards are stored + let reward_period = 10 * MIN_REWARD_PERIOD_SECS as u64; + let total_rewards = 100 * 1_000_000; + + let clock = Clock { + unix_timestamp: 0, + ..Default::default() + }; + + let mut pool_reward_manager = PoolRewardManager::default(); + + pool_reward_manager + .add_pool_reward(slnd_vault, 0, reward_period, total_rewards, &clock) + .expect("It adds pool reward"); + + let pool_reward_index = 0; + let (vault, amount) = pool_reward_manager + .edit_pool_reward(pool_reward_index, reward_period * 2, &clock) + .expect("It edits pool reward"); + + assert!(amount.is_positive()); + assert_eq!(amount as u64, total_rewards); + assert_eq!(vault, slnd_vault); + + let PoolRewardEntry::Occupied(pool_reward) = + &pool_reward_manager.pool_rewards[pool_reward_index] + else { + panic!("Expected pool reward to be occupied"); + }; + + assert_eq!(pool_reward.total_rewards, total_rewards * 2); + assert_eq!(pool_reward.duration_secs, reward_period as u32 * 2); + } + + proptest! { + #[test] + fn it_yields_expected_rewards_if_edited( + rng_seed in 0..u64::MAX, + user_count in 1..10usize, + reward_period in MIN_REWARD_PERIOD_SECS..1000*MIN_REWARD_PERIOD_SECS, + total_rewards in 1_000_000..10_000_000_000_000u64, + ) { + let mut rng = ChaCha8Rng::seed_from_u64(rng_seed); + + let usdc = Pubkey::new_unique(); + let position_kind = PositionKind::Deposit; + let foo_vault = Pubkey::new_unique(); // this one is not edited + let bar_vault = Pubkey::new_unique(); // we'll edit this one + + let edit_reward_after_timestamp = rng.gen_range( + 0..reward_period as i64 / 2, + ); + let edit_bar_to_end_at_timestamp = rng.gen_range( + 0..(reward_period * 2) as i64, + ); + + let mut clock = Clock { + unix_timestamp: 0, + ..Default::default() + }; + + let mut total_claimed_foo = 0; + let mut total_claimed_bar = 0; + + let mut pool_reward_manager = PoolRewardManager::default(); + + // both rewards start identically + + pool_reward_manager + .add_pool_reward(foo_vault, 0, reward_period as _, total_rewards, &clock) + .expect("It adds pool reward"); + + pool_reward_manager + .add_pool_reward(bar_vault, 0, reward_period as _, total_rewards, &clock) + .expect("It adds pool reward"); + + // all users start tracking the rewards with their respective shares + + let mut user_reward_managers: Vec<_> = (0..user_count) + .map(|_| { + let mut user_reward_manager = UserRewardManager::new(usdc, position_kind, &clock); + + user_reward_manager + .populate(&mut pool_reward_manager, &clock) + .expect("It populates user reward manager"); + + let user_share = rng.gen_range(0..1_000); + user_reward_manager.set_share(&mut pool_reward_manager, user_share); + user_reward_manager + }) + .collect(); + + while clock.unix_timestamp < edit_reward_after_timestamp { + clock.unix_timestamp += rng.gen_range(0..MIN_REWARD_PERIOD_SECS) as i64; + + for user_reward_manager in &mut user_reward_managers { + let (_, claimed_foo) = user_reward_manager + .claim_rewards(&mut pool_reward_manager, foo_vault, &clock) + .expect("It claims foo rewards"); + + let (_, claimed_bar) = user_reward_manager + .claim_rewards(&mut pool_reward_manager, bar_vault, &clock) + .expect("It claims bar rewards"); + + prop_assert_eq!(claimed_foo, claimed_bar); + + total_claimed_foo += claimed_foo; + total_claimed_bar += claimed_bar; + } + } + + // edit the second reward + + let bar_reward_index = 1; + let (_, change_in_bar_reward) = pool_reward_manager + .edit_pool_reward(bar_reward_index, edit_bar_to_end_at_timestamp as _, &clock) + .expect("It edits bar pool reward"); + + // now keep claiming until both rewards end + + loop { + clock.unix_timestamp += rng.gen_range(0..MIN_REWARD_PERIOD_SECS) as i64; + + let has_foo_ended = match &pool_reward_manager.pool_rewards[0] { + PoolRewardEntry::Occupied(pool_reward) => pool_reward.has_ended(&clock), + _ => unreachable!(), + }; + + let has_bar_ended = match &pool_reward_manager.pool_rewards[1] { + PoolRewardEntry::Occupied(pool_reward) => pool_reward.has_ended(&clock), + _ => unreachable!(), + }; + + let neither_has_ended = !has_foo_ended && !has_bar_ended; + + for user_reward_manager in &mut user_reward_managers { + let (_, claimed_foo) = user_reward_manager + .claim_rewards(&mut pool_reward_manager, foo_vault, &clock) + .expect("It claims foo rewards"); + + let (_, claimed_bar) = user_reward_manager + .claim_rewards(&mut pool_reward_manager, bar_vault, &clock) + .expect("It claims bar rewards"); + + total_claimed_foo += claimed_foo; + total_claimed_bar += claimed_bar; + + if neither_has_ended && claimed_foo != claimed_bar { + // due to rounding errors we can be a little off + let allowed_diff = 1; + let allowed_min = claimed_foo.saturating_sub(allowed_diff); + let allowed_range = allowed_min..=(claimed_foo + allowed_diff); + prop_assert!( + allowed_range.contains(&claimed_bar), + "Expected foo and bar rewards to equal, but got {} and {}", + claimed_foo, + claimed_bar + ); + } + } + + if has_foo_ended && has_bar_ended { + break; + } + } + + // check that no more rewards can be claimed + + for user_reward_manager in &mut user_reward_managers { + let (_, claimed_foo) = user_reward_manager + .claim_rewards(&mut pool_reward_manager, foo_vault, &clock) + .expect("It claims foo rewards"); + prop_assert_eq!(claimed_foo, 0); + + let (_, claimed_bar) = user_reward_manager + .claim_rewards(&mut pool_reward_manager, bar_vault, &clock) + .expect("It claims bar rewards"); + prop_assert_eq!(claimed_bar, 0); + } + + // check that the end state is what we'd expect + + // User's claimed no more than total_rewards and not much less either. + // Due to rounding issues we're ok with distributing one less token per user. + let max_allowed_diff = user_count as u64; + + let foo_allowed_range = (total_rewards - max_allowed_diff)..=total_rewards; + prop_assert!( + foo_allowed_range.contains(&total_claimed_foo), + "Foo claimed rewards {} not close to total rewards of {}..={}", + total_claimed_foo, + total_rewards - max_allowed_diff, + total_rewards + ); + + let expected_bar_total_rewards = (total_rewards as i64 + change_in_bar_reward) as u64; + let bar_allowed_range = + (expected_bar_total_rewards - max_allowed_diff)..=expected_bar_total_rewards; + prop_assert!( + bar_allowed_range.contains(&total_claimed_bar), + "Bar claimed rewards {} not close to total rewards of {}..={}", + total_claimed_bar, + expected_bar_total_rewards - max_allowed_diff, + expected_bar_total_rewards + ); + } + } +} + +#[cfg(test)] +mod suilend_tests { + //! These tests were taken from the Suilend's codebase and adapted to + //! the new codebase. + + use crate::{ + math::Decimal, + state::{ + PoolReward, PoolRewardEntry, PoolRewardId, PoolRewardManager, PositionKind, + UserRewardManager, MAX_REWARDS, + }, + }; + use pretty_assertions::assert_eq; + use solana_program::{clock::Clock, pubkey::Pubkey}; + + const SECONDS_IN_A_DAY: u64 = 86_400; + + /// This tests replicates calculations from Suilend's + /// "test_pool_reward_manager_basic" test. + #[test] + fn it_tests_pool_reward_manager_basic() { + let usdc = Pubkey::new_unique(); // reserve pubkey + let slnd_vault = Pubkey::new_unique(); // where rewards are stored + + let mut clock = Clock { + unix_timestamp: 0, + ..Default::default() + }; + + let mut pool_reward_manager = PoolRewardManager::default(); + { + // setup pool reward manager with one reward + + pool_reward_manager + .add_pool_reward( + slnd_vault, + 0, + 20 * SECONDS_IN_A_DAY, + 100 * 1_000_000, + &clock, + ) + .expect("It adds pool reward"); + assert_eq!( + pool_reward_manager.pool_rewards[0], + PoolRewardEntry::Occupied(Box::new(PoolReward { + id: PoolRewardId(1), + vault: slnd_vault, + start_time_secs: 0, + duration_secs: 20 * SECONDS_IN_A_DAY as u32, + total_rewards: 100 * 1_000_000, + cumulative_rewards_per_share: Decimal::zero(), + num_user_reward_managers: 0, + })) + ); + } + + let mut user_reward_manager_1 = UserRewardManager::new(usdc, PositionKind::Deposit, &clock); + { + // setup user reward manager with 100/100 shares + + user_reward_manager_1 + .populate(&mut pool_reward_manager, &clock) + .expect("It populates user reward manager"); + user_reward_manager_1.set_share(&mut pool_reward_manager, 100); + } + + { + // 1/4 of the reward time passes + clock.unix_timestamp = 5 * SECONDS_IN_A_DAY as i64; + + let (_, claimed_slnd) = user_reward_manager_1 + .claim_rewards(&mut pool_reward_manager, slnd_vault, &clock) + .expect("It claims rewards"); + assert_eq!(claimed_slnd, 25 * 1_000_000); + } + + let mut user_reward_manager_2 = UserRewardManager::new(usdc, PositionKind::Deposit, &clock); + { + // setup user reward manager with 400/500 shares + + user_reward_manager_2 + .populate(&mut pool_reward_manager, &clock) + .expect("It populates user reward manager"); + user_reward_manager_2.set_share(&mut pool_reward_manager, 400); + } + + { + // 1/2 of the reward time passes + clock.unix_timestamp = 10 * SECONDS_IN_A_DAY as i64; + + let (_, claimed_slnd) = user_reward_manager_1 + .claim_rewards(&mut pool_reward_manager, slnd_vault, &clock) + .expect("It claims rewards"); + assert_eq!(claimed_slnd, 5 * 1_000_000); + + let (_, claimed_slnd) = user_reward_manager_2 + .claim_rewards(&mut pool_reward_manager, slnd_vault, &clock) + .expect("It claims rewards"); + assert_eq!(claimed_slnd, 20 * 1_000_000); + } + + { + // set both user reward managers to 250/500 shares + user_reward_manager_1.set_share(&mut pool_reward_manager, 250); + user_reward_manager_2.set_share(&mut pool_reward_manager, 250); + } + + { + // the reward is finished + clock.unix_timestamp = 20 * SECONDS_IN_A_DAY as i64; + + let (_, claimed_slnd) = user_reward_manager_1 + .claim_rewards(&mut pool_reward_manager, slnd_vault, &clock) + .expect("It claims rewards"); + assert_eq!(claimed_slnd, 25 * 1_000_000); + + let (_, claimed_slnd) = user_reward_manager_2 + .claim_rewards(&mut pool_reward_manager, slnd_vault, &clock) + .expect("It claims rewards"); + assert_eq!(claimed_slnd, 25 * 1_000_000); + } + } + + /// This tests replicates calculations from Suilend's + /// "test_pool_reward_manager_multiple_rewards" test. + #[test] + fn it_tests_pool_reward_manager_multiple_rewards() { + let usdc = Pubkey::new_unique(); // reserve pubkey + let slnd_vault1 = Pubkey::new_unique(); // where rewards are stored + let slnd_vault2 = Pubkey::new_unique(); // where rewards are stored + + let mut clock = Clock { + unix_timestamp: 0, + ..Default::default() + }; + + let mut pool_reward_manager = PoolRewardManager::default(); + { + // setup a reward that starts now and lasts for 20 days + + pool_reward_manager + .add_pool_reward( + slnd_vault1, + 0, + 20 * SECONDS_IN_A_DAY, + 100 * 1_000_000, + &clock, + ) + .expect("It adds pool reward"); + + // and another reward that starts in 10 days and lasts for 10 days + + pool_reward_manager + .add_pool_reward( + slnd_vault2, + 10 * SECONDS_IN_A_DAY, + 20 * SECONDS_IN_A_DAY, + 100 * 1_000_000, + &clock, + ) + .expect("It adds pool reward"); + } + + let mut user_reward_manager_1 = UserRewardManager::new(usdc, PositionKind::Deposit, &clock); + { + // setup user reward manager with 100/100 shares + + user_reward_manager_1 + .populate(&mut pool_reward_manager, &clock) + .expect("It populates user reward manager"); + user_reward_manager_1.set_share(&mut pool_reward_manager, 100); + } + + clock.unix_timestamp = 15 * SECONDS_IN_A_DAY as i64; + + let mut user_reward_manager_2 = UserRewardManager::new(usdc, PositionKind::Deposit, &clock); + { + // setup user reward manager with 100/200 shares + + user_reward_manager_2 + .populate(&mut pool_reward_manager, &clock) + .expect("It populates user reward manager"); + user_reward_manager_2.set_share(&mut pool_reward_manager, 100); + } + + { + clock.unix_timestamp = 30 * SECONDS_IN_A_DAY as i64; + + let (_, claimed_slnd) = user_reward_manager_1 + .claim_rewards(&mut pool_reward_manager, slnd_vault1, &clock) + .expect("It claims rewards"); + assert_eq!(claimed_slnd, 87_500_000); + + let (_, claimed_slnd) = user_reward_manager_1 + .claim_rewards(&mut pool_reward_manager, slnd_vault2, &clock) + .expect("It claims rewards"); + assert_eq!(claimed_slnd, 75 * 1_000_000); + + let (_, claimed_slnd) = user_reward_manager_2 + .claim_rewards(&mut pool_reward_manager, slnd_vault1, &clock) + .expect("It claims rewards"); + assert_eq!(claimed_slnd, 12_500_000); + + let (_, claimed_slnd) = user_reward_manager_2 + .claim_rewards(&mut pool_reward_manager, slnd_vault2, &clock) + .expect("It claims rewards"); + assert_eq!(claimed_slnd, 25 * 1_000_000); + } + } + + /// This tests replicates calculations from Suilend's + /// "test_pool_reward_manager_zero_share" test. + #[test] + fn it_tests_pool_reward_zero_share() { + let usdc = Pubkey::new_unique(); // reserve pubkey + let slnd_vault = Pubkey::new_unique(); // where rewards are stored + + let mut clock = Clock { + unix_timestamp: 0, + ..Default::default() + }; + + let mut pool_reward_manager = PoolRewardManager::default(); + { + // setup pool reward manager with one reward + + pool_reward_manager + .add_pool_reward( + slnd_vault, + 0, + 20 * SECONDS_IN_A_DAY, + 100 * 1_000_000, + &clock, + ) + .expect("It adds pool reward"); + } + + clock.unix_timestamp = 10 * SECONDS_IN_A_DAY as i64; + let mut user_reward_manager_1 = UserRewardManager::new(usdc, PositionKind::Deposit, &clock); + user_reward_manager_1 + .populate(&mut pool_reward_manager, &clock) + .expect("It populates user reward manager"); + user_reward_manager_1.set_share(&mut pool_reward_manager, 1); + + clock.unix_timestamp = 20 * SECONDS_IN_A_DAY as i64; + let (_, claimed_slnd) = user_reward_manager_1 + .claim_rewards(&mut pool_reward_manager, slnd_vault, &clock) + .expect("It claims rewards"); + // 50 usdc is unallocated since there was zero share from 0-10 seconds + assert_eq!(claimed_slnd, 50 * 1_000_000); + } + + /// This tests replicates calculations from Suilend's + /// "test_pool_reward_manager_auto_farm" test. + #[test] + fn it_tests_pool_reward_manager_auto_farm() { + let usdc = Pubkey::new_unique(); // reserve pubkey + let slnd_vault = Pubkey::new_unique(); // where rewards are stored + + let mut clock = Clock { + unix_timestamp: 0, + ..Default::default() + }; + + let mut pool_reward_manager = PoolRewardManager::default(); + + let mut user_reward_manager_1 = UserRewardManager::new(usdc, PositionKind::Deposit, &clock); + user_reward_manager_1 + .populate(&mut pool_reward_manager, &clock) + .expect("It populates user reward manager"); + user_reward_manager_1.set_share(&mut pool_reward_manager, 1); + + pool_reward_manager + .add_pool_reward( + slnd_vault, + 0, + 20 * SECONDS_IN_A_DAY, + 100 * 1_000_000, + &clock, + ) + .expect("It adds pool reward"); + + clock.unix_timestamp = 10 * SECONDS_IN_A_DAY as i64; + + let mut user_reward_manager_2 = UserRewardManager::new(usdc, PositionKind::Deposit, &clock); + user_reward_manager_2 + .populate(&mut pool_reward_manager, &clock) + .expect("It populates user reward manager"); + user_reward_manager_2.set_share(&mut pool_reward_manager, 1); + + { + clock.unix_timestamp = 20 * SECONDS_IN_A_DAY as i64; + + let (_, claimed_slnd) = user_reward_manager_1 + .claim_rewards(&mut pool_reward_manager, slnd_vault, &clock) + .expect("It claims rewards"); + assert_eq!(claimed_slnd, 75 * 1_000_000); + + user_reward_manager_2.set_share(&mut pool_reward_manager, 1); + let (_, claimed_slnd) = user_reward_manager_2 + .claim_rewards(&mut pool_reward_manager, slnd_vault, &clock) + .expect("It claims rewards"); + assert_eq!(claimed_slnd, 25 * 1_000_000); + } + } + + /// This tests replicates Suilend's "test_add_too_many_pool_rewards" test. + #[test] + fn it_tests_add_too_many_pool_rewards() { + let clock = Clock::default(); + + let mut pool_reward_manager = PoolRewardManager::default(); + + for _ in 0..MAX_REWARDS { + let slnd_vault = Pubkey::new_unique(); // where rewards are stored + pool_reward_manager + .add_pool_reward( + slnd_vault, + 0, + 20 * SECONDS_IN_A_DAY, + 100 * 1_000_000, + &clock, + ) + .expect("It adds pool reward"); + } + + pool_reward_manager + .add_pool_reward( + Pubkey::new_unique(), + 0, + 20 * SECONDS_IN_A_DAY, + 100 * 1_000_000, + &clock, + ) + .expect_err("It fails to add pool reward"); + } + + /// This tests replicates Suilend's + /// "test_pool_reward_manager_cancel_and_close" test. + #[test] + fn it_tests_pool_reward_manager_cancel_and_close() { + let usdc = Pubkey::new_unique(); // reserve pubkey + let slnd_vault = Pubkey::new_unique(); // where rewards are stored + + let mut clock = Clock { + unix_timestamp: 0, + ..Default::default() + }; + + let mut pool_reward_manager = PoolRewardManager::default(); + + pool_reward_manager + .add_pool_reward( + slnd_vault, + 0, + 20 * SECONDS_IN_A_DAY, + 100 * 1_000_000, + &clock, + ) + .expect("It adds pool reward"); + + let mut user_reward_manager_1 = UserRewardManager::new(usdc, PositionKind::Deposit, &clock); + user_reward_manager_1 + .populate(&mut pool_reward_manager, &clock) + .expect("It populates user reward manager"); + user_reward_manager_1.set_share(&mut pool_reward_manager, 100); + + { + clock.unix_timestamp = 10 * SECONDS_IN_A_DAY as i64; + + let pool_reward_index = 0; + let new_end_time_secs = 0; // now + let (from_vault, unallocated_rewards) = pool_reward_manager + .edit_pool_reward(pool_reward_index, new_end_time_secs, &clock) + .expect("It cancels pool reward"); + assert_eq!(from_vault, slnd_vault); + assert_eq!(unallocated_rewards, -50 * 1_000_000 + 1); // approx + } + + { + clock.unix_timestamp = 15 * SECONDS_IN_A_DAY as i64; + + let (_, claimed_slnd) = user_reward_manager_1 + .claim_rewards(&mut pool_reward_manager, slnd_vault, &clock) + .expect("It claims rewards"); + assert_eq!(claimed_slnd, 50 * 1_000_000); + } + + let from_vault = pool_reward_manager + .close_pool_reward(0) + .expect("It closes pool reward"); + assert_eq!(from_vault, slnd_vault); + } + + /// This tests replicates Suilend's + /// "test_pool_reward_manager_cancel_and_close_regression" test. + #[test] + fn it_tests_pool_reward_manager_cancel_and_close_regression() { + let usdc = Pubkey::new_unique(); // reserve pubkey + let slnd_vault1 = Pubkey::new_unique(); // where rewards are stored + let slnd_vault2 = Pubkey::new_unique(); // where rewards are stored + + let mut clock = Clock { + unix_timestamp: 0, + ..Default::default() + }; + + let mut pool_reward_manager = PoolRewardManager::default(); + + pool_reward_manager + .add_pool_reward( + slnd_vault1, + 0, + 20 * SECONDS_IN_A_DAY, + 100 * 1_000_000, + &clock, + ) + .expect("It adds pool reward"); + + pool_reward_manager + .add_pool_reward( + slnd_vault2, + 20 * SECONDS_IN_A_DAY, + 30 * SECONDS_IN_A_DAY, + 100 * 1_000_000, + &clock, + ) + .expect("It adds pool reward"); + + let mut user_reward_manager_1 = UserRewardManager::new(usdc, PositionKind::Deposit, &clock); + user_reward_manager_1 + .populate(&mut pool_reward_manager, &clock) + .expect("It populates user reward manager"); + user_reward_manager_1.set_share(&mut pool_reward_manager, 100); + + { + clock.unix_timestamp = 10 * SECONDS_IN_A_DAY as i64; + + let pool_reward_index = 0; + let new_end_time_secs = 0; // now + let (from_vault, unallocated_rewards) = pool_reward_manager + .edit_pool_reward(pool_reward_index, new_end_time_secs, &clock) + .expect("It cancels pool reward"); + assert_eq!(from_vault, slnd_vault1); + assert_eq!(unallocated_rewards, -50 * 1_000_000 + 1); // approx + + clock.unix_timestamp = 15 * SECONDS_IN_A_DAY as i64; + let (_, claim_slnd) = user_reward_manager_1 + .claim_rewards(&mut pool_reward_manager, slnd_vault1, &clock) + .expect("It claims rewards"); + assert_eq!(claim_slnd, 50 * 1_000_000); + + let from_vault = pool_reward_manager + .close_pool_reward(0) + .expect("It closes pool reward"); + assert_eq!(from_vault, slnd_vault1); + } + + clock.unix_timestamp = 20 * SECONDS_IN_A_DAY as i64; + + let mut user_reward_manager_2 = UserRewardManager::new(usdc, PositionKind::Deposit, &clock); + user_reward_manager_2 + .populate(&mut pool_reward_manager, &clock) + .expect("It populates user reward manager"); + user_reward_manager_2.set_share(&mut pool_reward_manager, 100); + + { + clock.unix_timestamp = 30 * SECONDS_IN_A_DAY as i64; + + let (_, claimed_slnd) = user_reward_manager_2 + .claim_rewards(&mut pool_reward_manager, slnd_vault2, &clock) + .expect("It claims rewards"); + assert_eq!(claimed_slnd, 50 * 1_000_000); + } + } +} diff --git a/token-lending/sdk/src/state/liquidity_mining/pool_reward_manager.rs b/token-lending/sdk/src/state/liquidity_mining/pool_reward_manager.rs new file mode 100644 index 00000000000..e457c32bbb9 --- /dev/null +++ b/token-lending/sdk/src/state/liquidity_mining/pool_reward_manager.rs @@ -0,0 +1,655 @@ +//! [PoolRewardManager]s are stored in [crate::state::Reserve]s. +//! They can be either borrow or deposit but the logic is almost the same. +//! +//! The only difference is how shares are calculated: +//! For borrow managers the shares are "liability" and for deposit managers the shares are +//! "deposited collateral". + +use crate::{ + error::LendingError, + math::{Decimal, TryAdd, TryDiv, TryMul}, + state::{pack_decimal, unpack_decimal, MAX_REWARDS, MIN_REWARD_PERIOD_SECS}, +}; +use arrayref::{array_mut_ref, array_ref, array_refs, mut_array_refs}; +use core::convert::TryInto; +use solana_program::{ + clock::Clock, + msg, + program_error::ProgramError, + program_pack::{Pack, Sealed}, + pubkey::{Pubkey, PUBKEY_BYTES}, +}; +use std::cmp::Ordering; + +/// Each reserve has two managers: +/// - one for deposits +/// - one for borrows +#[derive(Clone, Debug, PartialEq)] +pub struct PoolRewardManager { + /// Is updated when we change user shares in the reserve. + pub total_shares: u64, + /// Monotonically increasing time taken from clock sysvar. + pub last_update_time_secs: u64, + /// New [PoolReward] are added to the first vacant entry. + pub pool_rewards: [PoolRewardEntry; MAX_REWARDS], +} + +/// Each pool reward gets an ID which is monotonically increasing with each new reward added to the +/// pool at the particular entry. +/// +/// This helps us distinguish between two distinct rewards in the same array index across time. +/// +/// # Wrapping +/// There are two strategies to handle wrapping: +/// 1. Consider the associated entry locked forever +/// 2. Go back to 0. +/// +/// Given that one reward lasts at least [MIN_REWARD_PERIOD_SECS] we've got at least half a million +/// years before we need to worry about wrapping in a single entry. +/// I'd call that someone else's problem. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct PoolRewardId(pub u32); + +/// # (Un)Packing +/// This is the unpacked representation. +/// When packing we use the [PoolReward] `reward_mint` to determine whether the reward is vacant or +/// not to save space. +/// +/// If the pubkey is eq to default pubkey then entry is vacant. +/// We always pack the ID of the reward because it's monotonically increasing. +/// See [PoolRewardId] for more details. +#[derive(Clone, Debug, PartialEq)] +pub enum PoolRewardEntry { + /// New reward can be added to this entry. + Vacant { + /// Increment this ID when adding new [PoolReward]. + last_pool_reward_id: PoolRewardId, + /// An optimization to avoid writing data that has not changed. + /// When vacating a entry we set this to true. + /// That way the packing logic knows whether it's fine to skip the packing or not. + has_been_just_vacated: bool, + }, + /// Reward has not been closed yet. + /// + /// We box the [PoolReward] to avoid stack overflow. + Occupied(Box), +} + +/// Tracks rewards in a specific mint over some period of time. +/// +/// # Reward cancellation +/// +/// In Suilend we also store the amount of rewards that have been made available to users already. +/// We keep adding `(total_rewards * time_passed) / (total_time)` every time someone interacts with +/// the manager. +/// 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] on +/// each [PoolReward]. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct PoolReward { + /// Unique ID for this entry that has never been used before, and will never be used again. + pub id: PoolRewardId, + /// # (Un)Packing + /// When we pack the reward we set this to default pubkey for vacant entries. + pub vault: Pubkey, + /// Monotonically increasing time taken from clock sysvar. + pub start_time_secs: u64, + /// For how long (since start time) will this reward be releasing tokens. + /// + /// # Reward Editing + /// + /// Is cut short or extended. + pub duration_secs: u32, + /// Total token amount to distribute. + /// The token account that holds the rewards holds at least this much in the beginning. + /// + /// # Reward Editing + /// + /// Is deducted or increased linearly to the duration. + pub total_rewards: u64, + /// How many users are still tracking this reward. + /// Once this reaches zero we can close this reward. + /// There's a permission-less ix with which user rewards can be distributed + /// that's used for cranking remaining rewards. + pub num_user_reward_managers: u64, + /// We keep adding `(unlocked_rewards) / (total_shares)` every time + /// someone interacts with the manager ([update_pool_reward_manager]) + /// where + /// `unlocked_rewards = (total_rewards * time_passed) / (total_time)` + /// + /// # (Un)Packing + /// We only store 16 most significant digits. + pub cumulative_rewards_per_share: Decimal, +} + +impl PoolRewardManager { + /// Adds a new pool reward. + /// + /// Will first update itself. + /// + /// Start time will be set to now if it's in the past. + /// Must last at least [MIN_REWARD_PERIOD_SECS]. + /// The amount of tokens to distribute must be greater than zero. + /// + /// Will return an error if no entry can be found for the new reward. + pub fn add_pool_reward( + &mut self, + vault: Pubkey, + start_time_secs: u64, + end_time_secs: u64, + reward_token_amount: u64, + clock: &Clock, + ) -> Result<(), ProgramError> { + self.update(clock)?; + + let start_time_secs = start_time_secs.max(clock.unix_timestamp as u64); + + if start_time_secs >= end_time_secs { + msg!("Pool reward must end after it starts"); + return Err(LendingError::PoolRewardPeriodTooShort.into()); + } + + let duration_secs: u32 = { + // SAFETY: just checked that start time is strictly smaller + let d = end_time_secs - start_time_secs; + d.try_into().map_err(|_| { + msg!("Pool reward duration is too long"); + LendingError::MathOverflow + })? + }; + if MIN_REWARD_PERIOD_SECS > duration_secs { + msg!("Pool reward duration must be at least {MIN_REWARD_PERIOD_SECS} secs"); + return Err(LendingError::PoolRewardPeriodTooShort.into()); + } + + if reward_token_amount == 0 { + msg!("Pool reward amount must be greater than zero"); + return Err(LendingError::InvalidAmount.into()); + } + + let eligible_entry = + self.pool_rewards + .iter_mut() + .enumerate() + .find_map(|(entry_index, entry)| match entry { + PoolRewardEntry::Vacant { + last_pool_reward_id: PoolRewardId(id), + .. + } if *id < u32::MAX => Some((entry_index, PoolRewardId(*id + 1))), + _ => None, + }); + + let Some((entry_index, next_id)) = eligible_entry else { + msg!("No vacant entry found for the new pool reward"); + return Err(LendingError::NoVacantEntryForPoolReward.into()); + }; + + self.pool_rewards[entry_index] = PoolRewardEntry::Occupied(Box::new(PoolReward { + id: next_id, + vault, + start_time_secs, + duration_secs, + total_rewards: reward_token_amount, + num_user_reward_managers: 0, + cumulative_rewards_per_share: Decimal::zero(), + })); + + Ok(()) + } + + /// Change the pool reward end time to `new_end_time_secs`. + /// This way the reward can be extended or shortened. + /// + /// The relative change in the total amount must remain the same, ie. a user wouldn't be able to + /// tell a difference between how much rewards they received over the same period of time. + /// That change in the token amount is what we return along with the vault the rewards are in. + /// + /// Positive change means the admin should add more tokens to the vault, negative means they + /// should transfer tokens out of the vault. + pub fn edit_pool_reward( + &mut self, + pool_reward_index: usize, + new_end_time_secs: u64, + clock: &Clock, + ) -> Result<(Pubkey, i64), ProgramError> { + self.update(clock)?; + + let Some(PoolRewardEntry::Occupied(pool_reward)) = + self.pool_rewards.get_mut(pool_reward_index) + else { + msg!("Cannot edit a non-existent pool reward"); + return Err(ProgramError::InvalidArgument); + }; + + if pool_reward.has_ended(clock) { + msg!("Cannot edit a pool reward that has already ended"); + return Err(LendingError::InvalidAccountInput.into()); + } + + let new_end_time_secs = new_end_time_secs + .max(clock.unix_timestamp as u64) + .max(pool_reward.start_time_secs); + + let new_duration_secs: u32 = (new_end_time_secs - pool_reward.start_time_secs) + .try_into() + .unwrap_or(u32::MAX) + .max(MIN_REWARD_PERIOD_SECS); + + // we'll use this to calculate how should the total reward change + let rewards_per_seconds = Decimal::from(pool_reward.total_rewards) + .try_div(Decimal::from(pool_reward.duration_secs as u64))?; + + let old_duration_secs = pool_reward.duration_secs; + + pool_reward.duration_secs = new_duration_secs; + match new_duration_secs.cmp(&old_duration_secs) { + Ordering::Equal => { + msg!("Pool reward duration is the same, nothing to do"); + Ok((pool_reward.vault, 0)) + } + Ordering::Greater => { + let extend_by_secs = new_duration_secs - old_duration_secs; + msg!("Extending pool reward duration by {}s", extend_by_secs); + + // ceil up so that we cannot extend a reward without adding more tokens + let rewards_to_add = rewards_per_seconds + .try_mul(Decimal::from(extend_by_secs as u64))? + .try_ceil_u64()?; + + pool_reward.total_rewards += rewards_to_add; + + Ok((pool_reward.vault, rewards_to_add as i64)) + } + Ordering::Less => { + let shorten_by_secs = old_duration_secs - new_duration_secs; + msg!("Shortening pool reward duration by {}s", shorten_by_secs); + + // floor down so that the vault is never short by a token + let rewards_to_remove = rewards_per_seconds + .try_mul(Decimal::from(shorten_by_secs as u64))? + .try_floor_u64()?; + + pool_reward.total_rewards -= rewards_to_remove; + + Ok((pool_reward.vault, -(rewards_to_remove as i64))) + } + } + } + + /// Closes a pool reward if it has been cancelled before. + /// Returns the vault the rewards are in. + pub fn close_pool_reward(&mut self, pool_reward_index: usize) -> Result { + let Some(PoolRewardEntry::Occupied(pool_reward)) = + self.pool_rewards.get_mut(pool_reward_index) + else { + msg!("Cannot close a non-existent pool reward"); + return Err(ProgramError::InvalidArgument); + }; + + if pool_reward.num_user_reward_managers > 0 { + msg!("Cannot close a pool reward with active user reward managers"); + return Err(LendingError::InvalidAccountInput.into()); + } + + let vault = pool_reward.vault; + + self.pool_rewards[pool_reward_index] = PoolRewardEntry::Vacant { + last_pool_reward_id: pool_reward.id, + has_been_just_vacated: true, + }; + + Ok(vault) + } +} + +impl PoolRewardManager { + /// Should be updated before any interaction with rewards. + pub(crate) fn update(&mut self, clock: &Clock) -> Result<(), ProgramError> { + let curr_unix_timestamp_secs = clock.unix_timestamp as u64; + + if self.last_update_time_secs >= curr_unix_timestamp_secs { + return Ok(()); + } + + if self.total_shares == 0 { + self.last_update_time_secs = curr_unix_timestamp_secs; + return Ok(()); + } + + let last_update_time_secs = self.last_update_time_secs; + + // get rewards that started already and did not finish yet + let running_rewards = self + .pool_rewards + .iter_mut() + .filter_map(|r| match r { + PoolRewardEntry::Occupied(reward) => Some(reward), + _ => None, + }) + .filter(|r| curr_unix_timestamp_secs > r.start_time_secs) + .filter(|r| last_update_time_secs < (r.start_time_secs + r.duration_secs as u64)); + + for reward in running_rewards { + let end_time_secs = reward.start_time_secs + reward.duration_secs as u64; + let time_passed_secs = curr_unix_timestamp_secs + .min(end_time_secs) + .checked_sub(reward.start_time_secs.max(last_update_time_secs)) + .ok_or(LendingError::MathOverflow)?; + + // When adding a reward we assert that a reward lasts for at least [MIN_REWARD_PERIOD_SECS]. + // Hence this won't error on overflow nor on division by zero. + let unlocked_rewards = Decimal::from(reward.total_rewards) + .try_mul(Decimal::from(time_passed_secs))? + .try_div(Decimal::from(end_time_secs - reward.start_time_secs))?; + + reward.cumulative_rewards_per_share = reward + .cumulative_rewards_per_share + .try_add(unlocked_rewards.try_div(Decimal::from(self.total_shares))?)?; + } + + self.last_update_time_secs = curr_unix_timestamp_secs; + + Ok(()) + } +} + +impl PoolReward { + const LEN: usize = Self::HEAD_LEN + Self::TAIL_LEN; + + const HEAD_LEN: usize = PoolRewardId::LEN + PUBKEY_BYTES; + + /// - `start_time_secs`` + /// - `duration_secs`` + /// - `total_rewards`` + /// - `num_user_reward_managers`` + /// - `cumulative_rewards_per_share`` + const TAIL_LEN: usize = 8 + 4 + 8 + 8 + 16; + + /// Returns whether the reward has ended. + pub(crate) fn has_ended(&self, clock: &Clock) -> bool { + let end_time_secs = self.start_time_secs + self.duration_secs as u64; + clock.unix_timestamp as u64 >= end_time_secs + } +} + +impl PoolRewardId { + pub(crate) const LEN: usize = std::mem::size_of::(); +} + +impl Default for PoolRewardManager { + fn default() -> Self { + Self { + total_shares: 0, + last_update_time_secs: 0, + pool_rewards: std::array::from_fn(|_| PoolRewardEntry::default()), + } + } +} + +impl Default for PoolRewardEntry { + fn default() -> Self { + Self::Vacant { + last_pool_reward_id: PoolRewardId(0), + // this is used for initialization of the pool reward manager so + // it makes sense as there are 0s in the account data already + has_been_just_vacated: false, + } + } +} + +impl PoolRewardManager { + #[inline(never)] + pub(crate) fn unpack_to_box(input: &[u8]) -> Result, ProgramError> { + Ok(Box::new(PoolRewardManager::unpack_from_slice(input)?)) + } +} + +impl Sealed for PoolRewardManager {} + +impl Pack for PoolRewardManager { + /// total_shares + last_update_time_secs + pool_rewards. + const LEN: usize = 8 + 8 + MAX_REWARDS * PoolReward::LEN; + + fn pack_into_slice(&self, output: &mut [u8]) { + output[0..8].copy_from_slice(&self.total_shares.to_le_bytes()); + output[8..16].copy_from_slice(&self.last_update_time_secs.to_le_bytes()); + + let rewards_to_pack = self + .pool_rewards + .iter() + .enumerate() + .filter(|(_, s)| s.should_be_packed()); + + for (index, pool_reward_entry) in rewards_to_pack { + let offset = 16 + index * PoolReward::LEN; + + let raw_pool_reward_head = array_mut_ref![output, offset, PoolReward::HEAD_LEN]; + let (dst_id, dst_vault) = + mut_array_refs![raw_pool_reward_head, PoolRewardId::LEN, PUBKEY_BYTES]; + + match pool_reward_entry { + PoolRewardEntry::Vacant { + last_pool_reward_id: PoolRewardId(id), + .. + } => { + dst_id.copy_from_slice(&id.to_le_bytes()); + dst_vault.copy_from_slice(Pubkey::default().as_ref()); + } + PoolRewardEntry::Occupied(pool_reward) => { + dst_id.copy_from_slice(&pool_reward.id.0.to_le_bytes()); + dst_vault.copy_from_slice(pool_reward.vault.as_ref()); + + let raw_pool_reward_tail = + array_mut_ref![output, offset + PoolReward::HEAD_LEN, PoolReward::TAIL_LEN]; + + let ( + dst_start_time_secs, + dst_duration_secs, + dst_total_rewards, + dst_num_user_reward_managers, + dst_cumulative_rewards_per_share_wads, + ) = mut_array_refs![ + raw_pool_reward_tail, + 8, // start_time_secs + 4, // duration_secs + 8, // total_rewards + 8, // num_user_reward_managers + 16 // cumulative_rewards_per_share + ]; + + *dst_start_time_secs = pool_reward.start_time_secs.to_le_bytes(); + *dst_duration_secs = pool_reward.duration_secs.to_le_bytes(); + *dst_total_rewards = pool_reward.total_rewards.to_le_bytes(); + *dst_num_user_reward_managers = + pool_reward.num_user_reward_managers.to_le_bytes(); + pack_decimal( + pool_reward.cumulative_rewards_per_share, + dst_cumulative_rewards_per_share_wads, + ); + } + }; + } + } + + #[inline(never)] + fn unpack_from_slice(input: &[u8]) -> Result { + let mut pool_reward_manager = PoolRewardManager { + total_shares: u64::from_le_bytes(*array_ref![input, 0, 8]), + last_update_time_secs: u64::from_le_bytes(*array_ref![input, 8, 8]), + ..Default::default() + }; + + for index in 0..MAX_REWARDS { + let offset = 8 + 8 + index * PoolReward::LEN; + let raw_pool_reward_head = array_ref![input, offset, PoolReward::HEAD_LEN]; + + #[allow(clippy::ptr_offset_with_cast)] + let (src_id, src_vault) = + array_refs![raw_pool_reward_head, PoolRewardId::LEN, PUBKEY_BYTES]; + + let pool_reward_id = PoolRewardId(u32::from_le_bytes(*src_id)); + let vault = Pubkey::new_from_array(*src_vault); + + // SAFETY: ok to assign because we know the index is less than length + pool_reward_manager.pool_rewards[index] = if vault == Pubkey::default() { + PoolRewardEntry::Vacant { + last_pool_reward_id: pool_reward_id, + // nope, has been vacant since unpack + has_been_just_vacated: false, + } + } else { + let raw_pool_reward_tail = + array_ref![input, offset + PoolReward::HEAD_LEN, PoolReward::TAIL_LEN]; + + let ( + src_start_time_secs, + src_duration_secs, + src_total_rewards, + src_num_user_reward_managers, + src_cumulative_rewards_per_share_wads, + ) = array_refs![ + raw_pool_reward_tail, + 8, // start_time_secs + 4, // duration_secs + 8, // total_rewards + 8, // num_user_reward_managers + 16 // cumulative_rewards_per_share + ]; + + PoolRewardEntry::Occupied(Box::new(PoolReward { + id: pool_reward_id, + vault, + start_time_secs: u64::from_le_bytes(*src_start_time_secs), + duration_secs: u32::from_le_bytes(*src_duration_secs), + total_rewards: u64::from_le_bytes(*src_total_rewards), + num_user_reward_managers: u64::from_le_bytes(*src_num_user_reward_managers), + cumulative_rewards_per_share: unpack_decimal( + src_cumulative_rewards_per_share_wads, + ), + })) + }; + } + + Ok(pool_reward_manager) + } +} + +impl PoolRewardEntry { + /// If we know for sure that data hasn't changed then we can just skip packing. + fn should_be_packed(&self) -> bool { + let for_sure_has_not_changed = matches!( + self, + Self::Vacant { + has_been_just_vacated: false, + .. + } + ); + + !for_sure_has_not_changed + } +} + +#[cfg(test)] +mod tests { + use super::*; + use proptest::prelude::*; + + impl PoolRewardManager { + pub(crate) fn new_rand(rng: &mut impl rand::Rng) -> Self { + Self { + total_shares: rng.gen(), + last_update_time_secs: rng.gen(), + pool_rewards: std::array::from_fn(|_| { + let is_vacant = rng.gen_bool(0.5); + + if is_vacant { + PoolRewardEntry::Vacant { + last_pool_reward_id: Default::default(), + has_been_just_vacated: false, + } + } else { + PoolRewardEntry::Occupied(Box::new(PoolReward { + id: PoolRewardId(rng.gen()), + vault: Pubkey::new_unique(), + start_time_secs: rng.gen(), + duration_secs: rng.gen(), + total_rewards: rng.gen(), + cumulative_rewards_per_share: Decimal::from_scaled_val(rng.gen()), + num_user_reward_managers: rng.gen(), + })) + } + }), + } + } + } + + #[test] + fn it_packs_id_if_vacated_in_this_tx() { + let mut m = PoolRewardManager::default(); + m.pool_rewards[0] = PoolRewardEntry::Vacant { + last_pool_reward_id: PoolRewardId(69), + has_been_just_vacated: true, + }; + + let mut packed = vec![0u8; PoolRewardManager::LEN]; + m.pack_into_slice(&mut packed); + let unpacked = PoolRewardManager::unpack_from_slice(&packed).unwrap(); + + assert_eq!( + unpacked.pool_rewards[0], + PoolRewardEntry::Vacant { + last_pool_reward_id: PoolRewardId(69), + has_been_just_vacated: false, + } + ); + } + + #[test] + fn it_unpacks_empty_pool_reward_manager_bytes_as_default() { + let packed = vec![0u8; PoolRewardManager::LEN]; + let unpacked = PoolRewardManager::unpack_from_slice(&packed).unwrap(); + assert_eq!(unpacked, PoolRewardManager::default()); + + // sanity check that everything starts at 0 + let all_rewards_are_empty = unpacked.pool_rewards.iter().all(|pool_reward| { + matches!( + pool_reward, + PoolRewardEntry::Vacant { + last_pool_reward_id: PoolRewardId(0), + has_been_just_vacated: false, + } + ) + }); + + assert!(all_rewards_are_empty); + } + + #[test] + fn it_fits_reserve_realloc_into_single_ix() { + const MAX_REALLOC: usize = solana_program::entrypoint::MAX_PERMITTED_DATA_INCREASE; + + let size_of_discriminant = 1; + let required_realloc = size_of_discriminant * PoolRewardManager::LEN; + assert!(required_realloc <= MAX_REALLOC); + } + + fn pool_reward_manager_strategy() -> impl Strategy { + (0..1u32).prop_perturb(|_, mut rng| PoolRewardManager::new_rand(&mut rng)) + } + + proptest! { + #[test] + fn it_packs_and_unpacks_pool_reward_manager(pool_reward_manager in pool_reward_manager_strategy()) { + let mut packed = vec![0u8; PoolRewardManager::LEN]; + Pack::pack_into_slice(&pool_reward_manager, &mut packed); + let unpacked = PoolRewardManager::unpack_from_slice(&packed).unwrap(); + + prop_assert_eq!(pool_reward_manager.last_update_time_secs, unpacked.last_update_time_secs); + prop_assert_eq!(pool_reward_manager.total_shares, unpacked.total_shares); + + for (og, unpacked) in pool_reward_manager.pool_rewards.iter().zip(unpacked.pool_rewards.iter()) { + prop_assert_eq!(og, unpacked); + } + } + } +} diff --git a/token-lending/sdk/src/state/liquidity_mining/user_reward_manager.rs b/token-lending/sdk/src/state/liquidity_mining/user_reward_manager.rs new file mode 100644 index 00000000000..914362290b6 --- /dev/null +++ b/token-lending/sdk/src/state/liquidity_mining/user_reward_manager.rs @@ -0,0 +1,654 @@ +//! [UserRewardManager]s are stored in [crate::state::Obligation]s for each +//! reserve the user has borrowed from or deposited into at the current time or +//! in the past. + +use crate::{ + error::LendingError, + math::{Decimal, TryAdd, TryMul, TrySub}, + state::{ + pack_decimal, unpack_decimal, PoolRewardEntry, PoolRewardId, PoolRewardManager, + PositionKind, MAX_OBLIGATION_RESERVES, MAX_REWARDS, + }, +}; +use arrayref::{array_mut_ref, array_ref, array_refs, mut_array_refs}; +use core::{ + convert::TryInto, + ops::{Deref, DerefMut}, +}; +use solana_program::{ + clock::Clock, + msg, + program_error::ProgramError, + pubkey::{Pubkey, PUBKEY_BYTES}, +}; + +/// Wraps over user reward managers and allows mutable access to them while +/// other obligation fields are borrowed. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct UserRewardManagers(Vec); + +/// Tracks user's LM rewards for a specific pool (reserve.) +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct UserRewardManager { + /// Links this manager to a reserve. + pub reserve: Pubkey, + /// Although a user cannot both borrow and deposit in the same reserve, they + /// can deposit, withdraw and then borrow the same reserve. + /// Meanwhile they could've accumulated some rewards that'd be lost. + /// + /// Also, have an explicit distinguish between borrow and deposit doesn't + /// suffer from a footgun of misattributing rewards. + pub position_kind: PositionKind, + /// For deposits, this is the amount of collateral token user has in + /// their obligation deposit. + /// + /// For borrows, this is (borrow_amount / cumulative_borrow_rate) user + /// has in their obligation borrow. + pub share: u64, + /// Monotonically increasing time taken from clock sysvar. + pub last_update_time_secs: u64, + /// The indices on [Self::rewards] are _not_ correlated with + /// [PoolRewardManager::pool_rewards]. + /// Instead, this vector only tracks meaningful rewards for the user. + /// See [UserReward::pool_reward_index]. + /// + /// This is a diversion from the Suilend implementation. + pub rewards: Vec, +} + +/// Track user rewards for a specific [PoolReward]. +#[derive(Debug, PartialEq, Eq, Default, Clone)] +pub struct UserReward { + /// Which [PoolReward] within the reserve's index does this [UserReward] + /// correspond to. + /// + /// # (Un)packing + /// There are ever only going to be at most [MAX_REWARDS]. + /// We therefore pack this value into a byte. + pub pool_reward_index: usize, + /// Each pool reward gets an ID which is monotonically increasing with each + /// new reward added to the pool. + pub pool_reward_id: PoolRewardId, + /// Before [UserReward.cumulative_rewards_per_share] is copied we find + /// time difference between current global rewards and last user update + /// rewards: + /// [PoolReward.cumulative_rewards_per_share] - [UserReward.cumulative_rewards_per_share] + /// + /// Then, we multiply that difference by [UserRewardManager.share] and + /// add the result to this counter. + pub earned_rewards: Decimal, + /// copied from [PoolReward.cumulative_rewards_per_share] at the time of the last update + pub cumulative_rewards_per_share: Decimal, +} + +/// When creating a new [UserRewardManager] we need to know whether we should +/// populate it with rewards or not. +enum CreatingNewUserRewardManager { + /// If we are creating a [UserRewardManager] then we want to populate it. + Yes, + /// If we are updating an existing [UserRewardManager] then we don't want + /// to populate it. + No, +} + +impl UserRewardManagers { + /// Returns [UserRewardManager] for the given reserve if any + pub fn find_mut( + &mut self, + reserve: Pubkey, + position_kind: PositionKind, + ) -> Option<(usize, &mut UserRewardManager)> { + self.0 + .iter_mut() + .enumerate() + .find(|(_, user_reward_manager)| { + user_reward_manager.reserve == reserve + && user_reward_manager.position_kind == position_kind + }) + } + + /// Updates the [UserRewardManager] for the given reserve. + /// + /// The caller must make sure that the provided [PoolRewardManager] is valid + /// for the given reserve. + /// + /// If an associated [UserRewardManager] is not found, it will be created. + /// + /// # Important + /// + /// Only call this if you're sure that the obligation should be tracking + /// rewards for the given reserve. + pub fn set_share( + &mut self, + reserve: Pubkey, + position_kind: PositionKind, + pool_reward_manager: &mut PoolRewardManager, + new_share: u64, + clock: &Clock, + ) -> Result<(), ProgramError> { + let (index, user_reward_manager) = if let Some((index, user_reward_manager)) = + self.find_mut(reserve, position_kind) + { + user_reward_manager.update( + pool_reward_manager, + clock, + CreatingNewUserRewardManager::No, + )?; + + (index, user_reward_manager) + } else if self.len() >= MAX_OBLIGATION_RESERVES { + // AUDIT: + // > Right now the max number of UserRewardManagers is 10 and the max number of rewards + // > is 30, but that's not really enforced because you are using debug_asserts which are + // > ignored in release mode (see MAX_REWARDS and MAX_OBLIGATION_REWARDS). + // > It's only enforced by checking the final packed length is less than + // > Obligation::MAX_LEN, so for example you can have an Obligation with 134 + // > UserRewardManagers, each tracking 1 reward. + msg!("User rewards full, claim rewards to make space."); + return Err(LendingError::ObligationReserveLimit.into()); + } else { + let mut new_user_reward_manager = UserRewardManager::new(reserve, position_kind, clock); + new_user_reward_manager.populate(pool_reward_manager, clock)?; + self.0.push(new_user_reward_manager); + + // SAFETY: we just pushed a new item to the vector + (self.0.len() - 1, self.0.last_mut().unwrap()) + }; + + user_reward_manager.set_share(pool_reward_manager, new_share); + + // AUDIT: + // > We believe you should remove UserRewardManager entries when all the earned rewards are + // > claimed and the share is set to 0 (ie there is no corresponding Position in the + // > obligation) + if new_share == 0 && user_reward_manager.rewards.is_empty() { + self.0.swap_remove(index); + } + + Ok(()) + } +} + +/// Whether the reward was removed from the user manager. +#[allow(missing_docs)] +pub enum HasRewardEnded { + No, + Yes, +} + +impl UserRewardManager { + /// Claims all rewards that the user has earned. + /// Returns how many tokens should be transferred to the user. + /// + /// # Note + /// + /// Errors if there is no pool reward with this vault. + pub fn claim_rewards( + &mut self, + pool_reward_manager: &mut PoolRewardManager, + vault: Pubkey, + clock: &Clock, + ) -> Result<(HasRewardEnded, u64), ProgramError> { + self.update(pool_reward_manager, clock, CreatingNewUserRewardManager::No)?; + + let (pool_reward_index, pool_reward) = pool_reward_manager + .pool_rewards + .iter_mut() + .enumerate() + .find_map(move |(index, slot)| match slot { + PoolRewardEntry::Occupied(pool_reward) if pool_reward.vault == vault => { + Some((index, pool_reward)) + } + _ => None, + }) + .ok_or(LendingError::NoPoolRewardMatches)?; + + let Some((user_reward_index, user_reward)) = + self.rewards + .iter_mut() + .enumerate() + .find(|(_, user_reward)| { + user_reward.pool_reward_index == pool_reward_index + && user_reward.pool_reward_id == pool_reward.id + }) + else { + // User is not tracking this reward, nothing to claim. + // Let's be graceful and make this a no-op. + // Prevents failures when multiple parties crank rewards. + return Ok((HasRewardEnded::Yes, 0)); + }; + + let to_claim = user_reward.withdraw_earned_rewards()?; + + if (self.share == 0 || pool_reward.has_ended(clock)) + && user_reward.earned_rewards.try_floor_u64()? == 0 + { + // This reward won't be used anymore as it ended and the user + // claimed all there was to claim. + // We can clean up this user reward. + + // AUDIT: + // > UserRewards tracked inside UserRewardManager.rewards can only be removed when the + // > pool_reward period has ended and the earned_reward has been claimed. + // > So this means that users are still forced to wait for reward expiration even when + // > they haven't any share. + // > I think you should also make it possible to cleanup the UserRewardManager.rewards + // > when the UserRewardManager.share is set to 0 and + // > UserRewardManager.rewards[i].earned_rewards.floor() == 0 + + // We're fine with swap remove bcs `user_reward_index` is meaningless. + // SAFETY: We got the index from enumeration, so must exist. + self.rewards.swap_remove(user_reward_index); + pool_reward.num_user_reward_managers -= 1; + + Ok((HasRewardEnded::Yes, to_claim)) + } else { + Ok((HasRewardEnded::No, to_claim)) + } + } +} + +impl UserRewardManager { + /// [Self] is dynamically sized based on how many [PoolReward]s are there + /// for the given [Self::reserve]. + /// + /// This is the maximum length a manager can have. + pub(crate) const MAX_LEN: usize = Self::HEAD_LEN + MAX_REWARDS * UserReward::LEN; + + /// Length of data before [Self::rewards] tail. + /// + /// - [Self::reserve] + /// - [Self::position_kind] + /// - [Self::share] + /// - [Self::last_update_time_secs] + /// - [Self::rewards] vector length as u8 + const HEAD_LEN: usize = PUBKEY_BYTES + 1 + 8 + 8 + 1; + + /// Creates a new empty [UserRewardManager] for the given reserve. + pub(crate) fn new(reserve: Pubkey, position_kind: PositionKind, clock: &Clock) -> Self { + Self { + reserve, + last_update_time_secs: clock.unix_timestamp as _, + position_kind, + share: 0, + rewards: Vec::new(), + } + } + + /// Sets new share value for this manager. + pub(crate) fn set_share( + &mut self, + pool_reward_manager: &mut PoolRewardManager, + new_share: u64, + ) { + msg!( + "For reserve {} there are {} total shares. \ + User's previous position was at {} and new is at {}", + self.reserve, + pool_reward_manager.total_shares, + self.share, + new_share + ); + + // This works even for migrations. + // User's old share is 0 although it shouldn't be bcs they have borrowed + // or deposited. + // We only now attribute the share to the user which is fine, it's as if + // they just now borrowed/deposited. + pool_reward_manager.total_shares = + pool_reward_manager.total_shares - self.share + new_share; + + self.share = new_share; + } + + /// When user borrows/deposits for a new reserve this function copies all + /// reserve rewards from the pool manager to the user manager and starts + /// accruing rewards. + /// + /// Invoker must have checked that this [PoolRewardManager] matches the + /// [UserRewardManager]. + pub(crate) fn populate( + &mut self, + pool_reward_manager: &mut PoolRewardManager, + clock: &Clock, + ) -> Result<(), ProgramError> { + self.update( + pool_reward_manager, + clock, + CreatingNewUserRewardManager::Yes, + ) + } + + /// Should be updated before any interaction with rewards. + /// + /// We expect the user share to be 0 if they are creating a new user manager. + /// The share is updated later. + /// + /// # Assumption + /// Invoker has checked that this [PoolRewardManager] matches the + /// [UserRewardManager]. + fn update( + &mut self, + pool_reward_manager: &mut PoolRewardManager, + clock: &Clock, + creating_new_reward_manager: CreatingNewUserRewardManager, + ) -> Result<(), ProgramError> { + pool_reward_manager.update(clock)?; + + let curr_unix_timestamp_secs = clock.unix_timestamp as u64; + let is_creating_new_reward_manager = matches!( + creating_new_reward_manager, + CreatingNewUserRewardManager::Yes + ); + + if !is_creating_new_reward_manager && curr_unix_timestamp_secs == self.last_update_time_secs + { + return Ok(()); + } + + for (pool_reward_index, pool_reward) in + pool_reward_manager.pool_rewards.iter_mut().enumerate() + { + let PoolRewardEntry::Occupied(pool_reward) = pool_reward else { + // no reward to track + continue; + }; + + let maybe_user_reward = self + .rewards + .iter_mut() + .enumerate() + .find(|(_, r)| r.pool_reward_index == pool_reward_index); + + let end_time_secs = pool_reward.start_time_secs + pool_reward.duration_secs as u64; + let has_ended_for_user = (!is_creating_new_reward_manager && self.share == 0) + || self.last_update_time_secs >= end_time_secs; + + match maybe_user_reward { + Some((user_reward_index, user_reward)) + if has_ended_for_user && user_reward.earned_rewards.try_floor_u64()? == 0 => + { + // Reward period ended and there's nothing to crank. + // We can clean up this user reward. + // We're fine with swap remove bcs `user_reward_index` is meaningless. + // SAFETY: We got the index from enumeration, so must exist. + self.rewards.swap_remove(user_reward_index); + pool_reward.num_user_reward_managers -= 1; + } + _ if has_ended_for_user => { + // reward period over & there are rewards yet to be cracked + } + Some((_, user_reward)) => { + // user is already accruing rewards, add the difference + + let new_reward_amount = pool_reward + .cumulative_rewards_per_share + .try_sub(user_reward.cumulative_rewards_per_share)? + .try_mul(Decimal::from(self.share))?; + + user_reward.earned_rewards = + user_reward.earned_rewards.try_add(new_reward_amount)?; + + user_reward.cumulative_rewards_per_share = + pool_reward.cumulative_rewards_per_share; + } + None if pool_reward.start_time_secs > curr_unix_timestamp_secs => { + // reward period has not started yet + } + None if self.share == 0 && !is_creating_new_reward_manager => { + // user has no share, nothing to accrue + } + None => { + // user did not yet start accruing rewards + + let new_user_reward = UserReward { + pool_reward_index, + pool_reward_id: pool_reward.id, + cumulative_rewards_per_share: pool_reward.cumulative_rewards_per_share, + earned_rewards: if self.last_update_time_secs <= pool_reward.start_time_secs + { + pool_reward + .cumulative_rewards_per_share + .try_mul(Decimal::from(self.share))? + } else { + debug_assert!(is_creating_new_reward_manager); + Decimal::zero() + }, + }; + + self.rewards.push(new_user_reward); + pool_reward.num_user_reward_managers += 1; + } + } + } + + self.last_update_time_secs = curr_unix_timestamp_secs; + + Ok(()) + } + + /// How many bytes are needed to pack this [UserRewardManager]. + pub(crate) fn get_packed_len(&self) -> usize { + Self::HEAD_LEN + self.rewards.len() * UserReward::LEN + } + + /// Because [Self] is dynamically sized we don't implement [Pack] that + /// contains a misleading const `LEN`. + /// + /// We return how many bytes were written. + pub(crate) fn pack_into_slice(&self, output: &mut [u8]) { + let raw_user_reward_manager = array_mut_ref![output, 0, UserRewardManager::HEAD_LEN]; + + let ( + dst_reserve, + dst_position_kind, + dst_share, + dst_last_update_time_secs, + dst_user_rewards_len, + ) = mut_array_refs![ + raw_user_reward_manager, + PUBKEY_BYTES, + 1, // position_kind + 8, // share + 8, // last_update_time_secs + 1 // length of rewards array that's next to come + ]; + + dst_reserve.copy_from_slice(self.reserve.as_ref()); + dst_position_kind.copy_from_slice(&(self.position_kind as u8).to_le_bytes()); + dst_share.copy_from_slice(&self.share.to_le_bytes()); + dst_last_update_time_secs.copy_from_slice(&self.last_update_time_secs.to_le_bytes()); + dst_user_rewards_len.copy_from_slice( + &({ + debug_assert!(MAX_REWARDS >= self.rewards.len()); + debug_assert!(u8::MAX >= MAX_REWARDS as _); + self.rewards.len() as u8 + }) + .to_le_bytes(), + ); + + for (index, user_reward) in self.rewards.iter().enumerate() { + let offset = Self::HEAD_LEN + index * UserReward::LEN; + let raw_user_reward = array_mut_ref![output, offset, UserReward::LEN]; + + let ( + dst_pool_reward_index, + dst_pool_reward_id, + dst_earned_rewards, + dst_cumulative_rewards_per_share, + ) = mut_array_refs![raw_user_reward, 1, PoolRewardId::LEN, 16, 16]; + + dst_pool_reward_id.copy_from_slice(&user_reward.pool_reward_id.0.to_le_bytes()); + pack_decimal(user_reward.earned_rewards, dst_earned_rewards); + pack_decimal( + user_reward.cumulative_rewards_per_share, + dst_cumulative_rewards_per_share, + ); + let pool_reward_index = { + assert!(user_reward.pool_reward_index < MAX_REWARDS); + assert!(MAX_REWARDS < u8::MAX as _); + // will always fit + user_reward.pool_reward_index as u8 + }; + dst_pool_reward_index.copy_from_slice(&pool_reward_index.to_le_bytes()); + } + } + + pub(crate) fn unpack_from_slice(input: &[u8]) -> Result { + #[allow(clippy::ptr_offset_with_cast)] + let raw_user_reward_manager_head = array_ref![input, 0, UserRewardManager::HEAD_LEN]; + + #[allow(clippy::ptr_offset_with_cast)] + let ( + src_reserve, + src_position_kind, + src_share, + src_last_update_time_secs, + src_user_rewards_len, + ) = array_refs![ + raw_user_reward_manager_head, + PUBKEY_BYTES, + 1, // position_kind + 8, // share + 8, // last_update_time_secs + 1 // length of rewards array that's next to come + ]; + + let reserve = Pubkey::new_from_array(*src_reserve); + let position_kind = u8::from_le_bytes(*src_position_kind).try_into()?; + let user_rewards_len = u8::from_le_bytes(*src_user_rewards_len) as _; + let share = u64::from_le_bytes(*src_share); + let last_update_time_secs = u64::from_le_bytes(*src_last_update_time_secs); + + let mut rewards = Vec::with_capacity(user_rewards_len); + for index in 0..user_rewards_len { + let offset = Self::HEAD_LEN + index * UserReward::LEN; + let raw_user_reward = array_ref![input, offset, UserReward::LEN]; + + #[allow(clippy::ptr_offset_with_cast)] + let ( + src_pool_reward_index, + src_pool_reward_id, + src_earned_rewards, + src_cumulative_rewards_per_share, + ) = array_refs![raw_user_reward, 1, PoolRewardId::LEN, 16, 16]; + + rewards.push(UserReward { + pool_reward_index: u8::from_le_bytes(*src_pool_reward_index) as _, + pool_reward_id: PoolRewardId(u32::from_le_bytes(*src_pool_reward_id)), + earned_rewards: unpack_decimal(src_earned_rewards), + cumulative_rewards_per_share: unpack_decimal(src_cumulative_rewards_per_share), + }); + } + + Ok(Self { + reserve, + position_kind, + share, + last_update_time_secs, + rewards, + }) + } +} + +impl UserReward { + /// - [UserReward::pool_reward_index] truncated to a byte + /// - [PoolRewardId] + /// - packed [Decimal] + /// - packed [Decimal] + const LEN: usize = 1 + PoolRewardId::LEN + 16 + 16; + + /// Removes all earned rewards from [Self] and returns them. + /// + /// # Note + /// Decimals are truncated to u64, dust is kept. + fn withdraw_earned_rewards(&mut self) -> Result { + let reward_amount = self.earned_rewards.try_floor_u64()?; + + if reward_amount > 0 { + self.earned_rewards = self.earned_rewards.try_sub(reward_amount.into())?; + } + + Ok(reward_amount) + } +} + +impl From> for UserRewardManagers { + fn from(user_reward_managers: Vec) -> Self { + Self(user_reward_managers) + } +} + +impl From for Vec { + fn from(user_reward_managers: UserRewardManagers) -> Self { + user_reward_managers.0 + } +} + +impl Deref for UserRewardManagers { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for UserRewardManagers { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl Default for UserRewardManager { + fn default() -> Self { + Self { + reserve: Pubkey::default(), + position_kind: PositionKind::Deposit, + share: 0, + last_update_time_secs: 0, + rewards: Vec::new(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use proptest::prelude::*; + + impl UserRewardManager { + pub(crate) fn new_rand(rng: &mut impl rand::Rng) -> Self { + let rewards_len = rng.gen_range(0..MAX_REWARDS); + Self { + reserve: Pubkey::new_unique(), + position_kind: rng.gen_range(0..=1u8).try_into().unwrap(), + share: rng.gen(), + last_update_time_secs: rng.gen(), + rewards: std::iter::from_fn(|| { + Some(UserReward { + pool_reward_index: rng.gen_range(0..MAX_REWARDS), + pool_reward_id: PoolRewardId(rng.gen()), + earned_rewards: Decimal::from_scaled_val(rng.gen()), + cumulative_rewards_per_share: Decimal::from_scaled_val(rng.gen()), + }) + }) + .take(rewards_len) + .collect(), + } + } + } + + fn user_reward_manager_strategy() -> impl Strategy { + (0..100u32).prop_perturb(|_, mut rng| UserRewardManager::new_rand(&mut rng)) + } + + proptest! { + #[test] + fn it_packs_and_unpacks_user_reward_manager(user_reward_manager in user_reward_manager_strategy()) { + let mut packed = vec![0u8; UserRewardManager::MAX_LEN]; + user_reward_manager.pack_into_slice(&mut packed); + let unpacked = UserRewardManager::unpack_from_slice(&packed).unwrap(); + prop_assert_eq!(user_reward_manager, unpacked); + } + } +} diff --git a/token-lending/sdk/src/state/mod.rs b/token-lending/sdk/src/state/mod.rs index e5b96b7cd73..2e3dda4b3fc 100644 --- a/token-lending/sdk/src/state/mod.rs +++ b/token-lending/sdk/src/state/mod.rs @@ -3,6 +3,7 @@ mod last_update; mod lending_market; mod lending_market_metadata; +mod liquidity_mining; mod obligation; mod rate_limiter; mod reserve; @@ -10,11 +11,13 @@ mod reserve; pub use last_update::*; pub use lending_market::*; pub use lending_market_metadata::*; +pub use liquidity_mining::*; pub use obligation::*; pub use rate_limiter::*; pub use reserve::*; use crate::math::{Decimal, WAD}; +use discriminator::AccountDiscriminator; use solana_program::{msg, program_error::ProgramError}; /// Collateral tokens are initially valued at a ratio of 5:1 (collateral:liquidity) @@ -22,17 +25,70 @@ use solana_program::{msg, program_error::ProgramError}; pub const INITIAL_COLLATERAL_RATIO: u64 = 1; const INITIAL_COLLATERAL_RATE: u64 = INITIAL_COLLATERAL_RATIO * WAD; -/// Current version of the program and all new accounts created -pub const PROGRAM_VERSION: u8 = 1; - -/// Accounts are created with data zeroed out, so uninitialized state instances -/// will have the version set to 0. -pub const UNINITIALIZED_VERSION: u8 = 0; - /// Number of slots per year // 2 (slots per second) * 60 * 60 * 24 * 365 = 63072000 pub const SLOTS_PER_YEAR: u64 = 63072000; +/// Unmigrated accounts have this as their leading byte. +pub const PROGRAM_VERSION_2_0_2: u8 = 1; + +pub mod discriminator { + //! First 1 byte determines the account kind. + + use std::convert::TryFrom; + + use crate::error::LendingError; + + /// Match the first byte of an account data against this enum to determine + /// the account type. + /// + /// # Note + /// + /// In versions before @v2.1.0 this byte represented program version. + /// That's why we skip value `1u8`. + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] + pub enum AccountDiscriminator { + /// Account is not initialized yet. + #[default] + Uninitialized = 0, + /// [crate::state::LendingMarket] + LendingMarket = 2, + /// [crate::state::Reserve] + Reserve = 3, + /// [crate::state::Obligation] + Obligation = 4, + } + + impl TryFrom for AccountDiscriminator { + type Error = LendingError; + + fn try_from(value: u8) -> Result { + match value { + // the account data were just created and are filled with 0s + 0 => Ok(Self::Uninitialized), + + // we skip 1 because it was used for program version + 1 => Err(Self::Error::AccountNotMigrated), + + // valid accounts + 2 => Ok(Self::LendingMarket), + 3 => Ok(Self::Reserve), + 4 => Ok(Self::Obligation), + + _ => Err(Self::Error::InvalidAccountDiscriminator), + } + } + } + + impl TryFrom<&[u8; 1]> for AccountDiscriminator { + type Error = LendingError; + + fn try_from(value: &[u8; 1]) -> Result { + Self::try_from(value[0]) + } + } +} + // Helpers fn pack_decimal(decimal: Decimal, dst: &mut [u8; 16]) { *dst = decimal diff --git a/token-lending/sdk/src/state/obligation.rs b/token-lending/sdk/src/state/obligation.rs index 6f9f43ef18e..313b3aed9c5 100644 --- a/token-lending/sdk/src/state/obligation.rs +++ b/token-lending/sdk/src/state/obligation.rs @@ -9,22 +9,35 @@ use solana_program::{ entrypoint::ProgramResult, msg, program_error::ProgramError, - program_pack::{IsInitialized, Pack, Sealed}, + program_pack::{IsInitialized, Sealed}, pubkey::{Pubkey, PUBKEY_BYTES}, }; use std::{ cmp::{min, Ordering}, convert::{TryFrom, TryInto}, + str::FromStr, }; /// Max number of collateral and liquidity reserve accounts combined for an obligation pub const MAX_OBLIGATION_RESERVES: usize = 10; /// Lending market obligation state +/// +/// # (Un)packing +/// [Obligation] used to implement `Pack` in versions prior to 2.1.0. +/// Now [Obligation] is dynamically sized based on the reserves in +/// [Obligation::user_reward_managers]. +/// We manually implement packing and unpacking functions the the `Pack` trait +/// instead. #[derive(Clone, Debug, Default, PartialEq)] pub struct Obligation { - /// Version of the struct - pub version: u8, + /// For uninitialized accounts, this will be equal to [AccountDiscriminator::Uninitialized]. + /// Otherwise this is [AccountDiscriminator::Obligation]. + /// + /// # Note + /// For accounts last used with version prior to @v2.1.0 this will be equal + /// to [PROGRAM_VERSION_2_0_2]. + pub discriminator: AccountDiscriminator, /// Last update to collateral, liquidity, or their market values pub last_update: LastUpdate, /// Lending market address @@ -63,6 +76,21 @@ pub struct Obligation { pub borrowing_isolated_asset: bool, /// Obligation can be marked as closeable pub closeable: bool, + /// Collects liquidity mining rewards for positions (collateral/borrows). + /// + /// # (Un)packing + /// If there are no rewards to be collected then the obligation is packed + /// as if there was no liquidity mining feature involved. + pub user_reward_managers: UserRewardManagers, +} + +/// These are the two foundational user interactions in a borrow-lending protocol. +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum PositionKind { + /// User is providing liquidity. + Deposit = 0, + /// User is owing liquidity. + Borrow = 1, } impl Obligation { @@ -75,7 +103,7 @@ impl Obligation { /// Initialize an obligation pub fn init(&mut self, params: InitObligationParams) { - self.version = PROGRAM_VERSION; + self.discriminator = AccountDiscriminator::Obligation; self.last_update = LastUpdate::new(params.current_slot); self.lending_market = params.lending_market; self.owner = params.owner; @@ -88,26 +116,40 @@ impl Obligation { self.borrowed_value.try_div(self.deposited_value) } - /// Repay liquidity and remove it from borrows if zeroed out - pub fn repay(&mut self, settle_amount: Decimal, liquidity_index: usize) -> ProgramResult { + /// Repay liquidity and remove it from borrows if zeroed out. + /// + /// Returns current liability shares. + pub fn repay( + &mut self, + settle_amount: Decimal, + liquidity_index: usize, + ) -> Result { let liquidity = &mut self.borrows[liquidity_index]; if settle_amount == liquidity.borrowed_amount_wads { self.borrows.remove(liquidity_index); + Ok(0) } else { liquidity.repay(settle_amount)?; + liquidity.liability_shares() } - Ok(()) } - /// Withdraw collateral and remove it from deposits if zeroed out - pub fn withdraw(&mut self, withdraw_amount: u64, collateral_index: usize) -> ProgramResult { + /// Withdraw collateral and remove it from deposits if zeroed out. + /// + /// Returns the new deposited amount. + pub fn withdraw( + &mut self, + withdraw_amount: u64, + collateral_index: usize, + ) -> Result { let collateral = &mut self.deposits[collateral_index]; if withdraw_amount == collateral.deposited_amount { self.deposits.remove(collateral_index); + Ok(0) } else { collateral.withdraw(withdraw_amount)?; + Ok(collateral.deposited_amount) } - Ok(()) } /// calculate the maximum amount of collateral that can be borrowed @@ -286,6 +328,28 @@ impl Obligation { .iter() .position(|liquidity| liquidity.borrow_reserve == borrow_reserve) } + + /// Find whether the reserve is a deposit or borrow + pub fn find_position_kind(&self, reserve: Pubkey) -> Result { + if self + .deposits + .iter() + .any(|collateral| collateral.deposit_reserve == reserve) + { + return Ok(PositionKind::Deposit); + } + + if self + .borrows + .iter() + .any(|liquidity| liquidity.borrow_reserve == reserve) + { + return Ok(PositionKind::Borrow); + } + + msg!("Reserve not found in obligation"); + Err(LendingError::InvalidAccountInput.into()) + } } /// Initialize an obligation @@ -305,7 +369,7 @@ pub struct InitObligationParams { impl Sealed for Obligation {} impl IsInitialized for Obligation { fn is_initialized(&self) -> bool { - self.version != UNINITIALIZED_VERSION + !matches!(self.discriminator, AccountDiscriminator::Uninitialized) } } @@ -410,20 +474,91 @@ impl ObligationLiquidity { Ok(()) } + + /// Calculates shares for liquidity mining. + pub fn liability_shares(&self) -> Result { + self.borrowed_amount_wads + .try_div(self.cumulative_borrow_rate_wads)? + .try_floor_u64() + } } const OBLIGATION_COLLATERAL_LEN: usize = 88; // 32 + 8 + 16 + 32 const OBLIGATION_LIQUIDITY_LEN: usize = 112; // 32 + 16 + 16 + 16 + 32 -const OBLIGATION_LEN: usize = 1300; // 1 + 8 + 1 + 32 + 32 + 16 + 16 + 16 + 16 + 64 + 1 + 1 + (88 * 1) + (112 * 9) - // @TODO: break this up by obligation / collateral / liquidity https://git.io/JOCca -impl Pack for Obligation { - const LEN: usize = OBLIGATION_LEN; +/// This is the size of the account _before_ LM feature was added. +const OBLIGATION_LEN_V2_0_2: usize = 1300; // 1 + 8 + 1 + 32 + 32 + 16 + 16 + 16 + 16 + 64 + 1 + 1 + (88 * 1) + (112 * 9) + // @TODO: break this up by obligation / collateral / liquidity https://git.io/JOCca +impl Obligation { + /// Obligation with no Liquidity Mining Rewards + pub const MIN_LEN: usize = OBLIGATION_LEN_V2_0_2; + + /// Maximum account size for obligation. + /// Scenario in which all reserves have all associated rewards filled. + /// + /// - [Self::user_reward_managers] vec length in u8 + /// - [Self::user_reward_managers] vector + pub const MAX_LEN: usize = + Self::MIN_LEN + 1 + MAX_OBLIGATION_RESERVES * UserRewardManager::MAX_LEN; + + /// How many bytes are needed to pack this [Obligation]. + pub fn get_packed_len(&self) -> usize { + if self.user_reward_managers.is_empty() { + return OBLIGATION_LEN_V2_0_2; + } + + let mut size = OBLIGATION_LEN_V2_0_2 + 1; + + for reward_manager in self.user_reward_managers.iter() { + size += reward_manager.get_packed_len(); + } + + size + } + + /// Unpacks from slice but returns an error if the account is already + /// initialized. + pub fn unpack_uninitialized(input: &[u8]) -> Result { + let account = Self::unpack_unchecked(input)?; + if account.is_initialized() { + Err(LendingError::AlreadyInitialized.into()) + } else { + Ok(account) + } + } + + /// Unpack from slice and check if initialized + pub fn unpack(input: &[u8]) -> Result { + let value = Self::unpack_unchecked(input)?; + if value.is_initialized() { + Ok(value) + } else { + Err(ProgramError::UninitializedAccount) + } + } + + /// Unpack from slice without checking if initialized + pub fn unpack_unchecked(input: &[u8]) -> Result { + if !(Self::MIN_LEN..=Self::MAX_LEN).contains(&input.len()) { + return Err(ProgramError::InvalidAccountData); + } + Self::unpack_from_slice(input) + } + + /// Pack into slice + pub fn pack(src: Self, dst: &mut [u8]) -> Result<(), ProgramError> { + if !(Self::MIN_LEN..=Self::MAX_LEN).contains(&dst.len()) { + return Err(ProgramError::InvalidAccountData); + } + src.pack_into_slice(dst); + Ok(()) + } - fn pack_into_slice(&self, dst: &mut [u8]) { - let output = array_mut_ref![dst, 0, OBLIGATION_LEN]; + /// Since @v2.1.0 we pack vec of user reward managers + pub fn pack_into_slice(&self, dst: &mut [u8]) { + let output = array_mut_ref![dst, 0, OBLIGATION_LEN_V2_0_2]; #[allow(clippy::ptr_offset_with_cast)] let ( - version, + discriminator, last_update_slot, last_update_stale, lending_market, @@ -464,7 +599,7 @@ impl Pack for Obligation { ]; // obligation - *version = self.version.to_le_bytes(); + discriminator[0] = self.discriminator as _; *last_update_slot = self.last_update.slot.to_le_bytes(); pack_bool(self.last_update.stale, last_update_stale); lending_market.copy_from_slice(self.lending_market.as_ref()); @@ -525,14 +660,37 @@ impl Pack for Obligation { pack_decimal(liquidity.market_value, market_value); offset += OBLIGATION_LIQUIDITY_LEN; } + + if !self.user_reward_managers.is_empty() { + // if the underlying buffer doesn't have enough space then we panic + + debug_assert!(MAX_OBLIGATION_RESERVES >= self.user_reward_managers.len()); + debug_assert!(u8::MAX > MAX_OBLIGATION_RESERVES as _); + let user_reward_managers_len = self.user_reward_managers.len() as u8; + dst[OBLIGATION_LEN_V2_0_2] = user_reward_managers_len; + + let mut offset = OBLIGATION_LEN_V2_0_2 + 1; + for user_reward_manager in self.user_reward_managers.iter() { + user_reward_manager.pack_into_slice(&mut dst[offset..]); + offset += user_reward_manager.get_packed_len(); + } + } else if dst.len() > OBLIGATION_LEN_V2_0_2 { + // set the length to 0 if obligation was resized before + + dst[OBLIGATION_LEN_V2_0_2] = 0; + }; + + // Any data after offset is garbage, but we don't zero it out bcs + // it costs CU and we'd have to do it bit by bit to avoid stack overflows. } - /// Unpacks a byte buffer into an [ObligationInfo](struct.ObligationInfo.html). - fn unpack_from_slice(src: &[u8]) -> Result { - let input = array_ref![src, 0, OBLIGATION_LEN]; + /// Unpacks a byte buffer into an [Obligation]. + /// Since @v2.1.0 we unpack vector of user reward managers + pub fn unpack_from_slice(src: &[u8]) -> Result { + let input = array_ref![src, 0, OBLIGATION_LEN_V2_0_2]; #[allow(clippy::ptr_offset_with_cast)] let ( - version, + discriminator, last_update_slot, last_update_stale, lending_market, @@ -572,11 +730,21 @@ impl Pack for Obligation { OBLIGATION_COLLATERAL_LEN + (OBLIGATION_LIQUIDITY_LEN * (MAX_OBLIGATION_RESERVES - 1)) ]; - let version = u8::from_le_bytes(*version); - if version > PROGRAM_VERSION { - msg!("Obligation version does not match lending program version"); - return Err(ProgramError::InvalidAccountData); - } + let discriminator = match AccountDiscriminator::try_from(discriminator) { + Ok(d @ AccountDiscriminator::Uninitialized) => d, // yet to be set + Ok(d @ AccountDiscriminator::Obligation) => d, // migrated to v2.1.0 + Ok(_) => { + msg!("Obligation discriminator does not match"); + return Err(LendingError::InvalidAccountDiscriminator.into()); + } + Err(LendingError::AccountNotMigrated) => { + // We're migrating the account from v2.0.2 to v2.1.0. + debug_assert_eq!(OBLIGATION_LEN_V2_0_2, input.len()); + + AccountDiscriminator::Obligation + } + Err(e) => return Err(e.into()), + }; let deposits_len = u8::from_le_bytes(*deposits_len); let borrows_len = u8::from_le_bytes(*borrows_len); @@ -621,8 +789,24 @@ impl Pack for Obligation { offset += OBLIGATION_LIQUIDITY_LEN; } + let user_reward_managers = match src.get(OBLIGATION_LEN_V2_0_2) { + Some(len @ 1..) => { + let mut user_reward_managers = Vec::with_capacity(*len as _); + + let mut offset = OBLIGATION_LEN_V2_0_2 + 1; + for _ in 0..*len { + let user_reward_manager = UserRewardManager::unpack_from_slice(&src[offset..])?; + offset += user_reward_manager.get_packed_len(); + user_reward_managers.push(user_reward_manager); + } + + user_reward_managers + } + _ => Vec::new(), + }; + Ok(Self { - version, + discriminator, last_update: LastUpdate { slot: u64::from_le_bytes(*last_update_slot), stale: unpack_bool(last_update_stale)?, @@ -640,10 +824,35 @@ impl Pack for Obligation { super_unhealthy_borrow_value: unpack_decimal(super_unhealthy_borrow_value), borrowing_isolated_asset: unpack_bool(borrowing_isolated_asset)?, closeable: unpack_bool(closeable)?, + user_reward_managers: user_reward_managers.into(), }) } } +impl TryFrom for PositionKind { + type Error = ProgramError; + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(PositionKind::Deposit), + 1 => Ok(PositionKind::Borrow), + _ => Err(LendingError::InstructionUnpackError.into()), + } + } +} + +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::*; @@ -658,12 +867,10 @@ mod test { Decimal::from_scaled_val(rand::thread_rng().gen()) } - #[test] - fn pack_and_unpack_obligation() { - let mut rng = rand::thread_rng(); - for _ in 0..100 { - let obligation = Obligation { - version: PROGRAM_VERSION, + impl Obligation { + fn new_rand(rng: &mut impl Rng) -> Self { + Self { + discriminator: AccountDiscriminator::Obligation, last_update: LastUpdate { slot: rng.gen(), stale: rng.gen(), @@ -691,15 +898,47 @@ mod test { super_unhealthy_borrow_value: rand_decimal(), borrowing_isolated_asset: rng.gen(), closeable: rng.gen(), - }; + user_reward_managers: { + let user_reward_managers_len = rng.gen_range(0..=MAX_OBLIGATION_RESERVES); + std::iter::repeat_with(|| UserRewardManager::new_rand(rng)) + .take(user_reward_managers_len) + .collect::>() + .into() + }, + } + } + } - let mut packed = [0u8; OBLIGATION_LEN]; + #[test] + fn pack_and_unpack_obligation_v2_1_0() { + let mut rng = rand::thread_rng(); + for _ in 0..100 { + let obligation = Obligation::new_rand(&mut rng); + + let mut packed = [0u8; Obligation::MAX_LEN]; Obligation::pack(obligation.clone(), &mut packed).unwrap(); let unpacked = Obligation::unpack(&packed).unwrap(); assert_eq!(obligation, unpacked); } } + #[test] + fn pack_and_unpack_obligation_v2_0_2() { + let mut rng = rand::thread_rng(); + for _ in 0..100 { + let obligation = Obligation::new_rand(&mut rng); + + let mut packed = [0u8; Obligation::MAX_LEN]; + Obligation::pack(obligation.clone(), &mut packed).unwrap(); + // this is what version looked like before the upgrade to v2.1.0 + packed[0] = PROGRAM_VERSION_2_0_2; + + let unpacked = Obligation::unpack(&packed).unwrap(); + // upgraded + assert_eq!(obligation, unpacked); + } + } + #[test] fn obligation_accrue_interest_failure() { assert_eq!( @@ -748,8 +987,9 @@ mod test { fn repay_partial_amounts()(amount in 1..=u64::MAX)( repay_amount in Just(WAD as u128 * amount as u128), borrowed_amount in (WAD as u128 * amount as u128 + 1)..=MAX_BORROWED, - ) -> (u128, u128) { - (repay_amount, borrowed_amount) + cumulative_borrow_rate in (WAD as u128)..=(WAD as u128 * MAX_COMPOUNDED_INTEREST as u128), + ) -> (u128, u128, u128) { + (repay_amount, borrowed_amount, cumulative_borrow_rate) } } @@ -765,19 +1005,22 @@ mod test { proptest! { #[test] fn repay_partial( - (repay_amount, borrowed_amount) in repay_partial_amounts(), + (repay_amount, borrowed_amount, cumulative_borrow_rate) in repay_partial_amounts(), ) { let borrowed_amount_wads = Decimal::from_scaled_val(borrowed_amount); let repay_amount_wads = Decimal::from_scaled_val(repay_amount); + let cumulative_borrow_rate_wads = Decimal::from_scaled_val(cumulative_borrow_rate); let mut obligation = Obligation { borrows: vec![ObligationLiquidity { borrowed_amount_wads, + cumulative_borrow_rate_wads, ..ObligationLiquidity::default() }], ..Obligation::default() }; - obligation.repay(repay_amount_wads, 0)?; + let liability_shares = obligation.repay(repay_amount_wads, 0)?; + assert_ne!(liability_shares, 0); assert!(obligation.borrows[0].borrowed_amount_wads < borrowed_amount_wads); assert!(obligation.borrows[0].borrowed_amount_wads > Decimal::zero()); } @@ -788,9 +1031,11 @@ mod test { ) { let borrowed_amount_wads = Decimal::from_scaled_val(borrowed_amount); let repay_amount_wads = Decimal::from_scaled_val(repay_amount); + let cumulative_borrow_rate_wads = Decimal::one(); let mut obligation = Obligation { borrows: vec![ObligationLiquidity { borrowed_amount_wads, + cumulative_borrow_rate_wads, ..ObligationLiquidity::default() }], ..Obligation::default() diff --git a/token-lending/sdk/src/state/rate_limiter.rs b/token-lending/sdk/src/state/rate_limiter.rs index 55f86774018..142acb31cd1 100644 --- a/token-lending/sdk/src/state/rate_limiter.rs +++ b/token-lending/sdk/src/state/rate_limiter.rs @@ -205,7 +205,7 @@ impl Pack for RateLimiter { } #[cfg(test)] -pub fn rand_rate_limiter() -> RateLimiter { +pub(crate) fn rand_rate_limiter() -> RateLimiter { use rand::Rng; let mut rng = rand::thread_rng(); diff --git a/token-lending/sdk/src/state/reserve.rs b/token-lending/sdk/src/state/reserve.rs index 14092c277e6..7f59f39384a 100644 --- a/token-lending/sdk/src/state/reserve.rs +++ b/token-lending/sdk/src/state/reserve.rs @@ -44,8 +44,13 @@ pub const MIN_SCALED_PRICE_OFFSET_BPS: i64 = -2000; /// Lending market reserve state #[derive(Clone, Debug, Default, PartialEq)] pub struct Reserve { - /// Version of the struct - pub version: u8, + /// For uninitialized accounts, this will be equal to [AccountDiscriminator::Uninitialized]. + /// Otherwise this is [AccountDiscriminator::Reserve]. + /// + /// # Note + /// For accounts last used with version prior to @v2.1.0 this will be equal + /// to [PROGRAM_VERSION_2_0_2]. + pub discriminator: AccountDiscriminator, /// Last slot when supply and rates updated pub last_update: LastUpdate, /// Lending market address @@ -60,6 +65,20 @@ pub struct Reserve { pub rate_limiter: RateLimiter, /// Attributed borrows in USD pub attributed_borrow_value: Decimal, + /// Contains liquidity mining rewards for borrows. + /// + /// Added @v2.1.0 + /// + /// TODO: measure compute units for packing/unpacking and if significant + /// then consider packing/unpacking on demand + pub borrows_pool_reward_manager: Box, + /// Contains liquidity mining rewards for deposits. + /// + /// Added @v2.1.0 + /// + /// TODO: measure compute units for packing/unpacking and if significant + /// then consider packing/unpacking on demand + pub deposits_pool_reward_manager: Box, } impl Reserve { @@ -72,7 +91,7 @@ impl Reserve { /// Initialize a reserve pub fn init(&mut self, params: InitReserveParams) { - self.version = PROGRAM_VERSION; + self.discriminator = AccountDiscriminator::Reserve; self.last_update = LastUpdate::new(params.current_slot); self.lending_market = params.lending_market; self.liquidity = params.liquidity; @@ -574,6 +593,25 @@ impl Reserve { .try_floor_u64()?, )) } + + /// Returns the pool reward manager for the given position kind + pub fn pool_reward_manager(&self, position_kind: PositionKind) -> &PoolRewardManager { + match position_kind { + PositionKind::Borrow => &self.borrows_pool_reward_manager, + PositionKind::Deposit => &self.deposits_pool_reward_manager, + } + } + + /// Returns the pool reward manager for the given position kind + pub fn pool_reward_manager_mut( + &mut self, + position_kind: PositionKind, + ) -> &mut PoolRewardManager { + match position_kind { + PositionKind::Borrow => &mut self.borrows_pool_reward_manager, + PositionKind::Deposit => &mut self.deposits_pool_reward_manager, + } + } } /// Initialize a reserve @@ -1224,20 +1262,25 @@ pub enum FeeCalculation { impl Sealed for Reserve {} impl IsInitialized for Reserve { fn is_initialized(&self) -> bool { - self.version != UNINITIALIZED_VERSION + !matches!(self.discriminator, AccountDiscriminator::Uninitialized) } } -const RESERVE_LEN: usize = 619; // 1 + 8 + 1 + 32 + 32 + 1 + 32 + 32 + 32 + 8 + 16 + 16 + 16 + 32 + 8 + 32 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 8 + 8 + 1 + 8 + 8 + 32 + 1 + 1 + 16 + 230 +/// This is the size of the account _before_ LM feature was added. +pub const RESERVE_LEN_V2_0_2: usize = 619; // 1 + 8 + 1 + 32 + 32 + 1 + 32 + 32 + 32 + 8 + 16 + 16 + 16 + 32 + 8 + 32 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 8 + 8 + 1 + 8 + 8 + 32 + 1 + 1 + 16 + 230 +/// This is the size of the account _after_ LM feature was added. +const RESERVE_LEN_V2_1_0: usize = RESERVE_LEN_V2_0_2 + PoolRewardManager::LEN * 2; + impl Pack for Reserve { - const LEN: usize = RESERVE_LEN; + const LEN: usize = RESERVE_LEN_V2_1_0; // @TODO: break this up by reserve / liquidity / collateral / config https://git.io/JOCca + // @v2.1.0: packs deposits_pool_reward_manager and borrows_pool_reward_manager fn pack_into_slice(&self, output: &mut [u8]) { - let output = array_mut_ref![output, 0, RESERVE_LEN]; + let output = array_mut_ref![output, 0, Reserve::LEN]; #[allow(clippy::ptr_offset_with_cast)] let ( - version, + discriminator, last_update_slot, last_update_stale, lending_market, @@ -1285,6 +1328,8 @@ impl Pack for Reserve { config_attributed_borrow_limit_open, config_attributed_borrow_limit_close, _padding, + output_for_borrows_pool_reward_manager, + output_for_deposits_pool_reward_manager, ) = mut_array_refs![ output, 1, @@ -1334,11 +1379,13 @@ impl Pack for Reserve { 16, 8, 8, - 49 + 49, + PoolRewardManager::LEN, + PoolRewardManager::LEN ]; // reserve - *version = self.version.to_le_bytes(); + discriminator[0] = self.discriminator as _; *last_update_slot = self.last_update.slot.to_le_bytes(); pack_bool(self.last_update.stale, last_update_stale); lending_market.copy_from_slice(self.lending_market.as_ref()); @@ -1420,14 +1467,26 @@ impl Pack for Reserve { self.config.attributed_borrow_limit_close.to_le_bytes(); pack_decimal(self.attributed_borrow_value, attributed_borrow_value); + + Pack::pack_into_slice( + &*self.borrows_pool_reward_manager, + output_for_borrows_pool_reward_manager, + ); + + Pack::pack_into_slice( + &*self.deposits_pool_reward_manager, + output_for_deposits_pool_reward_manager, + ); } - /// Unpacks a byte buffer into a [ReserveInfo](struct.ReserveInfo.html). + /// Unpacks a byte buffer into a [Reserve]. + /// + // @v2.1.0 unpacks deposits_pool_reward_manager and borrows_pool_reward_manager fn unpack_from_slice(input: &[u8]) -> Result { - let input = array_ref![input, 0, RESERVE_LEN]; + let input_v2_0_2 = array_ref![input, 0, RESERVE_LEN_V2_0_2]; #[allow(clippy::ptr_offset_with_cast)] let ( - version, + discriminator, last_update_slot, last_update_stale, lending_market, @@ -1476,7 +1535,7 @@ impl Pack for Reserve { config_attributed_borrow_limit_close, _padding, ) = array_refs![ - input, + input_v2_0_2, 1, 8, 1, @@ -1527,11 +1586,19 @@ impl Pack for Reserve { 49 ]; - let version = u8::from_le_bytes(*version); - if version > PROGRAM_VERSION { - msg!("Reserve version does not match lending program version"); - return Err(ProgramError::InvalidAccountData); - } + // Reserve migration v2.0.2 to v2.1.0 happens outside of the + // unpack method because there's no reliable way to ensure that we're + // migrating a reserve and not an obligation that's dynamically resized + // to the same length as a reserve. + let discriminator = match AccountDiscriminator::try_from(discriminator) { + Ok(d @ AccountDiscriminator::Uninitialized) => d, // yet to be set + Ok(d @ AccountDiscriminator::Reserve) => d, // migrated to v2.1.0 + Ok(_) => { + msg!("Reserve discriminator does not match"); + return Err(LendingError::InvalidAccountDiscriminator.into()); + } + Err(e) => return Err(e.into()), + }; let optimal_utilization_rate = u8::from_le_bytes(*config_optimal_utilization_rate); let max_borrow_rate = u8::from_le_bytes(*config_max_borrow_rate); @@ -1548,110 +1615,129 @@ impl Pack for Reserve { u8::from_le_bytes(*config_max_liquidation_threshold), ); - Ok(Self { - version, - last_update: LastUpdate { - slot: u64::from_le_bytes(*last_update_slot), - stale: unpack_bool(last_update_stale)?, + let last_update = LastUpdate { + slot: u64::from_le_bytes(*last_update_slot), + stale: unpack_bool(last_update_stale)?, + }; + + let liquidity = ReserveLiquidity { + mint_pubkey: Pubkey::new_from_array(*liquidity_mint_pubkey), + mint_decimals: u8::from_le_bytes(*liquidity_mint_decimals), + supply_pubkey: Pubkey::new_from_array(*liquidity_supply_pubkey), + pyth_oracle_pubkey: Pubkey::new_from_array(*liquidity_pyth_oracle_pubkey), + switchboard_oracle_pubkey: Pubkey::new_from_array(*liquidity_switchboard_oracle_pubkey), + available_amount: u64::from_le_bytes(*liquidity_available_amount), + borrowed_amount_wads: unpack_decimal(liquidity_borrowed_amount_wads), + cumulative_borrow_rate_wads: unpack_decimal(liquidity_cumulative_borrow_rate_wads), + accumulated_protocol_fees_wads: unpack_decimal( + liquidity_accumulated_protocol_fees_wads, + ), + market_price: unpack_decimal(liquidity_market_price), + smoothed_market_price: unpack_decimal(liquidity_smoothed_market_price), + extra_market_price: match liquidity_extra_market_price_flag[0] { + 0 => None, + 1 => Some(unpack_decimal(liquidity_extra_market_price)), + _ => { + msg!("Invalid extra market price flag"); + return Err(ProgramError::InvalidAccountData); + } }, - lending_market: Pubkey::new_from_array(*lending_market), - liquidity: ReserveLiquidity { - mint_pubkey: Pubkey::new_from_array(*liquidity_mint_pubkey), - mint_decimals: u8::from_le_bytes(*liquidity_mint_decimals), - supply_pubkey: Pubkey::new_from_array(*liquidity_supply_pubkey), - pyth_oracle_pubkey: Pubkey::new_from_array(*liquidity_pyth_oracle_pubkey), - switchboard_oracle_pubkey: Pubkey::new_from_array( - *liquidity_switchboard_oracle_pubkey, - ), - available_amount: u64::from_le_bytes(*liquidity_available_amount), - borrowed_amount_wads: unpack_decimal(liquidity_borrowed_amount_wads), - cumulative_borrow_rate_wads: unpack_decimal(liquidity_cumulative_borrow_rate_wads), - accumulated_protocol_fees_wads: unpack_decimal( - liquidity_accumulated_protocol_fees_wads, - ), - market_price: unpack_decimal(liquidity_market_price), - smoothed_market_price: unpack_decimal(liquidity_smoothed_market_price), - extra_market_price: match liquidity_extra_market_price_flag[0] { - 0 => None, - 1 => Some(unpack_decimal(liquidity_extra_market_price)), - _ => { - msg!("Invalid extra market price flag"); - return Err(ProgramError::InvalidAccountData); - } - }, + }; + + let collateral = ReserveCollateral { + mint_pubkey: Pubkey::new_from_array(*collateral_mint_pubkey), + mint_total_supply: u64::from_le_bytes(*collateral_mint_total_supply), + supply_pubkey: Pubkey::new_from_array(*collateral_supply_pubkey), + }; + + let config = ReserveConfig { + optimal_utilization_rate, + max_utilization_rate: max( + optimal_utilization_rate, + u8::from_le_bytes(*config_max_utilization_rate), + ), + loan_to_value_ratio: u8::from_le_bytes(*config_loan_to_value_ratio), + liquidation_bonus, + max_liquidation_bonus, + liquidation_threshold, + max_liquidation_threshold, + min_borrow_rate: u8::from_le_bytes(*config_min_borrow_rate), + optimal_borrow_rate: u8::from_le_bytes(*config_optimal_borrow_rate), + max_borrow_rate, + super_max_borrow_rate: max( + max_borrow_rate as u64, + u64::from_le_bytes(*config_super_max_borrow_rate), + ), + fees: ReserveFees { + borrow_fee_wad: u64::from_le_bytes(*config_fees_borrow_fee_wad), + flash_loan_fee_wad: u64::from_le_bytes(*config_fees_flash_loan_fee_wad), + host_fee_percentage: u8::from_le_bytes(*config_fees_host_fee_percentage), }, - collateral: ReserveCollateral { - mint_pubkey: Pubkey::new_from_array(*collateral_mint_pubkey), - mint_total_supply: u64::from_le_bytes(*collateral_mint_total_supply), - supply_pubkey: Pubkey::new_from_array(*collateral_supply_pubkey), + deposit_limit: u64::from_le_bytes(*config_deposit_limit), + borrow_limit: u64::from_le_bytes(*config_borrow_limit), + fee_receiver: Pubkey::new_from_array(*config_fee_receiver), + protocol_liquidation_fee: min( + u8::from_le_bytes(*config_protocol_liquidation_fee), + // the behaviour of this variable changed in v2.0.2 and now represents a + // fraction of the total liquidation value that the protocol receives as + // a bonus. Prior to v2.0.2, this variable used to represent a percentage of of + // the liquidator's bonus that would be sent to the protocol. For safety, we + // cap the value here to MAX_PROTOCOL_LIQUIDATION_FEE_DECA_BPS. + MAX_PROTOCOL_LIQUIDATION_FEE_DECA_BPS, + ), + protocol_take_rate: u8::from_le_bytes(*config_protocol_take_rate), + added_borrow_weight_bps: u64::from_le_bytes(*config_added_borrow_weight_bps), + reserve_type: ReserveType::from_u8(config_asset_type[0]).unwrap(), + scaled_price_offset_bps: i64::from_le_bytes(*config_scaled_price_offset_bps), + extra_oracle_pubkey: if config_extra_oracle_pubkey == &[0; 32] { + None + } else { + Some(Pubkey::new_from_array(*config_extra_oracle_pubkey)) }, - config: ReserveConfig { - optimal_utilization_rate, - max_utilization_rate: max( - optimal_utilization_rate, - u8::from_le_bytes(*config_max_utilization_rate), - ), - loan_to_value_ratio: u8::from_le_bytes(*config_loan_to_value_ratio), - liquidation_bonus, - max_liquidation_bonus, - liquidation_threshold, - max_liquidation_threshold, - min_borrow_rate: u8::from_le_bytes(*config_min_borrow_rate), - optimal_borrow_rate: u8::from_le_bytes(*config_optimal_borrow_rate), - max_borrow_rate, - super_max_borrow_rate: max( - max_borrow_rate as u64, - u64::from_le_bytes(*config_super_max_borrow_rate), - ), - fees: ReserveFees { - borrow_fee_wad: u64::from_le_bytes(*config_fees_borrow_fee_wad), - flash_loan_fee_wad: u64::from_le_bytes(*config_fees_flash_loan_fee_wad), - host_fee_percentage: u8::from_le_bytes(*config_fees_host_fee_percentage), - }, - deposit_limit: u64::from_le_bytes(*config_deposit_limit), - borrow_limit: u64::from_le_bytes(*config_borrow_limit), - fee_receiver: Pubkey::new_from_array(*config_fee_receiver), - protocol_liquidation_fee: min( - u8::from_le_bytes(*config_protocol_liquidation_fee), - // the behaviour of this variable changed in v2.0.2 and now represents a - // fraction of the total liquidation value that the protocol receives as - // a bonus. Prior to v2.0.2, this variable used to represent a percentage of of - // the liquidator's bonus that would be sent to the protocol. For safety, we - // cap the value here to MAX_PROTOCOL_LIQUIDATION_FEE_DECA_BPS. - MAX_PROTOCOL_LIQUIDATION_FEE_DECA_BPS, - ), - protocol_take_rate: u8::from_le_bytes(*config_protocol_take_rate), - added_borrow_weight_bps: u64::from_le_bytes(*config_added_borrow_weight_bps), - reserve_type: ReserveType::from_u8(config_asset_type[0]).unwrap(), - scaled_price_offset_bps: i64::from_le_bytes(*config_scaled_price_offset_bps), - extra_oracle_pubkey: if config_extra_oracle_pubkey == &[0; 32] { - None + // this field is added in v2.0.3 and we will never set it to zero. only time it'll + // the following two fields are added in v2.0.3 and we will never set it to zero. only time they will + // be zero is when we upgrade from v2.0.2 to v2.0.3. in that case, the correct + // thing to do is set the value to u64::MAX. + attributed_borrow_limit_open: { + let value = u64::from_le_bytes(*config_attributed_borrow_limit_open); + if value == 0 { + u64::MAX } else { - Some(Pubkey::new_from_array(*config_extra_oracle_pubkey)) - }, - // this field is added in v2.0.3 and we will never set it to zero. only time it'll - // the following two fields are added in v2.0.3 and we will never set it to zero. only time they will - // be zero is when we upgrade from v2.0.2 to v2.0.3. in that case, the correct - // thing to do is set the value to u64::MAX. - attributed_borrow_limit_open: { - let value = u64::from_le_bytes(*config_attributed_borrow_limit_open); - if value == 0 { - u64::MAX - } else { - value - } - }, - attributed_borrow_limit_close: { - let value = u64::from_le_bytes(*config_attributed_borrow_limit_close); - if value == 0 { - u64::MAX - } else { - value - } - }, + value + } }, + attributed_borrow_limit_close: { + let value = u64::from_le_bytes(*config_attributed_borrow_limit_close); + if value == 0 { + u64::MAX + } else { + value + } + }, + }; + + let input_v2_1_0 = array_ref![input, RESERVE_LEN_V2_0_2, PoolRewardManager::LEN * 2]; + #[allow(clippy::ptr_offset_with_cast)] + let (input_for_borrows_pool_reward_manager, input_for_deposits_pool_reward_manager) = + array_refs![input_v2_1_0, PoolRewardManager::LEN, PoolRewardManager::LEN]; + + let borrows_pool_reward_manager = + PoolRewardManager::unpack_to_box(input_for_borrows_pool_reward_manager)?; + + let deposits_pool_reward_manager = + PoolRewardManager::unpack_to_box(input_for_deposits_pool_reward_manager)?; + + Ok(Self { + discriminator, + last_update, + lending_market: Pubkey::new_from_array(*lending_market), + liquidity, + collateral, + config, rate_limiter: RateLimiter::unpack_from_slice(rate_limiter)?, attributed_borrow_value: unpack_decimal(attributed_borrow_value), + borrows_pool_reward_manager, + deposits_pool_reward_manager, }) } } @@ -1670,10 +1756,8 @@ mod test { Decimal::from_scaled_val(rand::thread_rng().gen()) } - #[test] - fn pack_and_unpack_reserve() { - let mut rng = rand::thread_rng(); - for _ in 0..100 { + impl Reserve { + fn new_rand(rng: &mut impl Rng) -> Self { let optimal_utilization_rate = rng.gen(); let liquidation_bonus: u8 = rng.gen(); let liquidation_threshold: u8 = rng.gen(); @@ -1688,8 +1772,8 @@ mod test { None }; - let reserve = Reserve { - version: PROGRAM_VERSION, + Self { + discriminator: AccountDiscriminator::Reserve, last_update: LastUpdate { slot: rng.gen(), stale: rng.gen(), @@ -1745,7 +1829,17 @@ mod test { }, rate_limiter: rand_rate_limiter(), attributed_borrow_value: rand_decimal(), - }; + borrows_pool_reward_manager: Box::new(PoolRewardManager::new_rand(rng)), + deposits_pool_reward_manager: Box::new(PoolRewardManager::new_rand(rng)), + } + } + } + + #[test] + fn pack_and_unpack_reserve_v2_1_0() { + let mut rng = rand::thread_rng(); + for _ in 0..100 { + let reserve = Reserve::new_rand(&mut rng); let mut packed = [0u8; Reserve::LEN]; Reserve::pack(reserve.clone(), &mut packed).unwrap(); @@ -1754,6 +1848,23 @@ mod test { } } + #[test] + fn pack_and_unpack_reserve_v2_0_2() { + let mut rng = rand::thread_rng(); + let reserve = Reserve::new_rand(&mut rng); + + let mut packed = [0u8; Reserve::LEN]; + Reserve::pack(reserve.clone(), &mut packed).unwrap(); + // this is what version looked like before the upgrade to v2.1.0 + packed[0] = PROGRAM_VERSION_2_0_2; + + // reserve must be upgraded with a special ix + assert_eq!( + Reserve::unpack(&packed).unwrap_err(), + LendingError::AccountNotMigrated.into() + ); + } + const MAX_LIQUIDITY: u64 = u64::MAX / 5; fn utilizations() -> impl Strategy { diff --git a/token-lending/tests/liquidity-mining.ts b/token-lending/tests/liquidity-mining.ts new file mode 100644 index 00000000000..c9e029aa14e --- /dev/null +++ b/token-lending/tests/liquidity-mining.ts @@ -0,0 +1,70 @@ +/** + * Temporary test to showcase that reserve upgrades work with CLI. + * We'll delete this once all reserves are upgraded. + * + * $ anchor test --provider.cluster localnet --detach + */ + +import * as anchor from "@coral-xyz/anchor"; +import { PublicKey } from "@solana/web3.js"; +import { expect } from "chai"; +import { exec } from "node:child_process"; + +describe("liquidity mining", () => { + // Configure the client to use the local cluster. + anchor.setProvider(anchor.AnchorProvider.env()); + + 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(SOME_TEST_RESERVE_TO_CHECK)); + + expect(reserveBefore.data.length).to.eq(619); // old version data length + const expectedRentBefore = await anchor + .getProvider() + .connection.getMinimumBalanceForRentExemption(reserveBefore.data.length); + // some reserves have more rent + expect(reserveBefore.lamports).to.be.greaterThanOrEqual(expectedRentBefore); + + const command = `cargo run --quiet --bin solend-cli -- --url ${rpcUrl} migrate-all-reserves-for-liquidity-mining`; + console.log(`\$ ${command}`); + const cliProcess = exec(command); + + // let us observe progress + cliProcess.stderr.setEncoding("utf8"); + cliProcess.stderr.pipe(process.stderr); + + console.log("Waiting for command to finish..."); + const exitCode = await new Promise((resolve) => + cliProcess.on("exit", (code) => resolve(code)) + ); + + if (exitCode !== 0) { + cliProcess.stdout.setEncoding("utf8"); + console.log("CLI stdout", cliProcess.stdout.read()); + + throw new Error(`Command failed with exit code ${exitCode}`); + } + + const reserveAfter = await anchor + .getProvider() + .connection.getAccountInfo(new PublicKey(SOME_TEST_RESERVE_TO_CHECK)); + + expect(reserveAfter.data.length).to.eq(5451); // new version data length + const expectedRentAfter = await anchor + .getProvider() + .connection.getMinimumBalanceForRentExemption(reserveAfter.data.length); + expect(reserveAfter.lamports).to.be.greaterThanOrEqual(expectedRentAfter); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000000..cd5d2e3d062 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "types": ["mocha", "chai"], + "typeRoots": ["./node_modules/@types"], + "lib": ["es2015"], + "module": "commonjs", + "target": "es6", + "esModuleInterop": true + } +} diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 00000000000..02cbfddcc0c --- /dev/null +++ b/yarn.lock @@ -0,0 +1,1181 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@babel/runtime@^7.25.0": + version "7.26.10" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.26.10.tgz#a07b4d8fa27af131a633d7b3524db803eb4764c2" + integrity sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw== + dependencies: + regenerator-runtime "^0.14.0" + +"@coral-xyz/anchor@^0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@coral-xyz/anchor/-/anchor-0.28.0.tgz#8345c3c9186a91f095f704d7b90cd256f7e8b2dc" + integrity sha512-kQ02Hv2ZqxtWP30WN1d4xxT4QqlOXYDxmEd3k/bbneqhV3X5QMO4LAtoUFs7otxyivOgoqam5Il5qx81FuI4vw== + dependencies: + "@coral-xyz/borsh" "^0.28.0" + "@solana/web3.js" "^1.68.0" + base64-js "^1.5.1" + bn.js "^5.1.2" + bs58 "^4.0.1" + buffer-layout "^1.2.2" + camelcase "^6.3.0" + cross-fetch "^3.1.5" + crypto-hash "^1.3.0" + eventemitter3 "^4.0.7" + js-sha256 "^0.9.0" + pako "^2.0.3" + snake-case "^3.0.4" + superstruct "^0.15.4" + toml "^3.0.0" + +"@coral-xyz/borsh@^0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@coral-xyz/borsh/-/borsh-0.28.0.tgz#fa368a2f2475bbf6f828f4657f40a52102e02b6d" + integrity sha512-/u1VTzw7XooK7rqeD7JLUSwOyRSesPUk0U37BV9zK0axJc1q0nRbKFGFLYCQ16OtdOJTTwGfGp11Lx9B45bRCQ== + dependencies: + bn.js "^5.1.2" + buffer-layout "^1.2.0" + +"@noble/curves@^1.4.2": + version "1.8.1" + resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.8.1.tgz#19bc3970e205c99e4bdb1c64a4785706bce497ff" + integrity sha512-warwspo+UYUPep0Q+vtdVB4Ugn8GGQj8iyB3gnRWsztmUHTI3S1nhdiWNsPUGL0vud7JlRRk1XEu7Lq1KGTnMQ== + dependencies: + "@noble/hashes" "1.7.1" + +"@noble/hashes@1.7.1", "@noble/hashes@^1.4.0": + version "1.7.1" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.7.1.tgz#5738f6d765710921e7a751e00c20ae091ed8db0f" + integrity sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ== + +"@solana/buffer-layout@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@solana/buffer-layout/-/buffer-layout-4.0.1.tgz#b996235eaec15b1e0b5092a8ed6028df77fa6c15" + integrity sha512-E1ImOIAD1tBZFRdjeM4/pzTiTApC0AOBGwyAMS4fwIodCWArzJ3DWdoh8cKxeFM2fElkxBh2Aqts1BPC373rHA== + dependencies: + buffer "~6.0.3" + +"@solana/web3.js@^1.68.0": + version "1.98.0" + resolved "https://registry.yarnpkg.com/@solana/web3.js/-/web3.js-1.98.0.tgz#21ecfe8198c10831df6f0cfde7f68370d0405917" + integrity sha512-nz3Q5OeyGFpFCR+erX2f6JPt3sKhzhYcSycBCSPkWjzSVDh/Rr1FqTVMRe58FKO16/ivTUcuJjeS5MyBvpkbzA== + dependencies: + "@babel/runtime" "^7.25.0" + "@noble/curves" "^1.4.2" + "@noble/hashes" "^1.4.0" + "@solana/buffer-layout" "^4.0.1" + agentkeepalive "^4.5.0" + bigint-buffer "^1.1.5" + bn.js "^5.2.1" + borsh "^0.7.0" + bs58 "^4.0.1" + buffer "6.0.3" + fast-stable-stringify "^1.0.0" + jayson "^4.1.1" + node-fetch "^2.7.0" + rpc-websockets "^9.0.2" + superstruct "^2.0.2" + +"@swc/helpers@^0.5.11": + version "0.5.15" + resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.5.15.tgz#79efab344c5819ecf83a43f3f9f811fc84b516d7" + integrity sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g== + dependencies: + tslib "^2.8.0" + +"@types/bn.js@^5.1.0": + version "5.1.6" + resolved "https://registry.yarnpkg.com/@types/bn.js/-/bn.js-5.1.6.tgz#9ba818eec0c85e4d3c679518428afdf611d03203" + integrity sha512-Xh8vSwUeMKeYYrj3cX4lGQgFSF/N03r+tv4AiLl1SucqV+uTQpxRcnM8AkXKHwYP9ZPXOYXRr2KPXpVlIvqh9w== + dependencies: + "@types/node" "*" + +"@types/chai@^4.3.0": + version "4.3.20" + resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.20.tgz#cb291577ed342ca92600430841a00329ba05cecc" + integrity sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ== + +"@types/connect@^3.4.33": + version "3.4.38" + resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.38.tgz#5ba7f3bc4fbbdeaff8dded952e5ff2cc53f8d858" + integrity sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug== + dependencies: + "@types/node" "*" + +"@types/json5@^0.0.29": + version "0.0.29" + resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" + integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== + +"@types/mocha@^9.0.0": + version "9.1.1" + resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-9.1.1.tgz#e7c4f1001eefa4b8afbd1eee27a237fee3bf29c4" + integrity sha512-Z61JK7DKDtdKTWwLeElSEBcWGRLY8g95ic5FoQqI9CMx0ns/Ghep3B4DfcEimiKMvtamNVULVNKEsiwV3aQmXw== + +"@types/node@*": + version "22.13.10" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.13.10.tgz#df9ea358c5ed991266becc3109dc2dc9125d77e4" + integrity sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw== + dependencies: + undici-types "~6.20.0" + +"@types/node@^12.12.54": + version "12.20.55" + resolved "https://registry.yarnpkg.com/@types/node/-/node-12.20.55.tgz#c329cbd434c42164f846b909bd6f85b5537f6240" + integrity sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ== + +"@types/uuid@^8.3.4": + version "8.3.4" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.4.tgz#bd86a43617df0594787d38b735f55c805becf1bc" + integrity sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw== + +"@types/ws@^7.4.4": + version "7.4.7" + resolved "https://registry.yarnpkg.com/@types/ws/-/ws-7.4.7.tgz#f7c390a36f7a0679aa69de2d501319f4f8d9b702" + integrity sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww== + dependencies: + "@types/node" "*" + +"@types/ws@^8.2.2": + version "8.18.0" + resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.18.0.tgz#8a2ec491d6f0685ceaab9a9b7ff44146236993b5" + integrity sha512-8svvI3hMyvN0kKCJMvTJP/x6Y/EoQbepff882wL+Sn5QsXb3etnamgrJq4isrBxSJj5L2AuXcI0+bgkoAXGUJw== + dependencies: + "@types/node" "*" + +"@ungap/promise-all-settled@1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz#aa58042711d6e3275dd37dc597e5d31e8c290a44" + integrity sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q== + +JSONStream@^1.3.5: + version "1.3.5" + resolved "https://registry.yarnpkg.com/JSONStream/-/JSONStream-1.3.5.tgz#3208c1f08d3a4d99261ab64f92302bc15e111ca0" + integrity sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ== + dependencies: + jsonparse "^1.2.0" + through ">=2.2.7 <3" + +agentkeepalive@^4.5.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/agentkeepalive/-/agentkeepalive-4.6.0.tgz#35f73e94b3f40bf65f105219c623ad19c136ea6a" + integrity sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ== + dependencies: + humanize-ms "^1.2.1" + +ansi-colors@4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348" + integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA== + +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-styles@^4.0.0, ansi-styles@^4.1.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +anymatch@~3.1.2: + version "3.1.3" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" + integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + +arrify@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d" + integrity sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA== + +assertion-error@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b" + integrity sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw== + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +base-x@^3.0.2: + version "3.0.11" + resolved "https://registry.yarnpkg.com/base-x/-/base-x-3.0.11.tgz#40d80e2a1aeacba29792ccc6c5354806421287ff" + integrity sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA== + dependencies: + safe-buffer "^5.0.1" + +base64-js@^1.3.1, base64-js@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" + integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== + +bigint-buffer@^1.1.5: + version "1.1.5" + resolved "https://registry.yarnpkg.com/bigint-buffer/-/bigint-buffer-1.1.5.tgz#d038f31c8e4534c1f8d0015209bf34b4fa6dd442" + integrity sha512-trfYco6AoZ+rKhKnxA0hgX0HAbVP/s808/EuDSe2JDzUnCp/xAsli35Orvk67UrTEcwuxZqYZDmfA2RXJgxVvA== + dependencies: + bindings "^1.3.0" + +binary-extensions@^2.0.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522" + integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw== + +bindings@^1.3.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.5.0.tgz#10353c9e945334bc0511a6d90b38fbc7c9c504df" + integrity sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ== + dependencies: + file-uri-to-path "1.0.0" + +bn.js@^5.1.2, bn.js@^5.2.0, bn.js@^5.2.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.2.1.tgz#0bc527a6a0d18d0aa8d5b0538ce4a77dccfa7b70" + integrity sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ== + +borsh@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/borsh/-/borsh-0.7.0.tgz#6e9560d719d86d90dc589bca60ffc8a6c51fec2a" + integrity sha512-CLCsZGIBCFnPtkNnieW/a8wmreDmfUtjU2m9yHrzPXIlNbqVs0AQrSatSG6vdNYUqdc83tkQi2eHfF98ubzQLA== + dependencies: + bn.js "^5.2.0" + bs58 "^4.0.0" + text-encoding-utf-8 "^1.0.2" + +brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" + integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +braces@~3.0.2: + version "3.0.3" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" + integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== + dependencies: + fill-range "^7.1.1" + +browser-stdout@1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60" + integrity sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw== + +bs58@^4.0.0, bs58@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/bs58/-/bs58-4.0.1.tgz#be161e76c354f6f788ae4071f63f34e8c4f0a42a" + integrity sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw== + dependencies: + base-x "^3.0.2" + +buffer-from@^1.0.0, buffer-from@^1.1.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" + integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== + +buffer-layout@^1.2.0, buffer-layout@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/buffer-layout/-/buffer-layout-1.2.2.tgz#b9814e7c7235783085f9ca4966a0cfff112259d5" + integrity sha512-kWSuLN694+KTk8SrYvCqwP2WcgQjoRCiF5b4QDvkkz8EmgD+aWAIceGFKMIAdmF/pH+vpgNV3d3kAKorcdAmWA== + +buffer@6.0.3, buffer@^6.0.3, buffer@~6.0.3: + version "6.0.3" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6" + integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.2.1" + +bufferutil@^4.0.1: + version "4.0.9" + resolved "https://registry.yarnpkg.com/bufferutil/-/bufferutil-4.0.9.tgz#6e81739ad48a95cad45a279588e13e95e24a800a" + integrity sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw== + dependencies: + node-gyp-build "^4.3.0" + +camelcase@^6.0.0, camelcase@^6.3.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" + integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== + +chai@^4.3.4: + version "4.5.0" + resolved "https://registry.yarnpkg.com/chai/-/chai-4.5.0.tgz#707e49923afdd9b13a8b0b47d33d732d13812fd8" + integrity sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw== + dependencies: + assertion-error "^1.1.0" + check-error "^1.0.3" + deep-eql "^4.1.3" + get-func-name "^2.0.2" + loupe "^2.3.6" + pathval "^1.1.1" + type-detect "^4.1.0" + +chalk@^4.1.0: + version "4.1.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +check-error@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.3.tgz#a6502e4312a7ee969f646e83bb3ddd56281bd694" + integrity sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg== + dependencies: + get-func-name "^2.0.2" + +chokidar@3.5.3: + version "3.5.3" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd" + integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw== + dependencies: + anymatch "~3.1.2" + braces "~3.0.2" + glob-parent "~5.1.2" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.6.0" + optionalDependencies: + fsevents "~2.3.2" + +cliui@^7.0.2: + version "7.0.4" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" + integrity sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.0" + wrap-ansi "^7.0.0" + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +commander@^2.20.3: + version "2.20.3" + resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" + integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== + +cross-fetch@^3.1.5: + version "3.2.0" + resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.2.0.tgz#34e9192f53bc757d6614304d9e5e6fb4edb782e3" + integrity sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q== + dependencies: + node-fetch "^2.7.0" + +crypto-hash@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/crypto-hash/-/crypto-hash-1.3.0.tgz#b402cb08f4529e9f4f09346c3e275942f845e247" + integrity sha512-lyAZ0EMyjDkVvz8WOeVnuCPvKVBXcMv1l5SVqO1yC7PzTwrD/pPje/BIRbWhMoPe436U+Y2nD7f5bFx0kt+Sbg== + +debug@4.3.3: + version "4.3.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.3.tgz#04266e0b70a98d4462e6e288e38259213332b664" + integrity sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q== + dependencies: + ms "2.1.2" + +decamelize@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-4.0.0.tgz#aa472d7bf660eb15f3494efd531cab7f2a709837" + integrity sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ== + +deep-eql@^4.1.3: + version "4.1.4" + resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-4.1.4.tgz#d0d3912865911bb8fac5afb4e3acfa6a28dc72b7" + integrity sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg== + dependencies: + type-detect "^4.0.0" + +delay@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/delay/-/delay-5.0.0.tgz#137045ef1b96e5071060dd5be60bf9334436bd1d" + integrity sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw== + +diff@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-5.0.0.tgz#7ed6ad76d859d030787ec35855f5b1daf31d852b" + integrity sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w== + +diff@^3.1.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12" + integrity sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA== + +dot-case@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/dot-case/-/dot-case-3.0.4.tgz#9b2b670d00a431667a8a75ba29cd1b98809ce751" + integrity sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w== + dependencies: + no-case "^3.0.4" + tslib "^2.0.3" + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +es6-promise@^4.0.3: + version "4.2.8" + resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.8.tgz#4eb21594c972bc40553d276e510539143db53e0a" + integrity sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w== + +es6-promisify@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/es6-promisify/-/es6-promisify-5.0.0.tgz#5109d62f3e56ea967c4b63505aef08291c8a5203" + integrity sha512-C+d6UdsYDk0lMebHNR4S2NybQMMngAOnOwYBQjTOiv0MkoJMP0Myw2mgpDLBcpfCmRLxyFqYhS/CfOENq4SJhQ== + dependencies: + es6-promise "^4.0.3" + +escalade@^3.1.1: + version "3.2.0" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" + integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== + +escape-string-regexp@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + +eventemitter3@^4.0.7: + version "4.0.7" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" + integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== + +eventemitter3@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.1.tgz#53f5ffd0a492ac800721bb42c66b841de96423c4" + integrity sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA== + +eyes@^0.1.8: + version "0.1.8" + resolved "https://registry.yarnpkg.com/eyes/-/eyes-0.1.8.tgz#62cf120234c683785d902348a800ef3e0cc20bc0" + integrity sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ== + +fast-stable-stringify@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fast-stable-stringify/-/fast-stable-stringify-1.0.0.tgz#5c5543462b22aeeefd36d05b34e51c78cb86d313" + integrity sha512-wpYMUmFu5f00Sm0cj2pfivpmawLZ0NKdviQ4w9zJeR8JVtOpOxHmLaJuj0vxvGqMJQWyP/COUkF75/57OKyRag== + +file-uri-to-path@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" + integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw== + +fill-range@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" + integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== + dependencies: + to-regex-range "^5.0.1" + +find-up@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + dependencies: + locate-path "^6.0.0" + path-exists "^4.0.0" + +flat@^5.0.2: + version "5.0.2" + resolved "https://registry.yarnpkg.com/flat/-/flat-5.0.2.tgz#8ca6fe332069ffa9d324c327198c598259ceb241" + integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ== + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== + +fsevents@~2.3.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +get-caller-file@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + +get-func-name@^2.0.1, get-func-name@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.2.tgz#0d7cf20cd13fda808669ffa88f4ffc7a3943fc41" + integrity sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ== + +glob-parent@~5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" + integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== + dependencies: + is-glob "^4.0.1" + +glob@7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.0.tgz#d15535af7732e02e948f4c41628bd910293f6023" + integrity sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + +growl@1.10.5: + version "1.10.5" + resolved "https://registry.yarnpkg.com/growl/-/growl-1.10.5.tgz#f2735dc2283674fa67478b10181059355c369e5e" + integrity sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA== + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + +he@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" + integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== + +humanize-ms@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/humanize-ms/-/humanize-ms-1.2.1.tgz#c46e3159a293f6b896da29316d8b6fe8bb79bbed" + integrity sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ== + dependencies: + ms "^2.0.0" + +ieee754@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" + integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +is-binary-path@~2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" + integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== + dependencies: + binary-extensions "^2.0.0" + +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +is-glob@^4.0.1, is-glob@~4.0.1: + version "4.0.3" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + +is-plain-obj@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287" + integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA== + +is-unicode-supported@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7" + integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + +isomorphic-ws@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz#55fd4cd6c5e6491e76dc125938dd863f5cd4f2dc" + integrity sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w== + +jayson@^4.1.1: + version "4.1.3" + resolved "https://registry.yarnpkg.com/jayson/-/jayson-4.1.3.tgz#db9be2e4287d9fef4fc05b5fe367abe792c2eee8" + integrity sha512-LtXh5aYZodBZ9Fc3j6f2w+MTNcnxteMOrb+QgIouguGOulWi0lieEkOUg+HkjjFs0DGoWDds6bi4E9hpNFLulQ== + dependencies: + "@types/connect" "^3.4.33" + "@types/node" "^12.12.54" + "@types/ws" "^7.4.4" + JSONStream "^1.3.5" + commander "^2.20.3" + delay "^5.0.0" + es6-promisify "^5.0.0" + eyes "^0.1.8" + isomorphic-ws "^4.0.1" + json-stringify-safe "^5.0.1" + uuid "^8.3.2" + ws "^7.5.10" + +js-sha256@^0.9.0: + version "0.9.0" + resolved "https://registry.yarnpkg.com/js-sha256/-/js-sha256-0.9.0.tgz#0b89ac166583e91ef9123644bd3c5334ce9d0966" + integrity sha512-sga3MHh9sgQN2+pJ9VYZ+1LPwXOxuBJBA5nrR5/ofPfuiJBE2hnjsaN8se8JznOmGLN2p49Pe5U/ttafcs/apA== + +js-yaml@4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" + integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== + dependencies: + argparse "^2.0.1" + +json-stringify-safe@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" + integrity sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA== + +json5@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.2.tgz#63d98d60f21b313b77c4d6da18bfa69d80e1d593" + integrity sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA== + dependencies: + minimist "^1.2.0" + +jsonparse@^1.2.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" + integrity sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg== + +locate-path@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" + integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== + dependencies: + p-locate "^5.0.0" + +log-symbols@4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503" + integrity sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg== + dependencies: + chalk "^4.1.0" + is-unicode-supported "^0.1.0" + +loupe@^2.3.6: + version "2.3.7" + resolved "https://registry.yarnpkg.com/loupe/-/loupe-2.3.7.tgz#6e69b7d4db7d3ab436328013d37d1c8c3540c697" + integrity sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA== + dependencies: + get-func-name "^2.0.1" + +lower-case@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-2.0.2.tgz#6fa237c63dbdc4a82ca0fd882e4722dc5e634e28" + integrity sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg== + dependencies: + tslib "^2.0.3" + +make-error@^1.1.1: + version "1.3.6" + resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" + integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== + +minimatch@4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-4.2.1.tgz#40d9d511a46bdc4e563c22c3080cde9c0d8299b4" + integrity sha512-9Uq1ChtSZO+Mxa/CL1eGizn2vRn3MlLgzhT0Iz8zaY8NdvxvB0d5QdPFmCKf7JKA9Lerx5vRrnwO03jsSfGG9g== + dependencies: + brace-expansion "^1.1.7" + +minimatch@^3.0.4: + version "3.1.2" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + dependencies: + brace-expansion "^1.1.7" + +minimist@^1.2.0, minimist@^1.2.6: + version "1.2.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" + integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + +mkdirp@^0.5.1: + version "0.5.6" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6" + integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw== + dependencies: + minimist "^1.2.6" + +mocha@^9.0.3: + version "9.2.2" + resolved "https://registry.yarnpkg.com/mocha/-/mocha-9.2.2.tgz#d70db46bdb93ca57402c809333e5a84977a88fb9" + integrity sha512-L6XC3EdwT6YrIk0yXpavvLkn8h+EU+Y5UcCHKECyMbdUIxyMuZj4bX4U9e1nvnvUUvQVsV2VHQr5zLdcUkhW/g== + dependencies: + "@ungap/promise-all-settled" "1.1.2" + ansi-colors "4.1.1" + browser-stdout "1.3.1" + chokidar "3.5.3" + debug "4.3.3" + diff "5.0.0" + escape-string-regexp "4.0.0" + find-up "5.0.0" + glob "7.2.0" + growl "1.10.5" + he "1.2.0" + js-yaml "4.1.0" + log-symbols "4.1.0" + minimatch "4.2.1" + ms "2.1.3" + nanoid "3.3.1" + serialize-javascript "6.0.0" + strip-json-comments "3.1.1" + supports-color "8.1.1" + which "2.0.2" + workerpool "6.2.0" + yargs "16.2.0" + yargs-parser "20.2.4" + yargs-unparser "2.0.0" + +ms@2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" + integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== + +ms@2.1.3, ms@^2.0.0: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +nanoid@3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.1.tgz#6347a18cac88af88f58af0b3594b723d5e99bb35" + integrity sha512-n6Vs/3KGyxPQd6uO0eH4Bv0ojGSUvuLlIHtC3Y0kEO23YRge8H9x1GCzLn28YX0H66pMkxuaeESFq4tKISKwdw== + +no-case@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/no-case/-/no-case-3.0.4.tgz#d361fd5c9800f558551a8369fc0dcd4662b6124d" + integrity sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg== + dependencies: + lower-case "^2.0.2" + tslib "^2.0.3" + +node-fetch@^2.7.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" + integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== + dependencies: + whatwg-url "^5.0.0" + +node-gyp-build@^4.3.0: + version "4.8.4" + resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.8.4.tgz#8a70ee85464ae52327772a90d66c6077a900cfc8" + integrity sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ== + +normalize-path@^3.0.0, normalize-path@~3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" + integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== + +once@^1.3.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== + dependencies: + wrappy "1" + +p-limit@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== + dependencies: + yocto-queue "^0.1.0" + +p-locate@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" + integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== + dependencies: + p-limit "^3.0.2" + +pako@^2.0.3: + version "2.1.0" + resolved "https://registry.yarnpkg.com/pako/-/pako-2.1.0.tgz#266cc37f98c7d883545d11335c00fbd4062c9a86" + integrity sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug== + +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== + +pathval@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.1.tgz#8534e77a77ce7ac5a2512ea21e0fdb8fcf6c3d8d" + integrity sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ== + +picomatch@^2.0.4, picomatch@^2.2.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + +prettier@^2.6.2: + version "2.8.8" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.8.tgz#e8c5d7e98a4305ffe3de2e1fc4aca1a71c28b1da" + integrity sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q== + +randombytes@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" + integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== + dependencies: + safe-buffer "^5.1.0" + +readdirp@~3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" + integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== + dependencies: + picomatch "^2.2.1" + +regenerator-runtime@^0.14.0: + version "0.14.1" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz#356ade10263f685dda125100cd862c1db895327f" + integrity sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw== + +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== + +rpc-websockets@^9.0.2: + version "9.1.1" + resolved "https://registry.yarnpkg.com/rpc-websockets/-/rpc-websockets-9.1.1.tgz#5764336f3623ee1c5cc8653b7335183e3c0c78bd" + integrity sha512-1IXGM/TfPT6nfYMIXkJdzn+L4JEsmb0FL1O2OBjaH03V3yuUDdKFulGLMFG6ErV+8pZ5HVC0limve01RyO+saA== + dependencies: + "@swc/helpers" "^0.5.11" + "@types/uuid" "^8.3.4" + "@types/ws" "^8.2.2" + buffer "^6.0.3" + eventemitter3 "^5.0.1" + uuid "^8.3.2" + ws "^8.5.0" + optionalDependencies: + bufferutil "^4.0.1" + utf-8-validate "^5.0.2" + +safe-buffer@^5.0.1, safe-buffer@^5.1.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +serialize-javascript@6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.0.tgz#efae5d88f45d7924141da8b5c3a7a7e663fefeb8" + integrity sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag== + dependencies: + randombytes "^2.1.0" + +snake-case@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/snake-case/-/snake-case-3.0.4.tgz#4f2bbd568e9935abdfd593f34c691dadb49c452c" + integrity sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg== + dependencies: + dot-case "^3.0.4" + tslib "^2.0.3" + +source-map-support@^0.5.6: + version "0.5.21" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" + integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== + dependencies: + buffer-from "^1.0.0" + source-map "^0.6.0" + +source-map@^0.6.0: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + +string-width@^4.1.0, string-width@^4.2.0: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-bom@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" + integrity sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA== + +strip-json-comments@3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" + integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== + +superstruct@^0.15.4: + version "0.15.5" + resolved "https://registry.yarnpkg.com/superstruct/-/superstruct-0.15.5.tgz#0f0a8d3ce31313f0d84c6096cd4fa1bfdedc9dab" + integrity sha512-4AOeU+P5UuE/4nOUkmcQdW5y7i9ndt1cQd/3iUe+LTz3RxESf/W/5lg4B74HbDMMv8PHnPnGCQFH45kBcrQYoQ== + +superstruct@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/superstruct/-/superstruct-2.0.2.tgz#3f6d32fbdc11c357deff127d591a39b996300c54" + integrity sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A== + +supports-color@8.1.1: + version "8.1.1" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" + integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== + dependencies: + has-flag "^4.0.0" + +supports-color@^7.1.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + +text-encoding-utf-8@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/text-encoding-utf-8/-/text-encoding-utf-8-1.0.2.tgz#585b62197b0ae437e3c7b5d0af27ac1021e10d13" + integrity sha512-8bw4MY9WjdsD2aMtO0OzOCY3pXGYNx2d2FfHRVUKkiCPDWjKuOlhLVASS+pD7VkLTVjW268LYJHwsnPFlBpbAg== + +"through@>=2.2.7 <3": + version "2.3.8" + resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" + integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg== + +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + +toml@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/toml/-/toml-3.0.0.tgz#342160f1af1904ec9d204d03a5d61222d762c5ee" + integrity sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w== + +tr46@~0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" + integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== + +ts-mocha@^10.0.0: + version "10.1.0" + resolved "https://registry.yarnpkg.com/ts-mocha/-/ts-mocha-10.1.0.tgz#17a1c055f5f7733fd82447c4420740db87221bc8" + integrity sha512-T0C0Xm3/WqCuF2tpa0GNGESTBoKZaiqdUP8guNv4ZY316AFXlyidnrzQ1LUrCT0Wb1i3J0zFTgOh/55Un44WdA== + dependencies: + ts-node "7.0.1" + optionalDependencies: + tsconfig-paths "^3.5.0" + +ts-node@7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-7.0.1.tgz#9562dc2d1e6d248d24bc55f773e3f614337d9baf" + integrity sha512-BVwVbPJRspzNh2yfslyT1PSbl5uIk03EZlb493RKHN4qej/D06n1cEhjlOJG69oFsE7OT8XjpTUcYf6pKTLMhw== + dependencies: + arrify "^1.0.0" + buffer-from "^1.1.0" + diff "^3.1.0" + make-error "^1.1.1" + minimist "^1.2.0" + mkdirp "^0.5.1" + source-map-support "^0.5.6" + yn "^2.0.0" + +tsconfig-paths@^3.5.0: + version "3.15.0" + resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz#5299ec605e55b1abb23ec939ef15edaf483070d4" + integrity sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg== + dependencies: + "@types/json5" "^0.0.29" + json5 "^1.0.2" + minimist "^1.2.6" + strip-bom "^3.0.0" + +tslib@^2.0.3, tslib@^2.8.0: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + +type-detect@^4.0.0, type-detect@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.1.0.tgz#deb2453e8f08dcae7ae98c626b13dddb0155906c" + integrity sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw== + +typescript@^5.7.3: + version "5.8.2" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.8.2.tgz#8170b3702f74b79db2e5a96207c15e65807999e4" + integrity sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ== + +undici-types@~6.20.0: + version "6.20.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.20.0.tgz#8171bf22c1f588d1554d55bf204bc624af388433" + integrity sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg== + +utf-8-validate@^5.0.2: + version "5.0.10" + resolved "https://registry.yarnpkg.com/utf-8-validate/-/utf-8-validate-5.0.10.tgz#d7d10ea39318171ca982718b6b96a8d2442571a2" + integrity sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ== + dependencies: + node-gyp-build "^4.3.0" + +uuid@^8.3.2: + version "8.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== + +webidl-conversions@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" + integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== + +whatwg-url@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" + integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== + dependencies: + tr46 "~0.0.3" + webidl-conversions "^3.0.0" + +which@2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +workerpool@6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.2.0.tgz#827d93c9ba23ee2019c3ffaff5c27fccea289e8b" + integrity sha512-Rsk5qQHJ9eowMH28Jwhe8HEbmdYDX4lwoMWshiCXugjtHqMD9ZbiqSDLxcsfdqsETPzVUtX5s1Z5kStiIM6l4A== + +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== + +ws@^7.5.10: + version "7.5.10" + resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.10.tgz#58b5c20dc281633f6c19113f39b349bd8bd558d9" + integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ== + +ws@^8.5.0: + version "8.18.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.1.tgz#ea131d3784e1dfdff91adb0a4a116b127515e3cb" + integrity sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w== + +y18n@^5.0.5: + version "5.0.8" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" + integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== + +yargs-parser@20.2.4: + version "20.2.4" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.4.tgz#b42890f14566796f85ae8e3a25290d205f154a54" + integrity sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA== + +yargs-parser@^20.2.2: + version "20.2.9" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee" + integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== + +yargs-unparser@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/yargs-unparser/-/yargs-unparser-2.0.0.tgz#f131f9226911ae5d9ad38c432fe809366c2325eb" + integrity sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA== + dependencies: + camelcase "^6.0.0" + decamelize "^4.0.0" + flat "^5.0.2" + is-plain-obj "^2.1.0" + +yargs@16.2.0: + version "16.2.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66" + integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw== + dependencies: + cliui "^7.0.2" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.0" + y18n "^5.0.5" + yargs-parser "^20.2.2" + +yn@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/yn/-/yn-2.0.0.tgz#e5adabc8acf408f6385fc76495684c88e6af689a" + integrity sha512-uTv8J/wiWTgUTg+9vLTi//leUl5vDQS6uii/emeTb2ssY7vl6QWf2fFbIIGjnhjvbdKlU0ed7QPgY1htTC86jQ== + +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==