Stylus Lessons Learned: Road to Superposition

Part 1

Superposition - (šŸ–¤,šŸ¤)
7 min readNov 22, 2023

In September, Fluidity Labs set out to build the first AMM with Stylus.

It is the first building block to the Superlayer, the first DeFi-native Layer-3 that pays you to use it, built on top of Arbitrum One.

Superposition AMM is a concentrated liquidity AMM, similar to Uniswap v3.

The difference?

  • It pays you yield for every single trade you do.
  • Gas savings due to Stylus + no LP fees while still being profitable for LPs.
  • Enables ā€˜Utility Miningā€™ and new forms of token distribution, exposing traders and LPers to different token incentives.

We wanted to build atop the magical properties of Super Assets, expand on the gas-saving features of Stylus, and deepen our strong commitment to the Arbitrum ecosystem. Super Assets reward users and LPs with incentives for every transaction offering a ā€œfee negativeā€ experience.

We intended to release a cheaper AMM that supported our novel ā€œUtility Miningā€ feature (gas comparison post coming soon!), in which ranges of trading can be incentivised.

As of today, weā€™re happy to report that our contracts are mostly complete, and are deployed to Stylus testnet. Building atop a new technology is always an interesting experience, and we discovered the ins and outs of the Stylus ecosystem. Weā€™ve shared our lessons below, and some thoughts on improvements that could be made to the Stylus tooling.

Shoutout to 0xkitsune for releasing the Uniswap maths library weā€™ve based our implementation on.

This is only but the first step in the Superposition Roadmap:

Super Assets

What are Super Assets?

Simply, Super Assets are wrapped assets with random yield rewarding properties. Every on-chain transaction of a Super Asset rewards the sender and transacting party (contract or otherwise) with a random (potentially large) amount of yield in the underlying asset. This takes place by observing every transaction off-chain with the Transfer Reward Function (TRF) that determines the probabilities of winning based on the platform, and based off the gas fees paid by the user. As the opportunity cost is always lower for transactions thatā€™d be better off setting and ignoring, users will not win out by simply sending their assets around for the sake of it. For value creation to happen with the reward pool, they must externalise the costs by deriving utility from their assets. With this format weā€™ve simultaneously developed a system that rewards on-chain use and adoption, while punishing Sybil attacks.

The good

Going in, Stylus was unreleased, so we had no idea what to expect. At that stage, it was purely an idea that weā€™d learned about. Once the code was released, we were fortunate that the implementation heavily depended on Alloy, which weā€™d mocked out.

Rust and security

Working with Rust is always a charming experience. Itā€™s easy to model problem domains with abstractions, and it appeals to our teamā€™s expertise. We enjoyed the benefits of the default memory safety, and the freedom to not be concerned with re-entrancy. Itā€™s a good security by default dimension that simplified our program writing. We were lucky that the environment afforded us to not even run in Stylus until the final cycle of our process.

Stylus performance

Stylus caches storage reads and writes. We found this made it easier to reason about the performance implications of storage accesses. It allowed us to more closely model the problem domain, without being concerned with performance. It afforded us to focus exclusively on the problem as opposed to the environment for the solution.

Solidity is full of footguns that Stylus lacks, so we found that thereā€™s less programmer overhead when writing Stylus. We like that the Arbitrum team paid close attention to this, and it made things a lot more pleasant for our team!

The undercooked

LLVM codesize

LLVM doesnā€™t support natively the 256bit words that the EVM needs, so we found codesize inefficient behaviour going between the Rust native and library Alloy type. This has a negative codegen effect, as mixing integers donā€™t work properly.

We found that Alloyā€™s types in some places are inline-only, and we believe that some optimisations could be made by attacking this avenue of optimisation.

In the end, we needed to split the contract into separate features that we called through a diamond proxy. We used LLVM optimisation as-is at first, but it wasnā€™t enough. We were forced to reduce codesize over efficiency, including replacing large branching code with binary search.

We used feature flags to gate for the Diamond Proxy WASM blobs:

