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:
| Language | Best For | Use Cases |
|---|---|---|
| C/C++ | Low-level control, cryptography | Hash functions, signature verification, algorithms |
| WAT | Learning, debugging, minimal contracts | Simple logic, educational examples |
| AssemblyScript | TypeScript developers | Web3 integration with familiar syntax |
| Other | Specific requirements | Domain-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.
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
- Download and install Zig 0.11.0.
- Install Rust, which the Stylus CLI tool needs to deploy your program.
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:
-
Optimize with wasm-opt:
wasm-opt -Oz input.wasm -o output.wasm -
Strip symbols:
wasm-strip output.wasm -
Remove unused code:
// Use static/inline for internal functionsstatic inline void helper(void) { } -
Minimize data section:
// ✅ Good: Minimal dataconst uint8_t PREFIX[4] = {0xEF, 0xF0, 0x00, 0x00};// ❌ Bad: Large dataconst char *STRINGS[1000] = { /* ... */ };
Compilation errors
Error: Clang fails to compile
Solutions:
-
Target wasm32:
clang -target wasm32 -nostdlib -c main.c -
Disable standard library:
// Don't use stdio, stdlib, etc.// Use SDK-provided functions -
Check imports/exports:
wasm-objdump -x contract.wasm
Runtime errors
Error: Contract reverts unexpectedly
Solutions:
-
Check gas usage:
cargo stylus deploy --estimate-gas --wasm-file=contract.wasm -
Add debug output (testnet only):
emit_log(error_msg, sizeof(error_msg)); -
Test with minimal input:
# Call with empty calldatacast call $CONTRACT "0x"
Examples repository
Official examples for different languages:
- Stylus C SDK - C/C++ examples
- Stylus Bf SDK - Brainfuck educational examples
- Awesome Stylus - Community examples
Language support matrix
| Language | Status | SDK | Best Use Case |
|---|---|---|---|
| Rust | ✅ Production | stylus-sdk-rs | Full-featured contracts |
| C/C++ | ✅ Production | stylus-sdk-c | Cryptography, algorithms |
| WAT | ✅ Supported | Manual | Minimal contracts, learning |
| AssemblyScript | 🔶 Community | Custom | TypeScript developers |
| Go | 🔶 Experimental | TinyGo | Custom applications |
| Zig | 🔶 Experimental | zig-on-stylus | Systems programming |
Advanced: custom languages
To support a new language:
-
Compile to wasm32-unknown-unknown
your-compiler --target=wasm32-unknown-unknown input.src -o output.wasm -
Export required functions
- user_entrypoint(i32) -> i32- memory -
Import only vm_hooks
- vm_hooks:msg_sender- vm_hooks:storage_*- etc. -
Test and deploy
cargo stylus check --wasm-file=output.wasmcargo stylus deploy --wasm-file=output.wasm --private-key-path=./key.txt
Resources
- Stylus C SDK
- WebAssembly specification
- WAT format reference
- Cargo Stylus CLI
- Awesome Stylus
- WASM binary toolkit (wabt)
- Binaryen optimization tools