Skip to main content

Stylus Rust SDK advanced features

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.

Info

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.

TypeInfo

| 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.

Storage layout in contracts using inheritance

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.

Tip

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 self.vm().msg_sender() == self.owner.get() {
self.owner.set(new_owner);
}
}

/// Unlike other storage type, stringStorage needs to
/// use `.set_str()` and `.get_string()` to set and get.
pub fn set_base_uri(&mut self, base_uri: String) {
self.base_uri.set_str(base_uri);
}

pub fn get_base_uri(&self) -> String {
self.base_uri.get_string()
}
}

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.

Tip

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 ERC-20 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.

#[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. Stylus contracts are reentrancy-safe by default: the high-level call functions (call, static_call, and delegate_call) automatically flush or clear the storage cache before invoking another contract, so cached values can't go stale across reentry. This happens through the type system and requires no configuration.

Warning

The reentrant Cargo feature flag and the deny_reentrant entrypoint guard are deprecated as of SDK 0.10.5 and should not be used. Reentrancy safety now comes from automatic storage-cache flushing, which makes the guard redundant.

Automatic cache flushing protects your storage, but it is only part of defending against exploits. Continue to follow the checks-effects-interactions pattern—update your own state before calling untrusted contracts—and review reentrant code carefully, ideally with third-party auditors. You can detect whether the current call is reentrant via self.vm().msg_reentrant() and condition your business logic accordingly.

TopLevelStorage

The #[entrypoint] macro will automatically implement the TopLevelStorage trait for the annotated struct. The single type implementing TopLevelStorage is special in that mutable access to it represents mutable access to the entire program’s state. This idea will become important when discussing calls to other programs in later sections.

Inheritance, #[inherit], and #[borrow].

info

Stylus doesn't support contract multi-inheritance yet.

Composition in Rust follows that of Solidity. Types that implement Router, the trait that #[public] provides, can be connected via inheritance.

#[public]
#[inherit(Erc20)]
impl Token {
pub fn mint(&mut self, amount: U256) -> Result<(), Vec<u8>> {
...
}
}

#[public]
impl Erc20 {
pub fn balance_of() -> Result<U256> {
...
}
}

Because Token inherits Erc20 in the above, if Token has the #[entrypoint], calls to the contract will first check if the requested method exists within Token. If a matching function is not found, it will then try the Erc20. Only after trying everything Token inherits will the call revert.

Note that because methods are checked in that order, if both implement the same method, the one in Token will override the one in Erc20, which won’t be callable. This allows for patterns where the developer imports a crate implementing a standard, like the ERC-20, and then adds or overrides just the methods they want to without modifying the imported Erc20 type.

Warning

Stylus does not currently contain explicit override or virtual keywords for explicitly marking override functions. It is important, therefore, to carefully ensure that contracts are only overriding the functions.

Inheritance can also be chained. #[inherit(Erc20, Erc721)] will inherit both Erc20 and Erc721, checking for methods in that order. Erc20 and Erc721 may also inherit other types themselves. Method resolution finds the first matching method by Depth First Search.

For the above to work, Token must implement Borrow<Erc20>. You can implement this yourself, but for simplicity, #[storage] and sol_storage! provide a #[borrow] annotation.

sol_storage! {
#[entrypoint]
pub struct Token {
#[borrow]
Erc20 erc20;
...
}

pub struct Erc20 {
...
}
}

Fallback and receive functions

Starting with SDK version 0.7.0, the Router trait supports the fallback and receive methods, which work similar to their Solidity counterparts:

  • fallback: This method is called when a transaction is sent to the contract with calldata that doesn't match any function signature. It serves as a catch-all function for contract interactions that don't match any defined interface.

  • receive: This method is called when a transaction is sent to the contract with no calldata (empty calldata). It allows the contract to receive ETH.

Here's an example implementation:

#[public]
impl Contract {
// Automatically called when transaction has calldata that doesn't match any function
#[fallback]
pub fn fallback(&mut self, calldata: Vec<u8>) -> Result<Vec<u8>, Vec<u8>> {
// Handle arbitrary calldata
Ok(Vec::new()) // Return empty response or custom response data
}

// Automatically called when transaction has empty calldata
#[receive]
pub fn receive(&mut self) -> Result<(), Vec<u8>> {
// Handle ETH receiving logic
Ok(())
}
}

Both methods can be annotated with #[payable] to accept ETH along with the transaction. Without this annotation, transactions that send ETH will be rejected.

Calls

Just as with storage and functions, Stylus SDK calls are Solidity ABI equivalent. This means you never have to know the implementation details of other contracts to invoke them. You simply import the Solidity interface of the target contract, which can be auto-generated via the cargo stylus CLI tool.

Tip

You can call contracts in any programming language with the Stylus SDK.

sol_interface!

This macro defines a struct for each of the Solidity interfaces provided.

sol_interface! {
interface IService {
function makePayment(address user) payable returns (string);
function getConstant() pure returns (bytes32)
}

interface ITree {
// other interface methods
}
}

The above will define IService and ITree for calling the methods of the two contracts.

Info

Currently only functions are supported, and any other items in the interface will cause an error.

For example, IService will have a make_payment method that accepts an Address and returns a B256.

pub fn do_call(&mut self, account: IService, user: Address) -> Result<String, Error> {
account.make_payment(self, user) // note the snake case
}

Observe the casing change. sol_interface! computes the selector based on the exact name passed in, which should almost always be CamelCase. For aesthetics, the rust functions will instead use snake_case.

Configuring gas and value with Call

Call lets you configure a call via optional configuration methods. This is similar to how one would configure opening a File in Rust.

