开场(痛点 + 类比)

把 ink! 合约想成共用厨房:锅碗瓢盆都在柜子里,钥匙只发给厨师长。要是人人都能随手换菜谱、往锅里加料,厨房立刻乱成灾——合约状态也是一样,谁都能改就等于没有安全感。新手常见的三个痛点:

  1. 只会写 #[ink(message)],不知道如何挡住陌生人;
  2. panic! 兜底,前端只看到“出错了”却不知道为啥;
  3. 想给伙伴开一个“临时权限”,却不知道怎么优雅地加角色。

这一篇就是教你把“厨房钥匙”管起来,让新手也能看懂、照抄、上线前就知道哪里可能翻车。

原理速写(最多 5 点)

  1. env().caller() 是门禁卡:每条消息都能读到“谁在敲门”,一切访问控制都从它开始。
  2. only_owner = 比对两个帐号:把部署者写进存储,调用时只要 caller == owner 就放行,否则立刻拒绝。
  3. 返回 Result<T, E> 而不是 panic!:panic 只会回滚,还让前端一脸懵;错误枚举能把原因明确抛出去。
  4. 角色 = Mapping<AccountId, ()>:用映射表记录授权账号,增删角色时只改这个表,逻辑更清爽。
  5. 测试就是模拟刷卡#[ink::test] 能设置不同调用者,把拒绝/放行都跑一遍,少掉链上踩坑。

实战步骤(手把手)

步骤 0:准备一口干净锅(环境)

  • rustc 1.80+、cargo-contract 4.x、ink! 5.0 模板;
  • 新建工程:cargo contract new access-guard
  • 后续命令都在工程目录执行,默认目标是 swanky-nodesubstrate-contracts-node

步骤 1:把 owner 写进存储(为什么)

没有 owner,访问控制无从谈起。最简单的做法是在构造函数里把部署者写入存储,后续所有检查都用它。

#![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(),
            }
        }

        // 后面步骤会逐个完善消息函数

这段代码的“做到的样子”很直观:部署合约的人自动成为 owner 与 admin,后面若想拆开也能继续扩展。

步骤 2:把 panic 改成 Result(怎么做)

assert_eq! 固然能挡人,却会直接 panic。更实用的做法是返回 Result<(), AccessError>,让前端得知是“不是 owner”还是其他原因。

        #[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(())
        }

调用失败时再也不会出现“莫名其妙的 1010 错误”,而是明确告诉前端:当前用户没权限。

步骤 3:支持换锁 transfer_ownership(做到的样子)

真实业务里 owner 并不是永远同一个人。把换锁动作封成一个消息:

        #[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(())
        }

现在 owner 能在链上完成“交钥匙”,整个过程可审计、可追溯,避免私下改代码重新部署的混乱。

步骤 4:加上角色映射和事件(为什么 & 边界)

单一 owner 仍然太粗暴,像厨房里只有主厨能拿调料。角色映射让你像贴名牌一样授权多个人,事件则帮你记录谁动过库存。

        #[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 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(())
        }
    }
}

Mapping<AccountId, ()> 占用极小空间,只关心“有没有授权”。一旦发现 minter 乱来,只需从映射里删掉对应 key。

步骤 5:用 #[ink::test] 验证(常见误区)

别等上链才发现门锁没装紧。离链测试可以模拟不同调用者,最短范例:

#[cfg(test)]
mod tests {
    use super::*;

    #[ink::test]
    fn owner_and_roles_are_checked() {
        let accounts = ink::env::test::default_accounts::<ink::env::DefaultEnvironment>();
        let mut contract = AccessGuard::new();

        // 默认 caller 是 Alice
        assert_eq!(contract.set_value(42), Ok(()));

        // Bob 被拒绝
        ink::env::test::set_caller::<ink::env::DefaultEnvironment>(accounts.bob);
        assert_eq!(contract.set_value(7), Err(AccessError::NotOwner));

        // Alice 授权 Bob 成为 minter
        ink::env::test::set_caller::<ink::env::DefaultEnvironment>(accounts.alice);
        assert_eq!(contract.add_minter(accounts.bob), Ok(()));

        // Bob 现在可以 mint
        ink::env::test::set_caller::<ink::env::DefaultEnvironment>(accounts.bob);
        assert!(contract.mint(accounts.charlie, 5).is_ok());
    }
}

运行 cargo contract test 会看到 ok,同时也能复现“不授权就返回 AccessError”的分支。这样进入链上的就是已经刷过门禁的代码。

常见坑与对策

  • 忘记在构造函数里写 owner:默认值是全零地址,任何人都能通过检查。先打印 env().caller() 再赋值,部署后不要再依赖初始默认。
  • 所有错误都 panic:链上交易一失败就扣费还没日志。把 panic! 改成错误枚举,让前端或脚本能按原因提示用户。
  • 角色混成一张 Vec:遍历 Vec 查权限是 O(n),成本高还容易重复。换成 Mapping,授权/撤权都是 O(1)。
  • 忘了撤权路径:只加 add_minter 没有 remove_minter,账号被盗就麻烦。拆出单独的撤权函数或定期轮换 admin。
  • 测试里没切换 caller:默认调用者永远是 Alice,导致你以为 everyone 可写。多次 set_caller,把拒绝流程也断言掉。

总结与下一步

  • 访问控制的核心是 env().caller(),先认清谁在敲门再做判断。
  • 把 owner、admin、角色映射写进存储,所有消息围绕 Result 返回值展开。
  • 用事件与测试记录每次授权,保证合约上线前就能追责。

下一步 checklist:

  1. 把本文示例复制到你的项目里,确认 cargo contract build && cargo contract test 全绿;
  2. 视业务补充 remove_minterpause 等额外角色;
  3. 继续补链上可观测性(事件、日志、指标),为后续的“存储布局与可升级模式”打底。

下篇我们会接着拆“智能合约存储套路”,把状态结构设计得可升级、不浪费 gas。