/// Position management functions. Only enabled when the `positions` feature is set.
#[cfg_attr(feature = "positions", external)]
impl Pools {
/// Creates a new, empty position, owned by a user.
///
/// # Errors
/// Requires the pool to exist and be enabled.
pub fn mint_position(&mut self, pool: Address, lower: i32, upper: i32) -> Result<(), Revert

Stylus SDK is difficult to integrate with tooling

We found that Cargo Stylus is at times difficult to integrate with tooling. For instance, thereā€™s a lack of alternate display models. The Stylus SDK also feels a little underbaked at times. Although most of the core functionality is there, itā€™s missing a few quality of life features, including customising the code it generates, or supporting custom storage types better.

Test infrastructure lacking

Itā€™s not possible to use a forknet of any description as-is today without a custom solution, or to use alternative tooling for testing (ie, Hardhat). As our codebase also includes Solidity, we found this a little challenging, and were forced to deploy against a local Nitro testnode.

Fortunately, we found testing in the codebase sufficient:

#[test]
fn test_swap() -> Result<(), Revert> {
with_storage(|storage| {
storage.init(encode_sqrt_price(100, 1), 0, 1, u128::MAX)?;

let id = uint!(2_U256);
storage.create_position(
id,
tick_math::get_tick_at_sqrt_ratio(encode_sqrt_price(50, 1))?,
tick_math::get_tick_at_sqrt_ratio(encode_sqrt_price(150, 1))?,
).unwrap();
storage.update_position(id, 100)?;

Default entrypoint is too codesize heavy

The Stylus SDK autogenerates code with a strong dependency on alloyā€™s decoding code, and the Rust vector type. These can be bad for codesize. To solve for this, we forked the Stylus SDK to use different decoding code. This is due to Alloy decoding to space inefficient structures. This also included a custom attribution tag to simplify things in the forked Alloy code:

#[cfg(feature = "swaps")]
#[selector(id = "swap(address,bool,int256,uint256,uint256,uint256,uint256,bytes)")]
#[raw]
pub fn swap_permit2(

We also wrote a combinatorial-style decoder:

let (pool, data) = eth_serde::parse_addr(data);
let (zero_for_one, data) = eth_serde::parse_bool(data);
let (amount, data) = eth_serde::parse_i256(data);
let (price_limit_x96, data) = eth_serde::parse_u256(data);
let (sig, data) = eth_serde::parse_bytes(data);
let (nonce, data) = eth_serde::parse_u256(data);
let (deadline, data) = eth_serde::parse_u256(data);
let (max_amount, _) = eth_serde::parse_u256(data);

Wound up using another allocator in the end

Stylus recommends using weealloc, which has memory leaks, and is unmaintained. We use a project called lolalloc, which we reviewed and works fine.

extern crate alloc;
// only set a custom allocator if we're deploying on wasm
#[cfg(target_arch = "wasm32")]
mod allocator {
use lol_alloc::{AssumeSingleThreaded, FreeListAllocator};
// SAFETY: This application is single threaded, so using AssumeSingleThreaded is allowed.
#[global_allocator]
static ALLOCATOR: AssumeSingleThreaded<FreeListAllocator> =
unsafe { AssumeSingleThreaded::new(FreeListAllocator::new()) };
}

Documentation lacking

The Stylus docs are a work in progress! This is to be expected.

Useful hacks

Testing without Stylus

Itā€™s possible to run tests without Stylus in an unhosted environment by stubbing out functions Stylus expects. This is possible by asking the compiler not to mangle names then exporting them as dynamically linkable C functions. Then Stylus will link to them automatically!

#[cfg(test)]
#[no_mangle]
pub extern "C" fn native_keccak256(bytes: *const u8, len: usize, output: *mut u8) {
// SAFETY
// stylus promises `bytes` will have length `len`, `output` will have length one word
use std::slice;
use tiny_keccak::{Hasher, Keccak};

let mut hasher = Keccak::v256();

let data = unsafe { slice::from_raw_parts(bytes, len) };
hasher.update(data);

let output = unsafe { slice::from_raw_parts_mut(output, 32) };
hasher.finalize(output);
}

#[cfg(test)]
mod storage {
use std::collections::HashMap;
use std::ptr;
use std::sync::LazyLock;
use std::sync::Mutex;

const WORD_BYTES: usize = 32;
pub type Word = [u8; WORD_BYTES];

pub static STORAGE_EXTERNAL: Mutex<()> = Mutex::new(());

pub static STORAGE: LazyLock<Mutex<HashMap<Word, Word>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));

pub unsafe fn read_word(key: *const u8) -> Word {
let mut res = Word::default();
ptr::copy(key, res.as_mut_ptr(), WORD_BYTES);
res
}

pub unsafe fn write_word(key: *mut u8, val: Word) {
ptr::copy(val.as_ptr(), key, WORD_BYTES);
}
}

#[cfg(test)]
#[no_mangle]
pub extern "C" fn storage_store_bytes32(key: *const u8, value: *const u8) {
let (key, value) = unsafe {
// SAFETY - stylus insists these will both be valid words
(storage::read_word(key), storage::read_word(value))
};

storage::STORAGE.lock().unwrap().insert(key, value);
}

#[cfg(test)]
#[no_mangle]
pub extern "C" fn storage_load_bytes32(key: *const u8, out: *mut u8) {
// SAFETY - stylus promises etc
let key = unsafe { storage::read_word(key) };

let value = storage::STORAGE
.lock()
.unwrap()
.get(&key)
.map(storage::Word::to_owned)
.unwrap_or_default(); // defaults to zero value

unsafe { storage::write_word(out, value) };
}

#[cfg(test)]
pub fn log_storage() {
println!("{:?}", storage::STORAGE.lock().unwrap());
}

#[cfg(test)]
pub fn reset_storage() {
storage::STORAGE.lock().unwrap().clear();
}

#[cfg(test)]
pub fn acquire_storage() -> std::sync::MutexGuard<'static, ()> {
storage::STORAGE_EXTERNAL.lock().unwrap()
}

Diamond-like pattern

We used a Diamond-like pattern. Our proxy contract, implemented as boring Solidity, dispatches to three contracts deployed with features. We used a feature like below:

#[cfg_attr(feature = "swaps", external)]
impl Pools {

Disabling inline and cutting code size

Itā€™s possible to use the no inline attribute to prevent code inlining in some paths. Make sure to disable code stripping before experimenting with this (though run it enabled when you have your optimised version)! You might explore opportunities to do so using the Twiggy crate (twiggy). An example:

#[inline(never)]
fn hello_world() {
// your code would live here!
}

Closing remarks

Overall, we found working with Stylus a pleasant experience and enjoyed writing the codesize optimisations we were able to deploy. We think EVM+ has a very bright future, and are proud to be card-holding members of the Arbitrum ecosystem. Weā€™re thankful for the Arbitrum team for always pushing the envelope with their technology stack! We canā€™t wait to open source the codebase to help other builders in the space!

Stay tuned for part 2, where weā€™ll dig into the gas savings and the LP benefits.

About Superposition

Superposition is the first blockchain that pays you to use it. It includes a novel on-chain order book focused on steering order flow, with faster execution speeds, shared and permissionless liquidity, account abstraction and zero fees, while providing higher yields to Liquidity Providers and traders alike.

--

--

Superposition - (šŸ–¤,šŸ¤)

Superposition: DeFi Layer-3 infra. Create permissionless AMMs & derivatives, backed by strong governance & AA. More than a DEX; shaping the future DeFi.