Structuring Multi-Contract Stylus Projects Without Pain

Share this article:
Structuring Multi-Contract Stylus Projects Without Pain

If you've tried building a multi-contract Stylus project before v0.10, you know what it felt like. Each contract lived in its own isolated Cargo project. Sharing types between them meant copy-pasting. Cross-contract calls meant hand-rolling raw ABI encoding. Coordinating deployments meant juggling separate build directories and hoping nothing drifted out of sync.

It worked, technically. But it didn't feel like building a real application. It felt like fighting the toolchain.

Stylus SDK v0.10 changes that. You can now define multiple Stylus contracts in a single Cargo workspace, share interfaces between them through normal Rust imports, and make type-safe cross-contract calls without writing a single line of ABI encoding. This is how multi-contract development should work, and it's how Rust developers already expect it to work.

Let's walk through the feature using a full-stack voting dApp with two contracts: a voter registry and a polls manager. Simple enough to follow, complex enough to show why workspaces matter.

The workspace layout

A Stylus multi-contract workspace looks like any Cargo workspace with two additions: a root Stylus.toml marking the workspace, and a per-contract Stylus.toml marking each deployable contract.

voting-dapp/
  Cargo.toml           # workspace root
  Stylus.toml          # [workspace] marker
  contracts/
    voter-registry/
      Cargo.toml
      Stylus.toml      # [contract] marker
      src/lib.rs
    polls/
      Cargo.toml
      Stylus.toml      # [contract] marker
      src/lib.rs

The root Cargo.toml defines the workspace members and a shared release profile optimized for onchain size:

[workspace]
members = [
    "contracts/voter-registry",
    "contracts/polls",
]
resolver = "2"

[profile.release]
codegen-units = 1
strip = true
lto = true
panic = "abort"
opt-level = "z"

Every setting in that release profile matters. opt-level = "z" optimizes aggressively for binary size. lto = true enables link-time optimization across crate boundaries. Together, they keep your WASM binaries small enough to deploy onchain.

The Stylus.toml files are minimal. The root contains [workspace]. Each contract contains [contract]. That's it. The tooling uses these to distinguish deployable contracts from shared library crates in the same workspace.

Defining a cross-contract interface

The voter registry contract manages who can vote. The polls contract needs to check registration status before accepting a vote. In v0.10, you define that interface as a Rust trait.

#[public]
pub trait IVoterRegistry {
    fn is_registered(&self, voter: Address) -> bool;
    fn voter_count(&self) -> U256;
}

The #[public] attribute on the trait does two things. During a normal build, it generates Solidity-compatible function selectors and router dispatch for any struct that implements it. But when the contract-client-gen feature is active (more on that in a moment), it generates a type-safe client that can call those functions on a deployed contract through standard EVM calls.

The voter registry implements this trait alongside its own methods:

#[public]
#[implements(IVoterRegistry)]
impl VoterRegistry {
    pub fn register(&mut self) -> Result<(), RegistryError> {
        let voter = self.vm().msg_sender();
        if self.registered_voters.get(voter) {
            return Err(RegistryError::AlreadyRegistered(AlreadyRegistered { voter }));
        }
        self.registered_voters.setter(voter).set(true);
        let count = self.voter_count.get();
        self.voter_count.set(count + U256::from(1));
        self.vm().log(VoterRegistered { voter });
        Ok(())
    }
    // ...
}

#[public]
impl IVoterRegistry for VoterRegistry {
    fn is_registered(&self, voter: Address) -> bool {
        self.registered_voters.get(voter)
    }

    fn voter_count(&self) -> U256 {
        self.voter_count.get()
    }
}

Notice three #[public] attributes: one on the trait definition, one on the main impl block, and one on the trait implementation. The #[implements(IVoterRegistry)] attribute on the main impl block wires the trait's function selectors into the contract's router so both sets of methods are callable.

The feature flag that makes it all work

The voter registry's Cargo.toml declares an empty feature:

[features]
contract-client-gen = []

This feature is never enabled when building the voter registry for deployment. It exists solely for consumers. The polls contract depends on voter-registry with this feature turned on:

[dependencies]
voter-registry = { path = "../voter-registry", features = ["contract-client-gen"] }

When contract-client-gen is active, the SDK's proc macros produce a completely different version of the voter registry crate. The storage fields are erased. The entrypoint is removed. The #[public] methods are replaced with client stubs that ABI-encode arguments and make external calls. The VoterRegistry struct becomes a lightweight proxy that holds only an address.

