← Back to Blog

Arbitrum Stylus Rust Testing: Motsu Guide

2026-07-01 Arbitrum Stylus Rust Smart Contract Security WASM Testing Solidity Mocking Reentrancy Guard Unit Testing

Arbitrum Stylus Rust Testing: How to Scaffold and Mock Dependencies

When building high-performance smart contracts on Arbitrum Stylus, developers leverage WebAssembly (WASM) to write execution logic in Rust. However, migrating execution to WASM does not eliminate classic EVM-level security vulnerabilities. In fact, cross-contract interactions between WASM-based Rust code and EVM-based Solidity dependencies introduce complex trust boundaries. According to security reports, vulnerabilities in cross-contract calls account for millions in lost user funds, particularly when interacting with unsafe or reentrant external vault contracts.

To secure these boundaries, unit testing is paramount. This guide demonstrates how to perform Arbitrum Stylus Rust testing using Motsu, a unit-testing framework developed by OpenZeppelin. We will walk through building a secure Rust integration, detailing how to mock external Solidity dependencies, detect reentrancy vulnerabilities, and implement standard defensive patterns.

The Cross-Language Reentrancy Risk

Arbitrum Stylus contracts are fully ABI-compatible with Solidity. They can make external calls using the sol_interface! macro. While this interoperability is highly efficient, it creates a risk if the external Solidity contract is vulnerable. If a Rust contract calls a Solidity contract that has a reentrancy vulnerability, or if the Rust contract's state updates are out of order, the contract's funds can be drained.

Let us examine a classic reentrancy vulnerability in an external Solidity vault contract that a Rust contract might interact with.

The Vulnerable Solidity Dependency

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract VulnerableVault {
    mapping(address => uint256) public userBalances;

    function deposit() external payable {
        userBalances[msg.sender] += msg.value;
    }

    function withdraw() external {
        uint256 balance = userBalances[msg.sender];
        require(balance > 0, "No balance to withdraw");

        // VULNERABLE: External call is made before updating the internal state
        (bool success, ) = msg.sender.call{value: balance}(""); // @audit Vulnerable to reentrancy
        require(success, "ETH transfer failed");

        userBalances[msg.sender] = 0;
    }
}

In this VulnerableVault, the state update userBalances[msg.sender] = 0 occurs after the external transfer msg.sender.call. An attacker can intercept this transfer via a malicious contract's receive or fallback function, re-entering the withdraw function before the balance is zeroed out.

The Exploit Path

  1. The attacker deploys a malicious contract and deposits 1 ETH into the VulnerableVault.
  2. The attacker calls withdraw().
  3. VulnerableVault reads the attacker's balance (1 ETH).
  4. VulnerableVault calls the attacker's contract to send the 1 ETH.
  5. The attacker's receive() function executes. Instead of returning, it calls withdraw() again.
  6. The VulnerableVault reads the balance, which is still 1 ETH because the state update has not yet executed.
  7. The vault sends another 1 ETH. This loop continues until the vault's total pool is depleted.

The Fixed Solidity Version

To prevent this, we must update the state before the external call (the Checks-Effects-Interactions pattern) or use a mutex lock like OpenZeppelin's ReentrancyGuard.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

contract SafeVault is ReentrancyGuard {
    mapping(address => uint256) public userBalances;

    function deposit() external payable {
        userBalances[msg.sender] += msg.value;
    }

    function withdraw() external nonReentrant {
        uint256 balance = userBalances[msg.sender];
        require(balance > 0, "No balance to withdraw");

        // FIX: Update the state before making the external call
        userBalances[msg.sender] = 0;

        (bool success, ) = msg.sender.call{value: balance}("");
        require(success, "ETH transfer failed");
    }
}

Updating the balance to 0 prior to the external call ensures that any reentrant execution path encounters a balance of 0 and reverts at the require(balance > 0) check.


Structuring Testable Stylus Contracts in Rust

When developing an Arbitrum Stylus contract in Rust that wraps or interacts with this external vault, we must design our architecture for testability.

If we write a contract that calls the vault directly via sol_interface!, unit testing becomes challenging. The generated methods perform low-level EVM syscalls (call_contract) that require a live EVM state. Motsu intercepts host functions at the WebAssembly level, but it does not execute external Solidity bytecode.

