开场三问:今天到底要解决什么?
- 你是不是还在靠日志猜链上到底发生了什么?
- 合约一旦失败就
panic!回滚,前端只能报“出错了”? - 想把失败分门别类、让前端能给出明确提示?
答案分别是:用“事件”说清楚,用“错误处理”接住失败,用最小可跑通示例把这两件事一次性落地。
小提示:如果你还没跑过最小 ink! 合约,先看系列第 1 篇再回来更顺手:
- Rust 智能合约入门:ink! 计数器从零跑通 → http://rexai.top/tutorials/smart-contracts/rust-smart-contract-ink-counter/
事件(Event)到底是什么?
可以把事件理解为“链上可检索日志”,每次状态变化都会配套一条“结构化消息”广播出去:
- 便于前端(监听器)做 UI 更新,例如余额变化、NFT 转移等;
- 便于索引器(Subsquid/自建服务)筛选与聚合;
- 便于链上/链下审计回放历史行为。
在 ink! 里,事件用结构体 + 属性宏声明:
#[ink(event)]
pub struct Transferred {
#[ink(topic)]
from: Option<AccountId>,
#[ink(topic)]
to: Option<AccountId>,
value: Balance,
}
要点说明:
#[ink(event)]标记一个事件类型(结构体)。#[ink(topic)]为可检索字段建立“索引”,常用于地址、资源标识等;非 topic 字段不会进索引,但会随事件一起存档。
发出事件:
self.env().emit_event(Transferred {
from: Some(from),
to: Some(to),
value,
});
建议:把“外部可感知的状态变化”都配一条事件,例如铸币、转账、授权、参数变更等。
错误处理的正确姿势
在合约消息(#[ink(message)])中推荐返回 Result<T, E>,显式区分成功/失败,并用自定义错误枚举描述失败原因:
#[derive(scale::Encode, scale::Decode, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "std", derive(scale_info::TypeInfo))]
pub enum Error {
NotOwner,
InsufficientBalance,
}
pub type Result<T> = core::result::Result<T, Error>;
在逻辑中尽量避免 panic!:
panic!会导致陷阱回滚,既不优雅也难以定位;- 返回
Err(Error::Xxx)更可控,前端可据错误类型给出明确提示。
最小示例:带事件与错误的简化代币
下面这个最小合约演示了两点:
- 发生转账就发事件(含 topic 字段);
- 对余额不足/权限不符返回明确错误,而非
panic!。
#![cfg_attr(not(feature = "std"), no_std)]
#[ink::contract]
mod mini_token {
use ink::storage::Mapping;
#[ink(storage)]
pub struct MiniToken {
owner: AccountId,
total_supply: Balance,
balances: Mapping<AccountId, Balance>,
}
#[derive(scale::Encode, scale::Decode, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "std", derive(scale_info::TypeInfo))]
pub enum Error {
NotOwner,
InsufficientBalance,
}
pub type Result<T> = core::result::Result<T, Error>;
#[ink(event)]
pub struct Transferred {
#[ink(topic)]
from: Option<AccountId>,
#[ink(topic)]
to: Option<AccountId>,
value: Balance,
}
impl MiniToken {
#[ink(constructor)]
pub fn new() -> Self {
Self {
owner: Self::env().caller(),
total_supply: 0,
balances: Mapping::default(),
}
}
#[ink(message)]
pub fn mint(&mut self, to: AccountId, value: Balance) -> Result<()> {
let caller = self.env().caller();
if caller != self.owner {
return Err(Error::NotOwner);
}
let to_balance = self.balances.get(&to).unwrap_or(0);
self.balances.insert(to, &(to_balance.saturating_add(value)));
self.total_supply = self.total_supply.saturating_add(value);
self.env().emit_event(Transferred {
from: None,
to: Some(to),
value,
});
Ok(())
}
#[ink(message)]
pub fn transfer(&mut self, to: AccountId, value: Balance) -> Result<()> {
let from = self.env().caller();
let from_balance = self.balances.get(&from).unwrap_or(0);
if from_balance < value {
return Err(Error::InsufficientBalance);
}
let to_balance = self.balances.get(&to).unwrap_or(0);
self.balances.insert(from, &(from_balance - value));
self.balances.insert(to, &(to_balance + value));
self.env().emit_event(Transferred {
from: Some(from),
to: Some(to),
value,
});
Ok(())
}
#[ink(message)]
pub fn balance_of(&self, who: AccountId) -> Balance {
self.balances.get(&who).unwrap_or(0)
}
}
#[cfg(test)]
mod tests {
use super::*;
use ink::env::test;
#[ink::test]
fn mint_emits_event() {
let mut token = MiniToken::new();
let bob = ink::env::test::default_accounts::<ink::env::DefaultEnvironment>().bob;
assert!(token.mint(bob, 100).is_ok());
let events: Vec<_> = test::recorded_events().collect();
assert_eq!(events.len(), 1); // 简单断言:确实有事件记录
}
#[ink::test]
fn transfer_checks_balance() {
let mut token = MiniToken::new();
let accounts = test::default_accounts::<ink::env::DefaultEnvironment>();
// owner -> bob 铸 100
assert!(token.mint(accounts.bob, 100).is_ok());
// 以 bob 身份发送交易
test::set_caller::<ink::env::DefaultEnvironment>(accounts.bob);
assert!(matches!(token.transfer(accounts.alice, 150), Err(Error::InsufficientBalance)));
assert!(token.transfer(accounts.alice, 40).is_ok());
assert_eq!(token.balance_of(accounts.alice), 40);
}
}
}
编译与测试(需先安装 ink! 工具链,见系列第 1 篇):
cargo contract build --release
cargo test
实战清单:把“可观测性”和“可恢复性”落到代码
- 任何对外部有意义的状态变化(转账、授权、参数更新)都 emit 事件。
- 对前端要能友好提示的分支,统一走
Result<T, Error>,并定义清晰的错误枚举。 #[ink(topic)]只给需要检索/过滤的字段打标签(如from/to/id)。- 单元测试至少覆盖:错误路径与事件条数,必要时解码验证事件内容。
- 避免
panic!;必要时用saturating_*系列避免算术溢出。 - 保持事件命名稳定、语义清晰(如
Transferred、Minted、Approved)。
常见坑与修正
- 事件未标注 topic:导致前端/索引器难以过滤,检索成本高。
- 在消息里
panic!:失败不透明、可用性差;改为返回Err(Error::Xxx)。 - 错误类型过于随意:把“权限不足”“余额不足”“不存在”等拆成独立枚举项,便于前端精确提示。
- 单元测试只测成功路径:必须补充失败路径与事件断言,否则回归测试难兜底。
FAQ(新手最容易问的 3 个问题)
- 事件是不是必须有 topic?不是,但建议对“过滤维度”加 topic,例如地址、资源 ID。
panic!会不会回滚?会,但不可控且不友好;推荐用Result返回显式错误。- 怎么在测试里看事件内容?
ink::env::test::recorded_events()可取回原始字节, 结合事件类型的 SCALE 编解码可进一步断言字段(本文用条数断言示范最小路径)。
结语与下一步
你已经会在 ink! 合约里“说话”(事件)与“拒绝”(错误)。接下来可以尝试:
- 给前文的计数器加上
Incremented事件与“上限保护”错误; - 在本示例里补充
Approved/Approval流程与对应事件; - 编写 E2E 脚本监听事件,驱动前端数据联动。
如果你对 ink! 的事件编码、索引器接入或错误类型设计有更高阶的问题,欢迎在评论区交流,我们会在后续文章继续展开。
互动与支持(帮你我都更高效)
- 点赞、收藏、转发,支持一下创作;
- 关注「梦兽编程」,系列更新不错过;
- 有问题直接评论区留言,我会补充案例或修正文档。

参考资料与延伸阅读(权威/可落地)
- ink! 官方站点(The ink! Book):https://use.ink/
- cargo‑contract 工具:https://github.com/paritytech/cargo-contract
- Substrate 合约(Contracts Pallet)文档:https://docs.substrate.io/reference/frame-pallets/
- SCALE 编解码:https://docs.substrate.io/reference/scale-codec/
- Polkadot.js API / Apps(事件监听思路):https://polkadot.js.org/
- Subsquid(索引器示例与入门):https://docs.subsquid.io/
- 系列第 1 篇(计数器入门):/tutorials/smart-contracts/rust-smart-contract-ink-counter/
- 系列第 5 篇(访问控制模式):/tutorials/smart-contracts/rust-smart-contract-access-control/
