├── .github └── workflows │ └── pull-request.yml ├── .gitignore ├── README.md ├── commands.sh ├── contracts ├── oracles │ ├── Move.lock │ ├── Move.toml │ ├── sources │ │ ├── oracle_decimal.move │ │ ├── oracles.move │ │ ├── pyth.move │ │ ├── switchboard.move │ │ └── version.move │ └── tests │ │ ├── mock_pyth.move │ │ ├── oracles_tests.move │ │ ├── pyth_tests.move │ │ └── switchboard_tests.move ├── sprungsui │ ├── Move.lock │ ├── Move.toml │ └── sources │ │ └── sprungsui.move └── suilend │ ├── Move.beta.toml │ ├── Move.lock │ ├── Move.toml │ ├── sources │ ├── cell.move │ ├── decimal.move │ ├── lending_market.move │ ├── lending_market_registry.move │ ├── liquidity_mining.move │ ├── obligation.move │ ├── oracles.move │ ├── rate_limiter.move │ ├── reserve.move │ ├── reserve_config.move │ ├── staker.move │ ├── suilend.code-workspace │ └── suilend.move │ └── tests │ ├── bug.move │ ├── lending_market_tests.move │ ├── mock_pyth.move │ ├── obligation_tests.move │ ├── reserve_tests.move │ ├── staker_tests.move │ └── test_coins.move └── math.md /.github/workflows/pull-request.yml: -------------------------------------------------------------------------------- 1 | name: Pull Request 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [mainnet, devel, add_ci] 7 | 8 | jobs: 9 | move-test: 10 | runs-on: ubuntu-latest 11 | container: 12 | image: mysten/sui-tools:mainnet 13 | ports: 14 | - 80 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v2 18 | 19 | # bugfix: install git 20 | - name: install git 21 | run: apt-get install -y git 22 | 23 | - name: build 24 | run: cd contracts/suilend && sui move build 25 | 26 | - name: test 27 | run: cd contracts/suilend && sui move test 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | contracts/build 2 | contracts/suilend/build 3 | contracts/sprungsui/build 4 | suilend-cli/node_modules 5 | contracts/oracles/build -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Suilend 2 | Lending protocol on the Sui Blockchain 3 | 4 | # Overview of terminology 5 | 6 | A LendingMarket object holds many Reserves and Obligations. 7 | 8 | An Obligation is a representations of a user's deposits and borrows. An obligation has exactly one lending market. 9 | 10 | There is 1 Reserve per token type (e.g a SUI Reserve, a SOL Reserve, a USDC Reserve). 11 | A user can supply assets to the reserve to earn interest, and/or borrow assets from a reserve and pay interest. 12 | When a user deposits assets into a reserve, they will receive CTokens. 13 | The CToken represents the user's ownership of their deposit, and entitles the user to earn interest on their deposit. 14 | -------------------------------------------------------------------------------- /commands.sh: -------------------------------------------------------------------------------- 1 | # get gas 2 | curl --location --request POST 'http://127.0.0.1:9123/gas' \ 3 | --header 'Content-Type: application/json' \ 4 | --data-raw '{ 5 | "FixedAmountRequest": { 6 | "recipient": "0x4f3446baf9ca583b920e81924b94883b64addc2a2671963b546aaef77f79a28b" 7 | } 8 | }' 9 | 10 | # deploy package 11 | sui client publish --gas-budget 100000000 . 12 | 13 | curl --location --request POST 'https://fullnode.mainnet.sui.io:443' \ 14 | --header 'Content-Type: application/json' \ 15 | --data-raw '{ 16 | "jsonrpc": "2.0", 17 | "id": 1, 18 | "method": "suix_getOwnedObjects", 19 | "params": [ 20 | "0x3f5f0fcc52e8d0478627a23d62c8993b46013ded746295e225bbee54d6d7d4ca" 21 | ] 22 | }' 23 | 24 | curl --location --request POST 'https://fullnode.mainnet.sui.io:443' \ 25 | --header 'Content-Type: application/json' \ 26 | --data-raw '{ 27 | "jsonrpc": "2.0", 28 | "id": 1, 29 | "method": "suix_getDynamicFields", 30 | "params": [ 31 | "0x814bf15ce7c92e611db36affb6ff664ff284af8d614330b8bceb8bff660e9a47" 32 | ] 33 | }' 34 | 35 | curl --location --request POST 'https://fullnode.mainnet.sui.io:443' \ 36 | --header 'Content-Type: application/json' \ 37 | --data-raw '{ 38 | "jsonrpc": "2.0", 39 | "id": 1, 40 | "method": "suix_getObject", 41 | "params": [ 42 | "0x7330cd2015b5e0b24163fb1177bb3c6b707469ed2835f71ed8f6621d5c2f3b12" 43 | ] 44 | }' 45 | -------------------------------------------------------------------------------- /contracts/oracles/Move.lock: -------------------------------------------------------------------------------- 1 | # @generated by Move, please check-in and do not edit manually. 2 | 3 | [move] 4 | version = 3 5 | manifest_digest = "6811860B625026D8D08A174D1BC0DA9F51416F50BC0F05533D8A5DAEF04A4BF8" 6 | deps_digest = "52B406A7A21811BEF51751CF88DA0E76DAEFFEAC888D4F4060B1A72BBE7D8D35" 7 | dependencies = [ 8 | { id = "Bridge", name = "Bridge" }, 9 | { id = "MoveStdlib", name = "MoveStdlib" }, 10 | { id = "Pyth", name = "Pyth" }, 11 | { id = "Sui", name = "Sui" }, 12 | { id = "SuiSystem", name = "SuiSystem" }, 13 | { id = "Switchboard", name = "Switchboard" }, 14 | ] 15 | 16 | [[move.package]] 17 | id = "Bridge" 18 | source = { git = "https://github.com/MystenLabs/sui.git", rev = "04f11afaf5e0", subdir = "crates/sui-framework/packages/bridge" } 19 | 20 | dependencies = [ 21 | { id = "MoveStdlib", name = "MoveStdlib" }, 22 | { id = "Sui", name = "Sui" }, 23 | { id = "SuiSystem", name = "SuiSystem" }, 24 | ] 25 | 26 | [[move.package]] 27 | id = "MoveStdlib" 28 | source = { git = "https://github.com/MystenLabs/sui.git", rev = "04f11afaf5e0", subdir = "crates/sui-framework/packages/move-stdlib" } 29 | 30 | [[move.package]] 31 | id = "Pyth" 32 | source = { git = "https://github.com/solendprotocol/pyth-crosschain.git", rev = "98e218c64bb75cf1350eb7b021e1ffcc3aedfd62", subdir = "target_chains/sui/contracts" } 33 | 34 | dependencies = [ 35 | { id = "Sui", name = "Sui" }, 36 | { id = "Wormhole", name = "Wormhole" }, 37 | ] 38 | 39 | [[move.package]] 40 | id = "Sui" 41 | source = { git = "https://github.com/MystenLabs/sui.git", rev = "04f11afaf5e0", subdir = "crates/sui-framework/packages/sui-framework" } 42 | 43 | dependencies = [ 44 | { id = "MoveStdlib", name = "MoveStdlib" }, 45 | ] 46 | 47 | [[move.package]] 48 | id = "SuiSystem" 49 | source = { git = "https://github.com/MystenLabs/sui.git", rev = "04f11afaf5e0", subdir = "crates/sui-framework/packages/sui-system" } 50 | 51 | dependencies = [ 52 | { id = "MoveStdlib", name = "MoveStdlib" }, 53 | { id = "Sui", name = "Sui" }, 54 | ] 55 | 56 | [[move.package]] 57 | id = "Switchboard" 58 | source = { git = "https://github.com/suilend/switchboard.git", rev = "mainnet", subdir = "on_demand/" } 59 | 60 | dependencies = [ 61 | { id = "Sui", name = "Sui" }, 62 | ] 63 | 64 | [[move.package]] 65 | id = "Wormhole" 66 | source = { git = "https://github.com/solendprotocol/wormhole.git", rev = "e1698d3c72b15cdddd7da98ad43e151f83b72a0a", subdir = "sui/wormhole" } 67 | 68 | dependencies = [ 69 | { id = "Sui", name = "Sui" }, 70 | ] 71 | 72 | [move.toolchain-version] 73 | compiler-version = "1.46.1" 74 | edition = "2024.beta" 75 | flavor = "sui" 76 | 77 | [env] 78 | 79 | [env.mainnet] 80 | chain-id = "35834a8a" 81 | original-published-id = "0xe84b649199654d18c38e727212f5d8dacfc3cf78d60d0a7fc85fd589f280eb2b" 82 | latest-published-id = "0xe84b649199654d18c38e727212f5d8dacfc3cf78d60d0a7fc85fd589f280eb2b" 83 | published-version = "1" 84 | -------------------------------------------------------------------------------- /contracts/oracles/Move.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "oracles" 3 | edition = "2024.beta" # edition = "legacy" to use legacy (pre-2024) Move 4 | published-at = "0xe84b649199654d18c38e727212f5d8dacfc3cf78d60d0a7fc85fd589f280eb2b" 5 | # license = "" # e.g., "MIT", "GPL", "Apache 2.0" 6 | # authors = ["..."] # e.g., ["Joe Smith (joesmith@noemail.com)", "John Snow (johnsnow@noemail.com)"] 7 | 8 | [dependencies.Pyth] 9 | git = "https://github.com/solendprotocol/pyth-crosschain.git" 10 | subdir = "target_chains/sui/contracts" 11 | rev = "98e218c64bb75cf1350eb7b021e1ffcc3aedfd62" 12 | 13 | [dependencies.Switchboard] 14 | git = "https://github.com/suilend/switchboard.git" 15 | subdir = "on_demand/" 16 | rev = "mainnet" 17 | 18 | [addresses] 19 | # oracles = "0x0" 20 | oracles = "0xe84b649199654d18c38e727212f5d8dacfc3cf78d60d0a7fc85fd589f280eb2b" 21 | 22 | [dev-dependencies] 23 | # The dev-dependencies section allows overriding dependencies for `--test` and 24 | # `--dev` modes. You can introduce test-only dependencies here. 25 | # Local = { local = "../path/to/dev-build" } 26 | 27 | [dev-addresses] 28 | # The dev-addresses section allows overwriting named addresses for the `--test` 29 | # and `--dev` modes. 30 | # alice = "0xB0B" 31 | 32 | -------------------------------------------------------------------------------- /contracts/oracles/sources/oracle_decimal.move: -------------------------------------------------------------------------------- 1 | module oracles::oracle_decimal { 2 | 3 | public struct OracleDecimal has copy, drop, store { 4 | base: u128, 5 | expo: u64, 6 | is_expo_negative: bool, 7 | } 8 | 9 | public fun new(base: u128, expo: u64, is_expo_negative: bool): OracleDecimal { 10 | OracleDecimal { 11 | base, 12 | expo, 13 | is_expo_negative 14 | } 15 | } 16 | 17 | public fun base(decimal: &OracleDecimal): u128 { 18 | decimal.base 19 | } 20 | 21 | public fun expo(decimal: &OracleDecimal): u64 { 22 | decimal.expo 23 | } 24 | 25 | public fun is_expo_negative(decimal: &OracleDecimal): bool { 26 | decimal.is_expo_negative 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /contracts/oracles/sources/oracles.move: -------------------------------------------------------------------------------- 1 | module oracles::oracles { 2 | use sui::event; 3 | use sui::clock::Clock; 4 | use sui::bag::{Self, Bag}; 5 | use pyth::price_identifier::PriceIdentifier; 6 | use pyth::price_info::{PriceInfoObject}; 7 | use pyth::price_feed::{PriceFeed}; 8 | use oracles::version::{Version, Self}; 9 | use oracles::pyth; 10 | use switchboard::aggregator::{Aggregator, CurrentResult}; 11 | use oracles::switchboard; 12 | use oracles::oracle_decimal::OracleDecimal; 13 | 14 | /* Constants */ 15 | const CURRENT_VERSION: u16 = 1; 16 | 17 | /* Errors */ 18 | const EInvalidAdminCap: u64 = 0; 19 | const EInvalidOracleType: u64 = 1; 20 | 21 | public struct OracleRegistry has key, store { 22 | id: UID, 23 | config: OracleRegistryConfig, 24 | oracles: vector, 25 | version: Version, 26 | extra_fields: Bag 27 | } 28 | 29 | public struct NewRegistryEvent has copy, store, drop { 30 | registry_id: ID, 31 | admin_cap_id: ID, 32 | pyth_max_staleness_threshold_s: u64, 33 | pyth_max_confidence_interval_pct: u64, 34 | switchboard_max_staleness_threshold_s: u64, 35 | switchboard_max_confidence_interval_pct: u64, 36 | } 37 | 38 | public struct AdminCap has key, store { 39 | id: UID, 40 | oracle_registry_id: ID 41 | } 42 | 43 | public struct OracleRegistryConfig has store { 44 | pyth_max_staleness_threshold_s: u64, 45 | pyth_max_confidence_interval_pct: u64, 46 | 47 | switchboard_max_staleness_threshold_s: u64, 48 | switchboard_max_confidence_interval_pct: u64, 49 | 50 | extra_fields: Bag 51 | } 52 | 53 | public struct Oracle has store { 54 | oracle_type: OracleType, 55 | extra_fields: Bag 56 | } 57 | 58 | public enum OracleType has store, drop, copy { 59 | Pyth { 60 | price_identifier: PriceIdentifier, 61 | }, 62 | Switchboard { 63 | feed_id: ID, 64 | } 65 | } 66 | 67 | // hot potato ensures that price is fresh 68 | public struct OraclePriceUpdate has drop { 69 | oracle_registry_id: ID, 70 | oracle_index: u64, 71 | price: OracleDecimal, 72 | ema_price: Option, 73 | metadata: OracleMetadata, 74 | } 75 | 76 | public enum OracleMetadata has store, drop, copy { 77 | Pyth { 78 | price_feed: PriceFeed, 79 | }, 80 | Switchboard { 81 | current_result: CurrentResult, 82 | } 83 | } 84 | 85 | // == Public Getters == 86 | public fun price(price_update: &OraclePriceUpdate): OracleDecimal { 87 | price_update.price 88 | } 89 | 90 | public fun ema_price(price_update: &OraclePriceUpdate): Option { 91 | price_update.ema_price 92 | } 93 | 94 | public fun oracle_registry_id(price_update: &OraclePriceUpdate): ID { 95 | price_update.oracle_registry_id 96 | } 97 | 98 | public fun oracle_index(price_update: &OraclePriceUpdate): u64 { 99 | price_update.oracle_index 100 | } 101 | 102 | public fun metadata(price_update: &OraclePriceUpdate): OracleMetadata { 103 | price_update.metadata 104 | } 105 | 106 | public use fun metadata_pyth as OracleMetadata.pyth; 107 | 108 | public fun metadata_pyth(oracle_metadata: &OracleMetadata): &PriceFeed { 109 | match (oracle_metadata) { 110 | OracleMetadata::Pyth { price_feed } => price_feed, 111 | _ => abort EInvalidOracleType 112 | } 113 | } 114 | 115 | public use fun metadata_switchboard as OracleMetadata.switchboard; 116 | 117 | public fun metadata_switchboard(oracle_metadata: &OracleMetadata): &CurrentResult { 118 | match (oracle_metadata) { 119 | OracleMetadata::Switchboard { current_result } => current_result, 120 | _ => abort EInvalidOracleType 121 | } 122 | } 123 | 124 | public fun new( 125 | config: OracleRegistryConfig, 126 | ctx: &mut TxContext 127 | ): (OracleRegistry, AdminCap) { 128 | let registry = OracleRegistry { 129 | id: object::new(ctx), 130 | config: config, 131 | oracles: vector::empty(), 132 | version: version::new(CURRENT_VERSION), 133 | extra_fields: bag::new(ctx) 134 | }; 135 | 136 | let admin_cap = AdminCap { 137 | id: object::new(ctx), 138 | oracle_registry_id: object::id(®istry) 139 | }; 140 | 141 | event::emit(NewRegistryEvent { 142 | registry_id: object::id(®istry), 143 | admin_cap_id: object::id(&admin_cap), 144 | pyth_max_staleness_threshold_s: registry.config.pyth_max_staleness_threshold_s, 145 | pyth_max_confidence_interval_pct: registry.config.pyth_max_confidence_interval_pct, 146 | switchboard_max_staleness_threshold_s: registry.config.switchboard_max_staleness_threshold_s, 147 | switchboard_max_confidence_interval_pct: registry.config.switchboard_max_confidence_interval_pct, 148 | }); 149 | 150 | (registry, admin_cap) 151 | } 152 | 153 | public fun new_oracle_registry_config( 154 | pyth_max_staleness_threshold_s: u64, 155 | pyth_max_confidence_interval_pct: u64, 156 | switchboard_max_staleness_threshold_s: u64, 157 | switchboard_max_confidence_interval_pct: u64, 158 | ctx: &mut TxContext 159 | ): OracleRegistryConfig { 160 | OracleRegistryConfig { 161 | pyth_max_staleness_threshold_s, 162 | pyth_max_confidence_interval_pct, 163 | switchboard_max_staleness_threshold_s, 164 | switchboard_max_confidence_interval_pct, 165 | extra_fields: bag::new(ctx) 166 | } 167 | } 168 | 169 | public fun add_pyth_oracle( 170 | registry: &mut OracleRegistry, 171 | admin_cap: &AdminCap, 172 | price_info_obj: &PriceInfoObject, 173 | ctx: &mut TxContext 174 | ) { 175 | registry.version.assert_version_and_upgrade(CURRENT_VERSION); 176 | assert!(admin_cap.oracle_registry_id == object::id(registry), EInvalidAdminCap); 177 | 178 | registry.oracles.push_back(Oracle { 179 | oracle_type: OracleType::Pyth { 180 | price_identifier: price_info_obj.get_price_info_from_price_info_object().get_price_identifier() 181 | }, 182 | extra_fields: bag::new(ctx) 183 | }); 184 | } 185 | 186 | public fun set_pyth_oracle( 187 | registry: &mut OracleRegistry, 188 | admin_cap: &AdminCap, 189 | price_info_obj: &PriceInfoObject, 190 | oracle_index: u64 191 | ) { 192 | registry.version.assert_version_and_upgrade(CURRENT_VERSION); 193 | assert!(admin_cap.oracle_registry_id == object::id(registry), EInvalidAdminCap); 194 | 195 | registry.oracles[oracle_index].oracle_type = OracleType::Pyth { 196 | price_identifier: price_info_obj.get_price_info_from_price_info_object().get_price_identifier() 197 | }; 198 | } 199 | 200 | public fun add_switchboard_oracle( 201 | registry: &mut OracleRegistry, 202 | admin_cap: &AdminCap, 203 | aggregator: &Aggregator, 204 | ctx: &mut TxContext 205 | ) { 206 | registry.version.assert_version_and_upgrade(CURRENT_VERSION); 207 | assert!(admin_cap.oracle_registry_id == object::id(registry), EInvalidAdminCap); 208 | 209 | registry.oracles.push_back(Oracle { 210 | oracle_type: OracleType::Switchboard { feed_id: aggregator.id() }, 211 | extra_fields: bag::new(ctx) 212 | }); 213 | } 214 | 215 | public fun set_switchboard_oracle( 216 | registry: &mut OracleRegistry, 217 | admin_cap: &AdminCap, 218 | aggregator: &Aggregator, 219 | oracle_index: u64 220 | ) { 221 | registry.version.assert_version_and_upgrade(CURRENT_VERSION); 222 | assert!(admin_cap.oracle_registry_id == object::id(registry), EInvalidAdminCap); 223 | 224 | registry.oracles[oracle_index].oracle_type = OracleType::Switchboard { feed_id: aggregator.id() }; 225 | } 226 | 227 | public fun get_pyth_price( 228 | registry: &OracleRegistry, 229 | price_info_obj: &PriceInfoObject, 230 | oracle_index: u64, 231 | clock: &Clock, 232 | ): OraclePriceUpdate { 233 | registry.version.assert_version(CURRENT_VERSION); 234 | 235 | let oracle = ®istry.oracles[oracle_index]; 236 | 237 | match (oracle.oracle_type) { 238 | OracleType::Pyth { price_identifier } => { 239 | let (price, ema_price, price_feed) = pyth::get_prices( 240 | price_info_obj, 241 | clock, 242 | registry.config.pyth_max_staleness_threshold_s, 243 | registry.config.pyth_max_confidence_interval_pct, 244 | price_identifier 245 | ); 246 | 247 | OraclePriceUpdate { 248 | oracle_registry_id: object::id(registry), 249 | oracle_index, 250 | price, 251 | ema_price: option::some(ema_price), 252 | metadata: OracleMetadata::Pyth { price_feed } 253 | } 254 | }, 255 | _ => abort EInvalidOracleType 256 | } 257 | 258 | } 259 | 260 | public fun get_switchboard_price( 261 | registry: &OracleRegistry, 262 | aggregator: &Aggregator, 263 | oracle_index: u64, 264 | clock: &Clock, 265 | ): OraclePriceUpdate { 266 | registry.version.assert_version(CURRENT_VERSION); 267 | 268 | let oracle = ®istry.oracles[oracle_index]; 269 | 270 | match (oracle.oracle_type) { 271 | OracleType::Switchboard { feed_id } => { 272 | let (price, current_result) = switchboard::get_price( 273 | aggregator, 274 | clock, 275 | registry.config.switchboard_max_staleness_threshold_s, 276 | registry.config.switchboard_max_confidence_interval_pct, 277 | feed_id 278 | ); 279 | 280 | OraclePriceUpdate { 281 | oracle_registry_id: object::id(registry), 282 | oracle_index, 283 | price, 284 | ema_price: option::none(), 285 | metadata: OracleMetadata::Switchboard { current_result } 286 | } 287 | }, 288 | _ => abort EInvalidOracleType 289 | } 290 | } 291 | 292 | #[test_only] 293 | public fun new_oracle_registry_for_testing(config: OracleRegistryConfig, ctx: &mut TxContext): (OracleRegistry, AdminCap) { 294 | let registry = OracleRegistry { 295 | id: object::new(ctx), 296 | config, 297 | oracles: vector::empty(), 298 | version: version::new(CURRENT_VERSION), 299 | extra_fields: bag::new(ctx) 300 | }; 301 | 302 | let admin_cap = AdminCap { 303 | id: object::new(ctx), 304 | oracle_registry_id: object::id(®istry) 305 | }; 306 | 307 | (registry, admin_cap) 308 | } 309 | } 310 | 311 | -------------------------------------------------------------------------------- /contracts/oracles/sources/pyth.move: -------------------------------------------------------------------------------- 1 | /// This module contains logic for parsing pyth prices 2 | module oracles::pyth { 3 | use pyth::price_info::{Self, PriceInfoObject}; 4 | use pyth::price_feed::{Self, PriceFeed}; 5 | use pyth::price_identifier::PriceIdentifier; 6 | use pyth::price::{Self, Price}; 7 | use pyth::i64::{Self}; 8 | use sui::clock::{Self, Clock}; 9 | use oracles::oracle_decimal::{OracleDecimal, Self}; 10 | 11 | // Errors 12 | const EConfidenceIntervalExceeded: u64 = 0; 13 | const EPriceIsStale: u64 = 1; 14 | const EWrongPriceIdentifier: u64 = 2; 15 | const EPythDecimalIsZero: u64 = 3; 16 | 17 | public(package) fun get_prices( 18 | price_info_obj: &PriceInfoObject, 19 | clock: &Clock, 20 | max_staleness_threshold_s: u64, 21 | max_confidence_interval_pct: u64, 22 | expected_price_identifier: PriceIdentifier, 23 | ): (OracleDecimal, OracleDecimal, PriceFeed) { 24 | let price_info = price_info::get_price_info_from_price_info_object(price_info_obj); 25 | let price_feed = price_info::get_price_feed(&price_info); 26 | 27 | let price_identifier = price_feed::get_price_identifier(price_feed); 28 | assert!(price_identifier == expected_price_identifier, EWrongPriceIdentifier); 29 | 30 | let ema_price = price_feed::get_ema_price(price_feed); 31 | 32 | let price = price_feed::get_price(price_feed); 33 | let price_mag = i64::get_magnitude_if_positive(&price::get_price(&price)); 34 | let conf = price::get_conf(&price); 35 | 36 | // confidence interval check 37 | // we want to make sure conf / price <= x% 38 | // -> conf * (100 / x )<= price 39 | assert!( 40 | (conf as u128) * 100u128 <= (price_mag as u128) * (max_confidence_interval_pct as u128), 41 | EConfidenceIntervalExceeded 42 | ); 43 | 44 | // check current sui time against pythnet publish time. there can be some issues that arise because the 45 | // timestamps are from different sources and may get out of sync, but that's why we have a fallback oracle 46 | let cur_time_s = clock::timestamp_ms(clock) / 1000; 47 | if (cur_time_s > price::get_timestamp(&price) && // this is technically possible! 48 | cur_time_s - price::get_timestamp(&price) > max_staleness_threshold_s) { 49 | abort EPriceIsStale 50 | }; 51 | 52 | (from_pyth_price(&price), from_pyth_price(&ema_price), *price_feed) 53 | } 54 | 55 | public(package) fun from_pyth_price(price: &Price): OracleDecimal { 56 | let price = oracle_decimal::new( 57 | price.get_price().get_magnitude_if_positive() as u128, 58 | if (price.get_expo().get_is_negative()) { 59 | price.get_expo().get_magnitude_if_negative() 60 | } else { 61 | price.get_expo().get_magnitude_if_positive() 62 | }, 63 | price.get_expo().get_is_negative() 64 | ); 65 | 66 | assert!(price.base() > 0, EPythDecimalIsZero); 67 | price 68 | } 69 | 70 | } 71 | 72 | -------------------------------------------------------------------------------- /contracts/oracles/sources/switchboard.move: -------------------------------------------------------------------------------- 1 | module oracles::switchboard { 2 | use switchboard::aggregator::{Aggregator, CurrentResult}; 3 | use switchboard::decimal::Decimal; 4 | use oracles::oracle_decimal::{OracleDecimal, Self}; 5 | use sui::clock::{Self, Clock}; 6 | 7 | // Errors 8 | const EPriceIsStale: u64 = 0; 9 | const EPriceRangeIsTooLarge: u64 = 1; 10 | const EWrongFeedId: u64 = 2; 11 | const ESwitchboardDecimalIsNegative: u64 = 3; 12 | const ESwitchboardDecimalIsZero: u64 = 4; 13 | 14 | public fun get_price( 15 | switchboard_feed: &Aggregator, 16 | clock: &Clock, 17 | max_staleness_s: u64, 18 | max_confidence_interval_pct: u64, 19 | expected_feed_id: ID, 20 | ): (OracleDecimal, CurrentResult) { 21 | 22 | // get the switchboard feed id as a price identifier - here it's just 32 bytes 23 | assert!(switchboard_feed.id() == expected_feed_id, EWrongFeedId); 24 | 25 | // extract the current values from the switchboard feed 26 | let current_result = switchboard_feed.current_result(); 27 | let update_timestamp_ms = current_result.timestamp_ms(); 28 | 29 | let result: &Decimal = current_result.result(); 30 | let stdev: &Decimal = current_result.stdev(); 31 | 32 | // check current sui time against feed's update time to make sure the price is not stale 33 | let cur_time_ms = clock::timestamp_ms(clock); 34 | if (cur_time_ms > update_timestamp_ms && 35 | cur_time_ms - update_timestamp_ms > max_staleness_s * 1000) { 36 | abort EPriceIsStale 37 | }; 38 | 39 | // stddev / result <= x/100 40 | // stddev * 100 <= result * x 41 | assert!( 42 | (stdev.value() as u256) * 100u256 <= (result.value() as u256) * (max_confidence_interval_pct as u256), 43 | EPriceRangeIsTooLarge 44 | ); 45 | 46 | (from_switchboard_decimal(result), *current_result) 47 | } 48 | 49 | public(package) fun from_switchboard_decimal(d: &Decimal): OracleDecimal { 50 | assert!(!d.neg(), ESwitchboardDecimalIsNegative); 51 | assert!(d.value() > 0, ESwitchboardDecimalIsZero); 52 | 53 | oracle_decimal::new(d.value(), 18, true) 54 | } 55 | } -------------------------------------------------------------------------------- /contracts/oracles/sources/version.move: -------------------------------------------------------------------------------- 1 | module oracles::version { 2 | 3 | // ===== Errors ===== 4 | 5 | // When the package called has an outdated version 6 | const EIncorrectVersion: u64 = 0; 7 | 8 | /// Capability object given to the pool creator 9 | public struct Version has store, drop (u16) 10 | 11 | public(package) fun new( 12 | version: u16, 13 | ): Version { 14 | Version(version) 15 | } 16 | 17 | public(package) fun migrate_( 18 | version: &mut Version, 19 | current_version: u16, 20 | ) { 21 | assert!(version.0 < current_version, EIncorrectVersion); 22 | version.0 = current_version; 23 | } 24 | 25 | public(package) fun assert_version( 26 | version: &Version, 27 | current_version: u16, 28 | ) { 29 | assert!(version.0 == current_version, EIncorrectVersion); 30 | } 31 | 32 | public(package) fun assert_version_and_upgrade( 33 | version: &mut Version, 34 | current_version: u16, 35 | ) { 36 | if (version.0 < current_version) { 37 | version.0 = current_version; 38 | }; 39 | assert_version(version, current_version); 40 | } 41 | } -------------------------------------------------------------------------------- /contracts/oracles/tests/mock_pyth.move: -------------------------------------------------------------------------------- 1 | /// Copied from suilend::mock_pyth 2 | #[test_only] 3 | module oracles::mock_pyth { 4 | use pyth::price_info::{Self, PriceInfoObject}; 5 | use pyth::price_feed::{Self}; 6 | use pyth::price::{Self}; 7 | use pyth::price_identifier::{Self}; 8 | use pyth::i64::{Self}; 9 | use sui::bag::{Self, Bag}; 10 | use sui::clock::{Clock, Self}; 11 | 12 | 13 | public struct PriceState has key { 14 | id: UID, 15 | price_objs: Bag 16 | } 17 | 18 | public fun init_state(ctx: &mut TxContext): PriceState { 19 | PriceState { 20 | id: object::new(ctx), 21 | price_objs: bag::new(ctx) 22 | } 23 | } 24 | 25 | public fun register(state: &mut PriceState, ctx: &mut TxContext) { 26 | let price_info_obj = new_price_info_obj((bag::length(&state.price_objs) as u8), ctx); 27 | 28 | bag::add(&mut state.price_objs, std::type_name::get(), price_info_obj); 29 | } 30 | 31 | public fun new_price_info_obj(idx: u8, ctx: &mut TxContext): PriceInfoObject { 32 | let mut v = vector::empty(); 33 | vector::push_back(&mut v, idx); 34 | 35 | let mut i = 1; 36 | while (i < 32) { 37 | vector::push_back(&mut v, 0); 38 | i = i + 1; 39 | }; 40 | 41 | price_info::new_price_info_object_for_testing( 42 | price_info::new_price_info( 43 | 0, 44 | 0, 45 | price_feed::new( 46 | price_identifier::from_byte_vec(v), 47 | price::new( 48 | i64::new(0, false), 49 | 0, 50 | i64::new(0, false), 51 | 0 52 | ), 53 | price::new( 54 | i64::new(0, false), 55 | 0, 56 | i64::new(0, false), 57 | 0 58 | ) 59 | ) 60 | ), 61 | ctx 62 | ) 63 | } 64 | 65 | public fun get_price_obj(state: &PriceState): &PriceInfoObject { 66 | bag::borrow(&state.price_objs, std::type_name::get()) 67 | } 68 | 69 | public fun update_price(state: &mut PriceState, price: u64, expo: u8, clock: &Clock) { 70 | let price_info_obj = bag::borrow_mut(&mut state.price_objs, std::type_name::get()); 71 | let price_info = price_info::get_price_info_from_price_info_object(price_info_obj); 72 | 73 | let price = price::new( 74 | i64::new(price, false), 75 | 0, 76 | i64::new((expo as u64), false), 77 | clock::timestamp_ms(clock) / 1000 78 | ); 79 | 80 | price_info::update_price_info_object_for_testing( 81 | price_info_obj, 82 | &price_info::new_price_info( 83 | 0, 84 | 0, 85 | price_feed::new( 86 | price_info::get_price_identifier(&price_info), 87 | price, 88 | price 89 | ) 90 | ) 91 | ); 92 | 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /contracts/oracles/tests/oracles_tests.move: -------------------------------------------------------------------------------- 1 | module oracles::oracles_tests { 2 | use sui::test_scenario::{Self}; 3 | use oracles::oracles::{Self, OraclePriceUpdate}; 4 | use sui::clock::{Self, Clock}; 5 | use oracles::mock_pyth::{Self, PriceState}; 6 | use sui::sui::{SUI}; 7 | use sui::test_scenario::Scenario; // Ensure Scenario is imported 8 | use switchboard::decimal::{Self}; 9 | 10 | public struct TEST_USDC has drop {} 11 | 12 | fun setup(owner: address): (Scenario, Clock, PriceState) { 13 | let mut scenario = test_scenario::begin(owner); 14 | let clock = clock::create_for_testing(test_scenario::ctx(&mut scenario)); 15 | let mut prices = mock_pyth::init_state(test_scenario::ctx(&mut scenario)); 16 | 17 | prices.register(scenario.ctx()); 18 | prices.register(scenario.ctx()); 19 | 20 | (scenario, clock, prices) 21 | } 22 | 23 | #[test] 24 | fun test_oracles_happy_pyth() { 25 | let owner = @0x26; 26 | let (mut scenario, clock, mut prices) = setup(owner); 27 | 28 | let (mut registry, admin_cap) = oracles::new_oracle_registry_for_testing( 29 | oracles::new_oracle_registry_config( 30 | 60, 31 | 10, 32 | 60, 33 | 10, 34 | scenario.ctx() 35 | ), 36 | scenario.ctx() 37 | ); 38 | 39 | registry.add_pyth_oracle( 40 | &admin_cap, 41 | mock_pyth::get_price_obj(&prices), 42 | scenario.ctx() 43 | ); 44 | 45 | prices.update_price( 46 | 1, 47 | 0, 48 | &clock 49 | ); 50 | 51 | let price_update: OraclePriceUpdate = registry.get_pyth_price( 52 | mock_pyth::get_price_obj(&prices), 53 | 0, 54 | &clock 55 | ); 56 | 57 | assert!(price_update.price().base() == 1); 58 | assert!(price_update.price().expo() == 0); 59 | assert!(price_update.price().is_expo_negative() == false); 60 | 61 | prices.update_price( 62 | 10, 63 | 0, 64 | &clock 65 | ); 66 | 67 | registry.set_pyth_oracle( 68 | &admin_cap, 69 | mock_pyth::get_price_obj(&prices), 70 | 0 71 | ); 72 | 73 | let price_update: OraclePriceUpdate = registry.get_pyth_price( 74 | mock_pyth::get_price_obj(&prices), 75 | 0, 76 | &clock 77 | ); 78 | 79 | assert!(price_update.price().base() == 10); 80 | assert!(price_update.price().expo() == 0); 81 | assert!(price_update.price().is_expo_negative() == false); 82 | assert!(price_update.metadata().pyth() == mock_pyth::get_price_obj(&prices).get_price_info_from_price_info_object().get_price_feed()); 83 | 84 | sui::test_utils::destroy(admin_cap); 85 | sui::test_utils::destroy(registry); 86 | sui::test_utils::destroy(clock); 87 | sui::test_utils::destroy(prices); 88 | 89 | test_scenario::end(scenario); 90 | } 91 | 92 | #[test] 93 | fun test_oracles_happy_switchboard() { 94 | let owner = @0x26; 95 | let (mut scenario, clock, prices) = setup(owner); 96 | 97 | let (mut registry, admin_cap) = oracles::new_oracle_registry_for_testing( 98 | oracles::new_oracle_registry_config( 99 | 60, 100 | 10, 101 | 60, 102 | 10, 103 | scenario.ctx() 104 | ), 105 | scenario.ctx() 106 | ); 107 | 108 | let mut aggregator = switchboard::aggregator::new_aggregator( 109 | object::id_from_bytes(x"add8f0a36f15156b5f4720f94a62230dbef3c5d8bbfc6a799b5e6bfb56671bd4"), 110 | std::string::utf8(b"test"), 111 | @0x26, 112 | x"add8f0a36f15156b5f4720f94a62230dbef3c5d8bbfc6a799b5e6bfb56671bd4", // feed hash 113 | 1, // min samples 114 | 60, // max staleness for updates 115 | 1_000_000_000, // max variance scaled to 9 decimals (1e9 == 1%) 116 | 1, // min job responses 117 | 1337, // created at ms 118 | sui::test_scenario::ctx(&mut scenario) 119 | ); 120 | 121 | let price = 2 * 1_000_000_000_000_000_000; 122 | aggregator.set_current_value( 123 | decimal::new(price, false), 124 | 1337, 125 | 1337, 126 | 1337, 127 | decimal::new(price, false), 128 | decimal::new(price, false), 129 | decimal::new(1, false), 130 | decimal::new(0, false), 131 | decimal::new(price, false) 132 | ); 133 | 134 | registry.add_switchboard_oracle( 135 | &admin_cap, 136 | &aggregator, 137 | scenario.ctx() 138 | ); 139 | 140 | let price_update: OraclePriceUpdate = registry.get_switchboard_price( 141 | &aggregator, 142 | 0, 143 | &clock 144 | ); 145 | 146 | assert!(price_update.price().base() == price); 147 | assert!(price_update.price().expo() == 18); 148 | assert!(price_update.price().is_expo_negative() == true); 149 | assert!(price_update.metadata().switchboard() == aggregator.current_result()); 150 | 151 | let mut aggregator2 = switchboard::aggregator::new_aggregator( 152 | object::id_from_bytes(x"add8f0a36f15156b5f4720f94a62230dbef3c5d8bbfc6a799b5e6bfb56671bd4"), 153 | std::string::utf8(b"test"), 154 | @0x26, 155 | x"add8f0a36f15156b5f4720f94a62230dbef3c5d8bbfc6a799b5e6bfb56671bd4", // feed hash 156 | 1, // min samples 157 | 60, // max staleness for updates 158 | 1_000_000_000, // max variance scaled to 9 decimals (1e9 == 1%) 159 | 1, // min job responses 160 | 1337, // created at ms 161 | sui::test_scenario::ctx(&mut scenario) 162 | ); 163 | 164 | registry.set_switchboard_oracle( 165 | &admin_cap, 166 | &aggregator2, 167 | 0 168 | ); 169 | 170 | let price = 3 * 1_000_000_000_000_000_000; 171 | aggregator2.set_current_value( 172 | decimal::new(price, false), 173 | 1337, 174 | 1337, 175 | 1337, 176 | decimal::new(price, false), 177 | decimal::new(price, false), 178 | decimal::new(1, false), 179 | decimal::new(0, false), 180 | decimal::new(price, false) 181 | ); 182 | 183 | let price_update: OraclePriceUpdate = registry.get_switchboard_price( 184 | &aggregator2, 185 | 0, 186 | &clock 187 | ); 188 | 189 | assert!(price_update.price().base() == price); 190 | assert!(price_update.price().expo() == 18); 191 | assert!(price_update.price().is_expo_negative() == true); 192 | 193 | sui::test_utils::destroy(admin_cap); 194 | sui::test_utils::destroy(registry); 195 | sui::test_utils::destroy(clock); 196 | sui::test_utils::destroy(prices); 197 | sui::test_utils::destroy(aggregator); 198 | sui::test_utils::destroy(aggregator2); 199 | 200 | test_scenario::end(scenario); 201 | } 202 | 203 | #[test] 204 | #[expected_failure(abort_code = ::oracles::oracles::EInvalidOracleType)] 205 | fun test_oracles_fail_wrong_type() { 206 | let owner = @0x26; 207 | let (mut scenario, clock, prices) = setup(owner); 208 | 209 | 210 | let (mut registry, admin_cap) = oracles::new_oracle_registry_for_testing( 211 | oracles::new_oracle_registry_config( 212 | 60, 213 | 10, 214 | 60, 215 | 10, 216 | scenario.ctx() 217 | ), 218 | scenario.ctx() 219 | ); 220 | 221 | registry.add_pyth_oracle( 222 | &admin_cap, 223 | mock_pyth::get_price_obj(&prices), 224 | scenario.ctx() 225 | ); 226 | 227 | let aggregator = switchboard::aggregator::new_aggregator( 228 | object::id_from_bytes(x"add8f0a36f15156b5f4720f94a62230dbef3c5d8bbfc6a799b5e6bfb56671bd4"), 229 | std::string::utf8(b"test"), 230 | @0x26, 231 | x"add8f0a36f15156b5f4720f94a62230dbef3c5d8bbfc6a799b5e6bfb56671bd4", // feed hash 232 | 1, // min samples 233 | 60, // max staleness for updates 234 | 1_000_000_000, // max variance scaled to 9 decimals (1e9 == 1%) 235 | 1, // min job responses 236 | 1337, // created at ms 237 | sui::test_scenario::ctx(&mut scenario) 238 | ); 239 | 240 | registry.get_switchboard_price( 241 | &aggregator, 242 | 0, 243 | &clock 244 | ); 245 | 246 | sui::test_utils::destroy(admin_cap); 247 | sui::test_utils::destroy(registry); 248 | sui::test_utils::destroy(clock); 249 | sui::test_utils::destroy(prices); 250 | sui::test_utils::destroy(aggregator); 251 | test_scenario::end(scenario); 252 | } 253 | 254 | } -------------------------------------------------------------------------------- /contracts/oracles/tests/pyth_tests.move: -------------------------------------------------------------------------------- 1 | #[test_only] 2 | module oracles::pyth_tests { 3 | use pyth::price_info; 4 | use pyth::price_feed::{Self}; 5 | use pyth::price_identifier::{PriceIdentifier, Self}; 6 | use pyth::price; 7 | use pyth::i64::{Self}; 8 | use sui::clock; 9 | use oracles::pyth::{get_prices, from_pyth_price}; 10 | 11 | #[test_only] 12 | fun example_price_identifier(): PriceIdentifier { 13 | let mut v = vector::empty(); 14 | 15 | let mut i = 0; 16 | while (i < 32) { 17 | vector::push_back(&mut v, 0); 18 | i = i + 1; 19 | }; 20 | 21 | price_identifier::from_byte_vec(v) 22 | } 23 | 24 | #[test] 25 | fun happy() { 26 | use sui::test_scenario::{Self}; 27 | let owner = @0x26; 28 | let mut scenario = test_scenario::begin(owner); 29 | let clock = clock::create_for_testing(test_scenario::ctx(&mut scenario)); 30 | 31 | let spot_price = price::new( 32 | i64::new(8, false), 33 | 0, 34 | i64::new(5, false), 35 | 0 36 | ); 37 | 38 | let ema_price = price::new( 39 | i64::new(8, false), 40 | 0, 41 | i64::new(4, true), 42 | 0 43 | ); 44 | 45 | let price_info_object = price_info::new_price_info_object_for_testing( 46 | price_info::new_price_info( 47 | 0, 48 | 0, 49 | price_feed::new( 50 | example_price_identifier(), 51 | spot_price, 52 | ema_price 53 | ) 54 | ), 55 | test_scenario::ctx(&mut scenario) 56 | ); 57 | 58 | let (actual_spot_price, actual_ema_price, price_feed) = get_prices( 59 | &price_info_object, 60 | &clock, 61 | 100, 62 | 100, 63 | example_price_identifier(), 64 | ); 65 | 66 | assert!(actual_spot_price == from_pyth_price(&spot_price), 0); 67 | assert!(actual_ema_price == from_pyth_price(&ema_price), 0); 68 | assert!(price_feed == price_info_object.get_price_info_from_price_info_object().get_price_feed(), 0); 69 | 70 | price_info::destroy(price_info_object); 71 | clock::destroy_for_testing(clock); 72 | test_scenario::end(scenario); 73 | } 74 | 75 | #[test] 76 | #[expected_failure(abort_code = oracles::pyth::EConfidenceIntervalExceeded)] 77 | fun confidence_interval_exceeded() { 78 | use sui::test_scenario::{Self}; 79 | let owner = @0x26; 80 | let mut scenario = test_scenario::begin(owner); 81 | let clock = clock::create_for_testing(test_scenario::ctx(&mut scenario)); 82 | 83 | let spot_price = price::new( 84 | i64::new(100, false), 85 | 11, 86 | i64::new(5, false), 87 | 0 88 | ); 89 | 90 | let ema_price = price::new( 91 | i64::new(8, false), 92 | 0, 93 | i64::new(4, true), 94 | 0 95 | ); 96 | 97 | let price_info_object = price_info::new_price_info_object_for_testing( 98 | price_info::new_price_info( 99 | 0, 100 | 0, 101 | price_feed::new( 102 | example_price_identifier(), 103 | spot_price, 104 | ema_price 105 | ) 106 | ), 107 | test_scenario::ctx(&mut scenario) 108 | ); 109 | 110 | get_prices( 111 | &price_info_object, 112 | &clock, 113 | 100, 114 | 10, 115 | example_price_identifier(), 116 | ); 117 | 118 | price_info::destroy(price_info_object); 119 | clock::destroy_for_testing(clock); 120 | test_scenario::end(scenario); 121 | } 122 | 123 | #[test] 124 | #[expected_failure(abort_code = oracles::pyth::EPriceIsStale)] 125 | fun price_is_stale() { 126 | use sui::test_scenario::{Self}; 127 | let owner = @0x26; 128 | let mut scenario = test_scenario::begin(owner); 129 | 130 | let mut clock = clock::create_for_testing(test_scenario::ctx(&mut scenario)); 131 | clock.set_for_testing(5_000); 132 | 133 | let spot_price = price::new( 134 | i64::new(100, false), 135 | 10, 136 | i64::new(5, false), 137 | 1, 138 | ); 139 | 140 | let ema_price = price::new( 141 | i64::new(8, false), 142 | 0, 143 | i64::new(4, true), 144 | 0 145 | ); 146 | 147 | let price_info_object = price_info::new_price_info_object_for_testing( 148 | price_info::new_price_info( 149 | 0, 150 | 0, 151 | price_feed::new( 152 | example_price_identifier(), 153 | spot_price, 154 | ema_price 155 | ) 156 | ), 157 | test_scenario::ctx(&mut scenario) 158 | ); 159 | 160 | get_prices( 161 | &price_info_object, 162 | &clock, 163 | 3, 164 | 10, 165 | example_price_identifier(), 166 | ); 167 | 168 | price_info::destroy(price_info_object); 169 | clock::destroy_for_testing(clock); 170 | test_scenario::end(scenario); 171 | } 172 | 173 | #[test] 174 | #[expected_failure(abort_code = oracles::pyth::EWrongPriceIdentifier)] 175 | fun wrong_price_identifier() { 176 | use sui::test_scenario::{Self}; 177 | let owner = @0x26; 178 | let mut scenario = test_scenario::begin(owner); 179 | let clock = clock::create_for_testing(test_scenario::ctx(&mut scenario)); 180 | 181 | let spot_price = price::new( 182 | i64::new(100, false), 183 | 11, 184 | i64::new(5, false), 185 | 0 186 | ); 187 | 188 | let ema_price = price::new( 189 | i64::new(8, false), 190 | 0, 191 | i64::new(4, true), 192 | 0 193 | ); 194 | 195 | let price_info_object = price_info::new_price_info_object_for_testing( 196 | price_info::new_price_info( 197 | 0, 198 | 0, 199 | price_feed::new( 200 | example_price_identifier(), 201 | spot_price, 202 | ema_price 203 | ) 204 | ), 205 | test_scenario::ctx(&mut scenario) 206 | ); 207 | 208 | get_prices( 209 | &price_info_object, 210 | &clock, 211 | 100, 212 | 10, 213 | price_identifier::from_byte_vec(b"asdfasdfasdfasdfasdfasdfasdfasdf"), 214 | ); 215 | 216 | price_info::destroy(price_info_object); 217 | clock::destroy_for_testing(clock); 218 | test_scenario::end(scenario); 219 | } 220 | 221 | 222 | } -------------------------------------------------------------------------------- /contracts/oracles/tests/switchboard_tests.move: -------------------------------------------------------------------------------- 1 | #[test_only] 2 | module oracles::switchboard_tests { 3 | use sui::clock::{Self}; 4 | use oracles::switchboard::{get_price, from_switchboard_decimal}; 5 | 6 | #[test] 7 | fun happy_switchboard() { 8 | use sui::test_scenario::{Self}; 9 | use switchboard::decimal as switchboard_decimal; 10 | use switchboard::aggregator; 11 | 12 | let owner = @0x26; 13 | let mut scenario = test_scenario::begin(owner); 14 | let clock = clock::create_for_testing(test_scenario::ctx(&mut scenario)); 15 | 16 | let mut aggregator = switchboard::aggregator::new_aggregator( 17 | object::id_from_bytes(x"add8f0a36f15156b5f4720f94a62230dbef3c5d8bbfc6a799b5e6bfb56671bd4"), 18 | std::string::utf8(b"test"), 19 | @0x26, 20 | x"add8f0a36f15156b5f4720f94a62230dbef3c5d8bbfc6a799b5e6bfb56671bd4", // feed hash 21 | 1, // min samples 22 | 60, // max staleness for updates 23 | 1_000_000_000, // max variance scaled to 9 decimals (1e9 == 1%) 24 | 1, // min job responses 25 | 1337, // created at ms 26 | sui::test_scenario::ctx(&mut scenario) 27 | ); 28 | 29 | // scale the price to 18 decimals 30 | let price = 800_000 * 10u128.pow(18); 31 | let low_price = 799_900 * 10u128.pow(18); 32 | let high_price = 800_100 * 10u128.pow(18); 33 | let range = 200 * 10u128.pow(18); 34 | 35 | // set the current value (scaled, of course) 36 | aggregator::set_current_value( 37 | &mut aggregator, 38 | switchboard_decimal::new(price, false), 39 | 1337, 40 | 1337, 41 | 1337, 42 | switchboard_decimal::new(low_price, false), 43 | switchboard_decimal::new(high_price, false), 44 | switchboard_decimal::new(range, false), 45 | switchboard_decimal::new(0, false), 46 | switchboard_decimal::new(price, false) 47 | ); 48 | 49 | let (spot_price, current_result) = get_price( 50 | &aggregator, 51 | &clock, 52 | 60, 53 | 10, 54 | aggregator.id() 55 | ); 56 | 57 | assert!(spot_price == from_switchboard_decimal(&switchboard_decimal::new(price, false)), 0); 58 | assert!(current_result == aggregator.current_result()); 59 | 60 | switchboard::aggregator_delete_action::run(aggregator, sui::test_scenario::ctx(&mut scenario)); 61 | clock::destroy_for_testing(clock); 62 | test_scenario::end(scenario); 63 | } 64 | 65 | #[test] 66 | #[expected_failure(abort_code = oracles::switchboard::EPriceIsStale)] 67 | fun switchboard_fail_stale() { 68 | use sui::test_scenario::{Self}; 69 | use switchboard::decimal as switchboard_decimal; 70 | use switchboard::aggregator; 71 | 72 | let owner = @0x26; 73 | let mut scenario = test_scenario::begin(owner); 74 | let mut clock = clock::create_for_testing(test_scenario::ctx(&mut scenario)); 75 | 76 | let mut aggregator = switchboard::aggregator::new_aggregator( 77 | object::id_from_bytes(x"add8f0a36f15156b5f4720f94a62230dbef3c5d8bbfc6a799b5e6bfb56671bd4"), 78 | std::string::utf8(b"test"), 79 | @0x26, 80 | x"add8f0a36f15156b5f4720f94a62230dbef3c5d8bbfc6a799b5e6bfb56671bd4", // feed hash 81 | 1, // min samples 82 | 60, // max staleness for updates 83 | 1_000_000_000, // max variance scaled to 9 decimals (1e9 == 1%) 84 | 1, // min job responses 85 | 1337, // created at ms 86 | sui::test_scenario::ctx(&mut scenario) 87 | ); 88 | 89 | // scale the price to 18 decimals 90 | let price = 800_000 * 10u128.pow(18); 91 | let low_price = 799_900 * 10u128.pow(18); 92 | let high_price = 800_100 * 10u128.pow(18); 93 | let range = 200 * 10u128.pow(18); 94 | 95 | // set the current value (scaled, of course) 96 | aggregator::set_current_value( 97 | &mut aggregator, 98 | switchboard_decimal::new(price, false), 99 | 1337, 100 | 1337, 101 | 1337, 102 | switchboard_decimal::new(low_price, false), 103 | switchboard_decimal::new(high_price, false), 104 | switchboard_decimal::new(range, false), 105 | switchboard_decimal::new(0, false), 106 | switchboard_decimal::new(price, false) 107 | ); 108 | 109 | clock.set_for_testing(62_000); 110 | 111 | get_price( 112 | &aggregator, 113 | &clock, 114 | 60, 115 | 10, 116 | aggregator.id() 117 | ); 118 | 119 | switchboard::aggregator_delete_action::run(aggregator, sui::test_scenario::ctx(&mut scenario)); 120 | clock::destroy_for_testing(clock); 121 | test_scenario::end(scenario); 122 | } 123 | 124 | #[test] 125 | #[expected_failure(abort_code = oracles::switchboard::EPriceRangeIsTooLarge)] 126 | fun switchboard_fail_confidence_interval() { 127 | use sui::test_scenario::{Self}; 128 | use switchboard::decimal as switchboard_decimal; 129 | use switchboard::aggregator; 130 | 131 | let owner = @0x26; 132 | let mut scenario = test_scenario::begin(owner); 133 | let clock = clock::create_for_testing(test_scenario::ctx(&mut scenario)); 134 | 135 | let mut aggregator = switchboard::aggregator::new_aggregator( 136 | object::id_from_bytes(x"add8f0a36f15156b5f4720f94a62230dbef3c5d8bbfc6a799b5e6bfb56671bd4"), 137 | std::string::utf8(b"test"), 138 | @0x26, 139 | x"add8f0a36f15156b5f4720f94a62230dbef3c5d8bbfc6a799b5e6bfb56671bd4", // feed hash 140 | 1, // min samples 141 | 60, // max staleness for updates 142 | 1_000_000_000, // max variance scaled to 9 decimals (1e9 == 1%) 143 | 1, // min job responses 144 | 1337, // created at ms 145 | sui::test_scenario::ctx(&mut scenario) 146 | ); 147 | 148 | // scale the price to 18 decimals 149 | let price = 200 * 10u128.pow(18); 150 | let low_price = 190 * 10u128.pow(18); 151 | let high_price = 210* 10u128.pow(18); 152 | let stddev = 21 * 10u128.pow(18); 153 | 154 | // set the current value (scaled, of course) 155 | aggregator::set_current_value( 156 | &mut aggregator, 157 | switchboard_decimal::new(price, false), 158 | 1337, 159 | 1337, 160 | 1337, 161 | switchboard_decimal::new(low_price, false), 162 | switchboard_decimal::new(high_price, false), 163 | switchboard_decimal::new(stddev, false), 164 | switchboard_decimal::new(0, false), 165 | switchboard_decimal::new(price, false) 166 | ); 167 | 168 | get_price( 169 | &aggregator, 170 | &clock, 171 | 60, 172 | 10, 173 | aggregator.id() 174 | ); 175 | 176 | switchboard::aggregator_delete_action::run(aggregator, sui::test_scenario::ctx(&mut scenario)); 177 | clock::destroy_for_testing(clock); 178 | test_scenario::end(scenario); 179 | } 180 | 181 | #[test] 182 | #[expected_failure(abort_code = oracles::switchboard::EWrongFeedId)] 183 | fun switchboard_fail_wrong_feed_id() { 184 | use sui::test_scenario::{Self}; 185 | use switchboard::decimal as switchboard_decimal; 186 | use switchboard::aggregator; 187 | 188 | let owner = @0x26; 189 | let mut scenario = test_scenario::begin(owner); 190 | let clock = clock::create_for_testing(test_scenario::ctx(&mut scenario)); 191 | 192 | let mut aggregator = switchboard::aggregator::new_aggregator( 193 | object::id_from_bytes(x"add8f0a36f15156b5f4720f94a62230dbef3c5d8bbfc6a799b5e6bfb56671bd4"), 194 | std::string::utf8(b"test"), 195 | @0x26, 196 | x"add8f0a36f15156b5f4720f94a62230dbef3c5d8bbfc6a799b5e6bfb56671bd4", // feed hash 197 | 1, // min samples 198 | 60, // max staleness for updates 199 | 1_000_000_000, // max variance scaled to 9 decimals (1e9 == 1%) 200 | 1, // min job responses 201 | 1337, // created at ms 202 | sui::test_scenario::ctx(&mut scenario) 203 | ); 204 | 205 | // scale the price to 18 decimals 206 | let price = 200 * 10u128.pow(18); 207 | let low_price = 190 * 10u128.pow(18); 208 | let high_price = 210* 10u128.pow(18); 209 | let stddev = 21 * 10u128.pow(18); 210 | 211 | // set the current value (scaled, of course) 212 | aggregator::set_current_value( 213 | &mut aggregator, 214 | switchboard_decimal::new(price, false), 215 | 1337, 216 | 1337, 217 | 1337, 218 | switchboard_decimal::new(low_price, false), 219 | switchboard_decimal::new(high_price, false), 220 | switchboard_decimal::new(stddev, false), 221 | switchboard_decimal::new(0, false), 222 | switchboard_decimal::new(price, false) 223 | ); 224 | 225 | let random_id = object::new(scenario.ctx()); 226 | get_price( 227 | &aggregator, 228 | &clock, 229 | 60, 230 | 10, 231 | random_id.to_inner() 232 | ); 233 | 234 | switchboard::aggregator_delete_action::run(aggregator, sui::test_scenario::ctx(&mut scenario)); 235 | clock::destroy_for_testing(clock); 236 | sui::test_utils::destroy(random_id); 237 | 238 | test_scenario::end(scenario); 239 | } 240 | } -------------------------------------------------------------------------------- /contracts/sprungsui/Move.lock: -------------------------------------------------------------------------------- 1 | # @generated by Move, please check-in and do not edit manually. 2 | 3 | [move] 4 | version = 3 5 | manifest_digest = "794A057B38D8EA647BDAD6054CA23111C12632A803D8046EEF5E0EE8ED9BDF83" 6 | deps_digest = "F9B494B64F0615AED0E98FC12A85B85ECD2BC5185C22D30E7F67786BB52E507C" 7 | dependencies = [ 8 | { id = "Bridge", name = "Bridge" }, 9 | { id = "MoveStdlib", name = "MoveStdlib" }, 10 | { id = "Sui", name = "Sui" }, 11 | { id = "SuiSystem", name = "SuiSystem" }, 12 | ] 13 | 14 | [[move.package]] 15 | id = "Bridge" 16 | source = { git = "https://github.com/MystenLabs/sui.git", rev = "04f11afaf5e0", subdir = "crates/sui-framework/packages/bridge" } 17 | 18 | dependencies = [ 19 | { id = "MoveStdlib", name = "MoveStdlib" }, 20 | { id = "Sui", name = "Sui" }, 21 | { id = "SuiSystem", name = "SuiSystem" }, 22 | ] 23 | 24 | [[move.package]] 25 | id = "MoveStdlib" 26 | source = { git = "https://github.com/MystenLabs/sui.git", rev = "04f11afaf5e0", subdir = "crates/sui-framework/packages/move-stdlib" } 27 | 28 | [[move.package]] 29 | id = "Sui" 30 | source = { git = "https://github.com/MystenLabs/sui.git", rev = "04f11afaf5e0", subdir = "crates/sui-framework/packages/sui-framework" } 31 | 32 | dependencies = [ 33 | { id = "MoveStdlib", name = "MoveStdlib" }, 34 | ] 35 | 36 | [[move.package]] 37 | id = "SuiSystem" 38 | source = { git = "https://github.com/MystenLabs/sui.git", rev = "04f11afaf5e0", subdir = "crates/sui-framework/packages/sui-system" } 39 | 40 | dependencies = [ 41 | { id = "MoveStdlib", name = "MoveStdlib" }, 42 | { id = "Sui", name = "Sui" }, 43 | ] 44 | 45 | [move.toolchain-version] 46 | compiler-version = "1.46.1" 47 | edition = "2024.beta" 48 | flavor = "sui" 49 | 50 | [env] 51 | 52 | [env.mainnet] 53 | chain-id = "35834a8a" 54 | original-published-id = "0xb87cea7e4220461e35dff856185814d6a37ef479ce895ffbe4efa1d1af5aacbc" 55 | latest-published-id = "0xb87cea7e4220461e35dff856185814d6a37ef479ce895ffbe4efa1d1af5aacbc" 56 | published-version = "1" 57 | -------------------------------------------------------------------------------- /contracts/sprungsui/Move.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "sprungsui" 3 | edition = "2024.beta" # edition = "legacy" to use legacy (pre-2024) Move 4 | published-at = "0xb87cea7e4220461e35dff856185814d6a37ef479ce895ffbe4efa1d1af5aacbc" 5 | 6 | [dependencies] 7 | 8 | [addresses] 9 | sprungsui = "0xb87cea7e4220461e35dff856185814d6a37ef479ce895ffbe4efa1d1af5aacbc" 10 | 11 | [dev-dependencies] 12 | 13 | [dev-addresses] 14 | 15 | -------------------------------------------------------------------------------- /contracts/sprungsui/sources/sprungsui.move: -------------------------------------------------------------------------------- 1 | module sprungsui::sprungsui { 2 | use sui::coin::{Self}; 3 | 4 | public struct SPRUNGSUI has drop {} 5 | 6 | fun init(witness: SPRUNGSUI, ctx: &mut TxContext) { 7 | let (treasury, metadata) = coin::create_currency( 8 | witness, 9 | 9, 10 | b"", 11 | b"Staked SUI", 12 | b"", 13 | option::none(), 14 | ctx 15 | ); 16 | 17 | transfer::public_share_object(metadata); 18 | transfer::public_transfer(treasury, ctx.sender()) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /contracts/suilend/Move.beta.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "suilend" 3 | version = "0.0.1" 4 | published-at = "0xd1ad8c401da6933bb5a5ccde1420f35c45e6a42a79ea6003f3248fe3c510d418" 5 | edition = "2024.beta" 6 | 7 | [dependencies.Pyth] 8 | git = "https://github.com/solendprotocol/pyth-crosschain.git" 9 | subdir = "target_chains/sui/contracts" 10 | rev = "98e218c64bb75cf1350eb7b021e1ffcc3aedfd62" 11 | 12 | [dependencies.liquid_staking] 13 | git = "https://github.com/solendprotocol/liquid-staking.git" 14 | subdir = "contracts" 15 | rev = "main" 16 | 17 | [dependencies.sprungsui] 18 | local = "../sprungsui" 19 | 20 | [addresses] 21 | sui = "0x2" 22 | # suilend = "0x0" 23 | suilend = "0x1f54a9a2d71799553197e9ea24557797c6398d6a65f2d4d3818c9304b75d5e21" 24 | -------------------------------------------------------------------------------- /contracts/suilend/Move.lock: -------------------------------------------------------------------------------- 1 | # @generated by Move, please check-in and do not edit manually. 2 | 3 | [move] 4 | version = 3 5 | manifest_digest = "6781C6A6706E7547FDDE140BD7880B44DD07AF42D5F3EDB94B61E8433A04AF59" 6 | deps_digest = "CAFAD8A7CF51067FB4358215BECB86BD100DD64E57C2AC8A7AE7D74B688F5965" 7 | dependencies = [ 8 | { id = "Bridge", name = "Bridge" }, 9 | { id = "MoveStdlib", name = "MoveStdlib" }, 10 | { id = "Pyth", name = "Pyth" }, 11 | { id = "Sui", name = "Sui" }, 12 | { id = "SuiSystem", name = "SuiSystem" }, 13 | { id = "liquid_staking", name = "liquid_staking" }, 14 | { id = "sprungsui", name = "sprungsui" }, 15 | ] 16 | 17 | [[move.package]] 18 | id = "Bridge" 19 | source = { git = "https://github.com/MystenLabs/sui.git", rev = "3802482bd4e3", subdir = "crates/sui-framework/packages/bridge" } 20 | 21 | dependencies = [ 22 | { id = "MoveStdlib", name = "MoveStdlib" }, 23 | { id = "Sui", name = "Sui" }, 24 | { id = "SuiSystem", name = "SuiSystem" }, 25 | ] 26 | 27 | [[move.package]] 28 | id = "MoveStdlib" 29 | source = { git = "https://github.com/MystenLabs/sui.git", rev = "3802482bd4e3", subdir = "crates/sui-framework/packages/move-stdlib" } 30 | 31 | [[move.package]] 32 | id = "Pyth" 33 | source = { git = "https://github.com/solendprotocol/pyth-crosschain.git", rev = "98e218c64bb75cf1350eb7b021e1ffcc3aedfd62", subdir = "target_chains/sui/contracts" } 34 | 35 | dependencies = [ 36 | { id = "Sui", name = "Sui" }, 37 | { id = "Wormhole", name = "Wormhole" }, 38 | ] 39 | 40 | [[move.package]] 41 | id = "Sui" 42 | source = { git = "https://github.com/MystenLabs/sui.git", rev = "3802482bd4e3", subdir = "crates/sui-framework/packages/sui-framework" } 43 | 44 | dependencies = [ 45 | { id = "MoveStdlib", name = "MoveStdlib" }, 46 | ] 47 | 48 | [[move.package]] 49 | id = "SuiSystem" 50 | source = { git = "https://github.com/MystenLabs/sui.git", rev = "3802482bd4e3", subdir = "crates/sui-framework/packages/sui-system" } 51 | 52 | dependencies = [ 53 | { id = "MoveStdlib", name = "MoveStdlib" }, 54 | { id = "Sui", name = "Sui" }, 55 | ] 56 | 57 | [[move.package]] 58 | id = "Wormhole" 59 | source = { git = "https://github.com/solendprotocol/wormhole.git", rev = "e1698d3c72b15cdddd7da98ad43e151f83b72a0a", subdir = "sui/wormhole" } 60 | 61 | dependencies = [ 62 | { id = "Sui", name = "Sui" }, 63 | ] 64 | 65 | [[move.package]] 66 | id = "liquid_staking" 67 | source = { git = "https://github.com/solendprotocol/liquid-staking.git", rev = "main", subdir = "contracts" } 68 | 69 | dependencies = [ 70 | { id = "Sui", name = "Sui" }, 71 | { id = "SuiSystem", name = "SuiSystem" }, 72 | ] 73 | 74 | [[move.package]] 75 | id = "sprungsui" 76 | source = { local = "../sprungsui" } 77 | 78 | dependencies = [ 79 | { id = "Bridge", name = "Bridge" }, 80 | { id = "MoveStdlib", name = "MoveStdlib" }, 81 | { id = "Sui", name = "Sui" }, 82 | { id = "SuiSystem", name = "SuiSystem" }, 83 | ] 84 | 85 | [move.toolchain-version] 86 | compiler-version = "1.48.4" 87 | edition = "2024.beta" 88 | flavor = "sui" 89 | -------------------------------------------------------------------------------- /contracts/suilend/Move.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "suilend" 3 | version = "0.0.1" 4 | published-at = "0x43d25be6a55db4e7cc08dd914b8326e7d56fb64c67f0fb961a349e2872f4cc08" 5 | edition = "2024.beta" 6 | 7 | [dependencies.Pyth] 8 | git = "https://github.com/solendprotocol/pyth-crosschain.git" 9 | subdir = "target_chains/sui/contracts" 10 | rev = "98e218c64bb75cf1350eb7b021e1ffcc3aedfd62" 11 | 12 | [dependencies.liquid_staking] 13 | git = "https://github.com/solendprotocol/liquid-staking.git" 14 | subdir = "contracts" 15 | rev = "main" 16 | 17 | [dependencies.sprungsui] 18 | local = "../sprungsui" 19 | 20 | [addresses] 21 | sui = "0x2" 22 | # suilend = "0x0" 23 | suilend = "0xf95b06141ed4a174f239417323bde3f209b972f5930d8521ea38a52aff3a6ddf" 24 | -------------------------------------------------------------------------------- /contracts/suilend/sources/cell.move: -------------------------------------------------------------------------------- 1 | module suilend::cell { 2 | public struct Cell has store { 3 | element: Option, 4 | } 5 | 6 | public fun new(element: Element): Cell { 7 | Cell { element: option::some(element) } 8 | } 9 | 10 | public fun set(cell: &mut Cell, element: Element): Element { 11 | option::swap(&mut cell.element, element) 12 | } 13 | 14 | public fun get(cell: &Cell): &Element { 15 | option::borrow(&cell.element) 16 | } 17 | 18 | public fun destroy(cell: Cell): Element { 19 | let Cell { element } = cell; 20 | option::destroy_some(element) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /contracts/suilend/sources/decimal.move: -------------------------------------------------------------------------------- 1 | /// fixed point decimal representation. 18 decimal places are kept. 2 | module suilend::decimal { 3 | // 1e18 4 | const WAD: u256 = 1000000000000000000; 5 | const U64_MAX: u256 = 18446744073709551615; 6 | 7 | public struct Decimal has copy, drop, store { 8 | value: u256, 9 | } 10 | 11 | public fun from(v: u64): Decimal { 12 | Decimal { 13 | value: (v as u256) * WAD, 14 | } 15 | } 16 | 17 | public fun from_u128(v: u128): Decimal { 18 | Decimal { 19 | value: (v as u256) * WAD 20 | } 21 | } 22 | 23 | public fun from_percent(v: u8): Decimal { 24 | Decimal { 25 | value: (v as u256) * WAD / 100, 26 | } 27 | } 28 | 29 | public fun from_percent_u64(v: u64): Decimal { 30 | Decimal { 31 | value: (v as u256) * WAD / 100, 32 | } 33 | } 34 | 35 | public fun from_bps(v: u64): Decimal { 36 | Decimal { 37 | value: (v as u256) * WAD / 10_000, 38 | } 39 | } 40 | 41 | public fun from_scaled_val(v: u256): Decimal { 42 | Decimal { 43 | value: v, 44 | } 45 | } 46 | 47 | public fun to_scaled_val(v: Decimal): u256 { 48 | v.value 49 | } 50 | 51 | public fun add(a: Decimal, b: Decimal): Decimal { 52 | Decimal { 53 | value: a.value + b.value, 54 | } 55 | } 56 | 57 | public fun sub(a: Decimal, b: Decimal): Decimal { 58 | Decimal { 59 | value: a.value - b.value, 60 | } 61 | } 62 | 63 | public fun saturating_sub(a: Decimal, b: Decimal): Decimal { 64 | if (a.value < b.value) { 65 | Decimal { value: 0 } 66 | } else { 67 | Decimal { value: a.value - b.value } 68 | } 69 | } 70 | 71 | public fun mul(a: Decimal, b: Decimal): Decimal { 72 | Decimal { 73 | value: (a.value * b.value) / WAD, 74 | } 75 | } 76 | 77 | public fun div(a: Decimal, b: Decimal): Decimal { 78 | Decimal { 79 | value: (a.value * WAD) / b.value, 80 | } 81 | } 82 | 83 | public fun pow(b: Decimal, mut e: u64): Decimal { 84 | let mut cur_base = b; 85 | let mut result = from(1); 86 | 87 | while (e > 0) { 88 | if (e % 2 == 1) { 89 | result = mul(result, cur_base); 90 | }; 91 | cur_base = mul(cur_base, cur_base); 92 | e = e / 2; 93 | }; 94 | 95 | result 96 | } 97 | 98 | public fun floor(a: Decimal): u64 { 99 | ((a.value / WAD) as u64) 100 | } 101 | 102 | public fun saturating_floor(a: Decimal): u64 { 103 | if (a.value > U64_MAX * WAD) { 104 | (U64_MAX as u64) 105 | } else { 106 | floor(a) 107 | } 108 | } 109 | 110 | public fun ceil(a: Decimal): u64 { 111 | (((a.value + WAD - 1) / WAD) as u64) 112 | } 113 | 114 | public fun eq(a: Decimal, b: Decimal): bool { 115 | a.value == b.value 116 | } 117 | 118 | public fun ge(a: Decimal, b: Decimal): bool { 119 | a.value >= b.value 120 | } 121 | 122 | public fun gt(a: Decimal, b: Decimal): bool { 123 | a.value > b.value 124 | } 125 | 126 | public fun le(a: Decimal, b: Decimal): bool { 127 | a.value <= b.value 128 | } 129 | 130 | public fun lt(a: Decimal, b: Decimal): bool { 131 | a.value < b.value 132 | } 133 | 134 | public fun min(a: Decimal, b: Decimal): Decimal { 135 | if (a.value < b.value) { 136 | a 137 | } else { 138 | b 139 | } 140 | } 141 | 142 | public fun max(a: Decimal, b: Decimal): Decimal { 143 | if (a.value > b.value) { 144 | a 145 | } else { 146 | b 147 | } 148 | } 149 | } 150 | 151 | #[test_only] 152 | module suilend::decimal_tests { 153 | use suilend::decimal::{ 154 | add, 155 | sub, 156 | mul, 157 | div, 158 | floor, 159 | ceil, 160 | pow, 161 | lt, 162 | gt, 163 | le, 164 | ge, 165 | from, 166 | from_percent, 167 | saturating_sub, 168 | saturating_floor, 169 | from_u128 170 | }; 171 | 172 | #[test] 173 | fun test_basic() { 174 | let a = from(1); 175 | let b = from(2); 176 | 177 | assert!(add(a, b) == from(3), 0); 178 | assert!(sub(b, a) == from(1), 0); 179 | assert!(mul(a, b) == from(2), 0); 180 | assert!(div(b, a) == from(2), 0); 181 | assert!(floor(from_percent(150)) == 1, 0); 182 | assert!(ceil(from_percent(150)) == 2, 0); 183 | assert!(lt(a, b), 0); 184 | assert!(gt(b, a), 0); 185 | assert!(le(a, b), 0); 186 | assert!(ge(b, a), 0); 187 | assert!(saturating_sub(a, b) == from(0), 0); 188 | assert!(saturating_sub(b, a) == from(1), 0); 189 | assert!(saturating_floor(from(18446744073709551615)) == 18446744073709551615, 0); 190 | assert!(saturating_floor(add(from(18446744073709551615), from(1))) == 18446744073709551615, 0); 191 | assert!(from_u128(340282366920938463463374607431768211455).add(from(1)).to_scaled_val() == 340282366920938463463374607431768211456 * 1000000000000000000, 0); 192 | } 193 | 194 | #[test] 195 | fun test_pow() { 196 | assert!(pow(from(5), 4) == from(625), 0); 197 | assert!(pow(from(3), 0) == from(1), 0); 198 | assert!(pow(from(3), 1) == from(3), 0); 199 | assert!(pow(from(3), 7) == from(2187), 0); 200 | assert!(pow(from(3), 8) == from(6561), 0); 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /contracts/suilend/sources/lending_market_registry.move: -------------------------------------------------------------------------------- 1 | /// Top level object that tracks all lending markets. 2 | /// Ensures that there is only one LendingMarket of each type. 3 | /// Anyone can create a new LendingMarket via the registry. 4 | module suilend::lending_market_registry { 5 | use std::type_name::{Self, TypeName}; 6 | use sui::table::{Self, Table}; 7 | use suilend::lending_market::{Self, LendingMarket, LendingMarketOwnerCap}; 8 | 9 | // === Errors === 10 | const EIncorrectVersion: u64 = 1; 11 | 12 | // === Constants === 13 | const CURRENT_VERSION: u64 = 1; 14 | 15 | public struct Registry has key { 16 | id: UID, 17 | version: u64, 18 | lending_markets: Table, 19 | } 20 | 21 | fun init(ctx: &mut TxContext) { 22 | let registry = Registry { 23 | id: object::new(ctx), 24 | version: CURRENT_VERSION, 25 | lending_markets: table::new(ctx), 26 | }; 27 | 28 | transfer::share_object(registry); 29 | } 30 | 31 | public fun create_lending_market