#[payable]
pub fn do_call(&mut self, account: IService, user: Address) -> Result<String, Vec<u8>> {
let config = Call::new_payable(self, self.vm().msg_value()) // set the callvalue
.gas(self.vm().evm_gas_left() / 2); // limit to half the gas left

Ok(account.make_payment(self.vm(), config, user)?)
}

Use Call::new_mutating(self) for a non-payable call and Call::new_payable(self, value) when forwarding value. By default Call supplies all gas remaining and zero value, which often means Call::new() may be passed to the method directly.

Reentrant calls

Cross-contract calls flush or clear the StorageCache to safeguard state across reentry. This happens automatically via the type system, so you don't need to manage the cache yourself.

sol_interface! {
interface IMethods {
function pureFoo() external pure;
function viewFoo() external view;
function writeFoo() external;
function payableFoo() external payable;
}
}

#[public]
impl Contract {
pub fn call_pure(&self, methods: IMethods) -> Result<(), Vec<u8>> {
Ok(methods.pure_foo(self.vm(), Call::new())?) // `pure` methods might lie about not being `view`
}

pub fn call_view(&self, methods: IMethods) -> Result<(), Vec<u8>> {
Ok(methods.view_foo(self.vm(), Call::new())?)
}

pub fn call_write(&mut self, methods: IMethods) -> Result<(), Vec<u8>> {
methods.view_foo(self.vm(), Call::new())?; // allows `pure` and `view` methods too
let config = Call::new_mutating(self);
Ok(methods.write_foo(self.vm(), config)?)
}

#[payable]
pub fn call_payable(&mut self, methods: IMethods) -> Result<(), Vec<u8>> {
let config = Call::new_mutating(self);
methods.write_foo(self.vm(), config)?;
let config = Call::new_payable(self, U256::ZERO);
Ok(methods.payable_foo(self.vm(), config)?)
}
}

In the above, we’re able to pass self.vm() and &mut self because Contract implements TopLevelStorage, which means that a reference to it entails access to the entirety of the contract’s state. This is the reason it is sound to make a call, since it ensures all cached values are invalidated and/or persisted to state at the right time. Note that the call context is built as a separate let binding before the call so the immutable borrow from self.vm() and the mutable borrow from Call::new_mutating(self) don't overlap.

When writing Stylus libraries, a type might not be TopLevelStorage and therefore &self or &mut self won’t work directly. Building a Call from a generic parameter is the usual solution.

pub fn do_call<T: TopLevelStorage + core::borrow::BorrowMut<Contract>>(
storage: &mut T, // can be generic, but often just &mut self
account: IService, // serializes as an Address
user: Address,
) -> Result<String, Vec<u8>> {
let vm = storage.borrow().vm();
let value = vm.msg_value(); // set the callvalue
let gas = vm.evm_gas_left() / 2; // limit to half the gas left
let config = Call::new_payable(storage, value).gas(gas); // exclusive access to all contract storage

Ok(account.make_payment(storage.borrow().vm(), config, user)?) // note the snake case
}

In the context of a #[public] call, the &mut impl argument will correctly distinguish the method as being write or payable.

call, static_call, and delegate_call

Though sol_interface! and Call form the most common idiom to invoke other contracts, their underlying call and static_call are exposed for direct access.

let config = Call::new_mutating(self);
let return_data = call(self.vm(), config, contract, &call_data)?;

The host (self.vm()) is the first argument, followed by the call context, the target address, and the calldata. In each case the calldata is supplied as a Vec<u8>. The return result is either the raw return data on success, or a call Error on failure.

delegate_call is also available, though it's unsafe and doesn't have a richly-typed equivalent. This is because a delegate call must trust the other contract to uphold safety requirements. Though this function clears any cached values, the other contract may arbitrarily change storage, spend ether, and do other things one should never blindly allow other contracts to do.

transfer_eth

This method provides a convenient shorthand for transferring ether.

Note

This method invokes the other contract, which may in turn call others. All gas is supplied, which the recipient may burn. If this is not desired, the call function may be used instead.

// these two are equivalent
transfer_eth(self.vm(), recipient, value)?;

let config = Call::new_payable(self, value);
call(self.vm(), config, recipient, &[])?;

RawCall and unsafe calls

Occasionally, an untyped call to another contract is necessary. RawCall lets you configure an unsafe call by calling optional configuration methods. This is similar to how one would configure opening a File in Rust.

let data = unsafe {
RawCall::new_delegate(self.vm()) // configure a delegate call
.gas(2100) // supply 2100 gas
.limit_return_data(0, 32) // only read the first 32 bytes back
.flush_storage_cache() // flush the storage cache before the call
.call(contract, &calldata)? // do the call
};

Pass the host (self.vm()) to the constructor: use RawCall::new(self.vm()) for a regular call or RawCall::new_delegate(self.vm()) for a delegate call.

Note

The call method is always unsafe. Unlike the high-level call functions, RawCall does not flush the storage cache for you, so use flush_storage_cache and clear_storage_cache when reentry could observe stale state.

RawDeploy and unsafe deployments

Right now the only way to deploy a contract from inside Rust is to use RawDeploy, similar to RawCall. As with RawCall, this mechanism is inherently unsafe due to reentrancy concerns, and requires manual management of the StorageCache.

Note

That the EVM allows init code to make calls to other contracts, which provides a vector for reentrancy. This means that this technique may enable storage aliasing if used in the middle of a storage reference's lifetime and if reentrancy is allowed.

When configured with a salt, RawDeploy will use CREATE2 instead of the default CREATE, facilitating address determinism.