Three quick questions: what are we fixing today?
- Still guessing what happened on‑chain by grepping logs?
- Does your contract just
panic!and roll back, leaving the UI with a generic error? - Want clean, typed failures so the frontend can show precise messages?
The answers: let contracts “speak” via Events, catch failures via typed Results, and land both with a minimal, runnable example.
Tip: If you haven’t run a minimal ink! contract yet, start with our counter walkthrough, then come back:
- Rust Smart Contract Onboarding: Ship the ink! Counter → /tutorials/smart-contracts/rust-smart-contract-ink-counter/
What are Events (and why they matter)?
Think of events as “searchable on‑chain logs”. Whenever state changes, emit a structured message:
- Frontends (listeners) update UI (balances, NFT transfers…)
- Indexers filter/aggregate reliably
- Auditors can replay behavior on/off‑chain
Declare events with a struct + attribute macro:
#[ink(event)]
pub struct Transferred {
#[ink(topic)]
from: Option<AccountId>,
#[ink(topic)]
to: Option<AccountId>,
value: Balance,
}
Notes:
#[ink(event)]marks an event type.#[ink(topic)]indexes a field for filtering (addresses, IDs…). Non‑topic fields are still recorded but not indexed.
Emit an event where the change happens:
self.env().emit_event(Transferred {
from: Some(from),
to: Some(to),
value,
});
Thumb rule: any user‑visible state change (mint, transfer, approve, config change) should emit an event.
Error handling the right way
Prefer returning Result<T, E> from #[ink(message)] functions with a custom error enum. It’s predictable, testable, and friendly to UIs.
#[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>;
Avoid panic! in contract logic:
- Panics cause trap rollbacks — opaque and harder to diagnose.
- Typed errors let the frontend map cases to clear messages.
Minimal example: tiny token with events and errors
This micro‑contract demonstrates two things:
- Transfers emit a
Transferredevent with indexed topics. - Insufficient balance / wrong permission return typed errors instead of panics.
#![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); // there is an event recorded
}
#[ink::test]
fn transfer_checks_balance() {
let mut token = MiniToken::new();
let accounts = test::default_accounts::<ink::env::DefaultEnvironment>();
assert!(token.mint(accounts.bob, 100).is_ok());
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);
}
}
}
Build & test (see our ink! setup in the counter article):
cargo contract build --release
cargo test
Production checklist: observability + recoverability
- Emit events on any user‑visible state change (transfer, approve, config).
- Use
Result<T, Error>for UI‑relevant branches; make errors precise. - Only
#[ink(topic)]fields you need to filter on (addresses/IDs). - Unit‑test error paths and event counts; decode fields if necessary.
- Avoid
panic!; prefersaturating_*to sidestep arithmetic overflow. - Keep event names stable and clear (
Transferred,Minted,Approved).
Common pitfalls
- No topics on events → hard to filter; high indexing cost.
- Panicking inside messages → opaque rollbacks; return typed errors instead.
- One catch‑all error → split into precise enums (permission, balance, not‑found…).
- Tests only cover happy paths → add failure + event assertions.
FAQ
- Do events require topics? No, but add topics for key filter dimensions (addresses/IDs).
- Do
panic!s roll back? Yes, but they’re opaque. PreferResultwith typed errors. - How to assert event content? Use
ink::env::test::recorded_events(); decode as needed.
Wrap‑up & next steps
You can now make contracts “speak” (events) and “refuse clearly” (typed errors). Try:
- Add an
Incrementedevent and an upper‑bound error to the counter. - Add
Approved/Approvalflow plus events to this token. - Wire an E2E script to listen for events and drive UI state.
Engage & support
- Like, bookmark, and share if this helped your team.
- Follow “Rexai Programming” for upcoming Rust smart contract guides.
- Drop a comment with blockers; I’ll add examples or corrections.
References & further reading
- ink! official site (The ink! Book): https://use.ink/
- cargo‑contract tool: https://github.com/paritytech/cargo-contract
- Substrate Contracts (FRAME pallets): https://docs.substrate.io/reference/frame-pallets/
- SCALE codec: https://docs.substrate.io/reference/scale-codec/
- Polkadot.js (API / Apps for event watching): https://polkadot.js.org/
- Subsquid (indexer docs): https://docs.subsquid.io/
- Part 1 (counter walkthrough): /en/tutorials/smart-contracts/rust-smart-contract-ink-counter/
- Part 5 (access control patterns): /en/tutorials/smart-contracts/rust-smart-contract-access-control/
