开场三问:今天到底要解决什么?

  1. 你是不是还在靠日志猜链上到底发生了什么?
  2. 合约一旦失败就 panic! 回滚,前端只能报“出错了”?
  3. 想把失败分门别类、让前端能给出明确提示?

答案分别是:用“事件”说清楚,用“错误处理”接住失败,用最小可跑通示例把这两件事一次性落地。

小提示:如果你还没跑过最小 ink! 合约,先看系列第 1 篇再回来更顺手:


事件(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_* 系列避免算术溢出。
  • 保持事件命名稳定、语义清晰(如 TransferredMintedApproved)。

常见坑与修正

  • 事件未标注 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/