Rust智能合约实战:把第一个 ink! 计数器跑起来

开场三问:到底解决啥、为何用 Rust、你卡在何处?

问题 1:这活儿是干嘛的? 你想把业务规则写进链上,让它像自助售货机一样自动执行,资产和权限都透明可追溯。

问题 2:为什么非 Rust 不可? Polkadot 这类链要求代码既高性能又安全。Rust 的所有权系统就像房产证,谁拿钥匙谁负责,不会有人半夜来把你家墙拆了。

问题 3:新手最容易栽在哪? 大多卡在工具链:cargo-contract 是啥、为什么要装 nightly、命令一串看懵。今天我们用最小计数器,带你从空目录一路走到链上部署。

快速类比:把 ink! 当成小区门禁里的规则芯片

想象你的合约是小区大门的控制板:谁能进、什么时候开门、扣谁物业费都写在这块芯片里。Substrate runtime 就是物业,规定“芯片必须是 Wasm 尺寸,代码不能乱伸手”。Rust 负责把口语化的门禁规则翻译成安全、确定的 Wasm 二进制。

原理速写:五个钉子先钉牢

  • ink! 合约最终编译成 wasm32-unknown-unknown,在链上的 sandbox 中运行。
  • #[ink::contract] 宏把一个模块标记为合约,内部消息函数必须显式声明。
  • 构造函数(#[ink::constructor])只在部署时跑一次,消息(#[ink::message])才是链上可调用入口。
  • 状态写在 #[ink(storage)] 结构体里,读写都要走框架提供的 API,避免原地 new/Box。
  • 每条链上调用都会计量 weight/gas;循环、日志、序列化都要考虑“物业费”。

环境准备:稳定版 + 夜ly 组合拳

示例在 macOS 14.6 (ARM)、Rust 1.80.1 stable、nightly 2025-10-20、cargo-contract 4.0.0 验证。直接跑:

rustup default stable
rustup update nightly
rustup target add wasm32-unknown-unknown
cargo install cargo-contract --force

为什么要 nightly?当前 cargo-contract 的部分子命令仍默认调用 nightly toolchain(未来可能改动)。实战时也可以用 cargo +nightly 显式指定。

可选工具:wasm-opt(Binaryen)压缩包体、contracts-nodecanvas-node 做本地链、polkadot.js 扩展配合前端操作。

实战步骤:ink! 计数器六步通关

第 1 步:脚手架开局

cargo contract new counter
cd counter

目录里会有 Cargo.tomllib.rs 两个核心文件,其他辅助文件此阶段可以忽略。

第 2 步:检查 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",
]

重点:crate-type 里要包含 cdylib,否则构建不出 Wasm;ink/std 让本地测试可用标准库。

第 3 步:撰写合约代码

#![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 / saturating_sub 是为了防止数值上下溢,省得链上被恶意用户拉爆。

第 4 步:了解构造与消息

部署时调用构造函数:

cargo contract instantiate \
  --constructor new \
  --args 10

链上读写:

cargo contract call --message increment
cargo contract call --message decrement
cargo contract call --message get

调试阶段可以切换到 --dry-run,配合本地节点验证 weight。

第 5 步:编写与运行测试

cargo +nightly test

#[ink::test] 会为每个测试提供内存中的链环境,你能直接断言 storage 变化;别忘了加 +nightly,否则某些宏解析失败。

第 6 步:构建、部署与观测

cargo +nightly contract build --release

产物都在 target/ink/.wasm.contractmetadata.json。之后:

  1. 启动本地 Contracts Node(或使用 Canvas)。
  2. cargo contract upload 上传代码。
  3. cargo contract instantiate 初始化实例。
  4. polkadot.js 或 CLI 调 incrementdecrementget 确认效果。

日志里能看到最终 weight、Gas 费用,记下来方便后续调优。

核心语法对照表

元素作用
#[ink::contract]声明一个模块为智能合约
#[ink(storage)]标记可持久化的状态结构
#[ink(constructor)]部署时执行一次的初始化入口
#[ink(message)]链上可调用的公共方法
cargo-contract管理构建、测试、上传、实例化的 CLI
WASM上传到 Substrate runtime 的二进制产物

性能与权衡:Wasm 的甜点与代价

  • 优点:Wasm 模块小、易验证、跨节点 deterministic;Rust 提供内存安全和零成本抽象。
  • 代价:链上环境没有完整标准库、必须通过 host 函数访问外部资源;Nightly 工具链略增 CI 配置复杂度。
  • 建议:上线前用 wasm-opt -Oz target/ink/counter.wasm 压缩体积,一般能省下 5%~12% 的 Gas;同时保留未压缩版本,便于调试。

常见坑预警

  • 忘了装 nightly 或没加 cargo +nightly,导致宏扩展报错。
  • 漏写 #![cfg_attr(not(feature = "std"), no_std)],链上加载直接 panic。
  • 构造函数返回 Result 却没处理错误分支,实际部署失败却看不出原因。
  • 直接 value += 1,忽略溢出风险,被恶意调用后变负数。
  • 只在本地跑 cargo test,没用 #[ink::test],导致没有 storage 模拟。
  • 部署到主网前没锁定 ink 版本,团队同伴 cargo update 后合约字节码变了。

总结与下一步

  • ink! 合约像门禁芯片:构造函数设定初始状态,消息函数负责对外互动。
  • cargo-contract 是你的瑞士军刀,从生成项目到部署全都打包好了。
  • 有了计数器模板,你可以安心拆解更复杂的业务状态机。

下一步行动:

  • increment/decrement 加入访问控制,试试 caller 校验。
  • 写一个 reset 消息,把计数器重置到指定值,练习错误处理。
  • 参考 CosmWasm 的 counter 示例,对比入口设计和测试方式,挑一个方向继续深入。