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:

  1. Launch a local Contracts Node (Canvas works too).
  2. cargo contract upload pushes the Wasm.
  3. cargo contract instantiate provisions an instance.
  4. Call increment, decrement, get via CLI or polkadot.js and audit the weight reports.

Cheat sheet for the moving parts

ElementPurpose
#[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-contractCLI for scaffolding, testing, deploying
WASMBinary 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.wasm before deploying; expect 5–12% gas savings while keeping an unoptimized build for debugging.

Common pitfalls to dodge

  • Forgetting nightly or skipping the cargo +nightly prefix, leading to macro expansion errors.
  • Omitting #![cfg_attr(not(feature = "std"), no_std)], which causes runtime panics when uploaded.
  • Returning Result from constructors without surfacing errors—deployments fail silently.
  • Using raw value += 1 / -= 1 and allowing overflow/underflow to corrupt state.
  • Running plain cargo test instead of #[ink::test], so storage isn’t simulated.
  • Allowing ink to float in Cargo.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-contract remains 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/decrement to practice authorization patterns.
  • Implement a reset message with proper error handling and test coverage.
  • Compare CosmWasm’s counter template to understand different entry-point designs before choosing your next chain.