This article is Part 5 of our Rust smart contract series. It focuses on access control patterns in ink!: who is allowed to call what, how to model roles, and how to test them.
Opening: a shared kitchen, one key, many rules
Imagine your ink! contract as a shared kitchen.
- The pots and ingredients are your on‑chain state.
- The head chef holds the main key.
- Assistants can touch some things but not everything.
If everyone can walk in and change the menu or throw random stuff into the pot, the kitchen quickly turns into chaos. On‑chain, this looks like:
- Any account can change contract state;
- Failures show up as generic panics, so UIs have no idea why the call failed;
- You want “temporary roles” like minters or operators, but don’t have a clean pattern to add them.
In this chapter we’ll install proper “door access” on your contract:
- use
env().caller()to know who is knocking; - store
ownerandadminin storage; - add a minter role with a
Mapping; - return typed errors instead of panicking;
- test everything by simulating different callers.
Target reader:
- You’ve already gone through the minimal ink! counter;
- You’re okay editing
lib.rsand runningcargo contract build/test; - You now care about permissions, not just “making it work”.
Core ideas: 5 facts to anchor your mental model
env().caller()is your access card
Every message call comes with a caller. Access control is just “compare this caller to stored roles”, then either allow or return an error.owneris whoever got the first key
In the constructor, storeenv().caller()asowner. Any “only owner” check reduces tocaller == owner.Use
Result<T, E>, notpanic!, for access checkspanic!rolls back and burns fees, but doesn’t explain the reason. Error enums likeAccessError::NotOwnerorNotMintermake failures explicit for frontends and scripts.Roles are whitelists, not hard‑coded if‑else ladders
AMapping<AccountId, ()>is a compact whitelist: “if this key exists, the account has the role; otherwise it doesn’t”.Tests are “fake swipes” at the door
With#[ink::test]andink::env::test::set_caller, you can simulate owner/admin/minter/stranger behaviors off‑chain and prove your rules work before deployment.
Hands‑on: building an access_guard contract
Environment used while writing:
- OS: macOS / Linux;
- Rust: 1.80+ stable;
- ink!: 5.x;
- Tools:
cargo-contract 4.x; - Local node:
substrate-contracts-nodeor a similar contracts‑enabled chain.
You can adjust versions, but staying close avoids most macro/tooling surprises.
Step 0: scaffold a fresh project
We’ll dedicate a new project to access control:
cargo contract new access-guard
cd access-guard
You’ll get a default ink! template with a counter‑like contract. We’ll replace it with a small access control playground.
Step 1: write owner/admin/minters into storage
First, let’s define the storage struct, error enum, event, and constructor:
#![cfg_attr(not(feature = "std"), no_std, no_main)]
#[ink::contract]
mod access_guard {
use ink::storage::Mapping;
#[derive(scale::Encode, scale::Decode, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "std", derive(scale_info::TypeInfo))]
pub enum AccessError {
NotOwner,
NotAdmin,
NotMinter,
}
#[ink(event)]
pub struct Minted {
#[ink(topic)]
pub to: AccountId,
pub amount: u32,
}
#[ink(storage)]
pub struct AccessGuard {
owner: AccountId,
admin: AccountId,
value: u32,
minters: Mapping<AccountId, ()>,
}
impl AccessGuard {
#[ink(constructor)]
pub fn new() -> Self {
let caller = Self::env().caller();
Self {
owner: caller,
admin: caller,
value: 0,
minters: Mapping::default(),
}
}
// we’ll add messages below
}
}
What this gives you:
- On deploy, the caller becomes both
ownerandadmin; valueis a simple state we’ll mutate;mintersis a whitelist of “people allowed to mint”.
Check it compiles:
cargo contract build
If this works, your “kitchen” has at least a locked door and someone holding the key.
Step 2: replace panic! with a clear Result
Now add a message that only the owner can call to set the value:
impl AccessGuard {
#[ink(message)]
pub fn set_value(&mut self, new_value: u32) -> Result<(), AccessError> {
if self.env().caller() != self.owner {
return Err(AccessError::NotOwner);
}
self.value = new_value;
Ok(())
}
#[ink(message)]
pub fn get_value(&self) -> u32 {
self.value
}
}
Compared to a panic!, this:
- still rolls back the state on failure (as all errors do),
- but returns a specific reason (
NotOwner), so your frontend can show a helpful message instead of “execution failed”.
Step 3: allow handing over the key (transfer_ownership)
In real life, contract owners change. You shouldn’t redeploy just to hand over responsibilities. Add a “change the lock” message:
impl AccessGuard {
#[ink(message)]
pub fn transfer_ownership(
&mut self,
new_owner: AccountId,
) -> Result<(), AccessError> {
if self.env().caller() != self.owner {
return Err(AccessError::NotOwner);
}
self.owner = new_owner;
Ok(())
}
}
Now you have an on‑chain, auditable history of ownership changes. Auditors and teammates can see when and to whom the key was handed over.
Step 4: introduce admin + minter role mappings
A single owner role is often too coarse. A common split:
- Owner: ultimate governance, can appoint admins;
- Admin: manages operational roles, e.g. minters;
- Minters: can increase balances / mint tokens.
Extend the impl:
impl AccessGuard {
#[ink(message)]
pub fn set_admin(&mut self, new_admin: AccountId) -> Result<(), AccessError> {
if self.env().caller() != self.owner {
return Err(AccessError::NotOwner);
}
self.admin = new_admin;
Ok(())
}
#[ink(message)]
pub fn add_minter(&mut self, user: AccountId) -> Result<(), AccessError> {
if self.env().caller() != self.admin {
return Err(AccessError::NotAdmin);
}
self.minters.insert(user, &());
Ok(())
}
#[ink(message)]
pub fn remove_minter(&mut self, user: AccountId) -> Result<(), AccessError> {
if self.env().caller() != self.admin {
return Err(AccessError::NotAdmin);
}
self.minters.remove(user);
Ok(())
}
#[ink(message)]
pub fn mint(
&mut self,
to: AccountId,
amount: u32,
) -> Result<(), AccessError> {
let caller = self.env().caller();
if self.minters.get(caller).is_none() {
return Err(AccessError::NotMinter);
}
self.value = self.value.saturating_add(amount);
self.env().emit_event(Minted { to, amount });
Ok(())
}
}
Notes:
Mapping<AccountId, ()>is a lightweight whitelist: presence = allowed, absence = forbidden;- Only
admincan grant/revoke minter rights; mintuses an event so tools and UIs can track who minted what for whom.
Rebuild:
cargo contract build
This ensures the contract still compiles with all the new messages.
Step 5: test by simulating different callers
Finally, we’ll prove the rules work by simulating owner, admin, minter, and an unauthorized user:
#[cfg(test)]
mod tests {
use super::*;
#[ink::test]
fn owner_admin_and_minters_are_checked() {
let accounts =
ink::env::test::default_accounts::<ink::env::DefaultEnvironment>();
// Default caller is Alice
let mut contract = AccessGuard::new();
// Alice as owner can set the value
assert_eq!(contract.set_value(42), Ok(()));
// Bob is rejected when trying to set the value
ink::env::test::set_caller::<ink::env::DefaultEnvironment>(accounts.bob);
assert_eq!(contract.set_value(7), Err(AccessError::NotOwner));
// Switch back to Alice, set admin to Charlie
ink::env::test::set_caller::<ink::env::DefaultEnvironment>(accounts.alice);
assert_eq!(contract.set_admin(accounts.charlie), Ok(()));
// Charlie as admin can add Bob as a minter
ink::env::test::set_caller::<ink::env::DefaultEnvironment>(accounts.charlie);
assert_eq!(contract.add_minter(accounts.bob), Ok(()));
// Bob as a minter can mint
ink::env::test::set_caller::<ink::env::DefaultEnvironment>(accounts.bob);
assert!(contract.mint(accounts.django, 5).is_ok());
// Eve, who has no role, gets a NotMinter error
ink::env::test::set_caller::<ink::env::DefaultEnvironment>(accounts.eve);
assert_eq!(
contract.mint(accounts.frank, 5),
Err(AccessError::NotMinter)
);
}
}
Run:
cargo contract test
If everything passes, you’ve verified the happy paths and the denied paths, all without deploying to a real network.
Common pitfalls (and quick fixes)
No owner in storage
- Problem: any account can call privileged functions.
- Fix: store
env().caller()in the constructor and compare to it for critical operations.
Using
panic!for access control- Problem: users and dashboards see only “execution failed”.
- Fix: return
Result<T, AccessError>with explicit reasons (NotOwner,NotAdmin, …).
Roles stored in
Vec<AccountId>- Problem: every permission check is O(n), and revoking roles is tedious and bug‑prone.
- Fix: prefer
Mapping<AccountId, ()>orMapping<AccountId, bool>for quick membership checks.
No revoke path for roles
- Problem: compromised keys remain powerful forever.
- Fix: always add both grant and revoke flows, and consider rotating admins or using multi‑sig for them.
Forgetting to switch callers in tests
- Problem: all tests run as Alice; you never see the denied branches.
- Fix: get used to
ink::env::test::set_callerand test both success and failure.
Wrap‑up and next steps
In one sentence: access control in ink! is about checking env().caller() against stored roles and returning clear errors instead of panicking.
Key takeaways:
- Store
owner,admin, and role mappings in storage; - Guard privileged messages with
Result<T, AccessError>; - Use events to make role‑sensitive actions observable;
- Exercise all paths with
#[ink::test]and different callers before deploying.
Next steps:
- Add a
pause/unpauseflag controlled by the owner, and block sensitive actions when paused; - Combine this chapter with the events & error handling guide and the testing guide to build production‑ready workflows;
- Extract your favorite patterns into a small internal library and reuse them across multiple contracts.
Related Rust smart contract tutorials:
- Part 1 — Counter walkthrough: /en/tutorials/smart-contracts/rust-smart-contract-ink-counter/
- Part 3 — Events & error handling: /en/tutorials/smart-contracts/ink-events-error-handling/
- Part 4 — Testing ink! contracts: /en/tutorials/smart-contracts/ink-testing-smart-contracts/
- Part 6 — Storage patterns in ink!: /en/tutorials/smart-contracts/ink-storage-patterns/