Stylus Rust SDK advanced features
This document is currently in public preview and may change significantly as feedback is captured from readers like you. Click the Request an update button at the top of this document or join the Arbitrum Discord to share your feedback.
This document provides information about advanced features included in the Stylus Rust SDK, that are not described in the previous pages. For information about deploying Rust smart contracts, see the cargo stylus
CLI Tool. For a conceptual introduction to Stylus, see Stylus: A Gentle Introduction. To deploy your first Stylus smart contract using Rust, refer to the Quickstart.
Many of the affordances use macros. Though this section details what each does, it may be helpful to use cargo expand
to see what they expand into if you’re doing advanced work in Rust.
Storage
This section provides extra information about how the Stylus Rust SDK handles storage. You can find more information and basic examples in Variables.
Rust smart contracts may use state that persists across transactions. There’s two primary ways to define storage, depending on if you want to use Rust or Solidity definitions. Both are equivalent, and are up to the developer depending on their needs.
#[storage]
The #[storage]
macro allows a Rust struct to be used in persistent storage.
#[storage]
pub struct Contract {
owner: StorageAddress,
active: StorageBool,
sub_struct: SubStruct,
}
#[storage]
pub struct SubStruct {
// types implementing the `StorageType` trait.
}
Any type implementing the StorageType
trait may be used as a field, including other structs, which will implement the trait automatically when #[storage]
is applied. You can even implement StorageType
yourself to define custom storage types. However, we’ve gone ahead and implemented the common ones.
Type | Info |
---|---|
StorageBool | Stores a bool |
StorageAddress | Stores an Alloy Address |
StorageUint | Stores an Alloy Uint |
StorageSigned | Stores an Alloy Signed |
StorageFixedBytes | Stores an Alloy FixedBytes |
StorageBytes | Stores a Solidity bytes |
StorageString | Stores a Solidity string |
StorageVec | Stores a vector of StorageType |
StorageMap | Stores a mapping of StorageKey to StorageType |
StorageArray | Stores a fixed-sized array of StorageType |
Every Alloy primitive has a corresponding StorageType
implementation with the word Storage
before it. This includes aliases, like StorageU256
and StorageB64
.
sol_storage!
The types in #[storage]
are laid out in the EVM state trie exactly as they are in Solidity. This means that the fields of a struct definition will map to the same storage slots as they would in EVM programming languages.
Because of this, it is often nice to define your types using Solidity syntax, which makes that guarantee easier to see. For example, the earlier Rust struct can re-written to:
sol_storage! {
pub struct Contract {
address owner; // becomes a StorageAddress
bool active; // becomes a StorageBool
SubStruct sub_struct,
}
pub struct SubStruct {
// other solidity fields, such as
mapping(address => uint) balances; // becomes a StorageMap
Delegate delegates[]; // becomes a StorageVec
}
}
The above will expand to the equivalent definitions in Rust, each structure implementing the StorageType
trait. Many contracts, like our example ERC 20, do exactly this.
Because the layout is identical to Solidity’s, existing Solidity smart contracts can upgrade to Rust without fear of storage slots not lining up. You simply copy-paste your type definitions.
Note that one exception to this storage layout guarantee is contracts which utilize inheritance. The current solution in Stylus using #[borrow]
and #[inherits(...)]
packs nested (inherited) structs into their own slots. This is consistent with regular struct nesting in solidity, but not inherited structs. We plan to revisit this behavior in an upcoming release.
Existing Solidity smart contracts can upgrade to Rust if they use proxy patterns.
Consequently, the order of fields will affect the JSON ABIs produced that explorers and tooling might use. Most developers won’t need to worry about this though and can freely order their types when working on a Rust contract from scratch.
Reading and writing storage
You can access storage types via getters and setters. For example, the Contract
struct from earlier might access its owner
address as follows.
impl Contract {
/// Gets the owner from storage.
pub fn owner(&self) -> Address {
self.owner.get()
}
/// Updates the owner in storage
pub fn set_owner(&mut self, new_owner: Address) {
if msg::sender() == self.owner.get() { // we'll discuss msg::sender later
self.owner.set(new_owner);
}
}
}
In Solidity, one has to be very careful about storage access patterns. Getting or setting the same value twice doubles costs, leading developers to avoid storage access at all costs. By contrast, the Stylus SDK employs an optimal storage-caching policy that avoids the underlying SLOAD
or SSTORE
operations.
Stylus uses storage caching, so multiple accesses of the same variable is virtually free.
However it must be said that storage is ultimately more expensive than memory. So if a value doesn’t need to be stored in state, you probably shouldn’t do it.
Collections
Collections like StorageVec
and StorageMap
are dynamic and have methods like push
, insert
, replace
, and similar.
impl SubStruct {
pub fn add_delegate(&mut self, delegate: Address) {
self.delegates.push(delegate);
}
pub fn track_balance(&mut self, address: Address) {
self.balances.insert(address, address.balance());
}
}
You may notice that some methods return types like StorageGuard
and StorageGuardMut
. This allows us to leverage the Rust borrow checker for storage mistakes, just like it does for memory. Here’s an example that will fail to compile.
fn mistake(vec: &mut StorageVec<StorageU64>) -> U64 {
let value = vec.setter(0);
let alias = vec.setter(0);
value.set(32.into());
alias.set(48.into());
value.get() // uh, oh. what value should be returned?
}
Under the hood, vec.setter()
returns a StorageGuardMut
instead of a &mut StorageU64
. Because the guard is bound to a &mut StorageVec
lifetime, value
and alias
cannot be alive simultaneously. This causes the Rust compiler to reject the above code, saving you from entire classes of storage aliasing errors.
In this way the Stylus SDK safeguards storage access the same way Rust ensures memory safety. It should never be possible to alias Storage without unsafe
Rust.
SimpleStorageType
You may run into scenarios where a collection’s methods like push
and insert
aren’t available. This is because only primitives, which implement a special trait called SimpleStorageType
, can be added to a collection by value. For nested collections, one instead uses the equivalent grow
and setter
.
fn nested_vec(vec: &mut StorageVec<StorageVec<StorageU8>>) {
let mut inner = vec.grow(); // adds a new element accessible via `inner`
inner.push(0.into()); // inner is a guard to a StorageVec<StorageU8>
}
fn nested_map(map: &mut StorageMap<u32, StorageVec<U8>>) {
let mut slot = map.setter(0);
slot.push(0);
}
Erase
and #[derive(Erase)]
Some StorageType
values implement Erase
, which provides an erase()
method for clearing state. We’ve implemented Erase
for all primitives, and for vectors of primitives, but not maps. This is because a solidity mapping
does not provide iteration, and so it’s generally impossible to know which slots to set to zero.
Structs may also be Erase
if all of the fields are. #[derive(Erase)]
lets you do this automatically.
sol_storage! {
#[derive(Erase)]
pub struct Contract {
address owner; // can erase primitive
uint256[] hashes; // can erase vector of primitive
}
pub struct NotErase {
mapping(address => uint) balances; // can't erase a map
mapping(uint => uint)[] roots; // can't erase vector of maps
}
}
You can also implement Erase
manually if desired. Note that the reason we care about Erase
at all is that you get storage refunds when clearing state, lowering fees. There’s also minor implications for patterns using unsafe
Rust.
The storage cache
The Stylus SDK employs an optimal storage-caching policy that avoids the underlying SLOAD
or SSTORE
operations needed to get and set state. For the vast majority of use cases, this happens in the background and requires no input from the user.
However, developers working with unsafe
Rust implementing their own custom StorageType
collections, the StorageCache
type enables direct control over this data structure. Included are unsafe
methods for manipulating the cache directly, as well as for bypassing it altogether.
Immutables and PhantomData
So that generics are possible in sol_interface!
, core::marker::PhantomData
implements StorageType
and takes up zero space, ensuring that it won’t cause storage slots to change. This can be useful when writing libraries.
pub trait Erc20Params {
const NAME: &'static str;
const SYMBOL: &'static str;
const DECIMALS: u8;
}
sol_storage! {
pub struct Erc20<T> {
mapping(address => uint256) balances;
PhantomData<T> phantom;
}
}
The above allows consumers of Erc20
to choose immutable constants via specialization. See our WETH sample contract for a full example of this feature.
Functions
This section provides extra information about how the Stylus Rust SDK handles functions. You can find more information and basic examples in Functions, Bytes in, bytes out programming, Inheritance and Sending ether.
Pure, View, and Write functions
For non-payable methods the #[public]
macro can figure state mutability out for you based on the types of the arguments. Functions with &self
will be considered view
, those with &mut self
will be considered write
, and those with neither will be considered pure
. Please note that pure
and view
functions may change the state of other contracts by calling into them, or even this one if the reentrant
feature is enabled.
#[entrypoint]
This macro allows you to define the entrypoint, which is where Stylus execution begins. Without it, the contract will fail to pass cargo stylus check
. Most commonly, the macro is used to annotate the top level storage struct.
sol_storage! {
#[entrypoint]
pub struct Contract {
...
}
// only one entrypoint is allowed
pub struct SubStruct {
...
}
}
The above will make the public methods of Contract
the first to consider during invocation.
Reentrancy
If a contract calls another that then calls the first, it is said to be reentrant. By default, all Stylus programs revert when this happens. However, you can opt out of this behavior by enabling the reentrant
feature flag.
stylus-sdk = { version = "0.6.0", features = ["reentrant"] }
This is dangerous, and should be done only after careful review — ideally by 3rd party auditors. Numerous exploits and hacks have in Web3 are attributable to developers misusing or not fully understanding reentrant patterns.
If enabled, the Stylus SDK will flush the storage cache in between reentrant calls, persisting values to state that might be used by inner calls. Note that preventing storage invalidation is only part of the battle in the fight against exploits. You can tell if a call is reentrant via msg::reentrant
, and condition your business logic accordingly.