Skip to content

Commit a91d145

Browse files
authored
feat: receptor (#600)
This PR adds the Receptor module that reads the median of yin's price based on three quote tokens from Ekubo's oracle extension, and submits the value to Shrine for the Controller to actuate on. Additionally, the Receptor implements the ITask interface.
1 parent 61cfc5c commit a91d145

12 files changed

+1038
-4
lines changed

src/constants.cairo

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
// Constants for tokens
2+
pub const DAI_DECIMALS: u8 = 18;
3+
pub const LUSD_DECIMALS: u8 = 18;
24
pub const USDC_DECIMALS: u8 = 6;
5+
pub const USDT_DECIMALS: u8 = 6;
36
pub const WBTC_DECIMALS: u8 = 8;
47

58
// Constants for oracles

src/core/receptor.cairo

Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
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+
}

src/core/roles.cairo

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,19 @@ pub mod purger_roles {
6868
}
6969
}
7070

71+
pub mod receptor_roles {
72+
pub const SET_ORACLE_EXTENSION: u128 = 1;
73+
pub const SET_QUOTE_TOKENS: u128 = 2;
74+
pub const SET_TWAP_DURATION: u128 = 4;
75+
pub const SET_UPDATE_FREQUENCY: u128 = 8;
76+
pub const UPDATE_YIN_PRICE: u128 = 16;
77+
78+
#[inline(always)]
79+
pub fn default_admin_role() -> u128 {
80+
SET_ORACLE_EXTENSION + SET_QUOTE_TOKENS + SET_TWAP_DURATION + SET_UPDATE_FREQUENCY + UPDATE_YIN_PRICE
81+
}
82+
}
83+
7184
pub mod seer_roles {
7285
pub const SET_ORACLES: u128 = 1;
7386
pub const SET_UPDATE_FREQUENCY: u128 = 2;
@@ -177,6 +190,11 @@ pub mod shrine_roles {
177190
MELT + REDISTRIBUTE + SEIZE
178191
}
179192

193+
#[inline(always)]
194+
pub fn receptor() -> u128 {
195+
UPDATE_YIN_SPOT_PRICE
196+
}
197+
180198
#[inline(always)]
181199
pub fn seer() -> u128 {
182200
ADVANCE

src/external/interfaces.cairo

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
11
use opus::types::pragma;
2+
use starknet::ContractAddress;
3+
4+
#[starknet::interface]
5+
pub trait IEkuboOracleExtension<TContractState> {
6+
// Returns the geomean average price of a token as a 128.128 between the given start and end
7+
// time
8+
fn get_price_x128_over_last(
9+
self: @TContractState, base_token: ContractAddress, quote_token: ContractAddress, period: u64
10+
) -> u256;
11+
}
212

313
#[starknet::interface]
414
pub trait IPragmaSpotOracle<TContractState> {

src/interfaces/IReceptor.cairo

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
use opus::types::QuoteTokenInfo;
2+
use starknet::ContractAddress;
3+
use wadray::Wad;
4+
5+
#[starknet::interface]
6+
pub trait IReceptor<TContractState> {
7+
// getters
8+
fn get_oracle_extension(self: @TContractState) -> ContractAddress;
9+
fn get_quote_tokens(self: @TContractState) -> Span<QuoteTokenInfo>;
10+
fn get_quotes(self: @TContractState) -> Span<Wad>;
11+
fn get_twap_duration(self: @TContractState) -> u64;
12+
fn get_update_frequency(self: @TContractState) -> u64;
13+
// setters
14+
fn set_oracle_extension(ref self: TContractState, oracle_extension: ContractAddress);
15+
fn set_quote_tokens(ref self: TContractState, quote_tokens: Span<ContractAddress>);
16+
fn set_twap_duration(ref self: TContractState, twap_duration: u64);
17+
fn set_update_frequency(ref self: TContractState, new_frequency: u64);
18+
fn update_yin_price(ref self: TContractState);
19+
}

0 commit comments

Comments
 (0)