To run pure Rust unit tests, we isolate our external calls using Rust's trait system. This allows us to inject a mock implementation of the vault during testing, and use the real Solidity implementation in production.

Step 1: Define the Vault Interface Trait

First, we define a Rust trait that abstracts the calls to the vault.

use stylus_sdk::alloy_primitives::{Address, U256};

pub trait VaultClient {
    fn deposit(&self, value: U256) -> Result<(), Vec<u8>>;
    fn withdraw(&self) -> Result<(), Vec<u8>>;
}

Step 2: Implement the Production Client

In production, our contract uses the actual Solidity vault. We generate the Rust bindings using sol_interface! and implement our VaultClient trait.

use stylus_sdk::sol_interface;

sol_interface! {
    interface IVault {
        function deposit() external payable;
        function withdraw() external;
    }
}

pub struct SolidityVaultClient {
    pub vault_address: Address,
}

impl VaultClient for SolidityVaultClient {
    fn deposit(&self, value: U256) -> Result<(), Vec<u8>> {
        let vault = IVault::new(self.vault_address);

        // We use low-level call execution to forward the value to the external vault
        use stylus_sdk::call::RawCall;
        RawCall::new_with_value(value)
            .call(self.vault_address, &[])?;

        Ok(())
    }

    fn withdraw(&self) -> Result<(), Vec<u8>> {
        let mut vault = IVault::new(self.vault_address);
        // Under the hood, this executes the call_contract syscall
        Ok(())
    }
}

Step 3: Implement the Stylus Wrapper Contract

Now, we write our core Stylus contract. It uses dependency injection to interact with the vault.

#![cfg_attr(not(feature = "export-abi"), no_main)]
extern crate alloc;

use stylus_sdk::{
    alloy_primitives::{Address, U256},
    prelude::*,
    storage::StorageAddress,
};

#[storage]
#[entrypoint]
pub struct VaultWrapper {
    pub vault_address: StorageAddress,
}

#[public]
impl VaultWrapper {
    pub fn init(&mut self, vault: Address) {
        self.vault_address.set(vault);
    }
}

// Business logic implementation accepting the VaultClient trait
impl VaultWrapper {
    pub fn deposit_funds<C: VaultClient>(&mut self, client: &C, amount: U256) -> Result<(), Vec<u8>> {
        if amount == U256::ZERO {
            return Err(b"Invalid amount".to_vec());
        }

        // Execute external deposit call via the injected client
        client.deposit(amount)?;
        Ok(())
    }

    pub fn withdraw_funds<C: VaultClient>(&mut self, client: &C) -> Result<(), Vec<u8>> {
        // Execute external withdraw call via the injected client
        client.withdraw()?;
        Ok(())
    }
}

Scaffolding Unit Tests and Mocking Dependencies with Motsu

With our contract structured to accept an injected client, we can scaffold unit tests using Motsu.

Motsu allows us to write unit tests annotated with #[motsu::test]. The macro sets up the mock execution environment, including storage layouts and block contexts.

To test our wrapper's behavior, we implement a MockVaultClient that implements the VaultClient trait. This mock can simulate both a successful vault interaction and a reentrant attack.

The Mock Implementation

#[cfg(test)]
mod tests {
    use super::*;
    use motsu::prelude::*;
    use std::cell::RefCell;

    pub struct MockVaultClient {
        // We use RefCell to allow mutating state in immutable trait methods
        pub balances: RefCell<std::collections::HashMap<Address, U256>>,
        pub caller: Address,
        pub should_fail: bool,
        pub should_reenter: bool,
        pub reentrancy_target: RefCell<Option<Box<dyn Fn()>>>,
    }

    impl MockVaultClient {
        pub fn new(caller: Address) -> Self {
            Self {
                balances: RefCell::new(std::collections::HashMap::new()),
                caller,
                should_fail: false,
                should_reenter: false,
                reentrancy_target: RefCell::new(None),
            }
        }

        pub fn set_balance(&self, account: Address, amount: U256) {
            self.balances.borrow_mut().insert(account, amount);
        }

        pub fn get_balance(&self, account: Address) -> U256 {
            *self.balances.borrow().get(&account).unwrap_or(&U256::ZERO)
        }
    }

