1
// Copyright (C) Moondance Labs Ltd.
2
// This file is part of Tanssi.
3

            
4
// Tanssi is free software: you can redistribute it and/or modify
5
// it under the terms of the GNU General Public License as published by
6
// the Free Software Foundation, either version 3 of the License, or
7
// (at your option) any later version.
8

            
9
// Tanssi is distributed in the hope that it will be useful,
10
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
// GNU General Public License for more details.
13

            
14
// You should have received a copy of the GNU General Public License
15
// along with Tanssi.  If not, see <http://www.gnu.org/licenses/>
16

            
17
//! # Services Payment Price Oracle Pallet
18
//!
19
//! This pallet stores the token price in USD and provides functions
20
//! to calculate block production and collator assignment costs based on
21
//! a fixed monthly USD cost while preserving the ratio between the two services.
22
//!
23
//! ## Overview
24
//!
25
//! The pallet allows authorized accounts (via sudo) to set the current
26
//! STAR|TANSSI/USD price. It calculates costs such that:
27
//! 1. The total monthly cost equals `FixedMonthlyServicesCostUsd`
28
//! 2. The ratio between block_cost and session_cost is preserved from the
29
//!    reference values (`ReferenceBlockCost` and `ReferenceSessionCost`)
30
//!
31
//! ## Cost Calculation
32
//!
33
//! Given reference costs (e.g., 0.03 STAR|TANSSI/block and 50 STAR|TANSSI/session):
34
//! - Total reference monthly cost = (ref_block_cost * blocks_per_month) + (ref_session_cost * sessions_per_month)
35
//! - Scale factor = (monthly_cost_usd / token_price) / total_reference_monthly_cost
36
//! - Actual block_cost = ref_block_cost * scale_factor
37
//! - Actual session_cost = ref_session_cost * scale_factor
38
//!
39
//! This ensures the ratio is preserved while hitting the target monthly USD cost.
40

            
41
#![cfg_attr(not(feature = "std"), no_std)]
42
extern crate alloc;
43

            
44
#[cfg(test)]
45
mod mock;
46

            
47
#[cfg(test)]
48
mod tests;
49

            
50
#[cfg(feature = "runtime-benchmarks")]
51
mod benchmarking;
52

            
53
pub mod weights;
54
pub use weights::WeightInfo;
55

            
56
pub use pallet::*;
57

            
58
use {
59
    frame_support::pallet_prelude::*,
60
    frame_system::pallet_prelude::*,
61
    sp_runtime::{traits::Zero, FixedPointNumber, FixedU128},
62
};
63

            
64
/// Number of decimals for USD amounts (6 decimals, so $1 = 1_000_000)
65
pub const USD_DECIMALS: u32 = 6;
66
/// Seconds per month: 60 sec * 60 min * 24 hours * 30 days
67
pub const SECONDS_PER_MONTH: u128 = 60 * 60 * 24 * 30;
68

            
69
#[frame_support::pallet]
70
pub mod pallet {
71
    use super::*;
72

            
73
    #[pallet::pallet]
74
    pub struct Pallet<T>(_);
75

            
76
    #[pallet::config]
77
    pub trait Config: frame_system::Config<RuntimeEvent: From<Event<Self>>> {
78
        /// Origin that can set the token price (should be sudo).
79
        type SetPriceOrigin: EnsureOrigin<Self::RuntimeOrigin>;
80

            
81
        /// Fixed monthly services cost in USD (with USD_DECIMALS precision).
82
        /// For example, $2000 = 2_000_000_000 (2000 * 10^6)
83
        #[pallet::constant]
84
        type FixedMonthlyServicesCostUsd: Get<u128>;
85

            
86
        /// Block time in milliseconds.
87
        #[pallet::constant]
88
        type BlockTimeMs: Get<u64>;
89

            
90
        /// Session/Epoch duration in blocks.
91
        #[pallet::constant]
92
        type SessionDurationBlocks: Get<u32>;
93

            
94
        /// Token decimals (e.g., 12 for STAR|TANSSI).
95
        #[pallet::constant]
96
        type TokenDecimals: Get<u32>;
97

            
98
        /// Reference block production cost in token base units.
99
        /// This is used to maintain the ratio between block and session costs.
100
        /// Example: 0.03 STAR|TANSSI = 30_000_000_000 (with 12 decimals)
101
        #[pallet::constant]
102
        type ReferenceBlockCost: Get<u128>;
103

            
104
        /// Reference collator assignment cost per session in token base units.
105
        /// This is used to maintain the ratio between block and session costs.
106
        /// Example: 50 STAR|TANSSI = 50_000_000_000_000 (with 12 decimals)
107
        #[pallet::constant]
108
        type ReferenceSessionCost: Get<u128>;
109

            
110
        /// Weight information for extrinsics in this pallet.
111
        type WeightInfo: WeightInfo;
112

            
113
        /// The minimum acceptable token price in USD (with 18 decimals).
114
        #[pallet::constant]
115
        type MinTokenPrice: Get<u128>;
116

            
117
        /// The maximum acceptable token price in USD (with 18 decimals).
118
        #[pallet::constant]
119
        type MaxTokenPrice: Get<u128>;
120
    }
121

            
122
    #[pallet::error]
123
    pub enum Error<T> {
124
        /// The price cannot be zero.
125
        PriceCannotBeZero,
126
        /// The provided price is outside the acceptable bounds.
127
        PriceOutOfBounds,
128
    }
129

            
130
    #[pallet::event]
131
    #[pallet::generate_deposit(pub(super) fn deposit_event)]
132
    pub enum Event<T: Config> {
133
        /// Token price has been updated.
134
        PriceUpdated {
135
            /// The new price in USD (FixedU128 format).
136
            new_price: FixedU128,
137
        },
138
    }
139

            
140
    /// The current STAR|TANSSI/USD price stored as FixedU128.
141
    /// Represents how many USD one STAR|TANSSI token is worth.
142
    #[pallet::storage]
143
    #[pallet::getter(fn token_price_usd)]
144
    pub type TokenPriceUsd<T: Config> = StorageValue<_, FixedU128, OptionQuery>;
145

            
146
    #[pallet::genesis_config]
147
    #[derive(frame_support::DefaultNoBound)]
148
    pub struct GenesisConfig<T: Config> {
149
        /// Initial token price in USD (as FixedU128 inner value).
150
        /// If None, price will not be set at genesis.
151
        pub initial_price: Option<u128>,
152
        #[serde(skip)]
153
        pub _config: core::marker::PhantomData<T>,
154
    }
155

            
156
    #[pallet::genesis_build]
157
    impl<T: Config> BuildGenesisConfig for GenesisConfig<T> {
158
786
        fn build(&self) {
159
786
            if let Some(price) = self.initial_price {
160
3
                let fixed_price = FixedU128::from_inner(price);
161
3
                if !fixed_price.is_zero() {
162
3
                    let price_inner = fixed_price.into_inner();
163
3
                    assert!(
164
3
                        price_inner >= T::MinTokenPrice::get()
165
3
                            && price_inner <= T::MaxTokenPrice::get(),
166
                        "initial_price out of bounds"
167
                    );
168
3
                    TokenPriceUsd::<T>::put(fixed_price);
169
                }
170
783
            }
171
786
        }
172
    }
173

            
174
    #[pallet::call]
175
    impl<T: Config> Pallet<T> {
176
        /// Set the STAR|TANSSI token price in USD.
177
        ///
178
        /// The price is represented as a FixedU128 where the inner value
179
        /// represents the price with 18 decimal places.
180
        ///
181
        /// For example:
182
        /// - $1.00 = 1_000_000_000_000_000_000 (1 * 10^18)
183
        /// - $0.50 = 500_000_000_000_000_000 (0.5 * 10^18)
184
        #[pallet::call_index(0)]
185
        #[pallet::weight(T::WeightInfo::set_token_price())]
186
10
        pub fn set_token_price(origin: OriginFor<T>, price: FixedU128) -> DispatchResult {
187
10
            T::SetPriceOrigin::ensure_origin(origin)?;
188

            
189
9
            ensure!(!price.is_zero(), Error::<T>::PriceCannotBeZero);
190

            
191
8
            let price_inner = price.into_inner();
192
8
            ensure!(
193
8
                price_inner >= T::MinTokenPrice::get() && price_inner <= T::MaxTokenPrice::get(),
194
2
                Error::<T>::PriceOutOfBounds
195
            );
196

            
197
6
            TokenPriceUsd::<T>::put(price);
198

            
199
6
            Self::deposit_event(Event::PriceUpdated { new_price: price });
200

            
201
6
            Ok(())
202
        }
203
    }
204

            
205
    impl<T: Config> Pallet<T> {
206
        /// Get the current token price, or None if not set.
207
1661
        pub fn get_token_price() -> Option<FixedU128> {
208
1661
            TokenPriceUsd::<T>::get()
209
1661
        }
210

            
211
        /// Calculate the number of blocks per month based on block time.
212
12
        pub fn blocks_per_month() -> u128 {
213
12
            let block_time_ms = T::BlockTimeMs::get() as u128;
214
12
            if block_time_ms == 0 {
215
                return 0;
216
12
            }
217
12
            (SECONDS_PER_MONTH * 1000) / block_time_ms
218
12
        }
219

            
220
        /// Calculate the number of sessions per month.
221
6
        pub fn sessions_per_month() -> u128 {
222
6
            let session_duration = T::SessionDurationBlocks::get() as u128;
223
6
            if session_duration == 0 {
224
                return 0;
225
6
            }
226
6
            Self::blocks_per_month() / session_duration
227
6
        }
228

            
229
        /// Get one token unit based on token decimals.
230
5
        pub fn one_token() -> u128 {
231
5
            10u128.saturating_pow(T::TokenDecimals::get())
232
5
        }
233

            
234
        /// Calculate the total reference monthly cost in tokens.
235
        /// This is: (ref_block_cost * blocks_per_month) + (ref_session_cost * sessions_per_month)
236
4
        fn total_reference_monthly_cost() -> u128 {
237
4
            let blocks = Self::blocks_per_month();
238
4
            let sessions = Self::sessions_per_month();
239
4
            let ref_block_cost = T::ReferenceBlockCost::get();
240
4
            let ref_session_cost = T::ReferenceSessionCost::get();
241

            
242
4
            ref_block_cost
243
4
                .saturating_mul(blocks)
244
4
                .saturating_add(ref_session_cost.saturating_mul(sessions))
245
4
        }
246

            
247
        /// Calculate the scale factor to apply to reference costs.
248
        ///
249
        /// scale_factor = (monthly_cost_usd / token_price_usd) / total_reference_monthly_cost
250
        ///
251
        /// Returns the scale factor as FixedU128, or None if calculation fails.
252
1661
        fn calculate_scale_factor() -> Option<FixedU128> {
253
1661
            let price = Self::get_token_price()?;
254
4
            let monthly_cost_usd = T::FixedMonthlyServicesCostUsd::get();
255
4
            let total_ref_cost = Self::total_reference_monthly_cost();
256

            
257
4
            if total_ref_cost == 0 || price.is_zero() {
258
                return None;
259
4
            }
260

            
261
            // Convert monthly_cost_usd to tokens
262
            // monthly_tokens = monthly_cost_usd / price
263
4
            let monthly_tokens_in_usd = Self::usd_to_tokens(monthly_cost_usd, price)?;
264

            
265
            // scale_factor = monthly_tokens / total_ref_cost
266
            // We use FixedU128 for precision
267
4
            let scale = FixedU128::checked_from_rational(monthly_tokens_in_usd, total_ref_cost)?;
268

            
269
4
            Some(scale)
270
1661
        }
271

            
272
        /// Calculate the block production cost in tokens.
273
        ///
274
        /// block_cost = reference_block_cost * scale_factor
275
        ///
276
        /// This preserves the ratio while targeting the monthly USD cost.
277
        /// Returns None if price is not set or calculations overflow.
278
1620
        pub fn calculate_block_production_cost() -> Option<u128> {
279
1620
            let scale_factor = Self::calculate_scale_factor()?;
280
2
            let ref_block_cost = T::ReferenceBlockCost::get();
281

            
282
            // block_cost = ref_block_cost * scale_factor
283
2
            let cost = scale_factor.saturating_mul_int(ref_block_cost);
284

            
285
            // Ensure we don't return 0 if there's a valid price
286
2
            if cost == 0 && !scale_factor.is_zero() {
287
                Some(1) // Minimum cost of 1 base unit
288
            } else {
289
2
                Some(cost)
290
            }
291
1620
        }
292

            
293
        /// Calculate the collator assignment cost in tokens per session.
294
        ///
295
        /// session_cost = reference_session_cost * scale_factor
296
        ///
297
        /// This preserves the ratio while targeting the monthly USD cost.
298
        /// Returns None if price is not set or calculations overflow.
299
41
        pub fn calculate_collator_assignment_cost() -> Option<u128> {
300
41
            let scale_factor = Self::calculate_scale_factor()?;
301
2
            let ref_session_cost = T::ReferenceSessionCost::get();
302

            
303
            // session_cost = ref_session_cost * scale_factor
304
2
            let cost = scale_factor.saturating_mul_int(ref_session_cost);
305

            
306
            // Ensure we don't return 0 if there's a valid price
307
2
            if cost == 0 && !scale_factor.is_zero() {
308
                Some(1) // Minimum cost of 1 base unit
309
            } else {
310
2
                Some(cost)
311
            }
312
41
        }
313

            
314
        /// Convert USD amount (with USD_DECIMALS precision) to tokens.
315
4
        fn usd_to_tokens(usd_amount: u128, price: FixedU128) -> Option<u128> {
316
4
            if price.is_zero() {
317
                return None;
318
4
            }
319

            
320
4
            let one_token = Self::one_token();
321

            
322
            // usd_amount is in USD with USD_DECIMALS (6) precision
323
            // price is FixedU128 (18 decimals) representing USD per token
324
            // We want: tokens = usd_amount_in_dollars / price_per_token
325
            //
326
            // tokens = (usd_amount / 10^USD_DECIMALS) / price * one_token
327
            // tokens = usd_amount * one_token / (10^USD_DECIMALS * price)
328

            
329
4
            let usd_scaled = FixedU128::from_inner(
330
4
                usd_amount
331
4
                    .checked_mul(FixedU128::DIV)?
332
4
                    .checked_div(10u128.pow(USD_DECIMALS))?,
333
            );
334

            
335
4
            let tokens_fixed = usd_scaled.checked_div(&price)?;
336

            
337
            // Convert from FixedU128 to token amount
338
4
            tokens_fixed
339
4
                .into_inner()
340
4
                .checked_mul(one_token)?
341
4
                .checked_div(FixedU128::DIV)
342
4
        }
343
    }
344
}