如何用 ink! 编写 WASM 合约?
解读
国内区块链赛道对 Rust 的需求集中在 联盟链(FISCO-BCOS 的 Rust SDK、蚂蚁链) 与 公链(Polkadot、Near、Solana) 两大方向。ink! 是 Parity 官方为 Substrate 链打造的 eDSL,用 Rust 宏把合约代码编译成 .contract bundle(WASM+元数据),部署到 pallet-contracts 执行。面试官问“怎么用 ink! 写 WASM 合约”,并不是让你背宏定义,而是考察:
- 是否理解 ink! = Rust + 属性宏 + 专用运行时接口;
- 能否把 传统 Solidity 的存储、事件、接口、权限、测试、升级 等范式映射到 ink! 的语法;
- 是否具备 国内合规链上隐私、Gas 计量、升级策略 的工程经验。
回答时要体现“编译期保证内存安全 + 链上资源可计量”这一 Rust 核心优势,并给出 可落地的最小可用示例(ERC20 等价物),再补充 cargo-contract 工具链、链上调试、Weight 优化 等国内面试高频细节。
知识点
- ink! 宏三件套:
#[ink::contract]定义合约入口;#[ink(storage)]标注状态结构体;#[ink(message)]/#[ink(constructor)]暴露链上入口点。 - 存储模型:
Mapping<Key, Value>替代 Solidity 的mapping,自动实现SpreadLayout/PackedLayout,支持 跨合约调用键值证明。 - 环境接口:
self.env().caller()获取交易发送方,self.env().transferred_value()获取附带余额,等价于msg.sender与msg.value。 - 事件机制:
#[ink(event)]结构体 +self.env().emit_event(...),链下 Subscan、Polkadot.js 自动解析元数据,满足国内监管审计需求。 - 测试双轨:
cargo test跑 off-chain env(pallet-contracts 模拟器);cargo contract build生成.contract后,用 substrate-contracts-node 本地 DevNet 做 Weight、Gas 上限、存储押金 实测。 - 升级策略:ink! 3.x 使用
proxy + delegate_call手工实现;ink! 4.x 支持set_code_hash原地代码替换,需 Sudo 或 DAO 投票 满足国内联盟链治理要求。 - 合规细节:国密算法 需自定义
Hash接口;存储押金(Storage Deposit) 必须提醒用户 ** existential deposit 规则**;Weight 上限 500ms 是大多数联盟链节点默认配置,复杂计算需 off-chain worker 异步回写。
答案
下面给出一个 最小可用且符合国内审计习惯的 ERC20 等价合约,并穿插讲解 ink! 与 Solidity 差异。
- 环境准备(国内镜像加速)
# 安装 Rust nightly 工具链
rustup default nightly-2024-01-01
rustup target add wasm32-unknown-unknown
# 安装 cargo-contract,使用清华源
CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse cargo install \
--git https://github.com/paritytech/cargo-contract \
--tag v4.0.0-rc3 --force
# 拉取本地开发链(substrate-contracts-node 预置 pallet-contracts)
git clone --depth 1 https://github.com/paritytech/substrate-contracts-node.git
cargo build --release
./target/release/substrate-contracts-node --dev
- 新建合约
cargo contract new mytoken
cd mytoken
- 编写 lib.rs
#![cfg_attr(not(feature = "std"), no_std)]
#[ink::contract]
mod mytoken {
use ink::storage::Mapping;
/// 定义事件,方便链下审计
#[ink(event)]
pub struct Transfer {
#[ink(topic)]
from: Option<AccountId>,
#[ink(topic)]
to: Option<AccountId>,
value: Balance,
}
#[ink(storage)]
pub struct MyToken {
total_supply: Balance,
balances: Mapping<AccountId, Balance>,
allowances: Mapping<(AccountId, AccountId), Balance>,
}
impl MyToken {
/// 构造函数,初始发行 1_000_000 * 10^18
#[ink(constructor)]
pub fn new(initial_supply: Balance) -> Self {
let mut balances = Mapping::new();
let caller = Self::env().caller();
balances.insert(caller, &initial_supply);
Self::env().emit_event(Transfer {
from: None,
to: Some(caller),
value: initial_supply,
});
Self {
total_supply: initial_supply,
balances,
allowances: Mapping::new(),
}
}
/// 查询总供给,只读
#[ink(message)]
pub fn total_supply(&self) -> Balance {
self.total_supply
}
/// 查询余额,只读
#[ink(message)]
pub fn balance_of(&self, owner: AccountId) -> Balance {
self.balances.get(owner).unwrap_or(0)
}
/// 转账,写操作
#[ink(message)]
pub fn transfer(&mut self, to: AccountId, value: Balance) -> bool {
let caller = self.env().caller();
self.transfer_from_to(Some(caller), Some(to), value)
}
/// 授权,写操作
#[ink(message)]
pub fn approve(&mut self, spender: AccountId, value: Balance) -> bool {
let caller = self.env().caller();
self.allowances.insert((caller, spender), &value);
true
}
/// 查询授权额度,只读
#[ink(message)]
pub fn allowance(&self, owner: AccountId, spender: AccountId) -> Balance {
self.allowances.get((owner, spender)).unwrap_or(0)
}
/// 代理转账,写操作
#[ink(message)]
pub fn transfer_from(
&mut self,
from: AccountId,
to: AccountId,
value: Balance,
) -> bool {
let caller = self.env().caller();
let allowance = self.allowance(from, caller);
assert!(allowance >= value, "insufficient allowance");
self.allowances.insert((from, caller), &(allowance - value));
self.transfer_from_to(Some(from), Some(to), value)
}
/// 内部函数,统一 emit 事件
fn transfer_from_to(
&mut self,
from: Option<AccountId>,
to: Option<AccountId>,
value: Balance,
) -> bool {
let from = from.expect("from should not be none");
let to = to.expect("to should not be none");
let from_balance = self.balance_of(from);
assert!(from_balance >= value, "insufficient balance");
self.balances.insert(from, &(from_balance - value));
let to_balance = self.balance_of(to);
self.balances.insert(to, &(to_balance + value));
self.env().emit_event(Transfer { from, to, value });
true
}
}
/// 单元测试,off-chain env 零成本
#[cfg(test)]
mod tests {
use super::*;
#[ink::test]
fn constructor_works() {
let total = 1_000_000 * 10_u128.pow(18);
let contract = MyToken::new(total);
assert_eq!(contract.total_supply(), total);
assert_eq!(contract.balance_of(AccountId::from([0x01; 32])), total);
}
}
}
- 编译与部署
cargo contract build --release
# 生成 target/ink/mytoken.contract (含 WASM + metadata)
打开 Polkadot.js Apps 本地页面,上传 mytoken.contract,选择 构造函数 new 并传入 initial_supply,部署成功后会返回 合约地址 与 区块哈希,满足国内 “交易可审计、哈希可追踪” 的合规要求。
- 链上调试技巧
- 若交易失败,查看 节点日志
debug::print!,ink! 自动把panic!信息写入 System.Event; - 使用 cargo contract call 命令行快速只读调用,避免反复构造交易;
- 国内联盟链常把 Storage Deposit 单价调高,需在 README 里提醒业务方 预存 10 倍押金,防止 “存储不足” 导致交易回滚。
拓展思考
- 升级与治理:国内 BSN 适配 Substrate 时,往往 禁用 set_code_hash,要求 代理合约 + 多签 DAO 方案。可基于 ink! 的
delegate_call手写 Minimal Proxy Pattern,把状态与逻辑分离,升级审计报告 需提交到 工信部电子标准院 备案。 - 国密改造:ink! 默认
blake2_256,SM3/SM4 需引入libsmcrate,并在env::hash_bytes()层做 feature = sm-crypto 条件编译,链下配套国密 TLS 节点证书,否则无法通过 商用密码产品认证。 - 性能调优:ink! 4.x 引入
#[ink(payable)]合并存储读写,可把 ERC20 转账 Weight 从 6.8M 降到 4.2M;国内云厂商节点 CPU 主频较低,建议 批量 approve + transfer_from 合并为 单事务批量接口,减少 跨合约调用次数,从而 降低 30% 以上 Gas。 - 前端集成:国内小程序生态要求 包体积 < 2 MB,使用 @polkadot/api-contract 时务必 tree-shaking,并把 metadata 拆分为 CDN 懒加载;同时 WS 端点需做国密 SSL 卸载,否则 微信开发者工具 会报 “非可信证书” 错误。