Rust smart contracts in practice: spin up the first ink! counter
Three starter questions: what problem, why Rust, where’s the blocker?
What are we solving? You want business rules to live on-chain, self-service, tamper-proof—think of it as a vending machine that won’t miscount tokens.
Why Rust? Substrate-based chains expect code that’s fast and memory-safe. Rust’s ownership feels like a property title: whoever holds the key is accountable, and nobody smashes your walls in the night.
Where do beginners stumble? Tooling. cargo-contract? Nightly? Wasm target? Let’s calm the noise by building the tiniest counter end to end.
Analogy time: ink! is the rule chip inside a condo gate
Picture your contract as the circuit board that controls who enters the lobby. The Substrate runtime is the property manager who insists on Wasm-sized, fully auditable chips. Rust translates your plain-language “open the door at 9” rules into deterministic bytes that the gate will trust.
Five pins for the mental map
- ink! compiles contracts to
wasm32-unknown-unknown; they execute inside the runtime sandbox. #[ink::contract]marks a module as deployable; every entry point is explicitly annotated.- Constructors (
#[ink::constructor]) run exactly once at deployment; messages (#[ink::message]) are the callable endpoints. - Persistent state sits in a
#[ink(storage)]struct—use the provided APIs, don’t DIY raw pointers. - Each call burns weight/gas, so loops, logging, and serialization must be deterministic and data-driven.
Toolchain checklist: stable + nightly tag team
Validated on macOS 14.6 (ARM), Rust 1.80.1 stable, nightly 2025-10-20, cargo-contract 4.0.0. Run:
rustup default stable
rustup update nightly
rustup target add wasm32-unknown-unknown
cargo install cargo-contract --force
Nightly still matters because certain cargo-contract flows pull in nightly features (subject to change). When in doubt, prefix commands with cargo +nightly.
Nice-to-haves: wasm-opt (Binaryen) for size trimming, contracts-node/canvas-node for local chains, and polkadot.js for ergonomic calls.
Hands-on journey: six steps to a working counter
Step 1: scaffold the project
cargo contract new counter
cd counter
You’ll get Cargo.toml plus lib.rs. Everything else is garnish.
Step 2: review Cargo.toml
[package]
name = "counter"
version = "0.1.0"
edition = "2021"
[lib]
name = "counter"
path = "lib.rs"
crate-type = ["cdylib", "rlib"]
[dependencies]
ink = { version = "5", default-features = false, features = ["std"] }
[dev-dependencies]
ink = { version = "5", default-features = false, features = ["std", "ink-as-dependency"] }
[features]
default = ["std"]
std = [
"ink/std",
]
Leave cdylib in place or the Wasm build will fail; keep ink/std enabled for comfortable local tests.
Step 3: write the contract
#![cfg_attr(not(feature = "std"), no_std)]
#[ink::contract]
mod counter {
#[ink(storage)]
pub struct Counter {
value: i32,
}
impl Counter {
#[ink(constructor)]
pub fn new(init_value: i32) -> Self {
Self { value: init_value }
}
#[ink(message)]
pub fn increment(&mut self) {
self.value = self.value.saturating_add(1);
}
#[ink(message)]
pub fn decrement(&mut self) {
self.value = self.value.saturating_sub(1);
}
#[ink(message)]
pub fn get(&self) -> i32 {
self.value
}
}
#[cfg(test)]
mod tests {
use super::*;
#[ink::test]
fn counter_moves_both_directions() {
let mut counter = Counter::new(5);
counter.increment();
counter.decrement();
assert_eq!(counter.get(), 5);
}
}
}
saturating_add/sub keeps adversaries from wrapping the value, which is a real concern once the contract hits production.
Step 4: constructors & messages in action
Deployment example:
cargo contract instantiate \
--constructor new \
--args 10
Interact after deployment:
cargo contract call --message increment
cargo contract call --message decrement
cargo contract call --message get
Add --dry-run during testing to inspect weight without touching chain state.
Step 5: lean on ink! tests
cargo +nightly test
#[ink::test] spins up an in-memory runtime, mirrors storage, and lets you assert state changes. Nightly is mandatory for the macro expansion path today.
Step 6: build, deploy, observe
cargo +nightly contract build --release
Artifacts land in target/ink/: .wasm, .contract, metadata.json. Then:
- Launch a local Contracts Node (Canvas works too).
cargo contract uploadpushes the Wasm.cargo contract instantiateprovisions an instance.- Call
increment,decrement,getvia CLI orpolkadot.jsand audit the weight reports.
Cheat sheet for the moving parts
| Element | Purpose |
|---|---|
#[ink::contract] | Declares a module as deployable contract |
#[ink(storage)] | Defines on-chain storage layout |
#[ink(constructor)] | Runs once during instantiation |
#[ink(message)] | Public callable entry point |
cargo-contract | CLI for scaffolding, testing, deploying |
WASM | Binary uploaded to the Substrate runtime |
Performance trade-offs: Wasm sweets and costs
- Upside: Compact binaries, fast validation, deterministic execution across validators; Rust enforces safety invariants.
- Downside: No full standard library on-chain, all I/O goes through host APIs, and nightly complicates CI workflows slightly.
- Tip: Run
wasm-opt -Oz target/ink/counter.wasmbefore deploying; expect 5–12% gas savings while keeping an unoptimized build for debugging.
Common pitfalls to dodge
- Forgetting nightly or skipping the
cargo +nightlyprefix, leading to macro expansion errors. - Omitting
#![cfg_attr(not(feature = "std"), no_std)], which causes runtime panics when uploaded. - Returning
Resultfrom constructors without surfacing errors—deployments fail silently. - Using raw
value += 1/-= 1and allowing overflow/underflow to corrupt state. - Running plain
cargo testinstead of#[ink::test], so storage isn’t simulated. - Allowing
inkto float inCargo.lock, which changes bytecode between teammates.
Wrap-up & next moves
- ink! treats your module like a gate controller: constructor for setup, messages for runtime interactions.
cargo-contractremains the multipurpose tool covering scaffolding, build, upload, and instantiation.- With a counter in place, you can iterate toward richer state machines confidently.
Action plan:
- Add caller checks to
increment/decrementto practice authorization patterns. - Implement a
resetmessage with proper error handling and test coverage. - Compare CosmWasm’s counter template to understand different entry-point designs before choosing your next chain.