如何用 ink! 编写 WASM 合约?

解读

国内区块链赛道对 Rust 的需求集中在 联盟链(FISCO-BCOS 的 Rust SDK、蚂蚁链)公链(Polkadot、Near、Solana) 两大方向。ink! 是 Parity 官方为 Substrate 链打造的 eDSL,用 Rust 宏把合约代码编译成 .contract bundle(WASM+元数据),部署到 pallet-contracts 执行。面试官问“怎么用 ink! 写 WASM 合约”,并不是让你背宏定义,而是考察:

  1. 是否理解 ink! = Rust + 属性宏 + 专用运行时接口
  2. 能否把 传统 Solidity 的存储、事件、接口、权限、测试、升级 等范式映射到 ink! 的语法;
  3. 是否具备 国内合规链上隐私、Gas 计量、升级策略 的工程经验。
    回答时要体现“编译期保证内存安全 + 链上资源可计量”这一 Rust 核心优势,并给出 可落地的最小可用示例(ERC20 等价物),再补充 cargo-contract 工具链、链上调试、Weight 优化 等国内面试高频细节。

知识点

  1. ink! 宏三件套#[ink::contract] 定义合约入口;#[ink(storage)] 标注状态结构体;#[ink(message)] / #[ink(constructor)] 暴露链上入口点。
  2. 存储模型Mapping<Key, Value> 替代 Solidity 的 mapping,自动实现 SpreadLayout/PackedLayout,支持 跨合约调用键值证明
  3. 环境接口self.env().caller() 获取交易发送方,self.env().transferred_value() 获取附带余额,等价于 msg.sendermsg.value
  4. 事件机制#[ink(event)] 结构体 + self.env().emit_event(...),链下 Subscan、Polkadot.js 自动解析元数据,满足国内监管审计需求。
  5. 测试双轨cargo testoff-chain env(pallet-contracts 模拟器)cargo contract build 生成 .contract 后,用 substrate-contracts-node 本地 DevNet 做 Weight、Gas 上限、存储押金 实测。
  6. 升级策略:ink! 3.x 使用 proxy + delegate_call 手工实现;ink! 4.x 支持 set_code_hash 原地代码替换,需 Sudo 或 DAO 投票 满足国内联盟链治理要求。
  7. 合规细节国密算法 需自定义 Hash 接口;存储押金(Storage Deposit) 必须提醒用户 ** existential deposit 规则**;Weight 上限 500ms 是大多数联盟链节点默认配置,复杂计算需 off-chain worker 异步回写

答案

下面给出一个 最小可用且符合国内审计习惯的 ERC20 等价合约,并穿插讲解 ink! 与 Solidity 差异。

  1. 环境准备(国内镜像加速)
# 安装 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
  1. 新建合约
cargo contract new mytoken
cd mytoken
  1. 编写 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);
        }
    }
}
  1. 编译与部署
cargo contract build --release
# 生成 target/ink/mytoken.contract (含 WASM + metadata)

打开 Polkadot.js Apps 本地页面,上传 mytoken.contract,选择 构造函数 new 并传入 initial_supply,部署成功后会返回 合约地址区块哈希,满足国内 “交易可审计、哈希可追踪” 的合规要求。

  1. 链上调试技巧
  • 若交易失败,查看 节点日志 debug::print!,ink! 自动把 panic! 信息写入 System.Event
  • 使用 cargo contract call 命令行快速只读调用,避免反复构造交易;
  • 国内联盟链常把 Storage Deposit 单价调高,需在 README 里提醒业务方 预存 10 倍押金,防止 “存储不足” 导致交易回滚。

拓展思考

  1. 升级与治理:国内 BSN 适配 Substrate 时,往往 禁用 set_code_hash,要求 代理合约 + 多签 DAO 方案。可基于 ink! 的 delegate_call 手写 Minimal Proxy Pattern,把状态与逻辑分离,升级审计报告 需提交到 工信部电子标准院 备案。
  2. 国密改造:ink! 默认 blake2_256SM3/SM4 需引入 libsm crate,并在 env::hash_bytes() 层做 feature = sm-crypto 条件编译,链下配套国密 TLS 节点证书,否则无法通过 商用密码产品认证
  3. 性能调优:ink! 4.x 引入 #[ink(payable)] 合并存储读写,可把 ERC20 转账 Weight 从 6.8M 降到 4.2M;国内云厂商节点 CPU 主频较低,建议 批量 approve + transfer_from 合并为 单事务批量接口,减少 跨合约调用次数,从而 降低 30% 以上 Gas
  4. 前端集成:国内小程序生态要求 包体积 < 2 MB,使用 @polkadot/api-contract 时务必 tree-shaking,并把 metadata 拆分为 CDN 懒加载;同时 WS 端点需做国密 SSL 卸载,否则 微信开发者工具 会报 “非可信证书” 错误。