Code Guidelines - Rust

General note: the below is almost entirely in terms of contract code, and therefore must take into account the larger ecosystem which relies heavily on javascript/typescript/go clients. This requires a few tradeoffs which are not ideal for pure Rust, but are more ideal in the big picture and our requirements. Cli tools, bots, servers, and other Rust projects may depart from some of these guidelines in significant ways

General guidelines

  • Whenever possible, make coding requirements machine enforceable and part of the CI tooling.
  • Focus on guidelines that maximize the ability of the compiler to prevent us from making mistakes.

Basic rules

  • Avoid as-casting. It can truncate data depending on the types, and can silently upgrade a safe operation to a truncating one. Instead, leverage type::from, type::try_from, value.into(), and value.try_into().
  • Keep visibility (pub vs pub(crate) etc.) tight when possible. Motivation: we want to correctly receive notifications of unused exports.
  • Do not do things like disabling warnings on an entire module, much less a crate. Some specific warnings (like clippy too-many-arguments) can be ignored, but that should always happen at the specific warning site.
  • When possible, leverage techniques that match on all variants and fields. This ensures that, as code and types evolve, the compiler can warn about additions. This is easiest seen in an example:
#![allow(unused)]
fn main() {
enum Fruit {
    Apple { size: AppleSize },
    Banana,
}

// Bad
if let Apple {..} = fruit {
    println!("It's not yellow!")
}
}

This code looks fine, until we change our data types:

#![allow(unused)]
fn main() {
enum Color { Red, Green, Yellow };
enum Fruit {
    Apple { size: AppleSize, color: Color },
    Banana,
    Lemon,
    Grape,
}
}

This code is now broken in two ways:

  • Apples can now be yellow, and the .. pattern means we will still say "It's not yellow" for them.
  • Grapes are never yellow, but we don't include a message for them since we used an if let.

Instead, if the code had first been written as:

#![allow(unused)]
fn main() {
match fruit {
    Apple { size: _ } => println!("It's not yellow"),
    Banana {} => (),
}
}

Once the data types are updated, the code above will fail to compile, forcing us to deal with the new field and variants.

Safety

Panicking

  • Avoid operations which silently swallow errors. Errors should be handled or escalated.
  • Avoid panicking in shared library code (since a panic will break application code such as bots, services and cli tools). Instead, bubble up a Result::Err type.
    • Panics can occassionally be allowed for truly exceptional cases that should never happen, but that should be rare and on a case-by-case basis.
  • Generally avoid operations which can panic in contracts, since it makes debugging harder. This isn’t as all-encompassing as library code, since contracts do have an abort handler to catch panics, but it’s better to debug proper Result::Err types. Panicking is acceptable (and in fact encouraged) in test suite code, but it must be in code that does not get compiled with non-test code. (Real example: From impls in the message crate cannot include panicking, even if they’re only intended to be used from tests.) Some examples of panicking to be aware of:
    • Common methods like unwrap()
    • Using as-casts is generally dangerous, since it can either panic or truncate data. Instead, using things like x.into(), u64::from(x), or try-variants like x.try_into()? and u16::try_from(x)? is preferable. .into and from ensure that a conversion is safe and lossless.
    • Arithmetic operations (unfortunately) can panic or overflow. It’s a bit of a grey area, but often times it would be better to use named methods like checked_add instead of +. Then combined with anyhow, you can have code like x.checked_add(y).context("Unexpected overflow adding notional values")?.

Strong typing

  • Use newtype wrappers when appropriate to enforce safe usage of values and ensure we don’t swap values of different meaning. For example, with time management, we have a custom Timestamp type with an associated Duration type, which are both newtype wrappers around u64, but which represent different concepts (a point in time versus and amount of time). The operations on these types restrict you to only doing safe modifications (e.g. it’s impossible to multiply two Duration values).
    • Be careful about adding too many helper methods and impls that expose the innards of these types. Restricting what we can do is the whole point of strong typing!
  • Favor using enums and matching to ensure we have identified all possible cases and handle them correctly. For example, a reasonable first stab at “how did this position close” could be something like enum PositionCloseMethod { Manual { close_price: Number }, TakeProfit, Liquidation }.

Message structure

  • Any messaging that comes to/from a client (e.g. not submessages) should use Typescript-friendly datatypes. For example, numbers should use a type that has a String serialization format (such as Number), unless it is a 32 bits or lower (e.g. u32 is okay, u64 is not).
  • Messaging between contracts-only are free to use any serializable type without worrying about how it is represented over-the-wire.
  • Addr means “validated address”. Aside for tests, we should never have Addr::unchecked. This means that in messaging from clients we use String, not Addr, and immediately validate it into an Addr before use (see next point about “edges”/”sandwich”). Messaging between contracts may use Addr, and we can therefore rely on not validating twice to save gas.
  • Conversion between client messaging and native Rust messaging should happen solely at the “edges”, like a sandwich, and all logic should be done in native Rust types. If there are data types which require unusual (de)serialization logic, such as representing a numeric ID as a JSON String, provide custom Serialize, Deserialize, and JsonSchema impls.
  • Named fields We should almost exclusively use named fields in message types, as this allows for extensibility in the future. For example, instead of enum ExecuteMsg { Withdraw(Number) }, prefer enum ExecuteMsg { Withdraw { amount: Number } }.

Optimization

  • Prefer static dispatch over dynamic dispatch, to save gas, but be aware of implementing generic code for too many types and the impact on wasm size. For example, when possible, prefer concrete types or generic type parameters like fn foo<T: SomeTrait>(t: T) or fn foo(t: impl SomeTrait) instead of trait objects like fn foo(t: &dyn SomeTrait).
  • If you know the size, or approximate size, of a Vec, prefer Vec::with_capacity over Vec::new() or vec![].

Simplification

  • Monomorphic code. When possible, prefer monomorphic code, avoiding type parameters and generalizing to traits. This is even more relevant for creation of new types and traits.
    • Creating new types: do it, but monomorphize.
    • Creating new traits: generally avoid it.