|
| 1 | +#[starknet::contract] |
| 2 | +pub mod receptor { |
| 3 | + use access_control::access_control_component; |
| 4 | + use core::num::traits::Zero; |
| 5 | + use opus::core::roles::receptor_roles; |
| 6 | + use opus::external::interfaces::ITask; |
| 7 | + use opus::external::interfaces::{IEkuboOracleExtensionDispatcher, IEkuboOracleExtensionDispatcherTrait}; |
| 8 | + use opus::interfaces::IERC20::{IERC20Dispatcher, IERC20DispatcherTrait}; |
| 9 | + use opus::interfaces::IReceptor::IReceptor; |
| 10 | + use opus::interfaces::IShrine::{IShrineDispatcher, IShrineDispatcherTrait}; |
| 11 | + use opus::types::QuoteTokenInfo; |
| 12 | + use opus::utils::math::{median_of_three, scale_x128_to_wad}; |
| 13 | + use starknet::{ContractAddress, get_block_timestamp}; |
| 14 | + use wadray::{Wad, WAD_DECIMALS}; |
| 15 | + |
| 16 | + // |
| 17 | + // Components |
| 18 | + // |
| 19 | + |
| 20 | + component!(path: access_control_component, storage: access_control, event: AccessControlEvent); |
| 21 | + |
| 22 | + #[abi(embed_v0)] |
| 23 | + impl AccessControlPublic = access_control_component::AccessControl<ContractState>; |
| 24 | + impl AccessControlHelpers = access_control_component::AccessControlHelpers<ContractState>; |
| 25 | + |
| 26 | + // |
| 27 | + // Constants |
| 28 | + // |
| 29 | + |
| 30 | + const LOOP_START: u32 = 1; |
| 31 | + pub const NUM_QUOTE_TOKENS: u32 = 3; |
| 32 | + |
| 33 | + pub const MIN_TWAP_DURATION: u64 = 60; // seconds; acts as a sanity check |
| 34 | + |
| 35 | + pub const LOWER_UPDATE_FREQUENCY_BOUND: u64 = 15; // seconds (approx. Starknet block prod goal) |
| 36 | + pub const UPPER_UPDATE_FREQUENCY_BOUND: u64 = 4 * 60 * 60; // 4 hours * 60 minutes * 60 seconds |
| 37 | + |
| 38 | + // |
| 39 | + // Storage |
| 40 | + // |
| 41 | + |
| 42 | + #[storage] |
| 43 | + struct Storage { |
| 44 | + // components |
| 45 | + #[substorage(v0)] |
| 46 | + access_control: access_control_component::Storage, |
| 47 | + // Shrine associated with this module |
| 48 | + shrine: IShrineDispatcher, |
| 49 | + // Ekubo oracle extension for reading TWAP |
| 50 | + oracle_extension: IEkuboOracleExtensionDispatcher, |
| 51 | + // Collection of quote tokens, in no particular order |
| 52 | + // Starts from 1 |
| 53 | + // (idx) -> (token to be quoted per CASH) |
| 54 | + quote_tokens: LegacyMap<u32, QuoteTokenInfo>, |
| 55 | + // The duration in seconds for reading TWAP from Ekubo |
| 56 | + twap_duration: u64, |
| 57 | + // Block timestamp of the last `update_yin_price_internal` execution |
| 58 | + last_update_yin_price_call_timestamp: u64, |
| 59 | + // The minimal time difference in seconds of how often we |
| 60 | + // want to update yin spot price, |
| 61 | + update_frequency: u64, |
| 62 | + } |
| 63 | + |
| 64 | + // |
| 65 | + // Events |
| 66 | + // |
| 67 | + |
| 68 | + #[event] |
| 69 | + #[derive(Copy, Drop, starknet::Event, PartialEq)] |
| 70 | + pub enum Event { |
| 71 | + AccessControlEvent: access_control_component::Event, |
| 72 | + InvalidQuotes: InvalidQuotes, |
| 73 | + QuoteTokensUpdated: QuoteTokensUpdated, |
| 74 | + ValidQuotes: ValidQuotes, |
| 75 | + TwapDurationUpdated: TwapDurationUpdated, |
| 76 | + UpdateFrequencyUpdated: UpdateFrequencyUpdated, |
| 77 | + } |
| 78 | + |
| 79 | + #[derive(Copy, Drop, starknet::Event, PartialEq)] |
| 80 | + pub struct InvalidQuotes { |
| 81 | + pub quotes: Span<Wad> |
| 82 | + } |
| 83 | + |
| 84 | + #[derive(Copy, Drop, starknet::Event, PartialEq)] |
| 85 | + pub struct ValidQuotes { |
| 86 | + pub quotes: Span<Wad> |
| 87 | + } |
| 88 | + |
| 89 | + #[derive(Copy, Drop, starknet::Event, PartialEq)] |
| 90 | + pub struct QuoteTokensUpdated { |
| 91 | + pub quote_tokens: Span<QuoteTokenInfo> |
| 92 | + } |
| 93 | + |
| 94 | + #[derive(Copy, Drop, starknet::Event, PartialEq)] |
| 95 | + pub struct TwapDurationUpdated { |
| 96 | + pub old_duration: u64, |
| 97 | + pub new_duration: u64 |
| 98 | + } |
| 99 | + |
| 100 | + #[derive(Copy, Drop, starknet::Event, PartialEq)] |
| 101 | + pub struct UpdateFrequencyUpdated { |
| 102 | + pub old_frequency: u64, |
| 103 | + pub new_frequency: u64 |
| 104 | + } |
| 105 | + |
| 106 | + // |
| 107 | + // Constructor |
| 108 | + // |
| 109 | + |
| 110 | + #[constructor] |
| 111 | + fn constructor( |
| 112 | + ref self: ContractState, |
| 113 | + admin: ContractAddress, |
| 114 | + shrine: ContractAddress, |
| 115 | + oracle_extension: ContractAddress, |
| 116 | + update_frequency: u64, |
| 117 | + twap_duration: u64, |
| 118 | + quote_tokens: Span<ContractAddress> |
| 119 | + ) { |
| 120 | + self.access_control.initializer(admin, Option::Some(receptor_roles::default_admin_role())); |
| 121 | + |
| 122 | + self.shrine.write(IShrineDispatcher { contract_address: shrine }); |
| 123 | + |
| 124 | + self.set_oracle_extension_helper(oracle_extension); |
| 125 | + self.set_twap_duration_helper(twap_duration); |
| 126 | + self.set_quote_tokens_helper(quote_tokens); |
| 127 | + self.set_update_frequency_helper(update_frequency); |
| 128 | + } |
| 129 | + |
| 130 | + // |
| 131 | + // External Receptor functions |
| 132 | + // |
| 133 | + |
| 134 | + #[abi(embed_v0)] |
| 135 | + impl IReceptorImpl of IReceptor<ContractState> { |
| 136 | + fn get_oracle_extension(self: @ContractState) -> ContractAddress { |
| 137 | + self.oracle_extension.read().contract_address |
| 138 | + } |
| 139 | + |
| 140 | + fn get_quote_tokens(self: @ContractState) -> Span<QuoteTokenInfo> { |
| 141 | + let mut quote_tokens: Array<QuoteTokenInfo> = Default::default(); |
| 142 | + let mut index: u32 = LOOP_START; |
| 143 | + let end_index = LOOP_START + NUM_QUOTE_TOKENS; |
| 144 | + loop { |
| 145 | + if index == end_index { |
| 146 | + break quote_tokens.span(); |
| 147 | + } |
| 148 | + quote_tokens.append(self.quote_tokens.read(index)); |
| 149 | + index += 1; |
| 150 | + } |
| 151 | + } |
| 152 | + |
| 153 | + fn get_quotes(self: @ContractState) -> Span<Wad> { |
| 154 | + let oracle_extension = self.oracle_extension.read(); |
| 155 | + let twap_duration = self.twap_duration.read(); |
| 156 | + let cash = self.shrine.read().contract_address; |
| 157 | + |
| 158 | + let mut quotes: Array<Wad> = Default::default(); |
| 159 | + let mut index: u32 = LOOP_START; |
| 160 | + let end_index = LOOP_START + NUM_QUOTE_TOKENS; |
| 161 | + loop { |
| 162 | + if index == end_index { |
| 163 | + break quotes.span(); |
| 164 | + } |
| 165 | + |
| 166 | + let quote_token_info: QuoteTokenInfo = self.quote_tokens.read(index); |
| 167 | + let quote: u256 = oracle_extension |
| 168 | + .get_price_x128_over_last(cash, quote_token_info.address, twap_duration); |
| 169 | + let scaled_quote: Wad = scale_x128_to_wad(quote, quote_token_info.decimals); |
| 170 | + |
| 171 | + quotes.append(scaled_quote); |
| 172 | + index += 1; |
| 173 | + } |
| 174 | + } |
| 175 | + |
| 176 | + fn get_twap_duration(self: @ContractState) -> u64 { |
| 177 | + self.twap_duration.read() |
| 178 | + } |
| 179 | + |
| 180 | + fn get_update_frequency(self: @ContractState) -> u64 { |
| 181 | + self.update_frequency.read() |
| 182 | + } |
| 183 | + |
| 184 | + fn set_oracle_extension(ref self: ContractState, oracle_extension: ContractAddress) { |
| 185 | + self.access_control.assert_has_role(receptor_roles::SET_ORACLE_EXTENSION); |
| 186 | + |
| 187 | + self.set_oracle_extension_helper(oracle_extension); |
| 188 | + } |
| 189 | + |
| 190 | + fn set_quote_tokens(ref self: ContractState, quote_tokens: Span<ContractAddress>) { |
| 191 | + self.access_control.assert_has_role(receptor_roles::SET_QUOTE_TOKENS); |
| 192 | + |
| 193 | + self.set_quote_tokens_helper(quote_tokens); |
| 194 | + } |
| 195 | + |
| 196 | + fn set_update_frequency(ref self: ContractState, new_frequency: u64) { |
| 197 | + self.access_control.assert_has_role(receptor_roles::SET_UPDATE_FREQUENCY); |
| 198 | + assert( |
| 199 | + LOWER_UPDATE_FREQUENCY_BOUND <= new_frequency && new_frequency <= UPPER_UPDATE_FREQUENCY_BOUND, |
| 200 | + 'REC: Frequency out of bounds' |
| 201 | + ); |
| 202 | + |
| 203 | + self.set_update_frequency_helper(new_frequency); |
| 204 | + } |
| 205 | + |
| 206 | + fn set_twap_duration(ref self: ContractState, twap_duration: u64) { |
| 207 | + self.access_control.assert_has_role(receptor_roles::SET_TWAP_DURATION); |
| 208 | + |
| 209 | + self.set_twap_duration_helper(twap_duration); |
| 210 | + } |
| 211 | + |
| 212 | + fn update_yin_price(ref self: ContractState) { |
| 213 | + self.access_control.assert_has_role(receptor_roles::UPDATE_YIN_PRICE); |
| 214 | + self.update_yin_price_internal(); |
| 215 | + } |
| 216 | + } |
| 217 | + |
| 218 | + #[abi(embed_v0)] |
| 219 | + impl ITaskImpl of ITask<ContractState> { |
| 220 | + fn probe_task(self: @ContractState) -> bool { |
| 221 | + let seconds_since_last_update: u64 = get_block_timestamp() |
| 222 | + - self.last_update_yin_price_call_timestamp.read(); |
| 223 | + self.update_frequency.read() <= seconds_since_last_update |
| 224 | + } |
| 225 | + |
| 226 | + fn execute_task(ref self: ContractState) { |
| 227 | + assert(self.probe_task(), 'REC: Too soon to update price'); |
| 228 | + self.update_yin_price_internal(); |
| 229 | + } |
| 230 | + } |
| 231 | + |
| 232 | + // |
| 233 | + // Internal Receptor functions |
| 234 | + // |
| 235 | + |
| 236 | + #[generate_trait] |
| 237 | + impl ReceptorHelpers of ReceptorHelpersTrait { |
| 238 | + fn set_oracle_extension_helper(ref self: ContractState, oracle_extension: ContractAddress) { |
| 239 | + assert(oracle_extension.is_non_zero(), 'REC: Zero address for extension'); |
| 240 | + |
| 241 | + self.oracle_extension.write(IEkuboOracleExtensionDispatcher { contract_address: oracle_extension }); |
| 242 | + } |
| 243 | + |
| 244 | + // Note that this function does not check for duplicate tokens. |
| 245 | + fn set_quote_tokens_helper(ref self: ContractState, quote_tokens: Span<ContractAddress>) { |
| 246 | + assert(quote_tokens.len() == NUM_QUOTE_TOKENS, 'REC: Not 3 quote tokens'); |
| 247 | + |
| 248 | + let mut index = LOOP_START; |
| 249 | + let mut quote_tokens_copy = quote_tokens; |
| 250 | + let mut quote_tokens_info: Array<QuoteTokenInfo> = Default::default(); |
| 251 | + loop { |
| 252 | + match quote_tokens_copy.pop_front() { |
| 253 | + Option::Some(quote_token) => { |
| 254 | + let token = IERC20Dispatcher { contract_address: *quote_token }; |
| 255 | + let decimals: u8 = token.decimals(); |
| 256 | + assert(decimals <= WAD_DECIMALS, 'REC: Too many decimals'); |
| 257 | + |
| 258 | + let quote_token_info = QuoteTokenInfo { address: *quote_token, decimals }; |
| 259 | + self.quote_tokens.write(index, quote_token_info); |
| 260 | + quote_tokens_info.append(quote_token_info); |
| 261 | + index += 1; |
| 262 | + }, |
| 263 | + Option::None => { break; } |
| 264 | + } |
| 265 | + }; |
| 266 | + |
| 267 | + self.emit(QuoteTokensUpdated { quote_tokens: quote_tokens_info.span() }); |
| 268 | + } |
| 269 | + |
| 270 | + fn set_twap_duration_helper(ref self: ContractState, twap_duration: u64) { |
| 271 | + assert(twap_duration >= MIN_TWAP_DURATION, 'REC: TWAP duration too low'); |
| 272 | + |
| 273 | + let old_duration: u64 = self.twap_duration.read(); |
| 274 | + self.twap_duration.write(twap_duration); |
| 275 | + |
| 276 | + self.emit(TwapDurationUpdated { old_duration, new_duration: twap_duration }); |
| 277 | + } |
| 278 | + |
| 279 | + fn set_update_frequency_helper(ref self: ContractState, new_frequency: u64) { |
| 280 | + let old_frequency: u64 = self.update_frequency.read(); |
| 281 | + self.update_frequency.write(new_frequency); |
| 282 | + self.emit(UpdateFrequencyUpdated { old_frequency, new_frequency }); |
| 283 | + } |
| 284 | + |
| 285 | + fn update_yin_price_internal(ref self: ContractState) { |
| 286 | + let quotes = self.get_quotes(); |
| 287 | + |
| 288 | + let mut quotes_copy = quotes; |
| 289 | + loop { |
| 290 | + match quotes_copy.pop_front() { |
| 291 | + Option::Some(quote) => { if quote.is_zero() { |
| 292 | + self.emit(InvalidQuotes { quotes }); |
| 293 | + break; |
| 294 | + } }, |
| 295 | + Option::None => { |
| 296 | + let yin_price: Wad = median_of_three(quotes); |
| 297 | + self.shrine.read().update_yin_spot_price(yin_price); |
| 298 | + |
| 299 | + self.last_update_yin_price_call_timestamp.write(get_block_timestamp()); |
| 300 | + |
| 301 | + self.emit(ValidQuotes { quotes }); |
| 302 | + break; |
| 303 | + } |
| 304 | + }; |
| 305 | + }; |
| 306 | + } |
| 307 | + } |
| 308 | +} |
0 commit comments