Three quick questions: what are we fixing today?

  1. Still guessing what happened on‑chain by grepping logs?
  2. Does your contract just panic! and roll back, leaving the UI with a generic error?
  3. 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 Transferred event 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!; prefer saturating_* 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. Prefer Result with 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 Incremented event and an upper‑bound error to the counter.
  • Add Approved/Approval flow 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