Skip to main content

Deploying non-Rust WASM contracts

While Rust provides the best developer experience for Stylus, any language that compiles to WebAssembly can be deployed. This guide explains how to deploy WASM contracts written in C, C++, or even pure WebAssembly Text (WAT).

Overview

Stylus accepts any valid WebAssembly module that meets its requirements. You can:

  • Write contracts in C or C++ using the Stylus C SDK
  • Use WebAssembly Text (WAT) for direct bytecode control
  • Compile from any language that targets wasm32-unknown-unknown
  • Deploy pre-compiled WASM binaries directly

The key is using the --wasm-file flag with cargo stylus commands to bypass Rust compilation.

Why use non-Rust languages?

Different languages excel at different tasks:

LanguageBest ForUse Cases
C/C++Low-level control, cryptographyHash functions, signature verification, algorithms
WATLearning, debugging, minimal contractsSimple logic, educational examples
AssemblyScriptTypeScript developersWeb3 integration with familiar syntax
OtherSpecific requirementsDomain-specific computations

When to choose non-Rust

  • Existing codebase: Port existing C/C++ cryptography libraries
  • Performance-critical: Hand-optimized assembly-like control
  • Minimal size: Ultra-compact contracts for specific operations
  • Team expertise: Leverage existing C/C++ knowledge

When to stick with Rust

  • Full-featured contracts: Complex DeFi, NFTs, governance
  • Type safety: Strong guarantees and tooling
  • Ecosystem: Rich library support and examples
  • Productivity: Higher-level abstractions and macros

WASM requirements

All WASM modules deployed to Stylus must meet these requirements:

Required exports

(export "user_entrypoint" (func $user_entrypoint))
(export "memory" (memory 0))

The user_entrypoint function:

  • Signature: (param i32) (result i32)
  • Parameter: Length of input calldata in bytes
  • Returns: Length of output data in bytes

Allowed imports

Only functions from the vm_hooks module are permitted:

(import "vm_hooks" "msg_sender" (func $msg_sender (param i32)))
(import "vm_hooks" "storage_load_bytes32" (func $storage_load (param i32 i32)))
(import "vm_hooks" "storage_store_bytes32" (func $storage_store (param i32 i32)))

See the hostio exports documentation for the complete list of available VM hooks.

Memory requirements

  • Linear memory must be exported as "memory"
  • Memory growth must be explicitly paid for
  • Initial memory size should be minimal (often 0 0)
  • Gas costs limit maximum memory

Compilation target

  • Target triple: wasm32-unknown-unknown
  • No standard library: WASM runs in a sandboxed environment
  • No floating point: Not yet supported by Stylus
  • No SIMD: Not yet supported by Stylus
  • No reference types: Disabled for compatibility

WebAssembly Text (WAT)

WAT provides direct control over WASM bytecode using a human-readable text format.

Minimal contract

The simplest valid Stylus contract:

(module
;; Export linear memory
(memory 0 0)
(export "memory" (memory 0))

;; Required entrypoint
;; Takes calldata length, returns output length
(func (export "user_entrypoint") (param $args_len i32) (result i32)
(i32.const 0) ;; Return 0 bytes
))

Save as minimal.wat and deploy:

cargo stylus deploy --wasm-file=minimal.wat --private-key-path=./key.txt

Echo contract

Returns input data unchanged:

(module
(memory 1 1)
(export "memory" (memory 0))

;; Import VM hook to read calldata
(import "vm_hooks" "read_args" (func $read_args (param i32)))

(func (export "user_entrypoint") (param $args_len i32) (result i32)
;; Read calldata into memory at offset 0
(call $read_args (i32.const 0))

;; Return the same length (echo)
(local.get $args_len)
))

Storage counter

Increment a value in storage:

(module
(memory 1 1)
(export "memory" (memory 0))

;; Import storage operations
(import "vm_hooks" "storage_load_bytes32"
(func $storage_load (param i32 i32)))
(import "vm_hooks" "storage_store_bytes32"
(func $storage_store (param i32 i32)))

(func (export "user_entrypoint") (param $args_len i32) (result i32)
;; Load current value from storage slot 0
(call $storage_load
(i32.const 0) ;; key pointer
(i32.const 32)) ;; value destination

;; Increment the value at memory[32]
(i32.store (i32.const 32)
(i32.add
(i32.load (i32.const 32))
(i32.const 1)))

;; Store back to storage
(call $storage_store
(i32.const 0) ;; key pointer
(i32.const 32)) ;; value pointer

;; Return 0 bytes of output
(i32.const 0)
))