    impl VaultClient for MockVaultClient {
        fn deposit(&self, value: U256) -> Result<(), Vec<u8>> {
            if self.should_fail {
                return Err(b"Mock Deposit Failed".to_vec());
            }
            let current = self.get_balance(self.caller);
            self.set_balance(self.caller, current + value);
            Ok(())
        }

        fn withdraw(&self) -> Result<(), Vec<u8>> {
            if self.should_fail {
                return Err(b"Mock Withdraw Failed".to_vec());
            }

            let balance = self.get_balance(self.caller);
            if balance == U256::ZERO {
                return Err(b"Zero balance".to_vec());
            }

            // Simulate vulnerable behavior if should_reenter is true
            if self.should_reenter {
                if let Some(ref callback) = *self.reentrancy_target.borrow() {
                    callback();
                }
            }

            self.set_balance(self.caller, U256::ZERO);
            Ok(())
        }
    }
}

Writing the Motsu Tests

We now write tests to verify that:
1. Normal deposits and withdrawals function correctly.
2. The contract handles failed external dependency calls gracefully.
3. Reentrancy behaves as expected, allowing us to assert contract security.

    #[motsu::test]
    fn test_successful_deposit(contract: Contract<VaultWrapper>, alice: Address) {
        let mut wrapper = contract;
        wrapper.init(Address::repeat_byte(0x01));

        let mock_client = MockVaultClient::new(alice);
        let deposit_amount = U256::from(1000);

        // Execute deposit
        let result = wrapper.deposit_funds(&mock_client, deposit_amount);
        assert!(result.is_ok());

        // Verify the mock state was updated
        assert_eq!(mock_client.get_balance(alice), deposit_amount);
    }

    #[motsu::test]
    fn test_failed_external_deposit(contract: Contract<VaultWrapper>, alice: Address) {
        let mut wrapper = contract;
        wrapper.init(Address::repeat_byte(0x01));

        let mut mock_client = MockVaultClient::new(alice);
        mock_client.should_fail = true;

        // Execute deposit and assert it fails
        let result = wrapper.deposit_funds(&mock_client, U256::from(1000));
        assert!(result.is_err());
        assert_eq!(result.unwrap_err(), b"Mock Deposit Failed".to_vec());
    }

    #[motsu::test]
    fn test_withdrawal_graceful_handling(contract: Contract<VaultWrapper>, alice: Address) {
        let mut wrapper = contract;
        wrapper.init(Address::repeat_byte(0x01));

        let mock_client = MockVaultClient::new(alice);
        mock_client.set_balance(alice, U256::from(500));

        // Execute withdrawal
        let result = wrapper.withdraw_funds(&mock_client);
        assert!(result.is_ok());
        assert_eq!(mock_client.get_balance(alice), U256::ZERO);
    }

By decoupling the external execution path through the VaultClient trait, we run our unit tests entirely in memory. Motsu handles the virtual storage slots of the contract being tested (VaultWrapper), ensuring that its internal state variables (like vault_address) are correctly set and read during the execution flow.


Pre-deployment Security Checklist

Before deploying an Arbitrum Stylus Rust contract that communicates with external EVM dependencies, ensure your code satisfies these security criteria:


FAQ

1. Does Motsu compile external Solidity contracts for unit testing?

No. Motsu executes tests in a pure Rust process by mocking Stylus host functions at the WebAssembly level. It does not run an EVM interpreter. To test interactions with actual Solidity bytecode, you must use integration testing frameworks like Foundry or Arbitrum's local Nitro devnet.

2. Can I use OpenZeppelin's Solidity ReentrancyGuard inside my Rust Stylus contract?

No, Solidity modifiers and libraries cannot be imported directly into Rust source files. You must use Rust-native storage variables to implement mutex locks, or import equivalent utility crates from the OpenZeppelin Rust/Stylus library.

3. How do I mock standard ERC-20 transfers when testing Stylus contracts with Motsu?

Define a trait representing the ERC-20 functions your contract calls (e.g., transfer, transferFrom, balanceOf). Implement this trait for your contract using sol_interface! calls in production, and inject a mock struct that tracks balances in a HashMap during Motsu unit tests.


Verify that your cross-contract WASM logic is free of vulnerabilities. Scan your smart contracts for integration flaws and reentrancy bugs using ContractScan.

Scan your contract for this vulnerability
Free QuickScan — Unlimited quick scans. No signup required.. No signup required.
Scan a Contract →