#decimal #single-line #fixed-point #traits #generate #leverage #generics

macro safe_decimal_core

This is a Rust fixed-point numeric library targeting blockchain development. Originally created and used as a part of the Invariant Protocol. The current version leverages macros, traits and generics to exchange dozens of lines of error prone code with a single line and generating the rest.

2 releases

0.1.1 Jun 3, 2023
0.1.0 Jun 3, 2023

#512 in Procedural macros

MIT license

47KB
843 lines

Decimal library

Decimal macro crate

This is a Rust fixed-point numeric library targeting blockchain. It was created purely for practical reasons as a fast and simple way to use checked math with given decimal precision.

It has achieved present form over several iterations, first being implemented inside Synthetify protocol. The current version leverages macros, traits and generics to exchange dozens of lines of error prone code with a single line and generating the rest. In this form it is used inside of Invariant protocol and was audited as a part of it.

It allows a definition of multiple types with different precisions and primitive types and calculations in between them, see below for a quick example.

Quickstart

The library is used by adding a macro #[decimal(k)], where k is a desired decimal precision (number decimal places after the dot).

This macro generates an implementation of several generic traits for each struct it is called on allowing basic operations in between them.

Basic example

Having imported the library you can declare a type like so:

#[decimal(2)]
#[derive(Default, PartialEq, Debug, Clone, Copy)]
struct Percentage(u32);
  • #[decimal(3, u128)] - call to the macro generating the code for decimal
  • #[derive(Default, Debug, Clone, Copy, PartialEq)] - derivation of some common built-in traits (these five are needed)
  • struct R(u32); - declaration of the struct itself

Deserialization

Named structs can be deserialized without a problem like so:

#[decimal(6)]
#[zero_copy]
#[derive(AnchorSerialize, AnchorDeserialize, ...)]
pub struct Price {
    pub v: u128,
}

Basic operations

All methods generated by the macro use checked math and panic on overflow. Operators are overloaded where possible for ease of use.

Basic example using types defined above would look like this:

let price = Price::from_integer(10); // this corresponds with 10 * 10^k so 10^7 in this case

let discount = Percentage::new(10); // using new doesn't account for decimal places so 0.10 here

// addition expects being called for left and right values being of the same type
// multiplication doesn't so you can be used like this:
let price = price * (Percentage::from_integer(1) - discount); // the resulting type is always the type of the left value

For more examples continue to the walkthrough.rs

Parameters for the macro

As mentioned the first argument of macro controls the amount of places after the dot. It can range between 0 and 38. It can be read by Price::scale(). The second one is optional and can be a bit harder to grasp

The Big Type

The second argument taken has a weird name of a big type. It sets the type that is to be used when calling the methods with _big_ in the name. Its purpose is to avoid temporary overflows so an overflow that occurs while calculating theS return value despite that value fitting in the given type. Consider the example below

#[decimal(2)]
#[derive(Default, Debug, Clone, Copy, PartialEq)]
struct Percentage(u8, u128);

let p = Percentage(110); // 110% or 1.1

// assert_eq!(p * p, Percentage(121));      <- this would panic
assert_eq!(p.big_mul(p), Percentage(121));  <- this will work fine

To understand why it works like that look at the multiplication of decimal does under the hood:

What happens inside (on the math side)

Most of this library uses really basic math, a few things that might not be obvious are listed below

Keeping the scale

An multiplication of two percentages (scale of 2) using just the values would look like this :

(x / 10^2) / (y / 10^2) = x/y

(God i hate gh for not allowing LaTeX)

Using numbers it would look like this:

10% / 10% = 10 / 10 = 1

Which is obviously wrong. What we need is multiplying everything by 10^scale at every division. So it should look like this

(x / 10^scale) / (y / 10^scale) × 10^scale = x / y × 10^scale

Which checks out with the example above

In general at every multiplication of values there needs to be a division, and vice versa. This was the first purpose of this library - to abstract it away to make for less code, bugs and wasted time.

The important thing here is that multiplication has to occur before division to keep the precision, but this is also abstracted away.

Rounding errors

By default every method rounds down but has a counterpart ending with up rounding the opposite way.

Rounding works by addition of denominator - 1 to the numerator, so the mul_up would look like so:

(x × y + 10^scale - 1) / 10^scale

For example for 10% × 1%

(10 × 1 + (10^2 - 1)) / (10^2) = 109 / 100 = 1%

What happens inside (on a code level)

As you do know by this point the whole library is in a form of macro. Inside of it is an implementation of several traits in a generic form to allow calling methods between any two of the implementations.

  • Decimal - all other traits are dependent on it, and by implementing it you can you your implementation with any of the other traits. One of use cases my be implementing it on base 2

    • type U: Debug + Default; - an associated type, the primitive (or not) where value is kept, the type of first field in the struct on which macro was called
    • fn get(&self) -> Self::U; - the value of a decimal
    • fn new(value: Self::U) -> Self; - the constructor
    • fn max_value() -> Self::U - maximum value of underlying type
    • fn max_instance() -> Self - max_value() wrapped by decimal
    • fn here<Y: TryFrom<Self::U>>(&self) -> Y; - same as get, but also 'tries into' the needed value
    • fn scale() -> u8; - the amount of decimal places (given in the macro)
    • fn one<T: TryFrom<u128>>() -> T; - basically 10^scale, evaluated on the compile time
    • fn almost_one<T: TryFrom<u128>>() -> T; - same as above but -1, also on compile time
  • std::ops - addition, subtraction, multiplication and division together with there assignment counterparts (+=)

  • pub trait BigOps<T> - same as above but with previously mentioned big types used when calculating

  • pub trait Others<T> - trait for future operations if needed, right now with only two methods

    • fn mul_up(self, rhs: T) -> Self; - multiplication, rounding uo
    • fn div_up(self, rhs: T) -> Self; - division, rounding up
  • pub trait Factories<T> - methods used as constructors (excluding new)

    • fn from_integer(integer: T) -> Self; - creates self with value of: integer × 10^scale
    • fn from_scale(integer: T, scale: u8) -> Self; - creates self with value of: integer × 10^(scale - given\_scale)
    • fn checked_from_scale(val: T, scale: u8) -> <Self, String> - checked version of from_scale
    • fn from_scale_up(integer: T, scale: u8) -> Self; - same as above but with rounding up
  • pub trait BetweenDecimals<T> - used for conversion between different types, possibly with different scales

  • pub trait ToValue<T, B> and pub trait ByNumber<B> - can be used together to take overflowing values outside of a type and then put it inside, shouldn't be needed often

Dependencies

~3.5–4.5MB
~90K SLoC