Checking WAT contracts

Validate before deploying:

cargo stylus check --wasm-file=counter.wat

Output shows validation results:

Reading WASM file at counter.wat
Compressed WASM size: 142 B
Contract succeeded Stylus onchain activation checks with Stylus version: 2

C/C++ development

The Stylus C SDK enables C/C++ smart contract development.

Installation

Install the C SDK:

git clone https://github.com/OffchainLabs/stylus-sdk-c.git
cd stylus-sdk-c

Install dependencies:

# macOS
brew install llvm binaryen wabt

# Ubuntu/Debian
sudo apt-get install clang lld wasm-ld binaryen wabt

Project structure

Basic C project layout:

my-contract/
├── Makefile
├── src/
│ └── main.c
└── include/
└── stylus_sdk.h

Simple C contract

// main.c
#include "stylus_sdk.h"

// Storage slot for counter
static uint8_t counter_slot[32] = {0};

// Stylus calls user_entrypoint, not main. It receives the calldata length
// and returns the output length.
int user_entrypoint(int args_len) {
// Load counter from storage
uint8_t value[32];
storage_load_bytes32(counter_slot, value);

// Increment
value[31]++;

// Store back
storage_store_bytes32(counter_slot, value);

return 0; // No output
}

C SDK features

The C SDK provides:

// Account operations
void msg_sender(uint8_t *sender);
void tx_origin(uint8_t *origin);
void contract_address(uint8_t *addr);

// Storage operations
void storage_load_bytes32(uint8_t *key, uint8_t *dest);
void storage_store_bytes32(uint8_t *key, uint8_t *value);

// Block information
uint64_t block_timestamp(void);
uint64_t block_number(void);
void block_basefee(uint8_t *basefee);

// Call operations
void call_contract(
uint8_t *contract,
uint8_t *calldata,
uint32_t calldata_len,
uint8_t *value,
uint32_t gas,
uint8_t *return_data_len
);

// And many more...

Building C contracts

Create a Makefile:

CLANG = clang
WASM_LD = wasm-ld
WASM_OPT = wasm-opt

CFLAGS = -target wasm32 -nostdlib -O3
LDFLAGS = -no-entry --export=user_entrypoint --export=memory

SRC = src/main.c
OUT = build/contract.wasm
OUT_OPT = build/contract-opt.wasm

all: $(OUT_OPT)

$(OUT): $(SRC)
mkdir -p build
$(CLANG) $(CFLAGS) -c $(SRC) -o build/main.o
$(WASM_LD) $(LDFLAGS) build/main.o -o $(OUT)

$(OUT_OPT): $(OUT)
$(WASM_OPT) -Oz $(OUT) -o $(OUT_OPT)

clean:
rm -rf build

deploy: $(OUT_OPT)
cargo stylus deploy --wasm-file=$(OUT_OPT) \
--private-key-path=$$PRIVATE_KEY_PATH

check: $(OUT_OPT)
cargo stylus check --wasm-file=$(OUT_OPT)

Build and deploy:

make
make check
make deploy

C cryptography example

Verifying a signature:

#include "stylus_sdk.h"
#include <string.h>

// Verify ECDSA signature
int verify_signature(
uint8_t *message_hash,
uint8_t *signature,
uint8_t *public_key
) {
uint8_t recovered[65];

// Recover signer from signature
if (ecrecover(message_hash, signature, recovered) != 0) {
return -1; // Recovery failed
}

// Compare with expected public key
if (memcmp(recovered + 1, public_key, 64) == 0) {
return 0; // Valid signature
}

return -1; // Invalid signature
}

