Hello everyone! A bit of an oddball post outside our normal format, but hopefully one that everyone will enjoy. I wanted to discuss a real problem we’re all facing, which is how to take codesize lower. This is a tactic we’re implementing in our Passport smart contract to get things under the limit, despite the huge amount of thirdparty code we’re using. I wanted to share this technique since it’s been so effective for us on a very large and complex project.
Let’s set the scene: you’re building something complex, and the lion’s share of the codesize lives in a single function. You are right up against the limit. Your boss (Ivan in this case) wants extra features (cross-chain features, anyone?) but you’re not able to push things any further.
How can we go lower? The tactic in this case might be to use a reentrant pattern within your code, and a proxy entrypoint. With this method, we can break up a function that’s quite large into small pieces that are gradually executed, until the final result is handed back to the user. This is an extremely effective method for a large chained operation! You can take any complex interaction, and break it up into contract part 1, part 2, part 3, and so forth. You store each step of the operation using transient storage, which you read like registers.
Transient storage operation is a custom storage type similar to memory in the EVM, except that it lives for the length of the transaction. It could be used to protect a contract from reentrancy, implement role based authentication, and more. It’s accessed using the TSTORE
and TLOAD
functions, two functions that allow addressing with a 32 byte key, like the classic EVM storage.
This feature is not available in the SDK, but it is in the runtime. To use this feature in this context (with reentrancy), you must disable reentrancy guard using a feature, and it’s not documented, and even putting aside Stylus it’s an advanced technique.
Let’s peer behind the curtain and explore a strategy to make this work. Our contract will look like this:
A proxy will handle dispatch into two different contracts depending on the function that’s used. The proxy will need to understand which functions go where, a method we’ll leave up to the implementor. We have implemented different approaches for proxies in the past, with explicit function matching in Longtail (our first foray into Stylus, a bit yuck in retrospect), hardcoded magic byte matching in 9lives with a beacon, and in a previous release a classic proxy, and purely dynamic matching in Passport (which serves as the impetus for this method). Superposition Passport is unique in that it leverages a Borsh decoding-style method similar to Solana entrypoints, which it uses to know when to decompress incoming calldata as opposed to a classic 4byte selector match, so we won’t elaborate on that further today.
Our contract will implement behaviour like this:
In a real world example, any interaction that’s multiple step could be broken up this way. In Superposition Passport, we do some cryptography heavy lifting, coming up with some facts about the user, then we use this method to initiate the next part of the contract chain, a function that begins a cross-chain asset transfer.
In our code, we’ll simply have some code inside the first contract generate a number, and the second step of the contract return it to the user. This is a drop-in for any complex behaviour you might implement this way.
A refresher on reentrancy
Reentrancy is the practice of a contract calling itself. It often makes headlines in the context of an exploit that took place when a chain of contract interruption was broken thanks to a contract calling back to its original calling contract when it shouldn’t have, before the state has been persisted. We won’t be using it this way of course! Reentrancy is useful for reducing codesize and recursive patterns that would be otherwise difficult to implement in size-constrained contracts.
We can implement a reentrant contract in the Solidity context:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
contract Token {
function invoke() external {
Counter(msg.sender).kickoff(this);
}
}
contract Counter {
uint256 public counter;
function kickoff(Token _invoker) external returns (uint256) {
if (msg.sender == address(_invoker)) {
counter++;
} else {
_invoker.invoke();
}
return counter;
}
}
This code is visually similar to process forking and threading in the Unix context. The Counter contract is started using the kickoff
function, which checks to see if the invoker address is the caller, and if it’s not, it calls the function “invoke” on the invoker. The invoker contract then calls the kickoff
function again on the Counter, which then bumps the counter, which it returns. This returns 1.
Reentrancy in Stylus
To use reentrancy in Stylus, we need to enable a specific feature that disables the check for reentrancy in the contract. We do this by setting a feature in stylus-sdk
to be reentrant
:
// Cargo.toml
[dependencies]
...
stylus-sdk = { version = "0.9.0", features = ["reentrant"] }
This totally disables the built-in check that checks for reentrancy in the contract. The Stylus SDK developers wisely made the decision to protect us from this huge vulnerability for most contracts by default, and we’re right on the edge, so we have to disable it.
Reentrantly entering a contract again is as simple as invoking itself:
#[entrypoint]
#[storage]
struct TStoreExample;
sol! {
function hello() external view returns (string);
}
#[public]
impl TStoreExample {
pub fn hello() -> U256 {
U256::from(123)
}
pub fn reentrant(&self) -> FixedBytes<32> {
let addr = self.vm().contract_address();
FixedBytes::from_slice(
&self
.vm()
.static_call(&self, addr, &helloCall {}.abi_encode())
.unwrap(),
)
}
}
You can see that we return a fixed bytes word instead of a Vec<u8>
here. The reason is that encoding a Vec<u8> will literally encode a vector of u8 if it’s used as the Ok return type, something to be mindful of generally. Using a FixedBytes word when we’re trying to return the calldata here enables us to avoid decoding the return type.
Transient storage in Stylus
Transient storage use in Stylus is possible to use by explicitly using a hostio that’s unavailable to us ordinary SDK plebs, transient_load_bytes32
and transient_store_bytes32
.
We can implement support for the missing functions like this:
#[cfg(target_arch = "wasm32")]
#[link(wasm_import_module = "vm_hooks")]
unsafe extern "C" {
fn transient_load_bytes32(key: *const u8, dest: *const u8);
fn transient_store_bytes32(key: *const u8, value: *const u8);
}
#[cfg(not(target_arch = "wasm32"))]
pub fn transient_load_bytes32(key: *const u8, dest: *const u8) {}
#[cfg(not(target_arch = "wasm32"))]
pub fn transient_store_bytes32(key: *const u8, value: *const u8) {}
fn tload(key: U256) -> U256 {
let mut dest = [0u8; 32];
unsafe {
transient_load_bytes32(key.to_be_bytes::<32>().as_ptr(), dest.as_mut_ptr());
}
U256::from_be_bytes(dest)
}
fn store(key: U256, val: U256) {
unsafe {
transient_store_bytes32(
key.to_be_bytes::<32>().as_ptr(),
val.to_be_bytes::<32>().as_ptr(),
);
}
}
We could’ve used any type here instead of U256
(including FixedBytes
) technically. This code simply uses the TSTORE
and TLOAD
functions. tload
and tstore
wrap the code by getting the array underneath the complex U256
type as a big endian array (the default endianness format for the EVM).
We can simulate it like this:
#[entrypoint]
#[storage]
struct TStoreExample;
#[public]
impl TStoreExample {
pub fn begin(&self) -> U256 {
let k = U256::from(123);
store(k, U256::from(456));
tload(k)
}
}
The code returns 456 here, as you might imagine! This will be the case for the life of the transaction:
#[public]
impl TStoreExample {
pub fn hello() -> U256 {
tload(U256::from(123))
}
pub fn reentrant(&self) -> FixedBytes<32> {
store(U256::from(123), U256::from(456));
let addr = self.vm().contract_address();
FixedBytes::from_slice(
&self
.vm()
.static_call(&self, addr, &helloCall {}.abi_encode())
.unwrap(),
)
}
}
Bringing the technique together for our POC
So we can start to combine the reentrancy with the transient storage pattern here. Now, what if we were to split up our contract into two contracts:
#[cfg_attr(any(feature = "contract-1", feature = "contract-2"), entrypoint)]
#[storage]
struct TStoreExample;
sol! {
function hello() external view returns (string);
}
#[cfg_attr(feature = "contract-1", public)]
impl TStoreExample {
pub fn hello() -> U256 {
tload(U256::from(123))
}
}
#[cfg_attr(feature = "contract-2", public)]
impl TStoreExample {
pub fn reentrant(&self) -> FixedBytes<32> {
store(U256::from(123), U256::from(456));
let addr = self.vm().contract_address();
FixedBytes::from_slice(
&self
.vm()
.static_call(&self, addr, &helloCall {}.abi_encode())
.unwrap(),
)
}
}
#[cfg(not(any(feature = "contract-1", feature = "contract-2")))]
compile_error!("contract-1 and contract-2 not enabled!");
This code uses feature flags and a Stylus trick to allow us to compile either contract 1 or contract 2 at a given time. The Stylus team are adding the ability for multiple contracts to be compiled at any time, but for now, this approach is sufficient. In Passport, we’ve discovered other techniques for this that we’ll elaborate on another time.
We need a proxy that lets us do dispatch into either contract:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
contract Proxy {
address public immutable FACET_HELLO;
address public immutable FACET_REENTRANT;
constructor(address _hello, address _reentrant) {
FACET_HELLO = _hello;
FACET_REENTRANT = _reentrant;
}
function directDelegate(address to) internal {
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), to, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 {
revert(0, returndatasize())
}
default {
return(0, returndatasize())
}
}
}
function hello() public returns (uint256) {
directDelegate(FACET_HELLO);
}
function reentrant() external returns (uint256) {
directDelegate(FACET_REENTRANT);
}
}
This proxy is of the kind that explicitly loads a facet for the function given. Let’s deploy everything:
#!/usr/bin/env -S rc -e
SPN_SUPERPOSITION_URL=https://testnet-rpc.superposition.so
SPN_SUPERPOSITION_KEY=`{QEP superposition}
make
contract_1=`{./deploy.sh contract-1.wasm}
contract_2=`{./deploy.sh contract-2.wasm}
forge create \
--json \
--broadcast \
--rpc-url $SPN_SUPERPOSITION_URL \
--private-key $SPN_SUPERPOSITION_KEY \
src/Proxy.sol:Proxy \
--constructor-args $contract_1 $contract_2 \
| jq -r .deployedTo
In my case, the proxy contract was deployed at 0x70f694eA46a11965067aF701d5F410E314262b72
on Superposition Testnet. Testing it, we can see it works:
cast call --rpc-url https://testnet-rpc.superposition.so 0x70f694eA46a11965067aF701d5F410E314262b72 'reentrant()(uint256)'
Side note, it’s a terrible practice to take a private key as an argument. Linux (and BSD to an extent) systems have total transparency for running processes on the system by default, and I’m a repeat offending criminal for building tools that ignore this fact. It’s better to use environment variables!
I hope this very short article engages your imagination with how far you can push contracts to reduce codesize in the Stylus context! The sky is the limit with this technique.
Stylus Saturdays is brought to you by… the Arbitrum DAO! 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 and on Farcaster!
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!
Thumbnail photo by Sixteen Miles Out on Unsplash. Thank you!