I’m excited to feature Jack (0xjsi.eth) and his stablecoin using Rust Stylus on Arbitrum! Jack is a competent Swedish dev from the Matos DAO whom we introduced to Stylus for the purposes of this article. For future posts, we’ll be introducing other people from the rest of the space to author posts themselves! Jack will also be a recurring character, hopefully with another cool project as soon as something nice comes to mind. :)
To recap: Stylus is a transformative technology for writing smart contracts. It lets you write your Arbitrum smart contracts in Rust, Zig, C, or Go, which you can interact with like you would a Solidity smart contract. For the Arbitrum node (and the perspective of your users) there is no difference between a Solidity contract, and a Stylus contract, except the execution circumstances. This nets us several advantages, the main being the costs of your code (almost 90% cheaper), and a way more expressive and secure way of writing smart contracts!
Grab the Stylus: A Solidity dev’s journey through writing modern smart contracts on Arbitrum
This blogpost will be about the journey of developing and deploying my first contract on Arbitrum Stylus using Rust WASM, what the experience was like and what challenges I ran into.
Intro
First, quick intro: My name is Jack, you might see me under the pseudonym 0xjsi.eth online (say Hi if you see me, I’m always down to connect). My Web3 adventure kicked off in 2020 when I dove into Ethereum accidentally right in time for DeFi Summer, chasing yields and getting hooked on the tech behind it. That passion led me to a two-year blockchain development program at a vocational college in Sweden, and now I have spent the last two years working full time as a Solidity smart contract developer, and as of recently, a Rust backend dev. When I’m not elbow‑deep in work code you’ll find me anywhere around the world, grinding at a hackathon, jamming with my band on tour or shitposting on x dot com the everything app.
When my friend Alex (bayge.eth) nudged me to write about Arbitrum Stylus, I’ll admit—I barely knew what it was, there is just so much cool tech within this field and only so much space in my brain. I spent a day researching, expecting Stylus to be just a WASM VM with no EVM ties. Boy, was I wrong. Stylus is a dual VM powerhouse, blending EVM and WASM with shared state. Keyword here being a dual VM environment, this is way cooler than e.g. a transpiler that converts Rust code to Solidity, or a compiler that compiles Rust down to EVM bytecode, for multiple reasons. One being, both languages will be executed on the most efficient VM for the code, meaning that Rust code will run way cheaper than it would straight on the EVM, secondly, since the non EVM part is a WASM VM, this means that we are not forced to use Rust, but we have the flexibility to run any programming language which could be compiled down to WASM. Stylus doesn’t replace Solidity; it supercharges it, letting Solidity and WASM contracts call each other seamlessly. This opens endless possibilities for smart contracts that were unthinkable before.
I thought for a while about what kind of project would be interesting to develop for first getting my feet wet with Stylus development. I decided on porting shafu0x MicroStable fully to Rust on Stylus, both since it seemed like a good small project, as well as a fun opportunity to hit 2 birds with one rock, since I could also make a pull request to the MicroStable-World repository.
Building
So, let’s get started! I set up the project as instructed in the Stylus quickstart docs. After resolving some minor clunkiness regarding adding the correct WASM target I am ready to get started. Coming from a Solidity + Foundry background, I can right away tell that these Rust configs + running the dev node in a Docker container feels more clunky than just using the forge
command line tool. But anyway, now I am all setup and ready to go, git repo here!
So the project will essentially consist of 3 parts;
The ERC20 token, a contract inheriting the ERC20 standard, and then extending it with public mint and burn functions, guarded by modifiers which will only let the “manager” contract interact with these functions.
The Manager, a contract that will be fully responsible of receiving collateral deposits in the form of Wrapped ETH, and based on the current price fetched from the Oracle, determine how much of our stable coin the user can mint. Will also handle possible liquidations if the users collateral is too low.
The Oracle, this is a contract we’re not writing. This is the Chainlink data feed contract, where we’ll only interact with a function named
latestAnswer()
to receive the payment.
This led me down the first challenge! How does one do inheritance in Stylus? For me as a Solidity developer, inheritance is something I heavily use, and I don’t want to unnecessarily implement the default ERC20 implementation. I read through the stylus documentation, and it looks simple enough. Following the Solidity ethos, I find, through some googling, that OpenZeppelin supports Stylus. With very little information out there about this, I went straight to their GitHub repo to see what libs and contracts they have ported so far, which had the ERC20 package I was looking for.
But then, trouble struck: there were version conflicts between Stylus’s and OpenZeppelin’s EVM alloy.rs types that threw errors. I searched for simpler ERC20 libraries, hoping for a Solady-like gem. I found a few, but most were unmaintained or buggy. I ended up copying the least broken one into my project and manually patching it—a gritty but effective fix.
Another surprise? I got compiler errors from the SDK that warned that EVM methods like msg::sender()
and block::timestamp()
are obsolete, replaced by self.vm().msg_sender()
and self.vm().block_timestamp()
. Weirdly, Arbitrum’s docs still reference the old syntax.
Once this was all ported, the actual inheritance part was rather easy, firstly, we are instantiating a new struct, which we later use to hold the constant params of the ERC20 contract:
pub struct MicroParams;
impl erc20::Erc20Params for MicroParams {
const NAME: &'static str = "Shafu USD";
const SYMBOL: &'static str = "shUSD";
const DECIMALS: u8 = 18;
}
Afterwards, we’re using alloy.rs package to spin up solidity Storage, which is used to actually hold our MicroParams
struct, and whatever else data we need to keep in the constant space. In our case, we need the manager address too to guard the mint()
and burn()
functions to only be accessible to this address.
sol_storage! {
#[cfg_attr(any(feature = "sh-usd"), stylus_sdk::prelude::entrypoint)]
pub struct ShUSD {
#[borrow]
erc20::Erc20<MicroParams> erc20;
address manager;
}
}
Also, for some better error handling, and since the Arbitrum docs covered this topic good, I decided to throw in some Solidity errors to show the user if they try to wrongfully call any of these methods(nothing to do with the inheritance, but figured I might as well show it):
sol! {
error OnlyManagerCanCall();
error ERC20MintError();
error ERC20BurnError();
}
#[derive(SolidityError)]
pub enum ShUSDErrors {
OnlyManagerCanCall(OnlyManagerCanCall),
ERC20MintErr(ERC20MintError),
ERC20BurnErr(ERC20BurnError)
}
Now that we have everything prepared, lets show the actual contract part and where the inheritance is made:
#[cfg_attr(feature = "sh-usd", stylus_sdk::prelude::public, inherit(erc20::Erc20::<MicroParams>))]
impl ShUSD {
pub fn init(&mut self, manager_address: Address) {
self.manager.set(manager_address);
}
pub fn mint(&mut self, to: Address, amount: U256) -> Result<(), ShUSDErrors> {
if self.vm().msg_sender() != self.manager.get() {
return Err(ShUSDErrors::OnlyManagerCanCall(OnlyManagerCanCall {}));
}
self.erc20
.mint(to, amount)
.map_err(|_| ShUSDErrors::ERC20MintErr(ERC20MintError{}))?;
Ok(())
}
pub fn burn(&mut self, from: Address, amount: U256) -> Result<(), ShUSDErrors> {
if self.vm().msg_sender() != self.manager.get() {
return Err(ShUSDErrors::OnlyManagerCanCall(OnlyManagerCanCall {}));
}
self.erc20
.burn(from, amount)
.map_err(|_| ShUSDErrors::ERC20BurnErr(ERC20BurnError{}))?;
Ok(())
}
}
Notice the specific part in the decorator: [cfg_attr(... , inherit(erc20::Erc20::<MicroParams>))]
. This is where we do the actual inheritance. Very simple and straight forward logic. You might be confused by my cfg_attr()
and see in the arbitrum docs that the suggested way looks more like:
#[public]
#[inherit(Erc20)]
impl ShUSD {
The reason mine looks different leads us down to the next issue I ran into working with Stylus. When working with a cargo Stylus project, there is no simple integrated way to keep more than one different contracts in the same project, due to it only being possible to have one #[entrypoint]
decorator. A few hours of hair pulling and head scratching, led me to asking Alex regarding this seemingly weird issue. He showed me his very impressive repo for 9lives which I found way more useful than existing Stylus docs and example codebases. Reading through the codebase, I got a feel for the true value prop of Stylus, and seeing how this wild codebase seamlessly mixes Solidity, Rust and even Huff gave me a good idea of how Stylus could enhance the smart contract experience I already know into new interesting territory.
Anyways, the solution he showed me, to be able to use multiple entrypoints, was to use Rust feature flags to conditionally compile the contract that you wanna compile, then deploy it using the raw WASM files. This throws a bit of the simplicity of using the cargo stylus commands out the window, and gives you a bit more hands on experience, but it’s a clever workaround. The alternative would be for my github repository to host multiple cargo projects with each having a Cargo.toml. While this method works, to me it feels very clunky, and I’d much rather just have one project containing everything. When talking to the Arbitrum Stylus team I’ve been told that easier multi-contract workflow is something they plan on releasing soon (it might even be out by the time you read this).
Calling other contracts
Now once the ShUSD contract is fully implemented, let’s have a look at the manager. From the Shafu repo, everything is probably simple enough to follow here, but let’s check how this is made in Rust! Firstly, in order to call the other contracts, lets set up Solidity interfaces using alloy.rs:
sol_interface! {
interface IOracle {
function latest_answer() external view returns (uint);
}
interface IErc20 {
function transfer_from(address from, address to, uint256 value) external returns (bool);
function transfer(address to, uint256 value) external returns (bool);
function burn(address from, uint256 amount) external;
function mint(address from, uint256 amount) external;
}
}
Pretty straightforward this far, lets set up our global variables and constants:
const MIN_COLLAT_RATIO: u128 = 1_500_000_000_000_000_000; // 1.5e18
#[cfg_attr(feature = "manager", stylus_sdk::prelude::entrypoint)]
#[storage]
pub struct Manager {
sh_usd: StorageAddress,
weth: StorageAddress,
oracle: StorageAddress,
address_2deposit: StorageMap<Address, StorageU256>,
address_2minted: StorageMap<Address, StorageU256>,
is_initialized: StorageBool
}
Notice we set the MIN_COLLAT_RATIO
as a regular rust const here, then setting dedicated storage types that we can get from the Stylus SDK. I believe in the actual contract, most notable is my init()
workaround for the fact that Stylus don’t support constructors:
pub fn init(&mut self, weth_address: Address, oracle_address: Address, sh_usd_address: Address) -> Result<(), Vec<u8>> {
assert_or!(!self.is_initialized.get(), ManagerErrors::AlreadyInitialized(AlreadyInitialized {}));
self.weth.set(weth_address);
self.oracle.set(oracle_address);
self.sh_usd.set(sh_usd_address);
self.is_initialized.set(true);
Ok(())
}
Besides this, I found most of the syntax and logic implementing this manager contract very straightforward, given that I am reasonably comfortable in writing Rust already.
So calling another contract looked something like (This code won’t work properly for reasons I am covering right after):
pub fn deposit(&mut self, amount: U256) {
let weth_instance = IErc20::new(self.weth.get());
let sender = self.vm().msg_sender();
let this = self.vm().contract_address();
weth_instance.transfer_from(&mut *self, sender, this, amount)?;
let previus_balance = self.address_2deposit.get(sender);
self.address_2deposit.insert(sender, previus_balance + amount);
}
Issue and solution
Above method is the method recommended by Arbitrum Stylus team , however, in our special context, this method for calling other contracts lacks flexibility. Since in our contract doesn’t implement the TopLevelStorage
trait, passing self into the external contract calls doesn’t really do anything, and therefore these calls will revert. This took me a lot of head scratching and yapping with Alex to fully wrap my head around, so what I did for more rapid testing was that I wrote this little shell script . After some reading into the 9lives codebase I found out that they seem to use this method instead, where you setup one file for calls and import these into your contract. I scanned through these contracts for a while, and then decided to give it a go in my project. I first setup my calls.rs file:
use alloc::vec::Vec;
use alloy_primitives::Address;
use stylus_sdk::{prelude::*, alloy_primitives::{I256, U256}, call::RawCall, alloy_sol_types::{sol, SolCall}};
sol! {
error CouldNotCall();
error CouldNotUnpackBool();
}
#[derive(SolidityError)]
pub enum CallErrors {
CouldNotCall(CouldNotCall),
CouldNotUnpackBool(CouldNotUnpackBool)
}
sol! {
function latestAnswer() external view returns (int);
function transferFrom(address from, address to, uint256 value) external returns (bool);
function transfer(address to, uint256 value) external returns (bool);
function burn(address from, uint256 amount) external;
function mint(address to, uint256 amount) external;
}
pub fn latest_answer_call(oracle: Address) -> Result<I256, Vec<u8>> {
I256::try_from_be_slice(unsafe { &RawCall::new().call(oracle, &latestAnswerCall {}.abi_encode()).unwrap()}).ok_or(CallErrors::CouldNotCall(CouldNotCall {}).into())
}
pub fn transfer_from_call(token: Address, from: Address, to: Address, value: U256) -> Result<(), Vec<u8>> {
unpack_bool_safe(unsafe { &RawCall::new().call(token, &transferFromCall {
from,
to,
value,
}.abi_encode()).unwrap()})
}
pub fn transfer_call(token: Address, to: Address, value: U256) -> Result<(), Vec<u8>> {
unpack_bool_safe(unsafe { &RawCall::new().call(token, &transferCall {
to,
value,
}.abi_encode()).unwrap()})
}
pub fn mint_call(token: Address, to: Address, amount: U256) -> Result<(), Vec<u8>> {
unpack_bool_safe(unsafe { &RawCall::new().call(token, &mintCall {
to,
amount,
}.abi_encode()).unwrap()})
}
pub fn burn_call(token: Address, from: Address, amount: U256) -> Result<(), Vec<u8>> {
unpack_bool_safe(unsafe { &RawCall::new().call(token, &burnCall {
from,
amount,
}.abi_encode()).unwrap()})
}
pub fn unpack_bool_safe(data: &[u8]) -> Result<(), Vec<u8>> {
match data.get(31) {
None | Some(1) => Ok(()),
_ => Err(CallErrors::CouldNotUnpackBool(CouldNotUnpackBool {}).into()),
}
}
So now, instead of natively trying to call these functions and send the full contract context like before, we will instead only do RawCalls
to the contract address of the other contract. After this, I implemented these calls into my contract, so final contract ended up looking like:
use alloc::vec;
use alloc::vec::Vec;
use core::str::FromStr;
use alloy_sol_types::sol;
use crate::contracts::calls;
use alloy_primitives::Address;
use crate::alloc::string::ToString;
use stylus_sdk::{alloy_primitives::U256, prelude::*};
use stylus_sdk::storage::{StorageAddress, StorageMap, StorageU256, StorageBool};
const MIN_COLLAT_RATIO: u128 = 1_500_000_000_000_000_000; // 1.5e18
sol! {
error Undercollateralized();
error AlreadyInitialized();
error CouldNotAdd();
error CouldNotSub();
error CouldNotMul();
error CouldNotDiv();
error ConversionFailure();
}
#[derive(SolidityError)]
pub enum ManagerErrors {
Undercollateralized(Undercollateralized),
AlreadyInitialized(AlreadyInitialized),
CouldNotAdd(CouldNotAdd),
CouldNotSub(CouldNotSub),
CouldNotMul(CouldNotMul),
CouldNotDiv(CouldNotDiv),
ConversionFailure(ConversionFailure)
}
#[macro_export]
macro_rules! assert_or {
($cond:expr, $err:expr) => {
if !($cond) {
Err($err)?;
}
};
}
#[cfg_attr(feature = "manager", stylus_sdk::prelude::entrypoint)]
#[storage]
pub struct Manager {
sh_usd: StorageAddress,
weth: StorageAddress,
oracle: StorageAddress,
address_2deposit: StorageMap<Address, StorageU256>,
address_2minted: StorageMap<Address, StorageU256>,
is_initialized: StorageBool
}
#[cfg_attr(feature = "manager", stylus_sdk::prelude::public)]
#[cfg(feature = "manager")]
impl Manager {
pub fn init(&mut self, weth_address: Address, oracle_address: Address, sh_usd_address: Address) -> Result<(), Vec<u8>> {
assert_or!(!self.is_initialized.get(), ManagerErrors::AlreadyInitialized(AlreadyInitialized {}));
self.weth.set(weth_address);
self.oracle.set(oracle_address);
self.sh_usd.set(sh_usd_address);
self.is_initialized.set(true);
Ok(())
}
pub fn deposit(&mut self, amount: U256) -> Result<(), Vec<u8>> {
let sender = self.vm().msg_sender();
let this = self.vm().contract_address();
calls::transfer_from_call(self.weth.get(), sender, this, amount)?;
let previus_balance = self.address_2deposit.get(sender);
self.address_2deposit.insert(sender, previus_balance.checked_add(amount)
.ok_or(ManagerErrors::CouldNotAdd(CouldNotAdd {}))?);
Ok(())
}
pub fn burn(&mut self, amount: U256) -> Result<(), Vec<u8>> {
let sender = self.vm().msg_sender();
let previous_balance = self.address_2minted.get(sender);
self.address_2minted.insert(sender, previous_balance.checked_sub(amount)
.ok_or(ManagerErrors::CouldNotSub(CouldNotSub {}))?);
calls::burn_call(self.sh_usd.get(), sender, amount)?;
Ok(())
}
pub fn mint(&mut self, amount: U256) -> Result<(), Vec<u8>> {
let sender = self.vm().msg_sender();
let previous_balance = self.address_2minted.get(sender);
self.address_2minted.insert(sender, previous_balance.checked_add(amount)
.ok_or(ManagerErrors::CouldNotAdd(CouldNotAdd {}))?);
let ratio = self.collat_ratio(sender)?;
assert_or!(ratio > U256::from(MIN_COLLAT_RATIO), ManagerErrors::Undercollateralized(Undercollateralized {}));
calls::mint_call(self.sh_usd.get(), sender, amount)?;
Ok(())
}
pub fn withdraw(&mut self, amount: U256) -> Result<(), Vec<u8>> {
let sender = self.vm().msg_sender();
let previous_deposit = self.address_2deposit.get(sender);
self.address_2deposit.insert(sender, previous_deposit.checked_sub(amount)
.ok_or(ManagerErrors::CouldNotSub(CouldNotSub {}))?);
let ratio = self.collat_ratio(sender)?;
assert_or!(ratio > U256::from(MIN_COLLAT_RATIO), ManagerErrors::Undercollateralized(Undercollateralized {}));
calls::transfer_call(self.weth.get(), sender, amount)?;
Ok(())
}
pub fn liquidate(&mut self, user: Address) -> Result<(), Vec<u8>> {
let result = self.collat_ratio(user)?;
assert_or!(result <= U256::from(MIN_COLLAT_RATIO), ManagerErrors::Undercollateralized(Undercollateralized {}));
let sender = self.vm().msg_sender();
let amount_minted = self.address_2minted.get(user);
calls::burn_call(self.sh_usd.get(), user, amount_minted)?;
let amount_deposited = self.address_2deposit.get(user);
calls::transfer_call(self.weth.get(), sender, amount_deposited)?;
self.address_2deposit.insert(user, U256::ZERO);
self.address_2minted.insert(user, U256::ZERO);
Ok(())
}
pub fn collat_ratio(&self, user: Address) -> Result<U256, Vec<u8>> {
let minted = self.address_2minted.get(user);
if minted.is_zero() { return Ok(U256::MAX); }
let deposited = self.address_2deposit.get(user);
let price = calls::latest_answer_call(self.oracle.get())?;
let value = deposited.checked_mul(U256::from_str(&price.to_string()).unwrap()
.checked_mul(U256::from(1e10 as u64))
.ok_or(ManagerErrors::CouldNotMul(CouldNotMul {}))?)
.ok_or(ManagerErrors::CouldNotMul(CouldNotMul {}))?;
let value_scaled = value.checked_div(U256::from(1e18 as u64)).ok_or(ManagerErrors::CouldNotDiv(CouldNotDiv {}))?;
Ok(value_scaled.checked_div(minted).ok_or(ManagerErrors::CouldNotDiv(CouldNotDiv {}))?)
}
}
Finally, it’s working as intended, all of my tests were passing!
Scaling tangent
In my tests, I made a mock oracle that was returning what the actual oracle was returning that day . In my shell script, I was depositing .1 weth into the contract, thinking this would approximately let me borrow 117,177 ShUSD, given that 175.76555 / 1.5 = 117,177. However, it would of course need to mint me 117,177 *10^18 in order for me to actually have the correct amount, since ShUSD have 18 decimals. But this was not the case, I could only mint 117 of the smallest denominator, equivalent to wei, of my token, leading me to believe that my scaling implementation was wrong. I rescanned the 0xShafu repo to see if my logic was mapping it 1 to 1:
function collatRatio(address user) public view returns (uint) {
uint minted = address2minted[user];
if (minted == 0) return type(uint256).max;
uint256 totalValue = address2deposit[user] * (oracle.latestAnswer() * 1e10) / 1e18;
return totalValue / minted;
}
and my version being:
pub fn collat_ratio(&self, user: Address) -> Result<U256, Vec<u8>> {
let minted = self.address_2minted.get(user);
if minted.is_zero() { return Ok(U256::MAX); }
let deposited = self.address_2deposit.get(user);
let price = calls::latest_answer_call(self.oracle.get())?;
let value = deposited.checked_mul(U256::from_str(&price.to_string()).unwrap()
.checked_mul(U256::from(1e10 as u64))
.ok_or(ManagerErrors::CouldNotMul(CouldNotMul {}))?)
.ok_or(ManagerErrors::CouldNotMul(CouldNotMul {}))?;
let value_scaled = value.checked_div(U256::from(1e18 as u64)).ok_or(ManagerErrors::CouldNotDiv(CouldNotDiv {}))?;
Ok(value_scaled.checked_div(minted).ok_or(ManagerErrors::CouldNotDiv(CouldNotDiv {}))?)
}
I reread his and my code probably 100 times and couldn’t for the life of me see the potential error I was doing. As a last resort, I set up the same type of mock testing for his contract in the remix ide. To my confusion, and relief, his contract behaved the exact same way like mine:
and:
Which was enough for me to conclude my contract working as intended, at least mapping the original.
Final Words
Verdict time! My thoughts on Arbitrum Stylus is that it feels like a newborn fawn finding its legs. I am very excited about how it can enhance the EVM space, and I can really see the vision, even though as of now it’s still rough around the edges. I’m looking forward to experimenting with stylus more on this blog, and push the boundaries further, anything from trying out other WASM compatible languages, to making a more fully fledged project where Solidity and Rust both are utilized. I am also excited to see the tooling come to a more mature state. Until then, I encourage everyone reading this to try out building a small project using Stylus and please reach out to me and/or Alex and share your experiences while doing so 😎
Please follow me on x and/or on GitHub!
The repository lives here: https://github.com/jacksmithinsulander/microstable-stylus
Stylus Saturdays is brought to you by… Arbitrum! With a grant from the Fund the Stylus Sprint program. You can learn more about Arbitrum grants here: https://arbitrum.foundation/grants
Follow me on X: @baygeeth
Side note: I develop Superposition, a defi-first chain that pays you to use it. You can check our ecosystem of dapps out at https://superposition.so