int user_entrypoint(int args_len) {
uint8_t msg_hash[32];
uint8_t sig[65];
uint8_t pubkey[64];

// Read inputs from calldata
read_args(0);
memcpy(msg_hash, memory, 32);
memcpy(sig, memory + 32, 65);
memcpy(pubkey, memory + 97, 64);

// Verify
int result = verify_signature(msg_hash, sig, pubkey);

// Write result
memory[0] = (result == 0) ? 1 : 0;
write_result(memory, 1);

return 0;
}

AssemblyScript contracts

AssemblyScript is a TypeScript-like language that compiles to WebAssembly.

Installation

npm install -g assemblyscript
npm install @assemblyscript/loader

Simple AssemblyScript contract

// contract.ts

// Import Stylus VM hooks
@external("vm_hooks", "msg_sender")
declare function msg_sender(ptr: usize): void;

@external("vm_hooks", "storage_load_bytes32")
declare function storage_load(key: usize, dest: usize): void;

@external("vm_hooks", "storage_store_bytes32")
declare function storage_store(key: usize, value: usize): void;

// Storage key
const COUNTER_KEY: StaticArray<u8> = [0, 0, 0, 0, /* ... 32 zeros ... */];

// Entrypoint
export function user_entrypoint(args_len: i32): i32 {
// Load counter
let value = new StaticArray<u8>(32);
storage_load(
changetype<usize>(COUNTER_KEY),
changetype<usize>(value)
);

// Increment
value[31]++;

// Store
storage_store(
changetype<usize>(COUNTER_KEY),
changetype<usize>(value)
);

return 0; // No output
}

Compile AssemblyScript

asc contract.ts \
--target release \
--exportRuntime \
--exportTable \
-o contract.wasm

Deploy the AssemblyScript contract

cargo stylus deploy \
--wasm-file=contract.wasm \
--private-key-path=./key.txt

Example: writing a contract in Zig

Zig is a systems language often described as a spiritual successor to C: it adds memory-safety guardrails, produces small binaries, and ships with a C compiler so existing C projects can adopt it incrementally. Because Zig compiles to WebAssembly, you can use it to write Stylus contracts that fit comfortably within the 24 KB Brotli-compressed limit and meet Stylus gas-metering requirements. Programs written in Zig have gas costs comparable to C.

Info
This walkthrough targets Zig 0.11.0. Some APIs used below (for example std.mem.readIntSliceLittle and the std.heap.WasmAllocator internals) changed or were removed in later Zig releases, so pin to 0.11.0 when following along.

Requirements

This example also uses Rust to run a script that calls the Zig contract using the ethers-rs library.

Once Rust is installed, install the Stylus CLI tool:

RUSTFLAGS="-C link-args=-rdynamic" cargo install --force cargo-stylus

A minimal Zig entrypoint

Clone the example repository:

git clone https://github.com/offchainlabs/zig-on-stylus && cd zig-on-stylus

Then delete everything inside main.zig — you'll fill it out from scratch.

A Stylus contract needs a special entrypoint function that takes the length of its input arguments (len) and returns a status code i32 of either 0 or 1. It also needs memory_grow, a function injected into every Stylus contract as an external import to allocate memory. These imports are called vm_hooks (or host I/Os), and they give the contract access to the host EVM environment. You don't need the Zig standard library yet.

Replace everything in main.zig with:

pub extern "vm_hooks" fn memory_grow(len: u32) void;

export fn mark_unused() void {
memory_grow(0);
@panic("");
}

// The main entrypoint to use for execution of the Stylus WASM program.
export fn user_entrypoint(len: usize) i32 {
_ = len;
return 0;
}

Build the Zig library to a freestanding WASM file for onchain deployment:

zig build-lib ./src/main.zig -target wasm32-freestanding -dynamic --export=user_entrypoint -OReleaseSmall --export=mark_unused

Deploy it with the Stylus CLI tool. This example deploys to Arbitrum Sepolia; you can also target a local Stylus devnode at http://localhost:8547:

cargo stylus deploy \
--private-key=<YOUR_PRIVATE_KEY> \
--wasm-file=main.wasm \
--endpoint="https://sepolia-rollup.arbitrum.io/rpc"

The tool sends two transactions: one to deploy the contract code onchain, and one to activate it.

Uncompressed WASM size: 112 B
Compressed WASM size to be deployed onchain: 103 B