( 32 | registry: &mut Registry, 33 | ctx: &mut TxContext, 34 | ): (LendingMarketOwnerCap

, LendingMarket

) { 35 | assert!(registry.version == CURRENT_VERSION, EIncorrectVersion); 36 | 37 | let (owner_cap, lending_market) = lending_market::create_lending_market

(ctx); 38 | table::add(&mut registry.lending_markets, type_name::get

(), object::id(&lending_market)); 39 | (owner_cap, lending_market) 40 | } 41 | 42 | #[test_only] 43 | public struct LENDING_MARKET_1 {} 44 | public struct LENDING_MARKET_2 {} 45 | 46 | #[test] 47 | fun test_happy() { 48 | use sui::test_utils::{Self}; 49 | use sui::test_scenario::{Self}; 50 | 51 | let owner = @0x26; 52 | let mut scenario = test_scenario::begin(owner); 53 | 54 | init(test_scenario::ctx(&mut scenario)); 55 | test_scenario::next_tx(&mut scenario, owner); 56 | 57 | let mut registry = test_scenario::take_shared(&scenario); 58 | 59 | let (owner_cap_1, lending_market_1) = create_lending_market( 60 | &mut registry, 61 | test_scenario::ctx(&mut scenario), 62 | ); 63 | 64 | let (owner_cap_2, lending_market_2) = create_lending_market( 65 | &mut registry, 66 | test_scenario::ctx(&mut scenario), 67 | ); 68 | 69 | test_scenario::return_shared(registry); 70 | test_utils::destroy(owner_cap_1); 71 | test_utils::destroy(lending_market_1); 72 | test_utils::destroy(owner_cap_2); 73 | test_utils::destroy(lending_market_2); 74 | test_scenario::end(scenario); 75 | } 76 | 77 | #[test] 78 | #[expected_failure(abort_code = 0, location = sui::dynamic_field)] 79 | fun test_fail_duplicate_lending_market_type() { 80 | use sui::test_utils::{Self}; 81 | use sui::test_scenario::{Self}; 82 | 83 | let owner = @0x26; 84 | let mut scenario = test_scenario::begin(owner); 85 | 86 | init(test_scenario::ctx(&mut scenario)); 87 | test_scenario::next_tx(&mut scenario, owner); 88 | 89 | let mut registry = test_scenario::take_shared(&scenario); 90 | 91 | let (owner_cap_1, lending_market_1) = create_lending_market( 92 | &mut registry, 93 | test_scenario::ctx(&mut scenario), 94 | ); 95 | 96 | // this should fail 97 | let (owner_cap_1_too, lending_market_1_too) = create_lending_market( 98 | &mut registry, 99 | test_scenario::ctx(&mut scenario), 100 | ); 101 | 102 | test_scenario::return_shared(registry); 103 | test_utils::destroy(owner_cap_1); 104 | test_utils::destroy(owner_cap_1_too); 105 | test_utils::destroy(lending_market_1); 106 | test_utils::destroy(lending_market_1_too); 107 | test_scenario::end(scenario); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /contracts/suilend/sources/liquidity_mining.move: -------------------------------------------------------------------------------- 1 | /// A user_reward_manager farms pool_rewards to receive rewards proportional to their stake in the pool. 2 | module suilend::liquidity_mining { 3 | use std::type_name::{Self, TypeName}; 4 | use sui::bag::{Self, Bag}; 5 | use sui::balance::{Self, Balance}; 6 | use sui::clock::{Self, Clock}; 7 | use suilend::decimal::{Self, Decimal, add, sub, mul, div, floor}; 8 | 9 | // === Errors === 10 | const EIdMismatch: u64 = 0; 11 | const EInvalidTime: u64 = 1; 12 | const EInvalidType: u64 = 2; 13 | const EMaxConcurrentPoolRewardsViolated: u64 = 3; 14 | const ENotAllRewardsClaimed: u64 = 4; 15 | const EPoolRewardPeriodNotOver: u64 = 5; 16 | 17 | // === Constants === 18 | const MAX_REWARDS: u64 = 50; 19 | const MIN_REWARD_PERIOD_MS: u64 = 3_600_000; 20 | 21 | /// This struct manages all pool_rewards for a given stake pool. 22 | public struct PoolRewardManager has key, store { 23 | id: UID, 24 | total_shares: u64, 25 | pool_rewards: vector>, 26 | last_update_time_ms: u64, 27 | } 28 | 29 | public struct PoolReward has key, store { 30 | id: UID, 31 | pool_reward_manager_id: ID, 32 | coin_type: TypeName, 33 | start_time_ms: u64, 34 | end_time_ms: u64, 35 | total_rewards: u64, 36 | /// amount of rewards that have been earned by users 37 | allocated_rewards: Decimal, 38 | cumulative_rewards_per_share: Decimal, 39 | num_user_reward_managers: u64, 40 | additional_fields: Bag, 41 | } 42 | 43 | // == Dynamic Field Keys 44 | public struct RewardBalance has copy, drop, store {} 45 | 46 | public struct UserRewardManager has store { 47 | pool_reward_manager_id: ID, 48 | share: u64, 49 | rewards: vector>, 50 | last_update_time_ms: u64, 51 | } 52 | 53 | public struct UserReward has store { 54 | pool_reward_id: ID, 55 | earned_rewards: Decimal, 56 | cumulative_rewards_per_share: Decimal, 57 | } 58 | 59 | // === Public-View Functions === 60 | public fun pool_reward_manager_id(user_reward_manager: &UserRewardManager): ID { 61 | user_reward_manager.pool_reward_manager_id 62 | } 63 | 64 | public fun shares(user_reward_manager: &UserRewardManager): u64 { 65 | user_reward_manager.share 66 | } 67 | 68 | public fun last_update_time_ms(user_reward_manager: &UserRewardManager): u64 { 69 | user_reward_manager.last_update_time_ms 70 | } 71 | 72 | public fun pool_reward_id(pool_reward_manager: &PoolRewardManager, index: u64): ID { 73 | let optional_pool_reward = vector::borrow(&pool_reward_manager.pool_rewards, index); 74 | let pool_reward = option::borrow(optional_pool_reward); 75 | object::id(pool_reward) 76 | } 77 | 78 | public fun pool_reward( 79 | pool_reward_manager: &PoolRewardManager, 80 | index: u64, 81 | ): &Option { 82 | vector::borrow(&pool_reward_manager.pool_rewards, index) 83 | } 84 | 85 | public fun end_time_ms(pool_reward: &PoolReward): u64 { 86 | pool_reward.end_time_ms 87 | } 88 | 89 | // === Public-Friend functions 90 | public(package) fun new_pool_reward_manager(ctx: &mut TxContext): PoolRewardManager { 91 | PoolRewardManager { 92 | id: object::new(ctx), 93 | total_shares: 0, 94 | pool_rewards: vector::empty(), 95 | last_update_time_ms: 0, 96 | } 97 | } 98 | 99 | public(package) fun add_pool_reward( 100 | pool_reward_manager: &mut PoolRewardManager, 101 | rewards: Balance, 102 | start_time_ms: u64, 103 | end_time_ms: u64, 104 | clock: &Clock, 105 | ctx: &mut TxContext, 106 | ) { 107 | let start_time_ms = std::u64::max(start_time_ms, clock::timestamp_ms(clock)); 108 | assert!(end_time_ms - start_time_ms >= MIN_REWARD_PERIOD_MS, EInvalidTime); 109 | 110 | let pool_reward = PoolReward { 111 | id: object::new(ctx), 112 | pool_reward_manager_id: object::id(pool_reward_manager), 113 | coin_type: type_name::get(), 114 | start_time_ms, 115 | end_time_ms, 116 | total_rewards: balance::value(&rewards), 117 | allocated_rewards: decimal::from(0), 118 | cumulative_rewards_per_share: decimal::from(0), 119 | num_user_reward_managers: 0, 120 | additional_fields: { 121 | let mut bag = bag::new(ctx); 122 | bag::add(&mut bag, RewardBalance {}, rewards); 123 | bag 124 | }, 125 | }; 126 | 127 | let i = find_available_index(pool_reward_manager); 128 | assert!(i < MAX_REWARDS, EMaxConcurrentPoolRewardsViolated); 129 | 130 | let optional_pool_reward = vector::borrow_mut(&mut pool_reward_manager.pool_rewards, i); 131 | option::fill(optional_pool_reward, pool_reward); 132 | } 133 | 134 | /// Close pool_reward campaign, claim dust amounts of rewards, and destroy object. 135 | /// This can only be called if the pool_reward period is over and all rewards have been claimed. 136 | public(package) fun close_pool_reward( 137 | pool_reward_manager: &mut PoolRewardManager, 138 | index: u64, 139 | clock: &Clock, 140 | ): Balance { 141 | let optional_pool_reward = vector::borrow_mut(&mut pool_reward_manager.pool_rewards, index); 142 | let PoolReward { 143 | id, 144 | pool_reward_manager_id: _, 145 | coin_type: _, 146 | start_time_ms: _, 147 | end_time_ms, 148 | total_rewards: _, 149 | allocated_rewards: _, 150 | cumulative_rewards_per_share: _, 151 | num_user_reward_managers, 152 | mut additional_fields, 153 | } = option::extract(optional_pool_reward); 154 | 155 | object::delete(id); 156 | 157 | let cur_time_ms = clock::timestamp_ms(clock); 158 | 159 | assert!(cur_time_ms >= end_time_ms, EPoolRewardPeriodNotOver); 160 | assert!(num_user_reward_managers == 0, ENotAllRewardsClaimed); 161 | 162 | let reward_balance: Balance = bag::remove( 163 | &mut additional_fields, 164 | RewardBalance {}, 165 | ); 166 | 167 | bag::destroy_empty(additional_fields); 168 | 169 | reward_balance 170 | } 171 | 172 | /// Cancel pool_reward campaign and claim unallocated rewards. Effectively sets the 173 | /// end time of the pool_reward campaign to the current time. 174 | public(package) fun cancel_pool_reward( 175 | pool_reward_manager: &mut PoolRewardManager, 176 | index: u64, 177 | clock: &Clock, 178 | ): Balance { 179 | update_pool_reward_manager(pool_reward_manager, clock); 180 | 181 | let pool_reward = option::borrow_mut( 182 | vector::borrow_mut(&mut pool_reward_manager.pool_rewards, index), 183 | ); 184 | let cur_time_ms = clock::timestamp_ms(clock); 185 | 186 | let unallocated_rewards = floor( 187 | sub( 188 | decimal::from(pool_reward.total_rewards), 189 | pool_reward.allocated_rewards, 190 | ), 191 | ); 192 | 193 | pool_reward.end_time_ms = cur_time_ms; 194 | pool_reward.total_rewards = 0; 195 | 196 | let reward_balance: &mut Balance = bag::borrow_mut( 197 | &mut pool_reward.additional_fields, 198 | RewardBalance {}, 199 | ); 200 | 201 | balance::split(reward_balance, unallocated_rewards) 202 | } 203 | 204 | fun update_pool_reward_manager(pool_reward_manager: &mut PoolRewardManager, clock: &Clock) { 205 | let cur_time_ms = clock::timestamp_ms(clock); 206 | 207 | if (cur_time_ms == pool_reward_manager.last_update_time_ms) { 208 | return 209 | }; 210 | 211 | if (pool_reward_manager.total_shares == 0) { 212 | pool_reward_manager.last_update_time_ms = cur_time_ms; 213 | return 214 | }; 215 | 216 | let mut i = 0; 217 | while (i < vector::length(&pool_reward_manager.pool_rewards)) { 218 | let optional_pool_reward = vector::borrow_mut(&mut pool_reward_manager.pool_rewards, i); 219 | if (option::is_none(optional_pool_reward)) { 220 | i = i + 1; 221 | continue 222 | }; 223 | 224 | let pool_reward = option::borrow_mut(optional_pool_reward); 225 | if ( 226 | cur_time_ms < pool_reward.start_time_ms || 227 | pool_reward_manager.last_update_time_ms >= pool_reward.end_time_ms 228 | ) { 229 | i = i + 1; 230 | continue 231 | }; 232 | 233 | let time_passed_ms = 234 | std::u64::min(cur_time_ms, pool_reward.end_time_ms) - 235 | std::u64::max(pool_reward.start_time_ms, pool_reward_manager.last_update_time_ms); 236 | 237 | let unlocked_rewards = div( 238 | mul( 239 | decimal::from(pool_reward.total_rewards), 240 | decimal::from(time_passed_ms), 241 | ), 242 | decimal::from(pool_reward.end_time_ms - pool_reward.start_time_ms), 243 | ); 244 | pool_reward.allocated_rewards = add(pool_reward.allocated_rewards, unlocked_rewards); 245 | 246 | pool_reward.cumulative_rewards_per_share = 247 | add( 248 | pool_reward.cumulative_rewards_per_share, 249 | div( 250 | unlocked_rewards, 251 | decimal::from(pool_reward_manager.total_shares), 252 | ), 253 | ); 254 | 255 | i = i + 1; 256 | }; 257 | 258 | pool_reward_manager.last_update_time_ms = cur_time_ms; 259 | } 260 | 261 | fun update_user_reward_manager( 262 | pool_reward_manager: &mut PoolRewardManager, 263 | user_reward_manager: &mut UserRewardManager, 264 | clock: &Clock, 265 | new_user_reward_manager: bool, 266 | ) { 267 | assert!( 268 | object::id(pool_reward_manager) == user_reward_manager.pool_reward_manager_id, 269 | EIdMismatch, 270 | ); 271 | update_pool_reward_manager(pool_reward_manager, clock); 272 | 273 | let cur_time_ms = clock::timestamp_ms(clock); 274 | if (!new_user_reward_manager && cur_time_ms == user_reward_manager.last_update_time_ms) { 275 | return 276 | }; 277 | 278 | let mut i = 0; 279 | while (i < vector::length(&pool_reward_manager.pool_rewards)) { 280 | let optional_pool_reward = vector::borrow_mut(&mut pool_reward_manager.pool_rewards, i); 281 | if (option::is_none(optional_pool_reward)) { 282 | i = i + 1; 283 | continue 284 | }; 285 | 286 | let pool_reward = option::borrow_mut(optional_pool_reward); 287 | 288 | while (vector::length(&user_reward_manager.rewards) <= i) { 289 | vector::push_back(&mut user_reward_manager.rewards, option::none()); 290 | }; 291 | 292 | let optional_reward = vector::borrow_mut(&mut user_reward_manager.rewards, i); 293 | if (option::is_none(optional_reward)) { 294 | if (user_reward_manager.last_update_time_ms <= pool_reward.end_time_ms) { 295 | option::fill( 296 | optional_reward, 297 | UserReward { 298 | pool_reward_id: object::id(pool_reward), 299 | earned_rewards: { 300 | if ( 301 | user_reward_manager.last_update_time_ms <= pool_reward.start_time_ms 302 | ) { 303 | mul( 304 | pool_reward.cumulative_rewards_per_share, 305 | decimal::from(user_reward_manager.share), 306 | ) 307 | } else { 308 | decimal::from(0) 309 | } 310 | }, 311 | cumulative_rewards_per_share: pool_reward.cumulative_rewards_per_share, 312 | }, 313 | ); 314 | 315 | pool_reward.num_user_reward_managers = pool_reward.num_user_reward_managers + 1; 316 | }; 317 | } else { 318 | let reward = option::borrow_mut(optional_reward); 319 | let new_rewards = mul( 320 | sub( 321 | pool_reward.cumulative_rewards_per_share, 322 | reward.cumulative_rewards_per_share, 323 | ), 324 | decimal::from(user_reward_manager.share), 325 | ); 326 | 327 | reward.earned_rewards = add(reward.earned_rewards, new_rewards); 328 | reward.cumulative_rewards_per_share = pool_reward.cumulative_rewards_per_share; 329 | }; 330 | 331 | i = i + 1; 332 | }; 333 | 334 | user_reward_manager.last_update_time_ms = cur_time_ms; 335 | } 336 | 337 | /// Create a new user_reward_manager object with zero share. 338 | public(package) fun new_user_reward_manager( 339 | pool_reward_manager: &mut PoolRewardManager, 340 | clock: &Clock, 341 | ): UserRewardManager { 342 | let mut user_reward_manager = UserRewardManager { 343 | pool_reward_manager_id: object::id(pool_reward_manager), 344 | share: 0, 345 | rewards: vector::empty(), 346 | last_update_time_ms: clock::timestamp_ms(clock), 347 | }; 348 | 349 | // needed to populate the rewards vector 350 | update_user_reward_manager(pool_reward_manager, &mut user_reward_manager, clock, true); 351 | 352 | user_reward_manager 353 | } 354 | 355 | public(package) fun change_user_reward_manager_share( 356 | pool_reward_manager: &mut PoolRewardManager, 357 | user_reward_manager: &mut UserRewardManager, 358 | new_share: u64, 359 | clock: &Clock, 360 | ) { 361 | update_user_reward_manager(pool_reward_manager, user_reward_manager, clock, false); 362 | 363 | pool_reward_manager.total_shares = 364 | pool_reward_manager.total_shares - user_reward_manager.share + new_share; 365 | user_reward_manager.share = new_share; 366 | } 367 | 368 | public(package) fun claim_rewards( 369 | pool_reward_manager: &mut PoolRewardManager, 370 | user_reward_manager: &mut UserRewardManager, 371 | clock: &Clock, 372 | reward_index: u64, 373 | ): Balance { 374 | update_user_reward_manager(pool_reward_manager, user_reward_manager, clock, false); 375 | 376 | let pool_reward = option::borrow_mut( 377 | vector::borrow_mut(&mut pool_reward_manager.pool_rewards, reward_index), 378 | ); 379 | assert!(pool_reward.coin_type == type_name::get(), EInvalidType); 380 | 381 | let optional_reward = vector::borrow_mut(&mut user_reward_manager.rewards, reward_index); 382 | let reward = option::borrow_mut(optional_reward); 383 | 384 | let claimable_rewards = floor(reward.earned_rewards); 385 | 386 | reward.earned_rewards = sub(reward.earned_rewards, decimal::from(claimable_rewards)); 387 | let reward_balance: &mut Balance = bag::borrow_mut( 388 | &mut pool_reward.additional_fields, 389 | RewardBalance {}, 390 | ); 391 | 392 | if (clock::timestamp_ms(clock) >= pool_reward.end_time_ms) { 393 | let UserReward { 394 | pool_reward_id: _, 395 | earned_rewards: _, 396 | cumulative_rewards_per_share: _, 397 | } = option::extract(optional_reward); 398 | 399 | pool_reward.num_user_reward_managers = pool_reward.num_user_reward_managers - 1; 400 | }; 401 | 402 | balance::split(reward_balance, claimable_rewards) 403 | } 404 | 405 | // === Private Functions === 406 | fun find_available_index(pool_reward_manager: &mut PoolRewardManager): u64 { 407 | let mut i = 0; 408 | while (i < vector::length(&pool_reward_manager.pool_rewards)) { 409 | let optional_pool_reward = vector::borrow(&pool_reward_manager.pool_rewards, i); 410 | if (option::is_none(optional_pool_reward)) { 411 | return i 412 | }; 413 | 414 | i = i + 1; 415 | }; 416 | 417 | vector::push_back(&mut pool_reward_manager.pool_rewards, option::none()); 418 | 419 | i 420 | } 421 | 422 | #[test_only] 423 | public struct USDC has drop {} 424 | 425 | #[test_only] 426 | public struct SUI has drop {} 427 | 428 | #[test_only] 429 | const MILLISECONDS_IN_DAY: u64 = 86_400_000; 430 | 431 | #[test] 432 | fun test_pool_reward_manager_basic() { 433 | use sui::test_scenario::{Self}; 434 | 435 | let owner = @0x26; 436 | let mut scenario = test_scenario::begin(owner); 437 | let ctx = test_scenario::ctx(&mut scenario); 438 | 439 | let mut clock = clock::create_for_testing(ctx); 440 | clock::set_for_testing(&mut clock, 0); 441 | 442 | let mut pool_reward_manager = new_pool_reward_manager(ctx); 443 | let usdc = balance::create_for_testing(100 * 1_000_000); 444 | add_pool_reward(&mut pool_reward_manager, usdc, 0, 20 * MILLISECONDS_IN_DAY, &clock, ctx); 445 | 446 | let mut user_reward_manager_1 = new_user_reward_manager(&mut pool_reward_manager, &clock); 447 | change_user_reward_manager_share( 448 | &mut pool_reward_manager, 449 | &mut user_reward_manager_1, 450 | 100, 451 | &clock, 452 | ); 453 | 454 | // at this point, user_reward_manager 1 has earned 50 dollars 455 | clock::set_for_testing(&mut clock, 5 * MILLISECONDS_IN_DAY); 456 | { 457 | let usdc = claim_rewards( 458 | &mut pool_reward_manager, 459 | &mut user_reward_manager_1, 460 | &clock, 461 | 0, 462 | ); 463 | assert!(balance::value(&usdc) == 25 * 1_000_000, 0); 464 | sui::test_utils::destroy(usdc); 465 | }; 466 | 467 | let mut user_reward_manager_2 = new_user_reward_manager(&mut pool_reward_manager, &clock); 468 | change_user_reward_manager_share( 469 | &mut pool_reward_manager, 470 | &mut user_reward_manager_2, 471 | 400, 472 | &clock, 473 | ); 474 | 475 | clock::set_for_testing(&mut clock, 10 * MILLISECONDS_IN_DAY); 476 | { 477 | let usdc = claim_rewards( 478 | &mut pool_reward_manager, 479 | &mut user_reward_manager_1, 480 | &clock, 481 | 0, 482 | ); 483 | assert!(balance::value(&usdc) == 5 * 1_000_000, 0); 484 | sui::test_utils::destroy(usdc); 485 | }; 486 | { 487 | let usdc = claim_rewards( 488 | &mut pool_reward_manager, 489 | &mut user_reward_manager_2, 490 | &clock, 491 | 0, 492 | ); 493 | assert!(balance::value(&usdc) == 20 * 1_000_000, 0); 494 | sui::test_utils::destroy(usdc); 495 | }; 496 | 497 | change_user_reward_manager_share( 498 | &mut pool_reward_manager, 499 | &mut user_reward_manager_1, 500 | 250, 501 | &clock, 502 | ); 503 | change_user_reward_manager_share( 504 | &mut pool_reward_manager, 505 | &mut user_reward_manager_2, 506 | 250, 507 | &clock, 508 | ); 509 | 510 | clock::set_for_testing(&mut clock, 20 * MILLISECONDS_IN_DAY); 511 | { 512 | let usdc = claim_rewards( 513 | &mut pool_reward_manager, 514 | &mut user_reward_manager_1, 515 | &clock, 516 | 0, 517 | ); 518 | assert!(balance::value(&usdc) == 25 * 1_000_000, 0); 519 | sui::test_utils::destroy(usdc); 520 | }; 521 | { 522 | let usdc = claim_rewards( 523 | &mut pool_reward_manager, 524 | &mut user_reward_manager_2, 525 | &clock, 526 | 0, 527 | ); 528 | assert!(balance::value(&usdc) == 25 * 1_000_000, 0); 529 | sui::test_utils::destroy(usdc); 530 | }; 531 | 532 | sui::test_utils::destroy(clock); 533 | sui::test_utils::destroy(pool_reward_manager); 534 | sui::test_utils::destroy(user_reward_manager_1); 535 | sui::test_utils::destroy(user_reward_manager_2); 536 | test_scenario::end(scenario); 537 | } 538 | 539 | #[test] 540 | fun test_pool_reward_manager_multiple_rewards() { 541 | use sui::test_scenario::{Self}; 542 | 543 | let owner = @0x26; 544 | let mut scenario = test_scenario::begin(owner); 545 | let ctx = test_scenario::ctx(&mut scenario); 546 | 547 | let mut clock = clock::create_for_testing(ctx); 548 | clock::set_for_testing(&mut clock, 0); 549 | 550 | let mut pool_reward_manager = new_pool_reward_manager(ctx); 551 | let usdc = balance::create_for_testing(100 * 1_000_000); 552 | add_pool_reward(&mut pool_reward_manager, usdc, 0, 20 * MILLISECONDS_IN_DAY, &clock, ctx); 553 | 554 | let sui = balance::create_for_testing(100 * 1_000_000); 555 | add_pool_reward( 556 | &mut pool_reward_manager, 557 | sui, 558 | 10 * MILLISECONDS_IN_DAY, 559 | 20 * MILLISECONDS_IN_DAY, 560 | &clock, 561 | ctx, 562 | ); 563 | 564 | let mut user_reward_manager_1 = new_user_reward_manager(&mut pool_reward_manager, &clock); 565 | change_user_reward_manager_share( 566 | &mut pool_reward_manager, 567 | &mut user_reward_manager_1, 568 | 100, 569 | &clock, 570 | ); 571 | 572 | clock::set_for_testing(&mut clock, 15 * MILLISECONDS_IN_DAY); 573 | let mut user_reward_manager_2 = new_user_reward_manager(&mut pool_reward_manager, &clock); 574 | change_user_reward_manager_share( 575 | &mut pool_reward_manager, 576 | &mut user_reward_manager_2, 577 | 100, 578 | &clock, 579 | ); 580 | 581 | clock::set_for_testing(&mut clock, 30 * MILLISECONDS_IN_DAY); 582 | { 583 | let usdc = claim_rewards( 584 | &mut pool_reward_manager, 585 | &mut user_reward_manager_1, 586 | &clock, 587 | 0, 588 | ); 589 | assert!(balance::value(&usdc) == 87_500_000, 0); 590 | sui::test_utils::destroy(usdc); 591 | }; 592 | { 593 | let sui = claim_rewards( 594 | &mut pool_reward_manager, 595 | &mut user_reward_manager_1, 596 | &clock, 597 | 1, 598 | ); 599 | assert!(balance::value(&sui) == 75 * 1_000_000, 0); 600 | sui::test_utils::destroy(sui); 601 | }; 602 | 603 | { 604 | let usdc = claim_rewards( 605 | &mut pool_reward_manager, 606 | &mut user_reward_manager_2, 607 | &clock, 608 | 0, 609 | ); 610 | assert!(balance::value(&usdc) == 12_500_000, 0); 611 | sui::test_utils::destroy(usdc); 612 | }; 613 | { 614 | let sui = claim_rewards( 615 | &mut pool_reward_manager, 616 | &mut user_reward_manager_2, 617 | &clock, 618 | 1, 619 | ); 620 | assert!(balance::value(&sui) == 25 * 1_000_000, 0); 621 | sui::test_utils::destroy(sui); 622 | }; 623 | 624 | sui::test_utils::destroy(clock); 625 | sui::test_utils::destroy(pool_reward_manager); 626 | sui::test_utils::destroy(user_reward_manager_1); 627 | sui::test_utils::destroy(user_reward_manager_2); 628 | test_scenario::end(scenario); 629 | } 630 | 631 | #[test] 632 | fun test_pool_reward_manager_cancel_and_close() { 633 | use sui::test_scenario::{Self}; 634 | 635 | let owner = @0x26; 636 | let mut scenario = test_scenario::begin(owner); 637 | let ctx = test_scenario::ctx(&mut scenario); 638 | 639 | let mut clock = clock::create_for_testing(ctx); 640 | clock::set_for_testing(&mut clock, 0); 641 | 642 | let mut pool_reward_manager = new_pool_reward_manager(ctx); 643 | let usdc = balance::create_for_testing(100 * 1_000_000); 644 | add_pool_reward(&mut pool_reward_manager, usdc, 0, 20 * MILLISECONDS_IN_DAY, &clock, ctx); 645 | 646 | let mut user_reward_manager_1 = new_user_reward_manager(&mut pool_reward_manager, &clock); 647 | change_user_reward_manager_share( 648 | &mut pool_reward_manager, 649 | &mut user_reward_manager_1, 650 | 100, 651 | &clock, 652 | ); 653 | 654 | clock::set_for_testing(&mut clock, 10 * MILLISECONDS_IN_DAY); 655 | 656 | let unallocated_rewards = cancel_pool_reward(&mut pool_reward_manager, 0, &clock); 657 | assert!(balance::value(&unallocated_rewards) == 50 * 1_000_000, 0); 658 | 659 | clock::set_for_testing(&mut clock, 15 * MILLISECONDS_IN_DAY); 660 | let user_reward_manager_rewards = claim_rewards( 661 | &mut pool_reward_manager, 662 | &mut user_reward_manager_1, 663 | &clock, 664 | 0, 665 | ); 666 | assert!(balance::value(&user_reward_manager_rewards) == 50 * 1_000_000, 0); 667 | 668 | let dust_rewards = close_pool_reward(&mut pool_reward_manager, 0, &clock); 669 | assert!(balance::value(&dust_rewards) == 0, 0); 670 | 671 | sui::test_utils::destroy(unallocated_rewards); 672 | sui::test_utils::destroy(user_reward_manager_rewards); 673 | sui::test_utils::destroy(dust_rewards); 674 | sui::test_utils::destroy(clock); 675 | sui::test_utils::destroy(pool_reward_manager); 676 | sui::test_utils::destroy(user_reward_manager_1); 677 | test_scenario::end(scenario); 678 | } 679 | 680 | #[test] 681 | fun test_pool_reward_manager_zero_share() { 682 | use sui::test_scenario::{Self}; 683 | 684 | let owner = @0x26; 685 | let mut scenario = test_scenario::begin(owner); 686 | let ctx = test_scenario::ctx(&mut scenario); 687 | 688 | let mut clock = clock::create_for_testing(ctx); 689 | clock::set_for_testing(&mut clock, 0); 690 | 691 | let mut pool_reward_manager = new_pool_reward_manager(ctx); 692 | let usdc = balance::create_for_testing(100 * 1_000_000); 693 | add_pool_reward(&mut pool_reward_manager, usdc, 0, 20 * MILLISECONDS_IN_DAY, &clock, ctx); 694 | 695 | clock::set_for_testing(&mut clock, 10 * MILLISECONDS_IN_DAY); 696 | let mut user_reward_manager_1 = new_user_reward_manager(&mut pool_reward_manager, &clock); 697 | change_user_reward_manager_share( 698 | &mut pool_reward_manager, 699 | &mut user_reward_manager_1, 700 | 1, 701 | &clock, 702 | ); 703 | 704 | clock::set_for_testing(&mut clock, 20 * MILLISECONDS_IN_DAY); 705 | { 706 | let usdc = claim_rewards( 707 | &mut pool_reward_manager, 708 | &mut user_reward_manager_1, 709 | &clock, 710 | 0, 711 | ); 712 | // 50 usdc is unallocated since there was zero share from 0-10 seconds 713 | assert!(balance::value(&usdc) == 50 * 1_000_000, 0); 714 | sui::test_utils::destroy(usdc); 715 | }; 716 | 717 | sui::test_utils::destroy(clock); 718 | sui::test_utils::destroy(pool_reward_manager); 719 | sui::test_utils::destroy(user_reward_manager_1); 720 | test_scenario::end(scenario); 721 | } 722 | 723 | #[test] 724 | fun test_pool_reward_manager_auto_farm() { 725 | use sui::test_scenario::{Self}; 726 | 727 | let owner = @0x26; 728 | let mut scenario = test_scenario::begin(owner); 729 | let ctx = test_scenario::ctx(&mut scenario); 730 | 731 | let mut clock = clock::create_for_testing(ctx); 732 | clock::set_for_testing(&mut clock, 0); 733 | 734 | let mut pool_reward_manager = new_pool_reward_manager(ctx); 735 | 736 | let mut user_reward_manager_1 = new_user_reward_manager(&mut pool_reward_manager, &clock); 737 | change_user_reward_manager_share( 738 | &mut pool_reward_manager, 739 | &mut user_reward_manager_1, 740 | 1, 741 | &clock, 742 | ); 743 | 744 | let usdc = balance::create_for_testing(100 * 1_000_000); 745 | add_pool_reward(&mut pool_reward_manager, usdc, 0, 20 * MILLISECONDS_IN_DAY, &clock, ctx); 746 | 747 | clock::set_for_testing(&mut clock, 10 * MILLISECONDS_IN_DAY); 748 | let mut user_reward_manager_2 = new_user_reward_manager(&mut pool_reward_manager, &clock); 749 | change_user_reward_manager_share( 750 | &mut pool_reward_manager, 751 | &mut user_reward_manager_2, 752 | 1, 753 | &clock, 754 | ); 755 | 756 | clock::set_for_testing(&mut clock, 20 * MILLISECONDS_IN_DAY); 757 | { 758 | let usdc = claim_rewards( 759 | &mut pool_reward_manager, 760 | &mut user_reward_manager_1, 761 | &clock, 762 | 0, 763 | ); 764 | assert!(balance::value(&usdc) == 75 * 1_000_000, 0); 765 | sui::test_utils::destroy(usdc); 766 | }; 767 | change_user_reward_manager_share( 768 | &mut pool_reward_manager, 769 | &mut user_reward_manager_2, 770 | 1, 771 | &clock, 772 | ); 773 | { 774 | let usdc = claim_rewards( 775 | &mut pool_reward_manager, 776 | &mut user_reward_manager_2, 777 | &clock, 778 | 0, 779 | ); 780 | assert!(balance::value(&usdc) == 25 * 1_000_000, 0); 781 | sui::test_utils::destroy(usdc); 782 | }; 783 | 784 | sui::test_utils::destroy(clock); 785 | sui::test_utils::destroy(pool_reward_manager); 786 | sui::test_utils::destroy(user_reward_manager_1); 787 | sui::test_utils::destroy(user_reward_manager_2); 788 | test_scenario::end(scenario); 789 | } 790 | 791 | #[test] 792 | #[expected_failure(abort_code = EMaxConcurrentPoolRewardsViolated)] 793 | fun test_add_too_many_pool_rewards() { 794 | use sui::test_scenario::{Self}; 795 | 796 | let owner = @0x26; 797 | let mut scenario = test_scenario::begin(owner); 798 | let ctx = test_scenario::ctx(&mut scenario); 799 | 800 | let mut clock = clock::create_for_testing(ctx); 801 | clock::set_for_testing(&mut clock, 0); 802 | 803 | let mut pool_reward_manager = new_pool_reward_manager(ctx); 804 | let mut i = 0; 805 | while (i < MAX_REWARDS) { 806 | let usdc = balance::create_for_testing(100 * 1_000_000); 807 | add_pool_reward( 808 | &mut pool_reward_manager, 809 | usdc, 810 | 0, 811 | 20 * MILLISECONDS_IN_DAY, 812 | &clock, 813 | ctx, 814 | ); 815 | i = i + 1; 816 | }; 817 | 818 | let usdc = balance::create_for_testing(100 * 1_000_000); 819 | add_pool_reward(&mut pool_reward_manager, usdc, 0, 20 * MILLISECONDS_IN_DAY, &clock, ctx); 820 | 821 | sui::test_utils::destroy(clock); 822 | sui::test_utils::destroy(pool_reward_manager); 823 | test_scenario::end(scenario); 824 | } 825 | 826 | #[test] 827 | fun test_pool_reward_manager_cancel_and_close_regression() { 828 | use sui::test_scenario::{Self}; 829 | 830 | let owner = @0x26; 831 | let mut scenario = test_scenario::begin(owner); 832 | let ctx = test_scenario::ctx(&mut scenario); 833 | 834 | let mut clock = clock::create_for_testing(ctx); 835 | clock::set_for_testing(&mut clock, 0); 836 | 837 | let mut pool_reward_manager = new_pool_reward_manager(ctx); 838 | let usdc = balance::create_for_testing(100 * 1_000_000); 839 | add_pool_reward(&mut pool_reward_manager, usdc, 0, 20 * MILLISECONDS_IN_DAY, &clock, ctx); 840 | let usdc = balance::create_for_testing(100 * 1_000_000); 841 | add_pool_reward( 842 | &mut pool_reward_manager, 843 | usdc, 844 | 20 * MILLISECONDS_IN_DAY, 845 | 30 * MILLISECONDS_IN_DAY, 846 | &clock, 847 | ctx, 848 | ); 849 | 850 | let mut user_reward_manager_1 = new_user_reward_manager(&mut pool_reward_manager, &clock); 851 | change_user_reward_manager_share( 852 | &mut pool_reward_manager, 853 | &mut user_reward_manager_1, 854 | 100, 855 | &clock, 856 | ); 857 | 858 | clock::set_for_testing(&mut clock, 10 * MILLISECONDS_IN_DAY); 859 | 860 | let unallocated_rewards = cancel_pool_reward(&mut pool_reward_manager, 0, &clock); 861 | assert!(balance::value(&unallocated_rewards) == 50 * 1_000_000, 0); 862 | 863 | clock::set_for_testing(&mut clock, 15 * MILLISECONDS_IN_DAY); 864 | let user_reward_manager_rewards = claim_rewards( 865 | &mut pool_reward_manager, 866 | &mut user_reward_manager_1, 867 | &clock, 868 | 0, 869 | ); 870 | assert!(balance::value(&user_reward_manager_rewards) == 50 * 1_000_000, 0); 871 | 872 | let dust_rewards = close_pool_reward(&mut pool_reward_manager, 0, &clock); 873 | assert!(balance::value(&dust_rewards) == 0, 0); 874 | 875 | clock::set_for_testing(&mut clock, 20 * MILLISECONDS_IN_DAY); 876 | 877 | let mut user_reward_manager_2 = new_user_reward_manager(&mut pool_reward_manager, &clock); 878 | change_user_reward_manager_share( 879 | &mut pool_reward_manager, 880 | &mut user_reward_manager_2, 881 | 100, 882 | &clock, 883 | ); 884 | 885 | clock::set_for_testing(&mut clock, 30 * MILLISECONDS_IN_DAY); 886 | let user_reward_manager_rewards_2 = claim_rewards( 887 | &mut pool_reward_manager, 888 | &mut user_reward_manager_2, 889 | &clock, 890 | 1, 891 | ); 892 | // std::debug::print(&balance::value(&user_reward_manager_rewards_2)); 893 | 894 | assert!(balance::value(&user_reward_manager_rewards_2) == 50 * 1_000_000, 0); 895 | 896 | sui::test_utils::destroy(unallocated_rewards); 897 | sui::test_utils::destroy(user_reward_manager_rewards); 898 | sui::test_utils::destroy(user_reward_manager_rewards_2); 899 | sui::test_utils::destroy(dust_rewards); 900 | sui::test_utils::destroy(clock); 901 | sui::test_utils::destroy(pool_reward_manager); 902 | sui::test_utils::destroy(user_reward_manager_1); 903 | sui::test_utils::destroy(user_reward_manager_2); 904 | test_scenario::end(scenario); 905 | } 906 | } 907 | -------------------------------------------------------------------------------- /contracts/suilend/sources/oracles.move: -------------------------------------------------------------------------------- 1 | /// This module contains logic for parsing pyth prices (and eventually switchboard prices) 2 | module suilend::oracles { 3 | use pyth::i64; 4 | use pyth::price::{Self, Price}; 5 | use pyth::price_feed; 6 | use pyth::price_identifier::{PriceIdentifier}; 7 | use pyth::price_info::{Self, PriceInfoObject}; 8 | use sui::clock::{Self, Clock}; 9 | use suilend::decimal::{Self, Decimal, mul, div}; 10 | 11 | // min confidence ratio of X means that the confidence interval must be less than (100/x)% of the price 12 | const MIN_CONFIDENCE_RATIO: u64 = 10; 13 | const MAX_STALENESS_SECONDS: u64 = 60; 14 | 15 | /// parse the pyth price info object to get a price and identifier. This function returns an None if the 16 | /// price is invalid due to confidence interval checks or staleness checks. It returns None instead of aborting 17 | /// so the caller can handle invalid prices gracefully by eg falling back to a different oracle 18 | /// return type: (spot price, ema price, price identifier) 19 | public fun get_pyth_price_and_identifier( 20 | price_info_obj: &PriceInfoObject, 21 | clock: &Clock, 22 | ): (Option, Decimal, PriceIdentifier) { 23 | let price_info = price_info::get_price_info_from_price_info_object(price_info_obj); 24 | let price_feed = price_info::get_price_feed(&price_info); 25 | let price_identifier = price_feed::get_price_identifier(price_feed); 26 | 27 | let ema_price = parse_price_to_decimal(price_feed::get_ema_price(price_feed)); 28 | 29 | let price = price_feed::get_price(price_feed); 30 | let price_mag = i64::get_magnitude_if_positive(&price::get_price(&price)); 31 | let conf = price::get_conf(&price); 32 | 33 | // confidence interval check 34 | // we want to make sure conf / price <= x% 35 | // -> conf * (100 / x )<= price 36 | if (conf * MIN_CONFIDENCE_RATIO > price_mag) { 37 | return (option::none(), ema_price, price_identifier) 38 | }; 39 | 40 | // check current sui time against pythnet publish time. there can be some issues that arise because the 41 | // timestamps are from different sources and may get out of sync, but that's why we have a fallback oracle 42 | let cur_time_s = clock::timestamp_ms(clock) / 1000; 43 | if ( 44 | cur_time_s > price::get_timestamp(&price) && // this is technically possible! 45 | cur_time_s - price::get_timestamp(&price) > MAX_STALENESS_SECONDS 46 | ) { 47 | return (option::none(), ema_price, price_identifier) 48 | }; 49 | 50 | let spot_price = parse_price_to_decimal(price); 51 | (option::some(spot_price), ema_price, price_identifier) 52 | } 53 | 54 | fun parse_price_to_decimal(price: Price): Decimal { 55 | // suilend doesn't support negative prices 56 | let price_mag = i64::get_magnitude_if_positive(&price::get_price(&price)); 57 | let expo = price::get_expo(&price); 58 | 59 | if (i64::get_is_negative(&expo)) { 60 | div( 61 | decimal::from(price_mag), 62 | decimal::from(std::u64::pow(10, (i64::get_magnitude_if_negative(&expo) as u8))), 63 | ) 64 | } else { 65 | mul( 66 | decimal::from(price_mag), 67 | decimal::from(std::u64::pow(10, (i64::get_magnitude_if_positive(&expo) as u8))), 68 | ) 69 | } 70 | } 71 | 72 | #[test_only] 73 | fun example_price_identifier(): PriceIdentifier { 74 | use pyth::price_identifier::{Self}; 75 | let mut v = vector::empty(); 76 | 77 | let mut i = 0; 78 | while (i < 32) { 79 | vector::push_back(&mut v, 0); 80 | i = i + 1; 81 | }; 82 | 83 | price_identifier::from_byte_vec(v) 84 | } 85 | 86 | #[test] 87 | fun happy() { 88 | use sui::test_scenario::{Self}; 89 | let owner = @0x26; 90 | let mut scenario = test_scenario::begin(owner); 91 | let clock = clock::create_for_testing(test_scenario::ctx(&mut scenario)); 92 | 93 | let price_info_object = price_info::new_price_info_object_for_testing( 94 | price_info::new_price_info( 95 | 0, 96 | 0, 97 | price_feed::new( 98 | example_price_identifier(), 99 | price::new( 100 | i64::new(8, false), 101 | 0, 102 | i64::new(5, false), 103 | 0, 104 | ), 105 | price::new( 106 | i64::new(8, false), 107 | 0, 108 | i64::new(4, true), 109 | 0, 110 | ), 111 | ), 112 | ), 113 | test_scenario::ctx(&mut scenario), 114 | ); 115 | let (spot_price, ema_price, price_identifier) = get_pyth_price_and_identifier( 116 | &price_info_object, 117 | &clock, 118 | ); 119 | assert!(spot_price == option::some(decimal::from(800_000)), 0); 120 | assert!(ema_price == decimal::from_bps(8), 0); 121 | assert!(price_identifier == example_price_identifier(), 0); 122 | 123 | price_info::destroy(price_info_object); 124 | clock::destroy_for_testing(clock); 125 | test_scenario::end(scenario); 126 | } 127 | 128 | #[test] 129 | fun confidence_interval_exceeded() { 130 | use sui::test_scenario::{Self}; 131 | let owner = @0x26; 132 | let mut scenario = test_scenario::begin(owner); 133 | let clock = clock::create_for_testing(test_scenario::ctx(&mut scenario)); 134 | 135 | let price_info_object = price_info::new_price_info_object_for_testing( 136 | price_info::new_price_info( 137 | 0, 138 | 0, 139 | price_feed::new( 140 | example_price_identifier(), 141 | price::new( 142 | i64::new(100, false), 143 | 11, 144 | i64::new(5, false), 145 | 0, 146 | ), 147 | price::new( 148 | i64::new(8, false), 149 | 0, 150 | i64::new(4, true), 151 | 0, 152 | ), 153 | ), 154 | ), 155 | test_scenario::ctx(&mut scenario), 156 | ); 157 | 158 | let (spot_price, ema_price, price_identifier) = get_pyth_price_and_identifier( 159 | &price_info_object, 160 | &clock, 161 | ); 162 | 163 | // condience interval higher than 10% of the price 164 | assert!(spot_price == option::none(), 0); 165 | assert!(ema_price == decimal::from_bps(8), 0); 166 | assert!(price_identifier == example_price_identifier(), 0); 167 | 168 | price_info::destroy(price_info_object); 169 | clock::destroy_for_testing(clock); 170 | test_scenario::end(scenario); 171 | } 172 | 173 | #[test] 174 | fun price_is_stale() { 175 | use sui::test_scenario::{Self}; 176 | let owner = @0x26; 177 | let mut scenario = test_scenario::begin(owner); 178 | let mut clock = clock::create_for_testing(test_scenario::ctx(&mut scenario)); 179 | clock::set_for_testing(&mut clock, 61_000); 180 | 181 | let price_info_object = price_info::new_price_info_object_for_testing( 182 | price_info::new_price_info( 183 | 0, 184 | 0, 185 | price_feed::new( 186 | example_price_identifier(), 187 | price::new( 188 | i64::new(100, false), 189 | 0, 190 | i64::new(5, false), 191 | 0, 192 | ), 193 | price::new( 194 | i64::new(8, false), 195 | 0, 196 | i64::new(4, true), 197 | 0, 198 | ), 199 | ), 200 | ), 201 | test_scenario::ctx(&mut scenario), 202 | ); 203 | 204 | let (spot_price, ema_price, price_identifier) = get_pyth_price_and_identifier( 205 | &price_info_object, 206 | &clock, 207 | ); 208 | 209 | assert!(spot_price == option::none(), 0); 210 | assert!(ema_price == decimal::from_bps(8), 0); 211 | assert!(price_identifier == example_price_identifier(), 0); 212 | 213 | price_info::destroy(price_info_object); 214 | clock::destroy_for_testing(clock); 215 | test_scenario::end(scenario); 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /contracts/suilend/sources/rate_limiter.move: -------------------------------------------------------------------------------- 1 | module suilend::rate_limiter { 2 | use suilend::decimal::{Self, Decimal, add, sub, mul, div, le, saturating_sub}; 3 | 4 | const EInvalidConfig: u64 = 0; 5 | const EInvalidTime: u64 = 1; 6 | const ERateLimitExceeded: u64 = 2; 7 | 8 | public struct RateLimiter has copy, drop, store { 9 | /// configuration parameters 10 | config: RateLimiterConfig, 11 | // state 12 | /// prev qty is the sum of all outflows from [window_start - config.window_duration, window_start) 13 | prev_qty: Decimal, 14 | /// time when window started 15 | window_start: u64, 16 | /// cur qty is the sum of all outflows from [window_start, window_start + config.window_duration) 17 | cur_qty: Decimal, 18 | } 19 | 20 | public struct RateLimiterConfig has copy, drop, store { 21 | /// Rate limiter window duration 22 | window_duration: u64, 23 | /// Rate limiter param. Max outflow in a window 24 | max_outflow: u64, 25 | } 26 | 27 | public fun new_config(window_duration: u64, max_outflow: u64): RateLimiterConfig { 28 | assert!(window_duration > 0, EInvalidConfig); 29 | RateLimiterConfig { 30 | window_duration, 31 | max_outflow, 32 | } 33 | } 34 | 35 | public fun new(config: RateLimiterConfig, cur_time: u64): RateLimiter { 36 | RateLimiter { 37 | config, 38 | prev_qty: decimal::from(0), 39 | window_start: cur_time, 40 | cur_qty: decimal::from(0), 41 | } 42 | } 43 | 44 | fun update_internal(rate_limiter: &mut RateLimiter, cur_time: u64) { 45 | assert!(cur_time >= rate_limiter.window_start, EInvalidTime); 46 | 47 | // |<-prev window->|<-cur window (cur_slot is in here)->| 48 | if (cur_time < rate_limiter.window_start + rate_limiter.config.window_duration) { 49 | return 50 | } else // |<-prev window->|<-cur window->| (cur_slot is in here) | 51 | if (cur_time < rate_limiter.window_start + 2 * rate_limiter.config.window_duration) { 52 | rate_limiter.prev_qty = rate_limiter.cur_qty; 53 | rate_limiter.window_start = 54 | rate_limiter.window_start + rate_limiter.config.window_duration; 55 | rate_limiter.cur_qty = decimal::from(0); 56 | } else // |<-prev window->|<-cur window->|<-cur window + 1->| ... | (cur_slot is in here) | 57 | { 58 | rate_limiter.prev_qty = decimal::from(0); 59 | rate_limiter.window_start = cur_time; 60 | rate_limiter.cur_qty = decimal::from(0); 61 | } 62 | } 63 | 64 | /// Calculate current outflow. Must only be called after update_internal()! 65 | fun current_outflow(rate_limiter: &RateLimiter, cur_time: u64): Decimal { 66 | // assume the prev_window's outflow is even distributed across the window 67 | // this isn't true, but it's a good enough approximation 68 | let prev_weight = div( 69 | sub( 70 | decimal::from(rate_limiter.config.window_duration), 71 | decimal::from(cur_time - rate_limiter.window_start + 1), 72 | ), 73 | decimal::from(rate_limiter.config.window_duration), 74 | ); 75 | 76 | add( 77 | mul(rate_limiter.prev_qty, prev_weight), 78 | rate_limiter.cur_qty, 79 | ) 80 | } 81 | 82 | /// update rate limiter with new quantity. errors if rate limit has been reached 83 | public fun process_qty(rate_limiter: &mut RateLimiter, cur_time: u64, qty: Decimal) { 84 | update_internal(rate_limiter, cur_time); 85 | 86 | rate_limiter.cur_qty = add(rate_limiter.cur_qty, qty); 87 | 88 | assert!( 89 | le( 90 | current_outflow(rate_limiter, cur_time), 91 | decimal::from(rate_limiter.config.max_outflow), 92 | ), 93 | ERateLimitExceeded, 94 | ); 95 | } 96 | 97 | public fun remaining_outflow(rate_limiter: &mut RateLimiter, cur_time: u64): Decimal { 98 | update_internal(rate_limiter, cur_time); 99 | saturating_sub( 100 | decimal::from(rate_limiter.config.max_outflow), 101 | current_outflow(rate_limiter, cur_time), 102 | ) 103 | } 104 | 105 | #[test] 106 | fun test_rate_limiter() { 107 | let mut rate_limiter = new( 108 | RateLimiterConfig { 109 | window_duration: 10, 110 | max_outflow: 100, 111 | }, 112 | 0, 113 | ); 114 | 115 | process_qty(&mut rate_limiter, 0, decimal::from(100)); 116 | 117 | let mut i = 0; 118 | while (i < 10) { 119 | assert!(current_outflow(&rate_limiter, i) == decimal::from(100), 0); 120 | i = i + 1; 121 | }; 122 | 123 | i = 10; 124 | while (i < 19) { 125 | process_qty(&mut rate_limiter, i, decimal::from(10)); 126 | assert!(current_outflow(&rate_limiter, i) == decimal::from(100), 0); 127 | assert!(remaining_outflow(&mut rate_limiter, i) == decimal::from(0), 0); 128 | i = i + 1; 129 | }; 130 | 131 | process_qty(&mut rate_limiter, 100, decimal::from(100)); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /contracts/suilend/sources/reserve_config.move: -------------------------------------------------------------------------------- 1 | /// parameters for a Reserve. 2 | module suilend::reserve_config { 3 | use sui::bag::{Self, Bag}; 4 | use suilend::decimal::{Self, Decimal, add, sub, mul, div, ge, le}; 5 | 6 | #[test_only] 7 | use sui::test_scenario::{Self}; 8 | 9 | const EInvalidReserveConfig: u64 = 0; 10 | const EInvalidUtil: u64 = 1; 11 | 12 | public struct ReserveConfig has store { 13 | // risk params 14 | open_ltv_pct: u8, 15 | close_ltv_pct: u8, 16 | max_close_ltv_pct: u8, // unused 17 | borrow_weight_bps: u64, 18 | // deposit limit in token amounts 19 | deposit_limit: u64, 20 | // borrow limit in token amounts 21 | borrow_limit: u64, 22 | // extra withdraw amount as bonus for liquidators 23 | liquidation_bonus_bps: u64, 24 | max_liquidation_bonus_bps: u64, // unused 25 | // deposit limit in usd 26 | deposit_limit_usd: u64, 27 | // borrow limit in usd 28 | borrow_limit_usd: u64, 29 | // interest params 30 | interest_rate_utils: vector, 31 | // in basis points 32 | interest_rate_aprs: vector, 33 | // fees 34 | borrow_fee_bps: u64, 35 | spread_fee_bps: u64, 36 | // extra withdraw amount as fee for protocol on liquidations 37 | protocol_liquidation_fee_bps: u64, 38 | // if true, the asset cannot be used as collateral 39 | // and can only be borrowed in isolation 40 | isolated: bool, 41 | // unused 42 | open_attributed_borrow_limit_usd: u64, 43 | close_attributed_borrow_limit_usd: u64, 44 | additional_fields: Bag, 45 | } 46 | 47 | public struct ReserveConfigBuilder has store { 48 | fields: Bag, 49 | } 50 | 51 | public fun create_reserve_config( 52 | open_ltv_pct: u8, 53 | close_ltv_pct: u8, 54 | max_close_ltv_pct: u8, 55 | borrow_weight_bps: u64, 56 | deposit_limit: u64, 57 | borrow_limit: u64, 58 | liquidation_bonus_bps: u64, 59 | max_liquidation_bonus_bps: u64, 60 | deposit_limit_usd: u64, 61 | borrow_limit_usd: u64, 62 | borrow_fee_bps: u64, 63 | spread_fee_bps: u64, 64 | protocol_liquidation_fee_bps: u64, 65 | interest_rate_utils: vector, 66 | interest_rate_aprs: vector, 67 | isolated: bool, 68 | open_attributed_borrow_limit_usd: u64, 69 | close_attributed_borrow_limit_usd: u64, 70 | ctx: &mut TxContext, 71 | ): ReserveConfig { 72 | let config = ReserveConfig { 73 | open_ltv_pct, 74 | close_ltv_pct, 75 | max_close_ltv_pct, 76 | borrow_weight_bps, 77 | deposit_limit, 78 | borrow_limit, 79 | liquidation_bonus_bps, 80 | max_liquidation_bonus_bps, 81 | deposit_limit_usd, 82 | borrow_limit_usd, 83 | interest_rate_utils, 84 | interest_rate_aprs, 85 | borrow_fee_bps, 86 | spread_fee_bps, 87 | protocol_liquidation_fee_bps, 88 | isolated, 89 | open_attributed_borrow_limit_usd, 90 | close_attributed_borrow_limit_usd, 91 | additional_fields: bag::new(ctx), 92 | }; 93 | 94 | validate_reserve_config(&config); 95 | config 96 | } 97 | 98 | fun validate_reserve_config(config: &ReserveConfig) { 99 | assert!(config.open_ltv_pct <= 100, EInvalidReserveConfig); 100 | assert!(config.close_ltv_pct <= 100, EInvalidReserveConfig); 101 | assert!(config.max_close_ltv_pct <= 100, EInvalidReserveConfig); 102 | 103 | assert!(config.open_ltv_pct <= config.close_ltv_pct, EInvalidReserveConfig); 104 | assert!(config.close_ltv_pct <= config.max_close_ltv_pct, EInvalidReserveConfig); 105 | 106 | assert!(config.borrow_weight_bps >= 10_000, EInvalidReserveConfig); 107 | assert!( 108 | config.liquidation_bonus_bps <= config.max_liquidation_bonus_bps, 109 | EInvalidReserveConfig, 110 | ); 111 | assert!( 112 | config.max_liquidation_bonus_bps + config.protocol_liquidation_fee_bps <= 2_000, 113 | EInvalidReserveConfig, 114 | ); 115 | 116 | if (config.isolated) { 117 | assert!(config.open_ltv_pct == 0 && config.close_ltv_pct == 0, EInvalidReserveConfig); 118 | }; 119 | 120 | assert!(config.borrow_fee_bps <= 10_000, EInvalidReserveConfig); 121 | assert!(config.spread_fee_bps <= 10_000, EInvalidReserveConfig); 122 | 123 | assert!( 124 | config.open_attributed_borrow_limit_usd <= config.close_attributed_borrow_limit_usd, 125 | EInvalidReserveConfig, 126 | ); 127 | 128 | validate_utils_and_aprs(&config.interest_rate_utils, &config.interest_rate_aprs); 129 | } 130 | 131 | fun validate_utils_and_aprs(utils: &vector, aprs: &vector) { 132 | assert!(vector::length(utils) >= 2, EInvalidReserveConfig); 133 | assert!(vector::length(utils) == vector::length(aprs), EInvalidReserveConfig); 134 | 135 | let length = vector::length(utils); 136 | assert!(*vector::borrow(utils, 0) == 0, EInvalidReserveConfig); 137 | assert!(*vector::borrow(utils, length-1) == 100, EInvalidReserveConfig); 138 | 139 | // check that: 140 | // - utils is strictly increasing 141 | // - aprs is monotonically increasing 142 | let mut i = 1; 143 | while (i < length) { 144 | assert!( 145 | *vector::borrow(utils, i - 1) < *vector::borrow(utils, i), 146 | EInvalidReserveConfig, 147 | ); 148 | assert!( 149 | *vector::borrow(aprs, i - 1) <= *vector::borrow(aprs, i), 150 | EInvalidReserveConfig, 151 | ); 152 | 153 | i = i + 1; 154 | } 155 | } 156 | 157 | public fun open_ltv(config: &ReserveConfig): Decimal { 158 | decimal::from_percent(config.open_ltv_pct) 159 | } 160 | 161 | public fun close_ltv(config: &ReserveConfig): Decimal { 162 | decimal::from_percent(config.close_ltv_pct) 163 | } 164 | 165 | public fun borrow_weight(config: &ReserveConfig): Decimal { 166 | decimal::from_bps(config.borrow_weight_bps) 167 | } 168 | 169 | public fun deposit_limit(config: &ReserveConfig): u64 { 170 | config.deposit_limit 171 | } 172 | 173 | public fun borrow_limit(config: &ReserveConfig): u64 { 174 | config.borrow_limit 175 | } 176 | 177 | public fun liquidation_bonus(config: &ReserveConfig): Decimal { 178 | decimal::from_bps(config.liquidation_bonus_bps) 179 | } 180 | 181 | public fun deposit_limit_usd(config: &ReserveConfig): u64 { 182 | config.deposit_limit_usd 183 | } 184 | 185 | public fun borrow_limit_usd(config: &ReserveConfig): u64 { 186 | config.borrow_limit_usd 187 | } 188 | 189 | public fun borrow_fee(config: &ReserveConfig): Decimal { 190 | decimal::from_bps(config.borrow_fee_bps) 191 | } 192 | 193 | public fun protocol_liquidation_fee(config: &ReserveConfig): Decimal { 194 | decimal::from_bps(config.protocol_liquidation_fee_bps) 195 | } 196 | 197 | public fun isolated(config: &ReserveConfig): bool { 198 | config.isolated 199 | } 200 | 201 | public fun spread_fee(config: &ReserveConfig): Decimal { 202 | decimal::from_bps(config.spread_fee_bps) 203 | } 204 | 205 | public fun calculate_apr(config: &ReserveConfig, cur_util: Decimal): Decimal { 206 | assert!(le(cur_util, decimal::from(1)), EInvalidUtil); 207 | 208 | let length = vector::length(&config.interest_rate_utils); 209 | 210 | let mut i = 1; 211 | while (i < length) { 212 | let left_util = decimal::from_percent( 213 | *vector::borrow(&config.interest_rate_utils, i - 1), 214 | ); 215 | let right_util = decimal::from_percent(*vector::borrow(&config.interest_rate_utils, i)); 216 | 217 | if (ge(cur_util, left_util) && le(cur_util, right_util)) { 218 | let left_apr = decimal::from_bps( 219 | *vector::borrow(&config.interest_rate_aprs, i - 1), 220 | ); 221 | let right_apr = decimal::from_bps(*vector::borrow(&config.interest_rate_aprs, i)); 222 | 223 | let weight = div( 224 | sub(cur_util, left_util), 225 | sub(right_util, left_util), 226 | ); 227 | 228 | let apr_diff = sub(right_apr, left_apr); 229 | return add( 230 | left_apr, 231 | mul(weight, apr_diff), 232 | ) 233 | }; 234 | 235 | i = i + 1; 236 | }; 237 | 238 | // should never get here 239 | assert!(1 == 0, EInvalidReserveConfig); 240 | decimal::from(0) 241 | } 242 | 243 | public fun calculate_supply_apr( 244 | config: &ReserveConfig, 245 | cur_util: Decimal, 246 | borrow_apr: Decimal, 247 | ): Decimal { 248 | let spread_fee = spread_fee(config); 249 | mul(mul(sub(decimal::from(1), spread_fee), borrow_apr), cur_util) 250 | } 251 | 252 | public fun destroy(config: ReserveConfig) { 253 | let ReserveConfig { 254 | open_ltv_pct: _, 255 | close_ltv_pct: _, 256 | max_close_ltv_pct: _, 257 | borrow_weight_bps: _, 258 | deposit_limit: _, 259 | borrow_limit: _, 260 | liquidation_bonus_bps: _, 261 | max_liquidation_bonus_bps: _, 262 | deposit_limit_usd: _, 263 | borrow_limit_usd: _, 264 | interest_rate_utils: _, 265 | interest_rate_aprs: _, 266 | borrow_fee_bps: _, 267 | spread_fee_bps: _, 268 | protocol_liquidation_fee_bps: _, 269 | isolated: _, 270 | open_attributed_borrow_limit_usd: _, 271 | close_attributed_borrow_limit_usd: _, 272 | additional_fields, 273 | } = config; 274 | 275 | bag::destroy_empty(additional_fields); 276 | } 277 | 278 | public fun from(config: &ReserveConfig, ctx: &mut TxContext): ReserveConfigBuilder { 279 | let mut builder = ReserveConfigBuilder { fields: bag::new(ctx) }; 280 | set_open_ltv_pct(&mut builder, config.open_ltv_pct); 281 | set_close_ltv_pct(&mut builder, config.close_ltv_pct); 282 | set_max_close_ltv_pct(&mut builder, config.max_close_ltv_pct); 283 | set_borrow_weight_bps(&mut builder, config.borrow_weight_bps); 284 | set_deposit_limit(&mut builder, config.deposit_limit); 285 | set_borrow_limit(&mut builder, config.borrow_limit); 286 | set_liquidation_bonus_bps(&mut builder, config.liquidation_bonus_bps); 287 | set_max_liquidation_bonus_bps(&mut builder, config.max_liquidation_bonus_bps); 288 | set_deposit_limit_usd(&mut builder, config.deposit_limit_usd); 289 | set_borrow_limit_usd(&mut builder, config.borrow_limit_usd); 290 | 291 | set_interest_rate_utils(&mut builder, config.interest_rate_utils); 292 | set_interest_rate_aprs(&mut builder, config.interest_rate_aprs); 293 | 294 | set_borrow_fee_bps(&mut builder, config.borrow_fee_bps); 295 | set_spread_fee_bps(&mut builder, config.spread_fee_bps); 296 | set_protocol_liquidation_fee_bps(&mut builder, config.protocol_liquidation_fee_bps); 297 | set_isolated(&mut builder, config.isolated); 298 | set_open_attributed_borrow_limit_usd(&mut builder, config.open_attributed_borrow_limit_usd); 299 | set_close_attributed_borrow_limit_usd( 300 | &mut builder, 301 | config.close_attributed_borrow_limit_usd, 302 | ); 303 | 304 | builder 305 | } 306 | 307 | fun set( 308 | builder: &mut ReserveConfigBuilder, 309 | field: K, 310 | value: V, 311 | ) { 312 | if (bag::contains(&builder.fields, field)) { 313 | let val: &mut V = bag::borrow_mut(&mut builder.fields, field); 314 | *val = value; 315 | } else { 316 | bag::add(&mut builder.fields, field, value); 317 | } 318 | } 319 | 320 | public fun set_open_ltv_pct(builder: &mut ReserveConfigBuilder, open_ltv_pct: u8) { 321 | set(builder, b"open_ltv_pct", open_ltv_pct); 322 | } 323 | 324 | public fun set_close_ltv_pct(builder: &mut ReserveConfigBuilder, close_ltv_pct: u8) { 325 | set(builder, b"close_ltv_pct", close_ltv_pct); 326 | } 327 | 328 | public fun set_max_close_ltv_pct(builder: &mut ReserveConfigBuilder, max_close_ltv_pct: u8) { 329 | set(builder, b"max_close_ltv_pct", max_close_ltv_pct); 330 | } 331 | 332 | public fun set_borrow_weight_bps(builder: &mut ReserveConfigBuilder, borrow_weight_bps: u64) { 333 | set(builder, b"borrow_weight_bps", borrow_weight_bps); 334 | } 335 | 336 | public fun set_deposit_limit(builder: &mut ReserveConfigBuilder, deposit_limit: u64) { 337 | set(builder, b"deposit_limit", deposit_limit); 338 | } 339 | 340 | public fun set_borrow_limit(builder: &mut ReserveConfigBuilder, borrow_limit: u64) { 341 | set(builder, b"borrow_limit", borrow_limit); 342 | } 343 | 344 | public fun set_liquidation_bonus_bps( 345 | builder: &mut ReserveConfigBuilder, 346 | liquidation_bonus_bps: u64, 347 | ) { 348 | set(builder, b"liquidation_bonus_bps", liquidation_bonus_bps); 349 | } 350 | 351 | public fun set_max_liquidation_bonus_bps( 352 | builder: &mut ReserveConfigBuilder, 353 | max_liquidation_bonus_bps: u64, 354 | ) { 355 | set(builder, b"max_liquidation_bonus_bps", max_liquidation_bonus_bps); 356 | } 357 | 358 | public fun set_deposit_limit_usd(builder: &mut ReserveConfigBuilder, deposit_limit_usd: u64) { 359 | set(builder, b"deposit_limit_usd", deposit_limit_usd); 360 | } 361 | 362 | public fun set_borrow_limit_usd(builder: &mut ReserveConfigBuilder, borrow_limit_usd: u64) { 363 | set(builder, b"borrow_limit_usd", borrow_limit_usd); 364 | } 365 | 366 | public fun set_interest_rate_utils( 367 | builder: &mut ReserveConfigBuilder, 368 | interest_rate_utils: vector, 369 | ) { 370 | set(builder, b"interest_rate_utils", interest_rate_utils); 371 | } 372 | 373 | public fun set_interest_rate_aprs( 374 | builder: &mut ReserveConfigBuilder, 375 | interest_rate_aprs: vector, 376 | ) { 377 | set(builder, b"interest_rate_aprs", interest_rate_aprs); 378 | } 379 | 380 | public fun set_borrow_fee_bps(builder: &mut ReserveConfigBuilder, borrow_fee_bps: u64) { 381 | set(builder, b"borrow_fee_bps", borrow_fee_bps); 382 | } 383 | 384 | public fun set_spread_fee_bps(builder: &mut ReserveConfigBuilder, spread_fee_bps: u64) { 385 | set(builder, b"spread_fee_bps", spread_fee_bps); 386 | } 387 | 388 | public fun set_protocol_liquidation_fee_bps( 389 | builder: &mut ReserveConfigBuilder, 390 | protocol_liquidation_fee_bps: u64, 391 | ) { 392 | set(builder, b"protocol_liquidation_fee_bps", protocol_liquidation_fee_bps); 393 | } 394 | 395 | public fun set_isolated(builder: &mut ReserveConfigBuilder, isolated: bool) { 396 | set(builder, b"isolated", isolated); 397 | } 398 | 399 | public fun set_open_attributed_borrow_limit_usd( 400 | builder: &mut ReserveConfigBuilder, 401 | open_attributed_borrow_limit_usd: u64, 402 | ) { 403 | set(builder, b"open_attributed_borrow_limit_usd", open_attributed_borrow_limit_usd); 404 | } 405 | 406 | public fun set_close_attributed_borrow_limit_usd( 407 | builder: &mut ReserveConfigBuilder, 408 | close_attributed_borrow_limit_usd: u64, 409 | ) { 410 | set(builder, b"close_attributed_borrow_limit_usd", close_attributed_borrow_limit_usd); 411 | } 412 | 413 | public fun build(mut builder: ReserveConfigBuilder, tx_context: &mut TxContext): ReserveConfig { 414 | let config = create_reserve_config( 415 | bag::remove(&mut builder.fields, b"open_ltv_pct"), 416 | bag::remove(&mut builder.fields, b"close_ltv_pct"), 417 | bag::remove(&mut builder.fields, b"max_close_ltv_pct"), 418 | bag::remove(&mut builder.fields, b"borrow_weight_bps"), 419 | bag::remove(&mut builder.fields, b"deposit_limit"), 420 | bag::remove(&mut builder.fields, b"borrow_limit"), 421 | bag::remove(&mut builder.fields, b"liquidation_bonus_bps"), 422 | bag::remove(&mut builder.fields, b"max_liquidation_bonus_bps"), 423 | bag::remove(&mut builder.fields, b"deposit_limit_usd"), 424 | bag::remove(&mut builder.fields, b"borrow_limit_usd"), 425 | bag::remove(&mut builder.fields, b"borrow_fee_bps"), 426 | bag::remove(&mut builder.fields, b"spread_fee_bps"), 427 | bag::remove(&mut builder.fields, b"protocol_liquidation_fee_bps"), 428 | bag::remove(&mut builder.fields, b"interest_rate_utils"), 429 | bag::remove(&mut builder.fields, b"interest_rate_aprs"), 430 | bag::remove(&mut builder.fields, b"isolated"), 431 | bag::remove(&mut builder.fields, b"open_attributed_borrow_limit_usd"), 432 | bag::remove(&mut builder.fields, b"close_attributed_borrow_limit_usd"), 433 | tx_context, 434 | ); 435 | 436 | let ReserveConfigBuilder { fields } = builder; 437 | bag::destroy_empty(fields); 438 | config 439 | } 440 | 441 | // === Tests == 442 | #[test] 443 | fun test_calculate_apr() { 444 | let owner = @0x26; 445 | let mut scenario = test_scenario::begin(owner); 446 | let mut config = default_reserve_config(scenario.ctx()); 447 | config.interest_rate_utils = { 448 | let mut v = vector::empty(); 449 | vector::push_back(&mut v, 0); 450 | vector::push_back(&mut v, 10); 451 | vector::push_back(&mut v, 100); 452 | v 453 | }; 454 | config.interest_rate_aprs = { 455 | let mut v = vector::empty(); 456 | vector::push_back(&mut v, 0); 457 | vector::push_back(&mut v, 10000); 458 | vector::push_back(&mut v, 100000); 459 | v 460 | }; 461 | 462 | assert!(calculate_apr(&config, decimal::from_percent(0)) == decimal::from(0), 0); 463 | assert!(calculate_apr(&config, decimal::from_percent(5)) == decimal::from_percent(50), 0); 464 | assert!(calculate_apr(&config, decimal::from_percent(10)) == decimal::from_percent(100), 0); 465 | assert!( 466 | calculate_apr(&config, decimal::from_percent(55)) == decimal::from_percent_u64(550), 467 | 0, 468 | ); 469 | assert!( 470 | calculate_apr(&config, decimal::from_percent(100)) == decimal::from_percent_u64(1000), 471 | 0, 472 | ); 473 | 474 | destroy(config); 475 | test_scenario::end(scenario); 476 | } 477 | 478 | #[test] 479 | fun test_valid_reserve_config() { 480 | let owner = @0x26; 481 | let mut scenario = test_scenario::begin(owner); 482 | 483 | let mut utils = vector::empty(); 484 | vector::push_back(&mut utils, 0); 485 | vector::push_back(&mut utils, 100); 486 | 487 | let mut aprs = vector::empty(); 488 | vector::push_back(&mut aprs, 0); 489 | vector::push_back(&mut aprs, 100); 490 | 491 | let config = create_reserve_config( 492 | 10, 493 | 10, 494 | 10, 495 | 10_000, 496 | 1, 497 | 1, 498 | 5, 499 | 5, 500 | 100000, 501 | 100000, 502 | 10, 503 | 2000, 504 | 30, 505 | utils, 506 | aprs, 507 | false, 508 | 0, 509 | 0, 510 | test_scenario::ctx(&mut scenario), 511 | ); 512 | 513 | destroy(config); 514 | test_scenario::end(scenario); 515 | } 516 | 517 | // TODO: there are so many other invalid states to test 518 | #[test] 519 | #[expected_failure(abort_code = EInvalidReserveConfig)] 520 | fun test_invalid_reserve_config() { 521 | let owner = @0x26; 522 | let mut scenario = test_scenario::begin(owner); 523 | 524 | let config = create_reserve_config( 525 | // open ltv pct 526 | 10, 527 | // close ltv pct 528 | 9, 529 | // max close ltv pct 530 | 10, 531 | // borrow weight bps 532 | 10_000, 533 | // deposit_limit 534 | 1, 535 | // borrow_limit 536 | 1, 537 | // liquidation bonus pct 538 | 5, 539 | // max liquidation bonus pct 540 | 5, 541 | 10, 542 | 10, 543 | // borrow fee bps 544 | 10, 545 | // spread fee bps 546 | 2000, 547 | // liquidation fee bps 548 | 3000, 549 | // utils 550 | { 551 | let mut v = vector::empty(); 552 | vector::push_back(&mut v, 0); 553 | vector::push_back(&mut v, 100); 554 | v 555 | }, 556 | // aprs 557 | { 558 | let mut v = vector::empty(); 559 | vector::push_back(&mut v, 0); 560 | vector::push_back(&mut v, 100); 561 | v 562 | }, 563 | false, 564 | 0, 565 | 0, 566 | test_scenario::ctx(&mut scenario), 567 | ); 568 | 569 | destroy(config); 570 | test_scenario::end(scenario); 571 | } 572 | 573 | #[test_only] 574 | public fun default_reserve_config(ctx: &mut TxContext): ReserveConfig { 575 | let config = create_reserve_config( 576 | // open ltv pct 577 | 0, 578 | // close ltv pct 579 | 0, 580 | // max close ltv pct 581 | 0, 582 | // borrow weight bps 583 | 10_000, 584 | // deposit_limit 585 | 18_446_744_073_709_551_615, 586 | // borrow_limit 587 | 18_446_744_073_709_551_615, 588 | // liquidation bonus pct 589 | 0, 590 | // max liquidation bonus pct 591 | 0, 592 | // deposit_limit_usd 593 | 18_446_744_073_709_551_615, 594 | // borrow_limit_usd 595 | 18_446_744_073_709_551_615, 596 | // borrow fee bps 597 | 0, 598 | // spread fee bps 599 | 0, 600 | // liquidation fee bps 601 | 0, 602 | // utils 603 | { 604 | let mut v = vector::empty(); 605 | vector::push_back(&mut v, 0); 606 | vector::push_back(&mut v, 100); 607 | v 608 | }, 609 | // aprs 610 | { 611 | let mut v = vector::empty(); 612 | vector::push_back(&mut v, 0); 613 | vector::push_back(&mut v, 0); 614 | v 615 | }, 616 | false, 617 | 18_446_744_073_709_551_615, 618 | 18_446_744_073_709_551_615, 619 | ctx, 620 | ); 621 | 622 | config 623 | } 624 | } 625 | -------------------------------------------------------------------------------- /contracts/suilend/sources/staker.move: -------------------------------------------------------------------------------- 1 | /// Stake unlent Sui. 2 | module suilend::staker { 3 | use liquid_staking::fees; 4 | use liquid_staking::liquid_staking::{Self, LiquidStakingInfo, AdminCap}; 5 | use sui::balance::{Self, Balance}; 6 | use sui::coin::{Self, TreasuryCap}; 7 | use sui::sui::SUI; 8 | use sui_system::sui_system::SuiSystemState; 9 | 10 | // errors 11 | const ETreasuryCapNonZeroSupply: u64 = 0; 12 | const EInvariantViolation: u64 = 1; 13 | 14 | // constants 15 | const U64_MAX: u64 = 18446744073709551615; 16 | const SUILEND_VALIDATOR: address = 17 | @0xce8e537664ba5d1d5a6a857b17bd142097138706281882be6805e17065ecde89; 18 | 19 | // This is mostly so i don't hit the "zero lst coin mint" error. 20 | const MIN_DEPLOY_AMOUNT: u64 = 1_000_000; // 1 SUI 21 | const MIST_PER_SUI: u64 = 1_000_000_000; 22 | 23 | public struct Staker has store { 24 | admin: AdminCap

