referrerpolicy=no-referrer-when-downgrade

pallet_staking_reward_curve/
lib.rs

1// This file is part of Substrate.
2
3// Copyright (C) Parity Technologies (UK) Ltd.
4// SPDX-License-Identifier: Apache-2.0
5
6// Licensed under the Apache License, Version 2.0 (the "License");
7// you may not use this file except in compliance with the License.
8// You may obtain a copy of the License at
9//
10// 	http://www.apache.org/licenses/LICENSE-2.0
11//
12// Unless required by applicable law or agreed to in writing, software
13// distributed under the License is distributed on an "AS IS" BASIS,
14// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15// See the License for the specific language governing permissions and
16// limitations under the License.
17
18//! Proc macro to generate the reward curve functions and tests.
19
20mod log;
21
22use log::log2;
23use proc_macro::TokenStream;
24use proc_macro2::{Span, TokenStream as TokenStream2};
25use proc_macro_crate::{crate_name, FoundCrate};
26use quote::{quote, ToTokens};
27use syn::parse::{Parse, ParseStream};
28
29/// Accepts a number of expressions to create a instance of PiecewiseLinear which represents the
30/// NPoS curve (as detailed
31/// [here](https://research.web3.foundation/en/latest/polkadot/overview/2-token-economics.html#inflation-model))
32/// for those parameters. Parameters are:
33/// - `min_inflation`: the minimal amount to be rewarded between validators, expressed as a fraction
34///   of total issuance. Known as `I_0` in the literature. Expressed in millionth, must be between 0
35///   and 1_000_000.
36///
37/// - `max_inflation`: the maximum amount to be rewarded between validators, expressed as a fraction
38///   of total issuance. This is attained only when `ideal_stake` is achieved. Expressed in
39///   millionth, must be between min_inflation and 1_000_000.
40///
41/// - `ideal_stake`: the fraction of total issued tokens that should be actively staked behind
42///   validators. Known as `x_ideal` in the literature. Expressed in millionth, must be between
43///   0_100_000 and 0_900_000.
44///
45/// - `falloff`: Known as `decay_rate` in the literature. A co-efficient dictating the strength of
46///   the global incentivization to get the `ideal_stake`. A higher number results in less typical
47///   inflation at the cost of greater volatility for validators. Expressed in millionth, must be
48///   between 0 and 1_000_000.
49///
50/// - `max_piece_count`: The maximum number of pieces in the curve. A greater number uses more
51///   resources but results in higher accuracy. Must be between 2 and 1_000.
52///
53/// - `test_precision`: The maximum error allowed in the generated test. Expressed in millionth,
54///   must be between 0 and 1_000_000.
55///
56/// # Example
57///
58/// ```
59/// # fn main() {}
60/// use sp_runtime::curve::PiecewiseLinear;
61///
62/// pallet_staking_reward_curve::build! {
63///     const I_NPOS: PiecewiseLinear<'static> = curve!(
64///         min_inflation: 0_025_000,
65///         max_inflation: 0_100_000,
66///         ideal_stake: 0_500_000,
67///         falloff: 0_050_000,
68///         max_piece_count: 40,
69///         test_precision: 0_005_000,
70///     );
71/// }
72/// ```
73#[proc_macro]
74pub fn build(input: TokenStream) -> TokenStream {
75	let input = syn::parse_macro_input!(input as INposInput);
76
77	let points = compute_points(&input);
78
79	let declaration = generate_piecewise_linear(points);
80	let test_module = generate_test_module(&input);
81
82	let imports = match crate_name("sp-runtime") {
83		Ok(FoundCrate::Itself) => quote!(
84			#[doc(hidden)]
85			pub use sp_runtime as _sp_runtime;
86		),
87		Ok(FoundCrate::Name(sp_runtime)) => {
88			let ident = syn::Ident::new(&sp_runtime, Span::call_site());
89			quote!( #[doc(hidden)] pub use #ident as _sp_runtime; )
90		},
91		Err(e) => match crate_name("polkadot-sdk") {
92			Ok(FoundCrate::Name(polkadot_sdk)) => {
93				let ident = syn::Ident::new(&polkadot_sdk, Span::call_site());
94				quote!( #[doc(hidden)] pub use #ident::sp_runtime as _sp_runtime; )
95			},
96			_ => syn::Error::new(Span::call_site(), e).to_compile_error(),
97		},
98	};
99
100	let const_name = input.ident;
101	let const_type = input.typ;
102
103	quote!(
104		const #const_name: #const_type = {
105			#imports
106			#declaration
107		};
108		#test_module
109	)
110	.into()
111}
112
113const MILLION: u32 = 1_000_000;
114
115mod keyword {
116	syn::custom_keyword!(curve);
117	syn::custom_keyword!(min_inflation);
118	syn::custom_keyword!(max_inflation);
119	syn::custom_keyword!(ideal_stake);
120	syn::custom_keyword!(falloff);
121	syn::custom_keyword!(max_piece_count);
122	syn::custom_keyword!(test_precision);
123}
124
125struct INposInput {
126	ident: syn::Ident,
127	typ: syn::Type,
128	min_inflation: u32,
129	ideal_stake: u32,
130	max_inflation: u32,
131	falloff: u32,
132	max_piece_count: u32,
133	test_precision: u32,
134}
135
136struct Bounds {
137	min: u32,
138	min_strict: bool,
139	max: u32,
140	max_strict: bool,
141}
142
143impl Bounds {
144	fn check(&self, value: u32) -> bool {
145		let wrong = (self.min_strict && value <= self.min) ||
146			(!self.min_strict && value < self.min) ||
147			(self.max_strict && value >= self.max) ||
148			(!self.max_strict && value > self.max);
149
150		!wrong
151	}
152}
153
154impl core::fmt::Display for Bounds {
155	fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
156		write!(
157			f,
158			"{}{:07}; {:07}{}",
159			if self.min_strict { "]" } else { "[" },
160			self.min,
161			self.max,
162			if self.max_strict { "[" } else { "]" },
163		)
164	}
165}
166
167fn parse_field<Token: Parse + Default + ToTokens>(
168	input: ParseStream,
169	bounds: Bounds,
170) -> syn::Result<u32> {
171	<Token>::parse(input)?;
172	<syn::Token![:]>::parse(input)?;
173	let value_lit = syn::LitInt::parse(input)?;
174	let value: u32 = value_lit.base10_parse()?;
175	if !bounds.check(value) {
176		return Err(syn::Error::new(
177			value_lit.span(),
178			format!(
179				"Invalid {}: {},  must be in {}",
180				Token::default().to_token_stream(),
181				value,
182				bounds,
183			),
184		))
185	}
186
187	Ok(value)
188}
189
190impl Parse for INposInput {
191	fn parse(input: ParseStream) -> syn::Result<Self> {
192		let args_input;
193
194		<syn::Token![const]>::parse(input)?;
195		let ident = <syn::Ident>::parse(input)?;
196		<syn::Token![:]>::parse(input)?;
197		let typ = <syn::Type>::parse(input)?;
198		<syn::Token![=]>::parse(input)?;
199		<keyword::curve>::parse(input)?;
200		<syn::Token![!]>::parse(input)?;
201		syn::parenthesized!(args_input in input);
202		<syn::Token![;]>::parse(input)?;
203
204		if !input.is_empty() {
205			return Err(input.error("expected end of input stream, no token expected"))
206		}
207
208		let min_inflation = parse_field::<keyword::min_inflation>(
209			&args_input,
210			Bounds { min: 0, min_strict: true, max: 1_000_000, max_strict: false },
211		)?;
212		<syn::Token![,]>::parse(&args_input)?;
213		let max_inflation = parse_field::<keyword::max_inflation>(
214			&args_input,
215			Bounds { min: min_inflation, min_strict: true, max: 1_000_000, max_strict: false },
216		)?;
217		<syn::Token![,]>::parse(&args_input)?;
218		let ideal_stake = parse_field::<keyword::ideal_stake>(
219			&args_input,
220			Bounds { min: 0_100_000, min_strict: false, max: 0_900_000, max_strict: false },
221		)?;
222		<syn::Token![,]>::parse(&args_input)?;
223		let falloff = parse_field::<keyword::falloff>(
224			&args_input,
225			Bounds { min: 0_010_000, min_strict: false, max: 1_000_000, max_strict: false },
226		)?;
227		<syn::Token![,]>::parse(&args_input)?;
228		let max_piece_count = parse_field::<keyword::max_piece_count>(
229			&args_input,
230			Bounds { min: 2, min_strict: false, max: 1_000, max_strict: false },
231		)?;
232		<syn::Token![,]>::parse(&args_input)?;
233		let test_precision = parse_field::<keyword::test_precision>(
234			&args_input,
235			Bounds { min: 0, min_strict: false, max: 1_000_000, max_strict: false },
236		)?;
237		<Option<syn::Token![,]>>::parse(&args_input)?;
238
239		if !args_input.is_empty() {
240			return Err(args_input.error("expected end of input stream, no token expected"))
241		}
242
243		Ok(Self {
244			ident,
245			typ,
246			min_inflation,
247			ideal_stake,
248			max_inflation,
249			falloff,
250			max_piece_count,
251			test_precision,
252		})
253	}
254}
255
256struct INPoS {
257	i_0: u32,
258	i_ideal_times_x_ideal: u32,
259	i_ideal: u32,
260	x_ideal: u32,
261	d: u32,
262}
263
264impl INPoS {
265	fn from_input(input: &INposInput) -> Self {
266		INPoS {
267			i_0: input.min_inflation,
268			i_ideal: (input.max_inflation as u64 * MILLION as u64 / input.ideal_stake as u64)
269				.try_into()
270				.unwrap(),
271			i_ideal_times_x_ideal: input.max_inflation,
272			x_ideal: input.ideal_stake,
273			d: input.falloff,
274		}
275	}
276
277	// calculates x from:
278	// y = i_0 + (i_ideal * x_ideal - i_0) * 2^((x_ideal - x)/d)
279	// See web3 docs for the details
280	fn compute_opposite_after_x_ideal(&self, y: u32) -> u32 {
281		if y == self.i_0 {
282			return u32::MAX
283		}
284		// Note: the log term calculated here represents a per_million value
285		let log = log2(self.i_ideal_times_x_ideal - self.i_0, y - self.i_0);
286
287		let term: u32 = ((self.d as u64 * log as u64) / 1_000_000).try_into().unwrap();
288
289		self.x_ideal + term
290	}
291}
292
293fn compute_points(input: &INposInput) -> Vec<(u32, u32)> {
294	let inpos = INPoS::from_input(input);
295
296	let mut points = vec![(0, inpos.i_0), (inpos.x_ideal, inpos.i_ideal_times_x_ideal)];
297
298	// For each point p: (next_p.0 - p.0) < segment_length && (next_p.1 - p.1) < segment_length.
299	// This ensures that the total number of segment doesn't overflow max_piece_count.
300	let max_length = (input.max_inflation - input.min_inflation + 1_000_000 - inpos.x_ideal) /
301		(input.max_piece_count - 1);
302
303	let mut delta_y = max_length;
304	let mut y = input.max_inflation;
305
306	// The algorithm divide the curve in segment with vertical len and horizontal len less
307	// than `max_length`. This is not very accurate in case of very consequent steep.
308	while delta_y != 0 {
309		let next_y = y - delta_y;
310
311		if next_y <= input.min_inflation {
312			delta_y = delta_y.saturating_sub(1);
313			continue
314		}
315
316		let next_x = inpos.compute_opposite_after_x_ideal(next_y);
317
318		if (next_x - points.last().unwrap().0) > max_length {
319			delta_y = delta_y.saturating_sub(1);
320			continue
321		}
322
323		if next_x >= 1_000_000 {
324			let prev = points.last().unwrap();
325			// Compute the y corresponding to x=1_000_000 using the this point and the previous one.
326
327			let delta_y: u32 = ((next_x - 1_000_000) as u64 * (prev.1 - next_y) as u64 /
328				(next_x - prev.0) as u64)
329				.try_into()
330				.unwrap();
331
332			let y = next_y + delta_y;
333
334			points.push((1_000_000, y));
335			return points
336		}
337		points.push((next_x, next_y));
338		y = next_y;
339	}
340
341	points.push((1_000_000, inpos.i_0));
342
343	points
344}
345
346fn generate_piecewise_linear(points: Vec<(u32, u32)>) -> TokenStream2 {
347	let mut points_tokens = quote!();
348
349	let max = points
350		.iter()
351		.map(|&(_, x)| x)
352		.max()
353		.unwrap_or(0)
354		.checked_mul(1_000)
355		// clip at 1.0 for sanity only since it'll panic later if too high.
356		.unwrap_or(1_000_000_000);
357
358	for (x, y) in points {
359		let error = || {
360			panic!(
361				"Generated reward curve approximation doesn't fit into [0, 1] -> [0, 1] because \
362				 of point:
363			x = {:07} per million
364			y = {:07} per million",
365				x, y
366			)
367		};
368
369		let x_perbill = x.checked_mul(1_000).unwrap_or_else(error);
370		let y_perbill = y.checked_mul(1_000).unwrap_or_else(error);
371
372		points_tokens.extend(quote!(
373			(
374				_sp_runtime::Perbill::from_parts(#x_perbill),
375				_sp_runtime::Perbill::from_parts(#y_perbill),
376			),
377		));
378	}
379
380	quote!(
381		_sp_runtime::curve::PiecewiseLinear::<'static> {
382			points: & [ #points_tokens ],
383			maximum: _sp_runtime::Perbill::from_parts(#max),
384		}
385	)
386}
387
388fn generate_test_module(input: &INposInput) -> TokenStream2 {
389	let inpos = INPoS::from_input(input);
390
391	let ident = &input.ident;
392	let precision = input.test_precision;
393	let i_0 = inpos.i_0 as f64 / MILLION as f64;
394	let i_ideal_times_x_ideal = inpos.i_ideal_times_x_ideal as f64 / MILLION as f64;
395	let i_ideal = inpos.i_ideal as f64 / MILLION as f64;
396	let x_ideal = inpos.x_ideal as f64 / MILLION as f64;
397	let d = inpos.d as f64 / MILLION as f64;
398	let max_piece_count = input.max_piece_count;
399
400	quote!(
401		#[cfg(test)]
402		mod __pallet_staking_reward_curve_test_module {
403			fn i_npos(x: f64) -> f64 {
404				if x <= #x_ideal {
405					#i_0 + x * (#i_ideal - #i_0 / #x_ideal)
406				} else {
407					#i_0 + (#i_ideal_times_x_ideal - #i_0) * 2_f64.powf((#x_ideal - x) / #d)
408				}
409			}
410
411			const MILLION: u32 = 1_000_000;
412
413			#[test]
414			fn reward_curve_precision() {
415				for &base in [MILLION, u32::MAX].iter() {
416					let number_of_check = 100_000.min(base);
417					for check_index in 0..=number_of_check {
418						let i = (check_index as u64 * base as u64 / number_of_check as u64) as u32;
419						let x = i as f64 / base as f64;
420						let float_res = (i_npos(x) * base as f64).round() as u32;
421						let int_res = super::#ident.calculate_for_fraction_times_denominator(i, base);
422						let err = (
423							(float_res.max(int_res) - float_res.min(int_res)) as u64
424							* MILLION as u64
425							/ float_res as u64
426						) as u32;
427						if err > #precision {
428							panic!("\n\
429								Generated reward curve approximation differ from real one:\n\t\
430								for i = {} and base = {}, f(i/base) * base = {},\n\t\
431								but approximation = {},\n\t\
432								err = {:07} millionth,\n\t\
433								try increase the number of segment: {} or the test_error: {}.\n",
434								i, base, float_res, int_res, err, #max_piece_count, #precision
435							);
436						}
437					}
438				}
439			}
440
441			#[test]
442			fn reward_curve_piece_count() {
443				assert!(
444					super::#ident.points.len() as u32 - 1 <= #max_piece_count,
445					"Generated reward curve approximation is invalid: \
446					has more points than specified, please fill an issue."
447				);
448			}
449		}
450	)
451}