The Zig program is tiny when compiled to WASM. Call the contract with any Ethereum tooling — here, the cast CLI from Foundry:

export ADDR=<YOUR_DEPLOYED_CONTRACT_ADDRESS>
cast call --rpc-url 'https://sepolia-rollup.arbitrum.io/rpc' $ADDR '0x'

Calling the contract returns 0, as programmed:

0x

Reading input and writing output data

To do anything useful, a contract needs to read input and write output. The Stylus runtime provides two host I/Os for this:

pub extern "vm_hooks" fn read_args(dest: *u8) void;
pub extern "vm_hooks" fn write_result(data: *const u8, len: usize) void;

Add these near the top of main.zig.

read_args takes a pointer to a byte slice where the input arguments are written. The slice length must equal the length of the program args received in user_entrypoint. Write a helper that wraps this host I/O and returns a Zig byte slice:

// Allocates a Zig byte slice of length=`len` and reads a Stylus contract's
// calldata using the read_args hostio function.
pub fn input(len: usize) ![]u8 {
var input = try allocator.alloc(u8, len);
read_args(@ptrCast(*u8, input));
return input;
}

Next, a helper that outputs bytes to the caller:

// Outputs data as bytes via the write_result hostio to the Stylus contract's caller.
pub fn output(data: []u8) void {
write_result(@ptrCast(*u8, data), data.len);
}

Put them together to echo the input back to the caller:

// The main entrypoint to use for execution of the Stylus WASM program.
// It echoes the input arguments to the caller.
export fn user_entrypoint(len: usize) i32 {
var in = input(len) catch return 1;
output(in);
return 0;
}

Rebuilding now fails because there's no allocator:

src/main.zig:21:20: error: use of undeclared identifier 'allocator'
var data = try allocator.alloc(u8, len);
^~~~~~~~~

Zig requires you to provide an allocator explicitly. The standard library ships one built for WASM programs, where memory grows in 64 KB increments. Add this to the top of main.zig:

const std = @import("std");
const allocator = std.heap.WasmAllocator;

The code compiles, but cargo stylus check --wasm-file=main.wasm fails:

Caused by:
missing import memory_grow

The standard-library WasmAllocator needs to call our memory_grow host I/O under the hood. Fix this by copying the WasmAllocator.zig file from the standard library and changing a single line to use memory_grow. You can find this modified file as WasmAllocator.zig in the zig-on-stylus repository. Use it like so:

const std = @import("std");
const WasmAllocator = @import("WasmAllocator.zig");

// Uses our custom WasmAllocator, a simple modification over the wasm allocator
// from the Zig standard library as of Zig 0.11.0.
pub const allocator = std.mem.Allocator{
.ptr = undefined,
.vtable = &WasmAllocator.vtable,
};

Rebuild and run cargo stylus check again — it now succeeds:

Uncompressed WASM size: 514 B
Compressed WASM size to be deployed onchain: 341 B
Connecting to Stylus RPC endpoint: https://sepolia-rollup.arbitrum.io/rpc
Stylus program with same WASM code is already activated onchain

Deploy it:

cargo stylus deploy \
--private-key=<YOUR_PRIVATE_KEY> \
--wasm-file=main.wasm \
--endpoint="https://sepolia-rollup.arbitrum.io/rpc"

Now calling the contract echoes back whatever input you send. Send it 0x123456:

export ADDR=<YOUR_DEPLOYED_CONTRACT_ADDRESS>
cast call --rpc-url 'https://sepolia-rollup.arbitrum.io/rpc' $ADDR '0x123456'

0x123456

Prime number checker

For something fancier, implement a primality checker using the Sieve of Eratosthenes. Given a number, the contract outputs 1 if it's prime or 0 otherwise. This example leverages Zig's comptime keyword, which tells the compiler to evaluate code at compile time. Here, it defines a slice of booleans up to a fixed limit at compile time, marking which numbers are prime.

fn sieve_of_erathosthenes(comptime limit: usize, nth: u16) bool {
var prime = [_]bool{true} ** limit;
prime[0] = false;
prime[1] = false;
var i: usize = 2;
while (i * i < limit) : (i += 1) {
if (prime[i]) {
var j = i * i;
while (j < limit) : (j += i)
prime[j] = false;
}
}
return prime[nth];
}