From the polls contract's perspective, the import looks like normal Rust:

use voter_registry::{VoterRegistry, IVoterRegistry};

But what you get is an auto-generated client, not the full contract.

Making the cross-contract call

Inside the polls contract's vote function, calling the registry is three lines:

let registry = VoterRegistry::new(registry_addr);
let is_registered: bool = registry
    .is_registered(self.vm(), Call::new(), voter)
    .expect("Cross-contract call to VoterRegistry failed");

VoterRegistry::new(registry_addr) creates a client pointing at the deployed voter registry. The .is_registered() call encodes the function selector and arguments, makes a static_call (because the original method takes &self, not &mut self), and ABI-decodes the return value.

The two extra parameters are the v0.10 calling convention. self.vm() provides the host context. Call::new() configures the call type. For view functions (&self), use Call::new(). For state-changing functions (&mut self), use Call::new_mutating(self). The SDK picks static_call or call based on this context automatically.

No hand-rolled selectors. No abi.encodeWithSignature. Just Rust method calls with type checking at compile time.

Deploying and linking

Deployment order matters. The voter registry has no dependencies, so it deploys first. The polls contract needs to know the registry's address after deployment.

# Deploy voter registry
cargo stylus deploy --wasm-file=target/.../voter_registry.wasm \
  --endpoint=$RPC_URL --private-key=$PRIVATE_KEY

# Deploy polls
cargo stylus deploy --wasm-file=target/.../polls.wasm \
  --endpoint=$RPC_URL --private-key=$PRIVATE_KEY

# Link them
cast send $POLLS_ADDRESS "setRegistry(address)" $VOTER_REGISTRY_ADDRESS \
  --rpc-url $RPC_URL --private-key $PRIVATE_KEY

The setRegistry call stores the voter registry address inside the polls contract and sets the caller as the owner. After this, any call to polls.vote() triggers the cross-contract call to the registry automatically.

Testing in a workspace

Unit tests run per-crate with the Stylus test harness. The voter registry tests use a TestVM to simulate the EVM environment:

#[cfg(all(test, not(feature = "contract-client-gen")))]
mod tests {
    use super::*;
    use stylus_sdk::testing::*;

    #[test]
    fn test_register_voter() {
        let vm = TestVM::default();
        let mut contract = VoterRegistry::from(&vm);

        assert!(contract.register().is_ok());
        assert!(contract.is_registered(vm.msg_sender()));
        assert_eq!(U256::from(1), contract.voter_count());
    }
}

Notice the #[cfg(all(test, not(feature = "contract-client-gen")))] guard. When contract-client-gen is active, storage types and the entrypoint don't exist, so tests wouldn't compile. This guard ensures tests only run during a normal build.

Cross-contract calls can't be unit tested locally because they need a real EVM runtime. For that, deploy to a local Arbitrum devnode and run integration tests against live contracts.

Connecting a frontend

The contracts expose standard Solidity ABIs, so any Ethereum frontend stack works. The companion project uses Next.js with wagmi and viem. The cross-contract interaction is invisible to the frontend. It calls polls.vote() like any other contract method, and the registry check happens onchain:

const { writeContract } = useWriteContract();

writeContract({
  address: pollsAddress,
  abi: pollsAbi,
  functionName: "vote",
  args: [pollId, true],
});

The user doesn't need to interact with the voter registry directly to vote. They register once, and every subsequent vote call validates their status through the cross-contract call behind the scenes.

Try it yourself

The full project, both contracts, deployment scripts, and a Next.js frontend, is on GitHub:

github.com/hummusonrails/arbitrum-stylus-multi-contract-example

Clone it, spin up a local Arbitrum devnode, and run the deploy script. You'll have two linked Stylus contracts and a working frontend in a few minutes.

Workspaces are how real applications get built

Single-contract projects are fine for learning. But production dApps almost always involve multiple contracts that share types, reference each other, and deploy together. Stylus SDK v0.10 brings that pattern to Rust with the same workspace model Cargo developers already use for non-blockchain projects.

The tooling is still young. Feature unification requires careful build ordering, and cross-contract integration tests need a running node. But the core workflow, defining interfaces as traits, importing generated clients, and making type-safe calls, is solid. It means you can structure a Stylus project the way you'd structure any Rust project and focus on your application logic instead of fighting the build system.

Resources

Read more