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, leveragetype::from,type::try_from,value.into(), andvalue.try_into(). - Keep visibility (
pubvspub(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:
Fromimpls 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 likex.into(),u64::from(x), ortry-variants likex.try_into()?andu16::try_from(x)?is preferable..intoandfromensure 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 withanyhow, you can have code likex.checked_add(y).context("Unexpected overflow adding notional values")?.
- Common methods like
Strong typing
- Use
newtypewrappers 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 customTimestamptype with an associatedDurationtype, which are both newtype wrappers aroundu64, 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 twoDurationvalues).- 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!
- Be careful about adding too many helper methods and
- Favor using
enums andmatching 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 likeenum 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.u32is okay,u64is not). - Messaging between contracts-only are free to use any serializable type without worrying about how it is represented over-the-wire.
Addrmeans “validated address”. Aside for tests, we should never haveAddr::unchecked. This means that in messaging from clients we useString, notAddr, and immediately validate it into anAddrbefore use (see next point about “edges”/”sandwich”). Messaging between contracts may useAddr, 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 customSerialize,Deserialize, andJsonSchemaimpls. - 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) }, preferenum 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)orfn foo(t: impl SomeTrait)instead of trait objects likefn foo(t: &dyn SomeTrait). - If you know the size, or approximate size, of a
Vec, preferVec::with_capacityoverVec::new()orvec![].
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.