, 25 | liquid_staking_info: LiquidStakingInfo

, 26 | lst_balance: Balance

, 27 | sui_balance: Balance, 28 | liabilities: u64, // how much sui is owed to the reserve 29 | } 30 | 31 | /* Public-View Functions */ 32 | public(package) fun liabilities

(staker: &Staker

): u64 { 33 | staker.liabilities 34 | } 35 | 36 | public(package) fun lst_balance

(staker: &Staker

): &Balance

{ 37 | &staker.lst_balance 38 | } 39 | 40 | public(package) fun sui_balance

(staker: &Staker

): &Balance { 41 | &staker.sui_balance 42 | } 43 | 44 | // this value can be stale if the staker hasn't refreshed the liquid_staking_info 45 | public(package) fun total_sui_supply

(staker: &Staker

): u64 { 46 | staker.liquid_staking_info.total_sui_supply() + staker.sui_balance.value() 47 | } 48 | 49 | public(package) fun liquid_staking_info

(staker: &Staker

): &LiquidStakingInfo

{ 50 | &staker.liquid_staking_info 51 | } 52 | 53 | /* Public Mutative Functions */ 54 | public(package) fun create_staker( 55 | treasury_cap: TreasuryCap

, 56 | ctx: &mut TxContext, 57 | ): Staker

