Skip to main content

multiversx_price_aggregator_sc/
lib.rs

1#![no_std]
2// TODO: remove once minimum version is 1.87+
3#![allow(unknown_lints)]
4#![allow(clippy::collapsible_if)]
5#![allow(clippy::manual_is_multiple_of)]
6
7multiversx_sc::imports!();
8
9mod events;
10pub mod median;
11pub mod price_aggregator_data;
12
13use events::{Round, Timestamp};
14use multiversx_sc_modules::staking;
15use price_aggregator_data::{OracleStatus, PriceFeed, TimestampedPrice, TokenPair};
16
17const SUBMISSION_LIST_MAX_LEN: usize = 50;
18const SUBMISSION_LIST_MIN_LEN: usize = 3;
19const FIRST_SUBMISSION_TIMESTAMP_MAX_DIFF_SECONDS: DurationSeconds = DurationSeconds::new(30);
20pub const MAX_ROUND_DURATION_SECONDS: DurationSeconds = DurationSeconds::new(1_800); // 30 minutes
21const PAUSED_ERROR_MSG: &[u8] = b"Contract is paused";
22const PAIR_DECIMALS_NOT_CONFIGURED_ERROR: &[u8] = b"pair decimals not configured";
23const WRONG_NUMBER_OF_DECIMALS_ERROR: &[u8] = b"wrong number of decimals";
24
25#[multiversx_sc::contract]
26pub trait PriceAggregator:
27    multiversx_sc_modules::pause::PauseModule + staking::StakingModule + events::EventsModule
28{
29    #[init]
30    fn init(
31        &self,
32        staking_token: EgldOrEsdtTokenIdentifier,
33        staking_amount: BigUint,
34        slash_amount: BigUint,
35        slash_quorum: usize,
36        submission_count: usize,
37        oracles: MultiValueEncoded<ManagedAddress>,
38    ) {
39        self.init_staking_module(
40            &staking_token,
41            &staking_amount,
42            &slash_amount,
43            slash_quorum,
44            &oracles.to_vec(),
45        );
46
47        self.add_oracles(oracles);
48
49        self.require_valid_submission_count(submission_count);
50        self.submission_count().set(submission_count);
51
52        self.set_paused(true);
53    }
54
55    #[upgrade]
56    fn upgrade(&self) {
57        self.set_paused(true);
58    }
59
60    #[only_owner]
61    #[endpoint(changeAmounts)]
62    fn change_amounts(&self, staking_amount: BigUint, slash_amount: BigUint) {
63        require!(
64            staking_amount > 0 && slash_amount > 0,
65            "Staking and slash amount cannot be 0"
66        );
67        require!(
68            slash_amount <= staking_amount,
69            "Slash amount cannot be higher than required stake"
70        );
71
72        let user_whitelist = self.user_whitelist();
73        let slash_quorum = self.slash_quorum().get();
74
75        let mut users_owning_new_amount = 0;
76        for user in user_whitelist.iter() {
77            if staking_amount < self.staked_amount(&user).get() {
78                users_owning_new_amount += 1;
79            }
80            if users_owning_new_amount > slash_quorum {
81                break;
82            }
83        }
84
85        require!(
86            users_owning_new_amount > slash_quorum,
87            "New staking amount is too big compared to members staked amount"
88        );
89        self.required_stake_amount().set(staking_amount);
90        self.slash_amount().set(slash_amount);
91    }
92
93    #[only_owner]
94    #[endpoint(addOracles)]
95    fn add_oracles(&self, oracles: MultiValueEncoded<ManagedAddress>) {
96        let mut oracle_mapper = self.oracle_status();
97        for oracle in oracles {
98            if !oracle_mapper.contains_key(&oracle) {
99                let _ = oracle_mapper.insert(
100                    oracle.clone(),
101                    OracleStatus {
102                        total_submissions: 0,
103                        accepted_submissions: 0,
104                    },
105                );
106                self.add_board_member(oracle);
107            }
108        }
109    }
110
111    /// Also receives submission count,
112    /// so the owner does not have to update it manually with setSubmissionCount before this call
113    #[only_owner]
114    #[endpoint(removeOracles)]
115    fn remove_oracles(&self, submission_count: usize, oracles: MultiValueEncoded<ManagedAddress>) {
116        let mut oracle_mapper = self.oracle_status();
117        for oracle in oracles {
118            let _ = oracle_mapper.remove(&oracle);
119            self.remove_board_member(&oracle);
120        }
121
122        self.require_valid_submission_count(submission_count);
123        self.submission_count().set(submission_count);
124    }
125
126    #[endpoint]
127    fn submit(
128        &self,
129        from: ManagedBuffer,
130        to: ManagedBuffer,
131        submission_timestamp: Timestamp,
132        price: BigUint,
133        decimals: u8,
134    ) {
135        self.require_not_paused();
136        self.require_is_oracle();
137
138        self.require_valid_submission_timestamp(submission_timestamp);
139
140        self.check_decimals(&from, &to, decimals);
141
142        self.submit_unchecked(from, to, price, decimals);
143    }
144
145    fn submit_unchecked(
146        &self,
147        from: ManagedBuffer,
148        to: ManagedBuffer,
149        price: BigUint,
150        decimals: u8,
151    ) {
152        let token_pair = TokenPair { from, to };
153        let mut submissions = self
154            .submissions()
155            .entry(token_pair.clone())
156            .or_default()
157            .get();
158
159        let first_sub_time_mapper = self.first_submission_timestamp(&token_pair);
160        let last_sub_time_mapper = self.last_submission_timestamp(&token_pair);
161
162        let mut round_id = 0;
163        let wrapped_rounds = self.rounds().get(&token_pair);
164        if wrapped_rounds.is_some() {
165            round_id = wrapped_rounds.unwrap().len() + 1;
166        }
167
168        let current_timestamp = self.blockchain().get_block_timestamp_seconds();
169        let mut is_first_submission = false;
170        let mut first_submission_timestamp = if submissions.is_empty() {
171            first_sub_time_mapper.set(current_timestamp);
172            is_first_submission = true;
173
174            current_timestamp
175        } else {
176            first_sub_time_mapper.get()
177        };
178
179        // round was not completed in time, so it's discarded
180        if current_timestamp > first_submission_timestamp + MAX_ROUND_DURATION_SECONDS {
181            submissions.clear();
182            first_sub_time_mapper.set(current_timestamp);
183            last_sub_time_mapper.set(current_timestamp);
184
185            first_submission_timestamp = current_timestamp;
186            is_first_submission = true;
187            self.discard_round_event(&token_pair.from.clone(), &token_pair.to.clone(), round_id)
188        }
189
190        let caller = self.blockchain().get_caller();
191        let has_caller_already_submitted = submissions.contains_key(&caller);
192        let accepted = !has_caller_already_submitted
193            && (is_first_submission || current_timestamp >= first_submission_timestamp);
194        if accepted {
195            submissions.insert(caller.clone(), price.clone());
196            last_sub_time_mapper.set(current_timestamp);
197
198            self.create_new_round(token_pair.clone(), round_id, submissions, decimals);
199            self.add_submission_event(
200                &token_pair.from.clone(),
201                &token_pair.to.clone(),
202                round_id,
203                &price,
204            );
205        } else {
206            self.emit_discard_submission_event(
207                &token_pair,
208                round_id,
209                current_timestamp,
210                first_submission_timestamp,
211                has_caller_already_submitted,
212            );
213        }
214
215        self.oracle_status()
216            .entry(self.blockchain().get_caller())
217            .and_modify(|oracle_status| {
218                oracle_status.accepted_submissions += accepted as u64;
219                oracle_status.total_submissions += 1;
220            });
221    }
222
223    fn require_valid_submission_timestamp(&self, submission_timestamp: Timestamp) {
224        let current_timestamp = self.blockchain().get_block_timestamp_seconds();
225        require!(
226            submission_timestamp <= current_timestamp,
227            "Timestamp is from the future"
228        );
229        require!(
230            current_timestamp - submission_timestamp <= FIRST_SUBMISSION_TIMESTAMP_MAX_DIFF_SECONDS,
231            "First submission too old"
232        );
233    }
234
235    #[endpoint(submitBatch)]
236    fn submit_batch(
237        &self,
238        submissions: MultiValueEncoded<
239            MultiValue5<ManagedBuffer, ManagedBuffer, Timestamp, BigUint, u8>,
240        >,
241    ) {
242        self.require_not_paused();
243        self.require_is_oracle();
244
245        for (from, to, submission_timestamp, price, decimals) in submissions
246            .into_iter()
247            .map(|submission| submission.into_tuple())
248        {
249            self.require_valid_submission_timestamp(submission_timestamp);
250
251            self.check_decimals(&from, &to, decimals);
252
253            self.submit_unchecked(from, to, price, decimals);
254        }
255    }
256
257    fn require_is_oracle(&self) {
258        let caller = self.blockchain().get_caller();
259        require!(
260            self.oracle_status().contains_key(&caller) && self.is_staked_board_member(&caller),
261            "only oracles allowed"
262        );
263    }
264
265    fn require_valid_submission_count(&self, submission_count: usize) {
266        require!(
267            submission_count >= SUBMISSION_LIST_MIN_LEN
268                && submission_count <= self.oracle_status().len()
269                && submission_count <= SUBMISSION_LIST_MAX_LEN,
270            "Invalid submission count"
271        )
272    }
273
274    fn create_new_round(
275        &self,
276        token_pair: TokenPair<Self::Api>,
277        round: Round,
278        mut submissions: MapMapper<ManagedAddress, BigUint>,
279        decimals: u8,
280    ) {
281        let submissions_len = submissions.len();
282        if submissions_len >= self.submission_count().get() {
283            require!(
284                submissions_len <= SUBMISSION_LIST_MAX_LEN,
285                "submission list capacity exceeded"
286            );
287
288            let mut submissions_vec = ArrayVec::<BigUint, SUBMISSION_LIST_MAX_LEN>::new();
289            for submission_value in submissions.values() {
290                submissions_vec.push(submission_value);
291            }
292
293            let price_result = median::calculate(submissions_vec.as_mut_slice());
294            let price_opt = price_result.unwrap_or_else(|err| sc_panic!(err.as_bytes()));
295            let price = price_opt.unwrap_or_else(|| sc_panic!("no submissions"));
296            let price_feed = TimestampedPrice {
297                price,
298                timestamp: self.blockchain().get_block_timestamp_seconds(),
299                decimals,
300            };
301
302            submissions.clear();
303            self.first_submission_timestamp(&token_pair).clear();
304            self.last_submission_timestamp(&token_pair).clear();
305
306            self.rounds()
307                .entry(token_pair.clone())
308                .or_default()
309                .get()
310                .push(&price_feed);
311            self.emit_new_round_event(&token_pair, round, &price_feed);
312        }
313    }
314
315    #[view(latestRoundData)]
316    fn latest_round_data(&self) -> MultiValueEncoded<PriceFeed<Self::Api>> {
317        self.require_not_paused();
318        require!(!self.rounds().is_empty(), "no completed rounds");
319
320        let mut result = MultiValueEncoded::new();
321        for (token_pair, round_values) in self.rounds().iter() {
322            result.push(self.make_price_feed(token_pair, round_values));
323        }
324
325        result
326    }
327
328    #[view(latestPriceFeed)]
329    fn latest_price_feed(
330        &self,
331        from: ManagedBuffer,
332        to: ManagedBuffer,
333    ) -> MultiValue6<u32, ManagedBuffer, ManagedBuffer, Timestamp, BigUint, u8> {
334        require!(self.not_paused(), PAUSED_ERROR_MSG);
335
336        let token_pair = TokenPair { from, to };
337        let round_values = self
338            .rounds()
339            .get(&token_pair)
340            .unwrap_or_else(|| sc_panic!("token pair not found"));
341        let feed = self.make_price_feed(token_pair, round_values);
342        (
343            feed.round_id,
344            feed.from,
345            feed.to,
346            feed.timestamp,
347            feed.price,
348            feed.decimals,
349        )
350            .into()
351    }
352
353    #[view(latestPriceFeedOptional)]
354    fn latest_price_feed_optional(
355        &self,
356        from: ManagedBuffer,
357        to: ManagedBuffer,
358    ) -> OptionalValue<MultiValue6<u32, ManagedBuffer, ManagedBuffer, Timestamp, BigUint, u8>> {
359        Some(self.latest_price_feed(from, to)).into()
360    }
361
362    #[only_owner]
363    #[endpoint(setSubmissionCount)]
364    fn set_submission_count(&self, submission_count: usize) {
365        self.require_valid_submission_count(submission_count);
366        self.submission_count().set(submission_count);
367    }
368
369    fn make_price_feed(
370        &self,
371        token_pair: TokenPair<Self::Api>,
372        round_values: VecMapper<TimestampedPrice<Self::Api>>,
373    ) -> PriceFeed<Self::Api> {
374        let round_id = round_values.len();
375        let last_price = round_values.get(round_id);
376
377        PriceFeed {
378            round_id: round_id as u32,
379            from: token_pair.from,
380            to: token_pair.to,
381            timestamp: last_price.timestamp,
382            price: last_price.price,
383            decimals: last_price.decimals,
384        }
385    }
386
387    #[view(getOracles)]
388    #[title("oracles")]
389    fn get_oracles(&self) -> MultiValueEncoded<ManagedAddress> {
390        let mut result = MultiValueEncoded::new();
391        for key in self.oracle_status().keys() {
392            result.push(key);
393        }
394        result
395    }
396
397    fn clear_submissions(&self, token_pair: &TokenPair<Self::Api>) {
398        if let Some(mut pair_submission_mapper) = self.submissions().get(token_pair) {
399            pair_submission_mapper.clear();
400        }
401        self.first_submission_timestamp(token_pair).clear();
402        self.last_submission_timestamp(token_pair).clear();
403    }
404
405    #[only_owner]
406    #[endpoint(setPairDecimals)]
407    fn set_pair_decimals(&self, from: ManagedBuffer, to: ManagedBuffer, decimals: u8) {
408        let pair_decimals_mapper = self.pair_decimals(&from, &to);
409        if !pair_decimals_mapper.is_empty() {
410            self.require_paused();
411        }
412        pair_decimals_mapper.set(Some(decimals));
413        let pair = TokenPair { from, to };
414        self.clear_submissions(&pair);
415    }
416
417    fn check_decimals(&self, from: &ManagedBuffer, to: &ManagedBuffer, decimals: u8) {
418        let configured_decimals = self.get_pair_decimals(from, to);
419        require!(
420            decimals == configured_decimals,
421            WRONG_NUMBER_OF_DECIMALS_ERROR
422        )
423    }
424
425    #[view(getPairDecimals)]
426    fn get_pair_decimals(&self, from: &ManagedBuffer, to: &ManagedBuffer) -> u8 {
427        self.pair_decimals(from, to)
428            .get()
429            .unwrap_or_else(|| sc_panic!(PAIR_DECIMALS_NOT_CONFIGURED_ERROR))
430    }
431
432    #[storage_mapper("pair_decimals")]
433    fn pair_decimals(
434        &self,
435        from: &ManagedBuffer,
436        to: &ManagedBuffer,
437    ) -> SingleValueMapper<Option<u8>>;
438
439    #[view]
440    #[storage_mapper("submission_count")]
441    fn submission_count(&self) -> SingleValueMapper<usize>;
442
443    #[storage_mapper("oracle_status")]
444    fn oracle_status(&self) -> MapMapper<ManagedAddress, OracleStatus>;
445
446    #[storage_mapper("rounds")]
447    fn rounds(
448        &self,
449    ) -> MapStorageMapper<TokenPair<Self::Api>, VecMapper<TimestampedPrice<Self::Api>>>;
450
451    #[storage_mapper("first_submission_timestamp")]
452    fn first_submission_timestamp(
453        &self,
454        token_pair: &TokenPair<Self::Api>,
455    ) -> SingleValueMapper<Timestamp>;
456
457    #[storage_mapper("last_submission_timestamp")]
458    fn last_submission_timestamp(
459        &self,
460        token_pair: &TokenPair<Self::Api>,
461    ) -> SingleValueMapper<Timestamp>;
462
463    #[storage_mapper("submissions")]
464    fn submissions(
465        &self,
466    ) -> MapStorageMapper<TokenPair<Self::Api>, MapMapper<ManagedAddress, BigUint>>;
467}