Checking whether a number N is prime is just reading index N of the prime slice. Integrate it into user_entrypoint:

// The main entrypoint to use for execution of the Stylus WASM program.
export fn user_entrypoint(len: usize) i32 {
// Expects the input is a u16 encoded as little endian bytes.
var in = input(len) catch return 1;
var check_nth_prime = std.mem.readIntSliceLittle(u16, in);
const limit: u16 = 10_000;
if (check_nth_prime > limit) {
@panic("input is greater than limit of 10,000 primes");
}
// Checks if the number is prime and returns a boolean using the output function.
var is_prime = sieve_of_erathosthenes(limit, check_nth_prime);
var out = in[0..1];
if (is_prime) {
out[0] = 1;
} else {
out[0] = 0;
}
output(out);
return 0;
}

Check and deploy:

Uncompressed WASM size: 10.8 KB
Compressed WASM size to be deployed onchain: 525 B

The uncompressed size is large because of the boolean array, but it compresses well since the values are mostly zeros.

Calling the Zig contract from Rust

The repository includes a rust-example that uses ethers-rs to call the prime-sieve contract. Run it with:

export STYLUS_PROGRAM_ADDRESS=<YOUR_DEPLOYED_CONTRACT_ADDRESS>
cargo run

You'll see output like:

Checking if 2 is_prime = true, took: 404.146917ms
Checking if 3 is_prime = true, took: 154.802083ms
Checking if 4 is_prime = false, took: 123.239583ms
Checking if 5 is_prime = true, took: 109.248709ms
Checking if 6 is_prime = false, took: 113.086625ms
Checking if 32 is_prime = false, took: 280.19975ms
Checking if 53 is_prime = true, took: 123.667958ms

The host I/Os shown here aren't the only ones — see stylus-sdk-c (hostio.h) for the full list, including affordances for the EVM, storage access, and calling other Arbitrum contracts.

Deployment workflow

1. Prepare your WASM

Ensure your WASM module meets requirements:

# Check WASM structure with wasm-objdump
wasm-objdump -x contract.wasm | grep -A 5 "Export\|Import"

# Should show:
# Export[0]:
# - func[0] <user_entrypoint>
# - memory[0]
# Import[0]:
# - module="vm_hooks" func=...

2. Optimize the WASM

Reduce size with wasm-opt:

wasm-opt -Oz contract.wasm -o contract-opt.wasm

3. Check before deploying

Validate the contract:

cargo stylus check --wasm-file=contract-opt.wasm

4. Deploy

Deploy to testnet:

cargo stylus deploy \
--wasm-file=contract-opt.wasm \
--private-key-path=./key.txt \
--endpoint="https://sepolia-rollup.arbitrum.io/rpc"

5. Verify deployment

Check deployment succeeded:

# Output shows:
Compressed WASM size: 245 B
Deploying contract to address 0x...
Confirmed tx 0x...
Activating contract at address 0x...
Confirmed tx 0x...

Best practices

1. Minimize binary size

# ✅ Good: Optimize aggressively
wasm-opt -Oz input.wasm -o output.wasm

# Use wasm-strip to remove symbols
wasm-strip output.wasm

# Check final size
ls -lh output.wasm

2. Test with cargo stylus check

# ✅ Good: Always check before deploying
cargo stylus check --wasm-file=contract.wasm

# Test on testnet first
cargo stylus deploy \
--wasm-file=contract.wasm \
--private-key-path=./key.txt \
--endpoint="https://sepolia-rollup.arbitrum.io/rpc"

3. Use standard memory layout

// ✅ Good: Predictable memory layout
uint8_t calldata[1024]; // 0-1023: Input data
uint8_t storage[32]; // 1024-1055: Storage scratch
uint8_t output[256]; // 1056-1311: Output buffer

// ❌ Bad: Unpredictable allocations
uint8_t *data = malloc(size); // No malloc in WASM!

4. Handle calldata properly

;; ✅ Good: Read calldata into memory
(call $read_args (i32.const 0))

;; Process the data
(call $process_calldata (local.get $args_len))