{ 58 | assert!(coin::total_supply(&treasury_cap) == 0, ETreasuryCapNonZeroSupply); 59 | 60 | let (admin_cap, liquid_staking_info) = liquid_staking::create_lst( 61 | fees::new_builder(ctx).to_fee_config(), 62 | treasury_cap, 63 | ctx, 64 | ); 65 | 66 | Staker { 67 | admin: admin_cap, 68 | liquid_staking_info, 69 | lst_balance: balance::zero(), 70 | sui_balance: balance::zero(), 71 | liabilities: 0, 72 | } 73 | } 74 | 75 | public(package) fun deposit

(staker: &mut Staker

, sui: Balance) { 76 | staker.liabilities = staker.liabilities + sui.value(); 77 | staker.sui_balance.join(sui); 78 | } 79 | 80 | public(package) fun withdraw( 81 | staker: &mut Staker

, 82 | withdraw_amount: u64, 83 | system_state: &mut SuiSystemState, 84 | ctx: &mut TxContext, 85 | ): Balance { 86 | staker.liquid_staking_info.refresh(system_state, ctx); 87 | 88 | if (withdraw_amount > staker.sui_balance.value()) { 89 | let unstake_amount = withdraw_amount - staker.sui_balance.value(); 90 | staker.unstake_n_sui(system_state, unstake_amount, ctx); 91 | }; 92 | 93 | let sui = staker.sui_balance.split(withdraw_amount); 94 | staker.liabilities = staker.liabilities - sui.value(); 95 | 96 | sui 97 | } 98 | 99 | public(package) fun rebalance( 100 | staker: &mut Staker

, 101 | system_state: &mut SuiSystemState, 102 | ctx: &mut TxContext, 103 | ) { 104 | staker.liquid_staking_info.refresh(system_state, ctx); 105 | 106 | if (staker.sui_balance.value() < MIN_DEPLOY_AMOUNT) { 107 | return 108 | }; 109 | 110 | let sui = staker.sui_balance.withdraw_all(); 111 | let lst = staker 112 | .liquid_staking_info 113 | .mint( 114 | system_state, 115 | coin::from_balance(sui, ctx), 116 | ctx, 117 | ); 118 | staker.lst_balance.join(lst.into_balance()); 119 | 120 | staker 121 | .liquid_staking_info 122 | .increase_validator_stake( 123 | &staker.admin, 124 | system_state, 125 | SUILEND_VALIDATOR, 126 | U64_MAX, 127 | ctx, 128 | ); 129 | } 130 | 131 | public(package) fun claim_fees( 132 | staker: &mut Staker

, 133 | system_state: &mut SuiSystemState, 134 | ctx: &mut TxContext, 135 | ): Balance { 136 | staker.liquid_staking_info.refresh(system_state, ctx); 137 | 138 | let total_sui_supply = staker.total_sui_supply(); 139 | 140 | // leave 1 SUI extra, just in case 141 | let excess_sui = if (total_sui_supply > staker.liabilities + MIST_PER_SUI) { 142 | total_sui_supply - staker.liabilities - MIST_PER_SUI 143 | } else { 144 | 0 145 | }; 146 | 147 | if (excess_sui > staker.sui_balance.value()) { 148 | let unstake_amount = excess_sui - staker.sui_balance.value(); 149 | staker.unstake_n_sui(system_state, unstake_amount, ctx); 150 | }; 151 | 152 | let sui = staker.sui_balance.split(excess_sui); 153 | 154 | assert!(staker.total_sui_supply() >= staker.liabilities, EInvariantViolation); 155 | 156 | sui 157 | } 158 | 159 | /* Private Functions */ 160 | 161 | // liquid_staking_info must be refreshed before calling this 162 | // this function can unstake slightly more sui than requested due to rounding. 163 | fun unstake_n_sui( 164 | staker: &mut Staker

, 165 | system_state: &mut SuiSystemState, 166 | sui_amount_out: u64, 167 | ctx: &mut TxContext, 168 | ) { 169 | if (sui_amount_out == 0) { 170 | return 171 | }; 172 | 173 | let total_sui_supply = (staker.liquid_staking_info.total_sui_supply() as u128); 174 | let total_lst_supply = (staker.liquid_staking_info.total_lst_supply() as u128); 175 | 176 | // ceil lst redemption amount 177 | let lst_to_redeem = 178 | ((sui_amount_out as u128) * total_lst_supply + total_sui_supply - 1) / total_sui_supply; 179 | let lst = balance::split(&mut staker.lst_balance, (lst_to_redeem as u64)); 180 | 181 | let sui = liquid_staking::redeem( 182 | &mut staker.liquid_staking_info, 183 | coin::from_balance(lst, ctx), 184 | system_state, 185 | ctx, 186 | ); 187 | 188 | staker.sui_balance.join(sui.into_balance()); 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /contracts/suilend/sources/suilend.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": ".." 5 | }, 6 | { 7 | "path": "../../suilend-old" 8 | }, 9 | { 10 | "path": "../../../github/sui" 11 | }, 12 | { 13 | "path": "../../solana-program-library" 14 | } 15 | ] 16 | } -------------------------------------------------------------------------------- /contracts/suilend/sources/suilend.move: -------------------------------------------------------------------------------- 1 | module suilend::suilend { 2 | public struct MAIN_POOL has drop {} 3 | } 4 | -------------------------------------------------------------------------------- /contracts/suilend/tests/bug.move: -------------------------------------------------------------------------------- 1 | module suilend::test_dynamic_field { 2 | use sui::dynamic_field as df; 3 | use sui::test_scenario; 4 | use sui::test_utils::{destroy}; 5 | 6 | // Define a simple struct to hold a dynamic field 7 | public struct Container has key { 8 | id: UID, 9 | } 10 | 11 | // Utility function to create a Container with a dynamic field 12 | public fun create_container_with_field(ctx: &mut TxContext): Container { 13 | let mut container = Container { 14 | id: object::new(ctx), 15 | }; 16 | df::add(&mut container.id, b"field_key", 42u64); // Add a dynamic field with value 42 17 | container 18 | } 19 | 20 | #[test] 21 | fun test_one() { 22 | let mut scenario = test_scenario::begin(@0x1); 23 | let ctx = test_scenario::ctx(&mut scenario); 24 | let container = create_container_with_field(ctx); 25 | 26 | // Verify the dynamic field exists and has the correct value 27 | assert!(df::exists_(&container.id, b"field_key"), 1); 28 | let value: &u64 = df::borrow(&container.id, b"field_key"); 29 | assert!(*value == 42, 2); 30 | 31 | destroy(container); 32 | test_scenario::end(scenario); 33 | } 34 | 35 | #[test] 36 | fun test_two() { 37 | let mut scenario = test_scenario::begin(@0x1); 38 | let ctx = test_scenario::ctx(&mut scenario); 39 | let container = create_container_with_field(ctx); 40 | 41 | // Verify the dynamic field exists and has the correct value 42 | assert!(df::exists_(&container.id, b"field_key"), 1); 43 | let value: &u64 = df::borrow(&container.id, b"field_key"); 44 | assert!(*value == 42, 2); 45 | 46 | destroy(container); 47 | test_scenario::end(scenario); 48 | } 49 | } -------------------------------------------------------------------------------- /contracts/suilend/tests/mock_pyth.move: -------------------------------------------------------------------------------- 1 | #[test_only] 2 | module suilend::mock_pyth { 3 | use pyth::i64; 4 | use pyth::price; 5 | use pyth::price_feed; 6 | use pyth::price_identifier; 7 | use pyth::price_info::{Self, PriceInfoObject}; 8 | use sui::bag::{Self, Bag}; 9 | use sui::clock::{Self, Clock}; 10 | 11 | public struct PriceState has key { 12 | id: UID, 13 | price_objs: Bag, 14 | } 15 | 16 | public fun init_state(ctx: &mut TxContext): PriceState { 17 | PriceState { 18 | id: object::new(ctx), 19 | price_objs: bag::new(ctx), 20 | } 21 | } 22 | 23 | public fun register(state: &mut PriceState, ctx: &mut TxContext) { 24 | let price_info_obj = new_price_info_obj((bag::length(&state.price_objs) as u8), ctx); 25 | 26 | bag::add(&mut state.price_objs, std::type_name::get(), price_info_obj); 27 | } 28 | 29 | public fun new_price_info_obj(idx: u8, ctx: &mut TxContext): PriceInfoObject { 30 | let mut v = vector::empty(); 31 | vector::push_back(&mut v, idx); 32 | 33 | let mut i = 1; 34 | while (i < 32) { 35 | vector::push_back(&mut v, 0); 36 | i = i + 1; 37 | }; 38 | 39 | price_info::new_price_info_object_for_testing( 40 | price_info::new_price_info( 41 | 0, 42 | 0, 43 | price_feed::new( 44 | price_identifier::from_byte_vec(v), 45 | price::new( 46 | i64::new(0, false), 47 | 0, 48 | i64::new(0, false), 49 | 0, 50 | ), 51 | price::new( 52 | i64::new(0, false), 53 | 0, 54 | i64::new(0, false), 55 | 0, 56 | ), 57 | ), 58 | ), 59 | ctx, 60 | ) 61 | } 62 | 63 | public fun get_price_obj(state: &PriceState): &PriceInfoObject { 64 | bag::borrow(&state.price_objs, std::type_name::get()) 65 | } 66 | 67 | public fun update_price(state: &mut PriceState, price: u64, expo: u8, clock: &Clock) { 68 | let price_info_obj = bag::borrow_mut(&mut state.price_objs, std::type_name::get()); 69 | let price_info = price_info::get_price_info_from_price_info_object(price_info_obj); 70 | 71 | let price = price::new( 72 | i64::new(price, false), 73 | 0, 74 | i64::new((expo as u64), false), 75 | clock::timestamp_ms(clock) / 1000, 76 | ); 77 | 78 | price_info::update_price_info_object_for_testing( 79 | price_info_obj, 80 | &price_info::new_price_info( 81 | 0, 82 | 0, 83 | price_feed::new( 84 | price_info::get_price_identifier(&price_info), 85 | price, 86 | price, 87 | ), 88 | ), 89 | ); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /contracts/suilend/tests/reserve_tests.move: -------------------------------------------------------------------------------- 1 | module suilend::reserve_tests { 2 | use sui::sui::{SUI}; 3 | use sui::balance::{Self}; 4 | use sprungsui::sprungsui::SPRUNGSUI; 5 | use sui::coin::{Self}; 6 | use suilend::decimal::{Self, add, sub}; 7 | use suilend::reserve_config; 8 | use suilend::reserve::{ 9 | Self, 10 | create_for_testing, 11 | deposit_liquidity_and_mint_ctokens, 12 | redeem_ctokens, 13 | borrow_liquidity, 14 | claim_fees, 15 | compound_interest, 16 | repay_liquidity, 17 | Balances 18 | }; 19 | use sui::clock::{Self}; 20 | use sui_system::sui_system::{SuiSystemState}; 21 | use sui::test_scenario::{Self, Scenario}; 22 | 23 | #[test_only] 24 | public struct TEST_LM {} 25 | 26 | #[test] 27 | fun test_deposit_happy() { 28 | use suilend::test_usdc::{TEST_USDC}; 29 | use suilend::reserve_config::{default_reserve_config}; 30 | 31 | let owner = @0x26; 32 | let mut scenario = test_scenario::begin(owner); 33 | 34 | let mut reserve = create_for_testing( 35 | default_reserve_config(scenario.ctx()), 36 | 0, 37 | 6, 38 | decimal::from(1), 39 | 0, 40 | 500, 41 | 200, 42 | decimal::from(500), 43 | decimal::from(1), 44 | 1, 45 | test_scenario::ctx(&mut scenario), 46 | ); 47 | 48 | let ctokens = deposit_liquidity_and_mint_ctokens( 49 | &mut reserve, 50 | balance::create_for_testing(1000), 51 | ); 52 | 53 | assert!(balance::value(&ctokens) == 200, 0); 54 | assert!(reserve.available_amount() == 1500, 0); 55 | assert!(reserve.ctoken_supply() == 400, 0); 56 | 57 | let balances: &Balances = reserve::balances(&reserve); 58 | 59 | assert!(balance::value(balances.available_amount()) == 1500, 0); 60 | assert!(balance::supply_value(balances.ctoken_supply()) == 400, 0); 61 | 62 | sui::test_utils::destroy(reserve); 63 | sui::test_utils::destroy(ctokens); 64 | 65 | test_scenario::end(scenario); 66 | } 67 | 68 | #[test] 69 | #[expected_failure(abort_code = suilend::reserve::EDepositLimitExceeded)] 70 | fun test_deposit_fail() { 71 | use suilend::test_usdc::{TEST_USDC}; 72 | use suilend::reserve_config::{default_reserve_config}; 73 | 74 | let owner = @0x26; 75 | let mut scenario = test_scenario::begin(owner); 76 | 77 | let mut reserve = create_for_testing( 78 | { 79 | let config = default_reserve_config(scenario.ctx()); 80 | let mut builder = reserve_config::from(&config, test_scenario::ctx(&mut scenario)); 81 | reserve_config::set_deposit_limit(&mut builder, 1000); 82 | sui::test_utils::destroy(config); 83 | 84 | reserve_config::build(builder, test_scenario::ctx(&mut scenario)) 85 | }, 86 | 0, 87 | 6, 88 | decimal::from(1), 89 | 0, 90 | 500, 91 | 200, 92 | decimal::from(500), 93 | decimal::from(1), 94 | 1, 95 | test_scenario::ctx(&mut scenario), 96 | ); 97 | 98 | let coins = balance::create_for_testing(1); 99 | let ctokens = deposit_liquidity_and_mint_ctokens(&mut reserve, coins); 100 | 101 | sui::test_utils::destroy(reserve); 102 | sui::test_utils::destroy(ctokens); 103 | test_scenario::end(scenario); 104 | } 105 | 106 | #[test] 107 | #[expected_failure(abort_code = suilend::reserve::EDepositLimitExceeded)] 108 | fun test_deposit_fail_usd_limit() { 109 | use suilend::test_usdc::{TEST_USDC}; 110 | use suilend::reserve_config::{default_reserve_config}; 111 | 112 | let owner = @0x26; 113 | let mut scenario = test_scenario::begin(owner); 114 | 115 | let mut reserve = create_for_testing( 116 | { 117 | let config = default_reserve_config(scenario.ctx()); 118 | let mut builder = reserve_config::from(&config, test_scenario::ctx(&mut scenario)); 119 | reserve_config::set_deposit_limit(&mut builder, 18_446_744_073_709_551_615); 120 | reserve_config::set_deposit_limit_usd(&mut builder, 1); 121 | sui::test_utils::destroy(config); 122 | 123 | reserve_config::build(builder, test_scenario::ctx(&mut scenario)) 124 | }, 125 | 0, 126 | 6, 127 | decimal::from(1), 128 | 0, 129 | 500_000, 130 | 1_000_000, 131 | decimal::from(500_000), 132 | decimal::from(1), 133 | 1, 134 | test_scenario::ctx(&mut scenario), 135 | ); 136 | 137 | let coins = balance::create_for_testing(1); 138 | let ctokens = deposit_liquidity_and_mint_ctokens(&mut reserve, coins); 139 | 140 | sui::test_utils::destroy(reserve); 141 | sui::test_utils::destroy(ctokens); 142 | test_scenario::end(scenario); 143 | } 144 | 145 | #[test] 146 | fun test_redeem_happy() { 147 | use suilend::test_usdc::{TEST_USDC}; 148 | use suilend::reserve_config::{default_reserve_config}; 149 | 150 | let owner = @0x26; 151 | let mut scenario = test_scenario::begin(owner); 152 | 153 | let mut reserve = create_for_testing( 154 | default_reserve_config(scenario.ctx()), 155 | 0, 156 | 6, 157 | decimal::from(1), 158 | 0, 159 | 500, 160 | 200, 161 | decimal::from(500), 162 | decimal::from(1), 163 | 1, 164 | test_scenario::ctx(&mut scenario), 165 | ); 166 | 167 | let available_amount_old = reserve.available_amount(); 168 | let ctoken_supply_old = reserve.ctoken_supply(); 169 | 170 | let ctokens = balance::create_for_testing(10); 171 | let liquidity_request = redeem_ctokens(&mut reserve, ctokens); 172 | assert!(reserve::liquidity_request_amount(&liquidity_request) == 50, 0); 173 | assert!(reserve::liquidity_request_fee(&liquidity_request) == 0, 0); 174 | 175 | let tokens = reserve::fulfill_liquidity_request(&mut reserve, liquidity_request); 176 | 177 | assert!(balance::value(&tokens) == 50, 0); 178 | assert!(reserve.available_amount() == available_amount_old - 50, 0); 179 | assert!(reserve.ctoken_supply() == ctoken_supply_old - 10, 0); 180 | 181 | let balances: &Balances = reserve::balances(&reserve); 182 | 183 | assert!(balance::value(balances.available_amount()) == available_amount_old - 50, 0); 184 | assert!(balance::supply_value(balances.ctoken_supply()) == ctoken_supply_old - 10, 0); 185 | 186 | sui::test_utils::destroy(reserve); 187 | sui::test_utils::destroy(tokens); 188 | 189 | test_scenario::end(scenario); 190 | } 191 | 192 | #[test] 193 | fun test_borrow_happy() { 194 | use suilend::test_usdc::{TEST_USDC}; 195 | use suilend::reserve_config::{default_reserve_config}; 196 | 197 | let owner = @0x26; 198 | let mut scenario = test_scenario::begin(owner); 199 | setup_sui_system(&mut scenario); 200 | let mut reserve = create_for_testing( 201 | { 202 | let config = default_reserve_config(scenario.ctx()); 203 | let mut builder = reserve_config::from(&config, test_scenario::ctx(&mut scenario)); 204 | reserve_config::set_borrow_fee_bps(&mut builder, 100); 205 | sui::test_utils::destroy(config); 206 | 207 | reserve_config::build(builder, test_scenario::ctx(&mut scenario)) 208 | }, 209 | 0, 210 | 6, 211 | decimal::from(1), 212 | 0, 213 | 0, 214 | 0, 215 | decimal::from(0), 216 | decimal::from(1), 217 | 1, 218 | test_scenario::ctx(&mut scenario), 219 | ); 220 | 221 | let ctokens = deposit_liquidity_and_mint_ctokens( 222 | &mut reserve, 223 | balance::create_for_testing(1000), 224 | ); 225 | 226 | let available_amount_old = reserve.available_amount(); 227 | let borrowed_amount_old = reserve.borrowed_amount(); 228 | 229 | let liquidity_request = borrow_liquidity(&mut reserve, 400); 230 | assert!(reserve::liquidity_request_amount(&liquidity_request) == 404, 0); 231 | assert!(reserve::liquidity_request_fee(&liquidity_request) == 4, 0); 232 | 233 | let tokens = reserve::fulfill_liquidity_request(&mut reserve, liquidity_request); 234 | assert!(balance::value(&tokens) == 400, 0); 235 | 236 | assert!(reserve.available_amount() == available_amount_old - 404, 0); 237 | assert!(reserve.borrowed_amount() == add(borrowed_amount_old, decimal::from(404)), 0); 238 | 239 | let balances: &Balances = reserve::balances(&reserve); 240 | 241 | assert!(balance::value(balances.available_amount()) == available_amount_old - 404, 0); 242 | assert!(balance::value(balances.fees()) == 4, 0); 243 | 244 | let mut system_state = test_scenario::take_shared(&scenario); 245 | let (ctoken_fees, fees) = claim_fees(&mut reserve, &mut system_state, test_scenario::ctx(&mut scenario)); 246 | test_scenario::return_shared(system_state); 247 | 248 | assert!(balance::value(&fees) == 4, 0); 249 | assert!(balance::value(&ctoken_fees) == 0, 0); 250 | 251 | sui::test_utils::destroy(fees); 252 | sui::test_utils::destroy(ctoken_fees); 253 | sui::test_utils::destroy(reserve); 254 | sui::test_utils::destroy(tokens); 255 | sui::test_utils::destroy(ctokens); 256 | 257 | test_scenario::end(scenario); 258 | } 259 | 260 | #[test] 261 | #[expected_failure(abort_code = suilend::reserve::EBorrowLimitExceeded)] 262 | fun test_borrow_fail() { 263 | use suilend::test_usdc::{TEST_USDC}; 264 | use suilend::reserve_config::{default_reserve_config}; 265 | 266 | let owner = @0x26; 267 | let mut scenario = test_scenario::begin(owner); 268 | 269 | let mut reserve = create_for_testing( 270 | { 271 | let config = default_reserve_config(scenario.ctx()); 272 | let mut builder = reserve_config::from(&config, test_scenario::ctx(&mut scenario)); 273 | reserve_config::set_borrow_limit(&mut builder, 0); 274 | sui::test_utils::destroy(config); 275 | 276 | reserve_config::build(builder, test_scenario::ctx(&mut scenario)) 277 | }, 278 | 0, 279 | 6, 280 | decimal::from(1), 281 | 0, 282 | 0, 283 | 0, 284 | decimal::from(0), 285 | decimal::from(1), 286 | 1, 287 | test_scenario::ctx(&mut scenario), 288 | ); 289 | 290 | let ctokens = deposit_liquidity_and_mint_ctokens( 291 | &mut reserve, 292 | balance::create_for_testing(1000), 293 | ); 294 | 295 | let liquidity_request = borrow_liquidity(&mut reserve, 1); 296 | 297 | sui::test_utils::destroy(liquidity_request); 298 | sui::test_utils::destroy(reserve); 299 | sui::test_utils::destroy(ctokens); 300 | 301 | test_scenario::end(scenario); 302 | } 303 | 304 | #[test] 305 | #[expected_failure(abort_code = suilend::reserve::EBorrowLimitExceeded)] 306 | fun test_borrow_fail_usd_limit() { 307 | use suilend::test_usdc::{TEST_USDC}; 308 | use suilend::reserve_config::{default_reserve_config}; 309 | 310 | let owner = @0x26; 311 | let mut scenario = test_scenario::begin(owner); 312 | 313 | let mut reserve = create_for_testing( 314 | { 315 | let config = default_reserve_config(scenario.ctx()); 316 | let mut builder = reserve_config::from(&config, test_scenario::ctx(&mut scenario)); 317 | reserve_config::set_borrow_limit_usd(&mut builder, 1); 318 | sui::test_utils::destroy(config); 319 | 320 | reserve_config::build(builder, test_scenario::ctx(&mut scenario)) 321 | }, 322 | 0, 323 | 6, 324 | decimal::from(1), 325 | 0, 326 | 0, 327 | 0, 328 | decimal::from(0), 329 | decimal::from(1), 330 | 1, 331 | test_scenario::ctx(&mut scenario), 332 | ); 333 | 334 | let ctokens = deposit_liquidity_and_mint_ctokens( 335 | &mut reserve, 336 | balance::create_for_testing(10_000_000), 337 | ); 338 | 339 | let liquidity_request = borrow_liquidity(&mut reserve, 1_000_000 + 1); 340 | 341 | sui::test_utils::destroy(liquidity_request); 342 | sui::test_utils::destroy(reserve); 343 | sui::test_utils::destroy(ctokens); 344 | 345 | test_scenario::end(scenario); 346 | } 347 | 348 | #[test] 349 | fun test_claim_fees() { 350 | use suilend::test_usdc::{TEST_USDC}; 351 | use suilend::reserve_config::{default_reserve_config}; 352 | 353 | let owner = @0x26; 354 | let mut scenario = test_scenario::begin(owner); 355 | setup_sui_system(&mut scenario); 356 | let mut clock = clock::create_for_testing(test_scenario::ctx(&mut scenario)); 357 | 358 | let mut reserve = create_for_testing( 359 | { 360 | let config = default_reserve_config(scenario.ctx()); 361 | let mut builder = reserve_config::from(&config, test_scenario::ctx(&mut scenario)); 362 | reserve_config::set_deposit_limit(&mut builder, 1000 * 1_000_000); 363 | reserve_config::set_borrow_limit(&mut builder, 1000 * 1_000_000); 364 | reserve_config::set_borrow_fee_bps(&mut builder, 0); 365 | reserve_config::set_spread_fee_bps(&mut builder, 5000); 366 | reserve_config::set_interest_rate_utils(&mut builder, { 367 | let mut v = vector::empty(); 368 | vector::push_back(&mut v, 0); 369 | vector::push_back(&mut v, 100); 370 | v 371 | }); 372 | reserve_config::set_interest_rate_aprs(&mut builder, { 373 | let mut v = vector::empty(); 374 | vector::push_back(&mut v, 0); 375 | vector::push_back(&mut v, 3153600000); 376 | v 377 | }); 378 | 379 | sui::test_utils::destroy(config); 380 | 381 | reserve_config::build(builder, test_scenario::ctx(&mut scenario)) 382 | }, 383 | 0, 384 | 6, 385 | decimal::from(1), 386 | 0, 387 | 0, 388 | 0, 389 | decimal::from(0), 390 | decimal::from(1), 391 | 0, 392 | test_scenario::ctx(&mut scenario), 393 | ); 394 | 395 | let ctokens = deposit_liquidity_and_mint_ctokens( 396 | &mut reserve, 397 | balance::create_for_testing(100 * 1_000_000), 398 | ); 399 | 400 | let liquidity_request = borrow_liquidity(&mut reserve, 50 * 1_000_000); 401 | let tokens = reserve::fulfill_liquidity_request(&mut reserve, liquidity_request); 402 | 403 | clock::set_for_testing(&mut clock, 1000); 404 | compound_interest(&mut reserve, &clock); 405 | 406 | let old_available_amount = reserve.available_amount(); 407 | let old_unclaimed_spread_fees = reserve.unclaimed_spread_fees(); 408 | 409 | let mut system_state = test_scenario::take_shared(&scenario); 410 | let (ctoken_fees, fees) = claim_fees(&mut reserve, &mut system_state, test_scenario::ctx(&mut scenario)); 411 | test_scenario::return_shared(system_state); 412 | 413 | // 0.5% interest a second with 50% take rate => 0.25% fee on 50 USDC = 0.125 USDC 414 | assert!(balance::value(&fees) == 125_000, 0); 415 | assert!(balance::value(&ctoken_fees) == 0, 0); 416 | 417 | assert!(reserve.available_amount() == old_available_amount - 125_000, 0); 418 | assert!( 419 | reserve.unclaimed_spread_fees() == sub(old_unclaimed_spread_fees, decimal::from(125_000)), 420 | 0, 421 | ); 422 | 423 | let balances: &Balances = reserve::balances(&reserve); 424 | assert!(balance::value(balances.available_amount()) == old_available_amount - 125_000, 0); 425 | 426 | sui::test_utils::destroy(clock); 427 | sui::test_utils::destroy(ctoken_fees); 428 | sui::test_utils::destroy(fees); 429 | sui::test_utils::destroy(reserve); 430 | sui::test_utils::destroy(tokens); 431 | sui::test_utils::destroy(ctokens); 432 | 433 | test_scenario::end(scenario); 434 | } 435 | 436 | use sui_system::governance_test_utils::{ 437 | advance_epoch_with_reward_amounts, 438 | create_validator_for_testing, 439 | create_sui_system_state_for_testing, 440 | }; 441 | 442 | 443 | const SUILEND_VALIDATOR: address = @0xce8e537664ba5d1d5a6a857b17bd142097138706281882be6805e17065ecde89; 444 | 445 | fun setup_sui_system(scenario: &mut Scenario) { 446 | test_scenario::next_tx(scenario, SUILEND_VALIDATOR); 447 | let validator = create_validator_for_testing(SUILEND_VALIDATOR, 100, test_scenario::ctx(scenario)); 448 | create_sui_system_state_for_testing(vector[validator], 0, 0, test_scenario::ctx(scenario)); 449 | 450 | advance_epoch_with_reward_amounts(0, 0, scenario); 451 | } 452 | 453 | #[test] 454 | fun test_claim_fees_with_staker() { 455 | use suilend::reserve_config::{default_reserve_config}; 456 | 457 | let owner = @0x26; 458 | let mut scenario = test_scenario::begin(owner); 459 | setup_sui_system(&mut scenario); 460 | 461 | let mut clock = clock::create_for_testing(test_scenario::ctx(&mut scenario)); 462 | 463 | let mut reserve = create_for_testing( 464 | { 465 | let config = default_reserve_config(scenario.ctx()); 466 | let mut builder = reserve_config::from(&config, test_scenario::ctx(&mut scenario)); 467 | reserve_config::set_spread_fee_bps(&mut builder, 5000); 468 | reserve_config::set_interest_rate_utils(&mut builder, { 469 | let mut v = vector::empty(); 470 | vector::push_back(&mut v, 0); 471 | vector::push_back(&mut v, 100); 472 | v 473 | }); 474 | reserve_config::set_interest_rate_aprs(&mut builder, { 475 | let mut v = vector::empty(); 476 | vector::push_back(&mut v, 0); 477 | vector::push_back(&mut v, 3153600000); 478 | v 479 | }); 480 | 481 | sui::test_utils::destroy(config); 482 | 483 | reserve_config::build(builder, test_scenario::ctx(&mut scenario)) 484 | }, 485 | 0, 486 | 9, 487 | decimal::from(1), 488 | 0, 489 | 0, 490 | 0, 491 | decimal::from(0), 492 | decimal::from(1), 493 | 0, 494 | test_scenario::ctx(&mut scenario) 495 | ); 496 | 497 | let ctokens = deposit_liquidity_and_mint_ctokens( 498 | &mut reserve, 499 | balance::create_for_testing(100 * 1_000_000_000) 500 | ); 501 | 502 | let liquidity_request = borrow_liquidity(&mut reserve, 50 * 1_000_000_000); 503 | let tokens = reserve::fulfill_liquidity_request(&mut reserve, liquidity_request); 504 | 505 | clock::set_for_testing(&mut clock, 1000); 506 | compound_interest(&mut reserve, &clock); 507 | 508 | let old_available_amount = reserve.available_amount(); 509 | let old_unclaimed_spread_fees = reserve.unclaimed_spread_fees(); 510 | 511 | let mut system_state = test_scenario::take_shared(&scenario); 512 | let treasury_cap = coin::create_treasury_cap_for_testing(scenario.ctx()); 513 | reserve::init_staker(&mut reserve, treasury_cap, test_scenario::ctx(&mut scenario)); 514 | reserve::rebalance_staker(&mut reserve, &mut system_state, test_scenario::ctx(&mut scenario)); 515 | 516 | let (ctoken_fees, fees) = claim_fees(&mut reserve, &mut system_state, test_scenario::ctx(&mut scenario)); 517 | 518 | // 0.5% interest a second with 50% take rate => 0.25% fee on 50 SUI = 0.125 SUI 519 | assert!(balance::value(&fees) == 125_000_000, 0); 520 | assert!(balance::value(&ctoken_fees) == 0, 0); 521 | 522 | assert!(reserve.available_amount() == old_available_amount - 125_000_000, 0); 523 | assert!(reserve.unclaimed_spread_fees() == sub(old_unclaimed_spread_fees, decimal::from(125_000_000)), 0); 524 | 525 | let balances: &Balances = reserve::balances(&reserve); 526 | assert!(balance::value(balances.available_amount()) == 0, 0); // all the sui has been staked 527 | 528 | test_scenario::return_shared(system_state); 529 | 530 | sui::test_utils::destroy(clock); 531 | sui::test_utils::destroy(ctoken_fees); 532 | sui::test_utils::destroy(fees); 533 | sui::test_utils::destroy(reserve); 534 | sui::test_utils::destroy(tokens); 535 | sui::test_utils::destroy(ctokens); 536 | 537 | test_scenario::end(scenario); 538 | } 539 | 540 | #[test] 541 | fun test_repay_happy() { 542 | use suilend::test_usdc::{TEST_USDC}; 543 | use suilend::reserve_config::{default_reserve_config}; 544 | 545 | let owner = @0x26; 546 | let mut scenario = test_scenario::begin(owner); 547 | 548 | let mut reserve = create_for_testing( 549 | { 550 | let config = default_reserve_config(scenario.ctx()); 551 | let mut builder = reserve_config::from(&config, test_scenario::ctx(&mut scenario)); 552 | reserve_config::set_borrow_fee_bps(&mut builder, 100); 553 | sui::test_utils::destroy(config); 554 | 555 | reserve_config::build(builder, test_scenario::ctx(&mut scenario)) 556 | }, 557 | 0, 558 | 6, 559 | decimal::from(1), 560 | 0, 561 | 0, 562 | 0, 563 | decimal::from(0), 564 | decimal::from(1), 565 | 1, 566 | test_scenario::ctx(&mut scenario), 567 | ); 568 | 569 | let ctokens = deposit_liquidity_and_mint_ctokens( 570 | &mut reserve, 571 | balance::create_for_testing(1000), 572 | ); 573 | 574 | let liquidity_request = borrow_liquidity(&mut reserve, 400); 575 | let tokens = reserve::fulfill_liquidity_request(&mut reserve, liquidity_request); 576 | 577 | let available_amount_old = reserve.available_amount(); 578 | let borrowed_amount_old = reserve.borrowed_amount(); 579 | 580 | repay_liquidity(&mut reserve, tokens, decimal::from_percent_u64(39_901)); 581 | 582 | assert!(reserve.available_amount() == available_amount_old + 400, 0); 583 | assert!( 584 | reserve.borrowed_amount() == sub(borrowed_amount_old, decimal::from_percent_u64(39_901)), 585 | 0, 586 | ); 587 | 588 | let balances: &Balances = reserve::balances(&reserve); 589 | assert!(balance::value(balances.available_amount()) == available_amount_old + 400, 0); 590 | 591 | sui::test_utils::destroy(reserve); 592 | sui::test_utils::destroy(ctokens); 593 | 594 | test_scenario::end(scenario); 595 | } 596 | } 597 | -------------------------------------------------------------------------------- /contracts/suilend/tests/staker_tests.move: -------------------------------------------------------------------------------- 1 | module suilend::staker_tests { 2 | use sui::balance; 3 | use sui::coin; 4 | use sui::sui::SUI; 5 | use sui::test_scenario::{Self, Scenario}; 6 | use sui_system::governance_test_utils::{ 7 | advance_epoch_with_reward_amounts, 8 | create_validator_for_testing, 9 | create_sui_system_state_for_testing 10 | }; 11 | use sui_system::sui_system::SuiSystemState; 12 | use suilend::staker::create_staker; 13 | 14 | public struct STAKER_TESTS has drop {} 15 | 16 | /* Constants */ 17 | const MIST_PER_SUI: u64 = 1_000_000_000; 18 | const SUILEND_VALIDATOR: address = 19 | @0xce8e537664ba5d1d5a6a857b17bd142097138706281882be6805e17065ecde89; 20 | 21 | public struct STAKER has drop {} 22 | 23 | fun setup_sui_system(scenario: &mut Scenario) { 24 | test_scenario::next_tx(scenario, SUILEND_VALIDATOR); 25 | let validator = create_validator_for_testing( 26 | SUILEND_VALIDATOR, 27 | 100, 28 | test_scenario::ctx(scenario), 29 | ); 30 | create_sui_system_state_for_testing(vector[validator], 0, 0, test_scenario::ctx(scenario)); 31 | 32 | advance_epoch_with_reward_amounts(0, 0, scenario); 33 | } 34 | 35 | #[test] 36 | public fun test_end_to_end_happy() { 37 | let owner = @0x26; 38 | let mut scenario = test_scenario::begin(owner); 39 | setup_sui_system(&mut scenario); 40 | 41 | let treasury_cap = coin::create_treasury_cap_for_testing( 42 | test_scenario::ctx(&mut scenario), 43 | ); 44 | 45 | let mut staker = create_staker(treasury_cap, test_scenario::ctx(&mut scenario)); 46 | assert!(staker.sui_balance().value() == 0, 0); 47 | assert!(staker.lst_balance().value() == 0, 0); 48 | assert!(staker.liabilities() == 0, 0); 49 | 50 | let mut system_state = test_scenario::take_shared(&scenario); 51 | staker.rebalance(&mut system_state, scenario.ctx()); 52 | 53 | let sui = balance::create_for_testing(100 * MIST_PER_SUI); 54 | staker.deposit(sui); 55 | 56 | assert!(staker.liabilities() == 100 * MIST_PER_SUI, 0); 57 | assert!(staker.sui_balance().value() == 100 * MIST_PER_SUI, 0); 58 | assert!(staker.lst_balance().value() == 0, 0); 59 | 60 | let sui = staker.withdraw(100 * MIST_PER_SUI, &mut system_state, scenario.ctx()); 61 | assert!(sui.value() == 100 * MIST_PER_SUI, 0); 62 | assert!(staker.liabilities() == 0, 0); 63 | assert!(staker.sui_balance().value() == 0, 0); 64 | assert!(staker.lst_balance().value() == 0, 0); 65 | 66 | staker.deposit(sui); 67 | staker.rebalance(&mut system_state, scenario.ctx()); 68 | 69 | assert!(staker.liabilities() == 100 * MIST_PER_SUI, 0); 70 | assert!(staker.sui_balance().value() == 0, 0); 71 | assert!(staker.lst_balance().value() == 100 * MIST_PER_SUI, 0); 72 | assert!(staker.total_sui_supply() == 100 * MIST_PER_SUI, 0); 73 | assert!(staker.liquid_staking_info().total_sui_supply() == 100 * MIST_PER_SUI, 0); 74 | 75 | test_scenario::return_shared(system_state); 76 | 77 | advance_epoch_with_reward_amounts(0, 0, &mut scenario); 78 | // 1 lst is worth 2 sui now 79 | advance_epoch_with_reward_amounts(0, 200, &mut scenario); // 100 SUI 80 | 81 | let mut system_state = test_scenario::take_shared(&scenario); 82 | 83 | staker.rebalance(&mut system_state, scenario.ctx()); 84 | assert!(staker.liabilities() == 100 * MIST_PER_SUI, 0); 85 | assert!(staker.sui_balance().value() == 0, 0); 86 | assert!(staker.liquid_staking_info().total_sui_supply() == 200 * MIST_PER_SUI, 0); 87 | assert!(staker.lst_balance().value() == 100 * MIST_PER_SUI, 0); 88 | assert!(staker.total_sui_supply() == 200 * MIST_PER_SUI, 0); 89 | 90 | let sui = staker.claim_fees(&mut system_state, scenario.ctx()); 91 | assert!(sui.value() == 99 * MIST_PER_SUI, 0); 92 | assert!(staker.liabilities() == 100 * MIST_PER_SUI, 0); 93 | assert!(staker.sui_balance().value() == 0, 0); 94 | assert!(staker.liquid_staking_info().total_sui_supply() == 101 * MIST_PER_SUI, 0); 95 | assert!(staker.lst_balance().value() == 50 * MIST_PER_SUI + 500_000_000, 0); 96 | assert!(staker.total_sui_supply() == 101 * MIST_PER_SUI, 0); 97 | sui::test_utils::destroy(sui); 98 | 99 | // should be no fees to claim 100 | let sui = staker.claim_fees(&mut system_state, scenario.ctx()); 101 | assert!(sui.value() == 0, 0); 102 | assert!(staker.liabilities() == 100 * MIST_PER_SUI, 0); 103 | assert!(staker.sui_balance().value() == 0, 0); 104 | assert!(staker.liquid_staking_info().total_sui_supply() == 101 * MIST_PER_SUI, 0); 105 | assert!(staker.lst_balance().value() == 50 * MIST_PER_SUI + 500_000_000, 0); 106 | assert!(staker.total_sui_supply() == 101 * MIST_PER_SUI, 0); 107 | sui::test_utils::destroy(sui); 108 | 109 | let sui = staker.withdraw(MIST_PER_SUI + 1, &mut system_state, scenario.ctx()); 110 | assert!(sui.value() == MIST_PER_SUI + 1, 0); 111 | assert!(staker.liabilities() == 99 * MIST_PER_SUI - 1, 0); 112 | assert!(staker.sui_balance().value() == 1, 0); 113 | assert!(staker.liquid_staking_info().total_sui_supply() == 100 * MIST_PER_SUI - 2, 0); 114 | assert!(staker.lst_balance().value() == 50 * MIST_PER_SUI - 1, 0); 115 | assert!(staker.total_sui_supply() == 100 * MIST_PER_SUI - 1, 0); 116 | sui::test_utils::destroy(sui); 117 | 118 | let sui = staker.claim_fees(&mut system_state, scenario.ctx()); 119 | assert!(sui.value() == 0); 120 | sui::test_utils::destroy(sui); 121 | 122 | let sui = staker.withdraw(0, &mut system_state, scenario.ctx()); 123 | assert!(sui.value() == 0); 124 | sui::test_utils::destroy(sui); 125 | 126 | test_scenario::return_shared(system_state); 127 | sui::test_utils::destroy(staker); 128 | test_scenario::end(scenario); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /contracts/suilend/tests/test_coins.move: -------------------------------------------------------------------------------- 1 | #[test_only] 2 | module suilend::test_usdc { 3 | use sui::coin::{Self, TreasuryCap, CoinMetadata}; 4 | 5 | public struct TEST_USDC has drop {} 6 | 7 | #[test_only] 8 | public fun create_currency( 9 | ctx: &mut TxContext, 10 | ): (TreasuryCap, CoinMetadata) { 11 | coin::create_currency( 12 | TEST_USDC {}, 13 | 6, 14 | vector::empty(), 15 | vector::empty(), 16 | vector::empty(), 17 | option::none(), 18 | ctx, 19 | ) 20 | } 21 | } 22 | 23 | #[test_only] 24 | module suilend::test_sui { 25 | use sui::coin::{Self, TreasuryCap, CoinMetadata}; 26 | 27 | public struct TEST_SUI has drop {} 28 | 29 | #[test_only] 30 | public fun create_currency( 31 | ctx: &mut TxContext, 32 | ): (TreasuryCap, CoinMetadata) { 33 | coin::create_currency( 34 | TEST_SUI {}, 35 | 9, 36 | vector::empty(), 37 | vector::empty(), 38 | vector::empty(), 39 | option::none(), 40 | ctx, 41 | ) 42 | } 43 | } 44 | 45 | #[test_only] 46 | module suilend::mock_metadata { 47 | use std::type_name; 48 | use sui::bag::{Self, Bag}; 49 | use sui::coin::CoinMetadata; 50 | use sui::test_utils; 51 | use suilend::test_sui::{Self, TEST_SUI}; 52 | use suilend::test_usdc::{Self, TEST_USDC}; 53 | 54 | public struct Metadata { 55 | metadata: Bag, 56 | } 57 | 58 | public fun init_metadata(ctx: &mut TxContext): Metadata { 59 | let mut bag = bag::new(ctx); 60 | 61 | let (test_usdc_cap, test_usdc_metadata) = test_usdc::create_currency(ctx); 62 | let (test_sui_cap, test_sui_metadata) = test_sui::create_currency(ctx); 63 | 64 | test_utils::destroy(test_usdc_cap); 65 | test_utils::destroy(test_sui_cap); 66 | 67 | bag::add(&mut bag, type_name::get(), test_usdc_metadata); 68 | bag::add(&mut bag, type_name::get(), test_sui_metadata); 69 | 70 | Metadata { 71 | metadata: bag, 72 | } 73 | } 74 | 75 | public fun get(metadata: &Metadata): &CoinMetadata { 76 | bag::borrow(&metadata.metadata, type_name::get()) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /math.md: -------------------------------------------------------------------------------- 1 | The goal of this page is to be a self-contained explanation of the big mathematical concepts involved in building a lending protocol. 2 | 3 | # Reserve 4 | 5 | For a given lending market, a reserve holds all deposits of a coin type for a given lending market. 6 | For example, the Suilend Main Market will have exactly 1 SUI reserve and 1 USDC reserve. 7 | 8 | If a user deposits/repays SUI, the SUI reserve will increase in supply. 9 | 10 | If a user borrows/withdraws SUI, the SUI reserve will decrease in supply. 11 | 12 | ## Reserve Utilization 13 | 14 | $$U_{r} = B_{r} / T_r = B_r / (B_{r} + A_{r})$$ 15 | 16 | Where: 17 | - $U_{r}$ is reserve utilization. $0 < U_{reserve} < 1$ 18 | - $B_r$ is the amount of tokens lent to borrowers from reserve $r$. 19 | - $A_r$ is the amount of tokens available in reserve $r$. These are tokens that are have been deposited into the reserve but not borrowed yet. 20 | - $T_r$ is the total supply of tokens in reserve $r$. 21 | 22 | Example: Say I (ripleys) deposit 100 USDC into Suilend, and Soju (our bd guy) deposit 100 SUI and borrows 50 USDC. 23 | 24 | The reserve utilization on the USDC reserve is $50 / (50 + 50)$ = 50%. 25 | 26 | ## CTokens 27 | 28 | When a user deposits SUI into Suilend, they will mint (ie get back) CSUI. This CSUI entitles the user to obtain their deposit from Suilend + additional interest. The interest is obtained by lending out the tokens to borrowers. 29 | 30 | The CToken ratio denotes the exchange rate between the CToken and its underlying asset. Formally, the ctoken ratio is calculated by: 31 | $$C_r = (B_r + A_r) / T_{C_r}$$ 32 | 33 | Where: 34 | - $C_r$ is the ctoken ratio for reserve $r$ 35 | - $B_r$ is the amount of tokens lent to borrowers for reserve $r$ (including interest) 36 | - $A_r$ is the amount of available tokens (ie not lent out) for reserve $r$ 37 | - $T_{C_r}$ is the total supply of ctokens in reserve $r$. 38 | 39 | 40 | Notes: 41 | - $C_r$ starts at 1 when the reserve is initialized, and grows over time. The CToken ratio never decreases. 42 | - a user cannot always exchange their CSUI back to SUI. In a worst case scenario, all deposited SUI could be lent out, so the protocol won't have any left for redemption. However, in this scenario, the interest rates will skyrocket, incentivizing new depositors and also incentivizing borrowers to pay back their debts. 43 | - the ctoken ratio captures the interest earned by a deposit. 44 | 45 | # Obligations 46 | 47 | An obligation tracks a user's deposits and borrows in a given lending market. 48 | 49 | The USD value of a user's borrows can never exceed the USD value of a user's deposits. Otherwise, the protocol can pick up bad debt! 50 | 51 | ## Obligation statuses 52 | 53 | ### Healthy 54 | 55 | An obligation O is healthy if: 56 | 57 | $$ \sum_{r}^{M}{B(O, r)} < \sum_{r}^{M}{LTV_{open}(r) * D(O, r)}$$ 58 | 59 | Where: 60 | - $M$ is the lending market 61 | - $r$ is a reserve in $M$ 62 | - $B(O, r)$ is the USD value of obligation O's borrows from reserve $r$ 63 | - $D(O, r)$ is the USD value of obligation O's deposits from reserve $r$ 64 | - $LTV_{open}(r)$ is the open LTV for reserve $r$. ($0 <= LTV_{open}(r) < 1$) 65 | 66 | 67 | ### Unhealthy 68 | 69 | An obligation O is unhealthy and eligible for liquidation if: 70 | 71 | $$ \sum_{r}^{M}{B(O, r)} >= \sum_{r}^{M}{LTV_{close}(r) * D(O, r)}$$ 72 | 73 | Where: 74 | - $LTV_{close}(r)$ is the close LTV for reserve $r$. ($0 <= LTV_{close}(r) < 1$) 75 | 76 | ### Underwater 77 | 78 | An obligation O is underwater if: 79 | 80 | $$ \sum_{r}^{M}{B(O, r)} > \sum_{r}^{M}{D(O, r)}$$ 81 | 82 | In this situation, the protocol has picked up bad debt. 83 | 84 | # Compounding debt and calculating interest rates 85 | 86 | In Suilend, debt is compounded every second. 87 | 88 | Compounded debt is tracked per obligation _and_ per reserve. Debt needs to be tracked per reserve because it affects the ctoken ratio. Debt needs to be tracked per obligation because otherwise users won't pay back their debt! 89 | 90 | This section is a bit complicated and only relevant if you want to understand the source code of the protocol. 91 | 92 | ## APR (Annual Percentage Rate) 93 | 94 | An APR is a representation of the yearly interest paid on your debt, without accounting for compounding. 95 | 96 | In Suilend, the APR is a function of reserve utilization. The exact function is subject to change. 97 | 98 | Note that reserve utilization only changes on a borrow or repay action. 99 | 100 | ## Compound debt per reserve 101 | 102 | $B_r$ from prior formulas (total tokens borrowed in reserve $r$) provides us a convenient way to compound global debt on a per-reserve basis. 103 | 104 | The formula below describes how to compound debt on a reserve: 105 | 106 | $$B(t=1)_r = B(t=0)_r * (1 + APR(U_r) / Y_{seconds})^1$$ 107 | 108 | Where: 109 | - $B(t)_r$ is the total amount of tokens borrowed in reserve $r$ at time $t$. 110 | - $APR(U_r)$ is the APR for a given utilization value. 111 | - $Y_{seconds}$ is the number of seconds in a year. 112 | 113 | Note that even if no additional users borrow tokens after $t=0$, due to compound interest, the borrowed amount will increase over time. 114 | 115 | ## Compound debt per obligation 116 | 117 | This is tricky to do efficiently since the APR can change every second and our borrowed amount can change on every borrow/repay action. Let's work through a simple example first. 118 | 119 | Lets say the owner of obligation $O$ initially borrows $b$ tokens from reserve $r$ at $t=T_1$. 120 | 121 | What is the compounded debt at $t=T_2$?. 122 | 123 | $$b\prod_{t=T_1 + 1}^{T_2}{(1 + APR_r(t)/365)}$$ 124 | Where: 125 | - $B$ is the amount of tokens borrowed from reserve $r$ at $t=0$. 126 | - $APR_r(t)$ is the variable APR for reserve $r$ at time $t$. 127 | 128 | Lets define a new variable I that accumulates the products. 129 | 130 | $$I_r(T) = \prod_{t=1}^{T}{(1 + APR_r(t)/365)}$$ 131 | 132 | Now, simplifying our first expression: 133 | 134 | $$b * I_r(T_2) / I_r(T_1) $$ 135 | 136 | Note that the term $b / I_r(T_1)$ is effectively normalizing the debt to $t=0$. In other words, if you borrowed $b / I_r(T_1)$ tokens at t=0, your debt at $t=T_1$ would be $b$ after compounding interest. 137 | 138 | Each obligation would "snapshot" the latest value of $I_r(t)$ after any action. This is equivalent to $I_r(T_1)$ in the expression above. 139 | 140 | $I_r(t)$ can be tracked globally per reserve. This is equivalent to $I_r(T_2)$ in the expression above. 141 | 142 | ## Compounding debt invariant 143 | 144 | At any time T and for any reserve, the following expression is true. 145 | 146 | $$B_r = \sum_{o}^{M}{B_r(o) * I_r(T) / I_r(T'(o))}$$ 147 | Where: 148 | - $B_r$ is the total amount of tokens borrowed in reserve $r$ at time $T$. 149 | - $B_r(o)$ is the amount of tokens borrowed from reserve $r$ by obligation $O$. 150 | - $I_r(T)$ is the latest cumulative borrow rate product for reserve $r$. 151 | - $T'(o)$ is the last time the obligation's debt was compounded. 152 | 153 | In other words, the global borrowed amount equals the sum of all borrowed tokens per obligation after compounding. 154 | 155 | # Liquidations 156 | 157 | The goal of liquidations is to force repay an unhealthy obligation's debt _before_ it goes underwater. Recall that an obligation O is unhealthy and eligible for liquidation if: 158 | 159 | $$ \sum_{r}^{M}{B(O, r)} >= \sum_{r}^{M}{LTV_{close}(r) * D(O, r)}$$ 160 | 161 | Where: 162 | - $M$ is the lending market 163 | - $r$ is a reserve in $M$ 164 | - $B(O, r)$ is the USD value of obligation O's borrows from reserve $r$ 165 | - $D(O, r)$ is the USD value of obligation O's deposits from reserve $r$ 166 | - $LTV_{close}(r)$ is the close LTV for reserve $r$. ($0 <= LTV_{close}(r) < 1$) 167 | 168 | Say the total value of a user's borrowed (deposited) amount is $B_{usd}$ ($D_{usd}$). The liquidator can repay $B_{usd} * CF$ of debt to receive $B_{usd} * CF * (1 + LB)$ worth of deposits. 169 | 170 | Where: 171 | - $CF$ is the close factor (see parameters section) 172 | - $LB$ is the liquidation bonus (see parameters section) 173 | 174 | Notes: 175 | - when an obligation is unhealthy but not underwater, the LTV decreases after a liquidation. This is good. 176 | - the liquidation bonus (LB) is what makes the liquidation profitable for a liquidator. 177 | 178 | # Parameters 179 | 180 | ## Open LTV (Loan-to-value) 181 | 182 | Open LTV is a percentage that limits how much can be _initially_ borrowed against a deposit. 183 | 184 | Open LTV is less than or equal to 1, and is defined _per_ reserve. This is because some tokens are more risky than others. For example, using USDC as collateral is much safer than using DOGE. 185 | 186 | ## Close LTV 187 | 188 | Close LTV is a percentage that represents the maximum amount that can be borrowed against a deposit. 189 | 190 | For a given reserve, Close LTV > Open LTV. 191 | 192 | ## Close Factor (CF) 193 | 194 | The Close Factor determines the percentage of an obligation's borrow that can be repaid on liquidation. 195 | 196 | Bounds: $0 < CF <= 1$ 197 | 198 | ## Liquidation Bonus (LB) 199 | 200 | The liquidation bonus determines the bonus a liquidator gets when they liquidate an obligation. This bonus value is what makes a liquidation profitable for the liquidator. 201 | 202 | Bounds: $0 <= LB < 1$ 203 | 204 | --------------------------------------------------------------------------------