;; ❌ Bad: Assume calldata location
(i32.load (i32.const 0)) ;; Calldata not automatically loaded

5. Export all required functions

;; ✅ Good: Export entrypoint and memory
(export "user_entrypoint" (func $main))
(export "memory" (memory 0))

;; ❌ Bad: Missing exports
(export "main" (func $main)) ;; Wrong name!

6. Use VM hooks correctly

// ✅ Good: Proper VM hook usage
uint8_t sender[20];
msg_sender(sender);

// ✅ Good: Check return values
uint8_t success;
call_contract(addr, data, len, value, gas, &success);
if (!success) {
revert("Call failed");
}

// ❌ Bad: Ignoring errors
call_contract(addr, data, len, value, gas, NULL);

7. Mind the size limit

# Check compressed size
cargo stylus check --wasm-file=contract.wasm

# Should show:
# Compressed WASM size: < 24 KB

# If too large:
# - Remove debug symbols
# - Enable aggressive optimization
# - Minimize code and data sections

Troubleshooting

Missing entrypoint

Error: WASM is missing the entrypoint export

Solution: Ensure user_entrypoint is exported:

;; WAT
(func (export "user_entrypoint") (param i32) (result i32)
;; Implementation
)

// C
int user_entrypoint(int argc) __attribute__((export_name("user_entrypoint")));

Invalid imports

Error: contract imports unauthorized function

Solution: Only import from vm_hooks:

;; ✅ Allowed
(import "vm_hooks" "msg_sender" (func $msg_sender (param i32)))

;; ❌ Not allowed
(import "env" "print" (func $print (param i32)))

Memory not exported

Error: WASM must export memory

Solution: Export linear memory:

;; WAT
(memory 1 1)
(export "memory" (memory 0))

// C Makefile
LDFLAGS = -no-entry --export=user_entrypoint --export=memory

Size too large

Error: Compressed WASM exceeds 24KB

Solutions:

  1. Optimize with wasm-opt:

    wasm-opt -Oz input.wasm -o output.wasm
  2. Strip symbols:

    wasm-strip output.wasm
  3. Remove unused code:

    // Use static/inline for internal functions
    static inline void helper(void) { }
  4. Minimize data section:

    // ✅ Good: Minimal data
    const uint8_t PREFIX[4] = {0xEF, 0xF0, 0x00, 0x00};

    // ❌ Bad: Large data
    const char *STRINGS[1000] = { /* ... */ };

Compilation errors

Error: Clang fails to compile

Solutions:

  1. Target wasm32:

    clang -target wasm32 -nostdlib -c main.c
  2. Disable standard library:

    // Don't use stdio, stdlib, etc.
    // Use SDK-provided functions
  3. Check imports/exports:

    wasm-objdump -x contract.wasm

Runtime errors

Error: Contract reverts unexpectedly

Solutions:

  1. Check gas usage:

    cargo stylus deploy --estimate-gas --wasm-file=contract.wasm
  2. Add debug output (testnet only):

    emit_log(error_msg, sizeof(error_msg));
  3. Test with minimal input:

    # Call with empty calldata
    cast call $CONTRACT "0x"

Examples repository

Official examples for different languages:

Language support matrix

LanguageStatusSDKBest Use Case
Rust✅ Productionstylus-sdk-rsFull-featured contracts
C/C++✅ Productionstylus-sdk-cCryptography, algorithms
WAT✅ SupportedManualMinimal contracts, learning
AssemblyScript🔶 CommunityCustomTypeScript developers
Go🔶 ExperimentalTinyGoCustom applications
Zig🔶 Experimentalzig-on-stylusSystems programming

Advanced: custom languages

To support a new language:

  1. Compile to wasm32-unknown-unknown

    your-compiler --target=wasm32-unknown-unknown input.src -o output.wasm
  2. Export required functions

    - user_entrypoint(i32) -> i32
    - memory
  3. Import only vm_hooks

    - vm_hooks:msg_sender
    - vm_hooks:storage_*
    - etc.
  4. Test and deploy

    cargo stylus check --wasm-file=output.wasm
    cargo stylus deploy --wasm-file=output.wasm --private-key-path=./key.txt

Resources

Sources