开场(痛点 + 类比)
把 ink! 合约想成共用厨房:锅碗瓢盆都在柜子里,钥匙只发给厨师长。要是人人都能随手换菜谱、往锅里加料,厨房立刻乱成灾——合约状态也是一样,谁都能改就等于没有安全感。新手常见的三个痛点:
- 只会写
#[ink(message)],不知道如何挡住陌生人; - 靠
panic!兜底,前端只看到“出错了”却不知道为啥; - 想给伙伴开一个“临时权限”,却不知道怎么优雅地加角色。
这一篇就是教你把“厨房钥匙”管起来,让新手也能看懂、照抄、上线前就知道哪里可能翻车。
原理速写(最多 5 点)
env().caller()是门禁卡:每条消息都能读到“谁在敲门”,一切访问控制都从它开始。only_owner= 比对两个帐号:把部署者写进存储,调用时只要caller == owner就放行,否则立刻拒绝。- 返回
Result<T, E>而不是panic!:panic 只会回滚,还让前端一脸懵;错误枚举能把原因明确抛出去。 - 角色 =
Mapping<AccountId, ()>:用映射表记录授权账号,增删角色时只改这个表,逻辑更清爽。 - 测试就是模拟刷卡:
#[ink::test]能设置不同调用者,把拒绝/放行都跑一遍,少掉链上踩坑。
实战步骤(手把手)
步骤 0:准备一口干净锅(环境)
- rustc 1.80+、cargo-contract 4.x、ink! 5.0 模板;
- 新建工程:
cargo contract new access-guard; - 后续命令都在工程目录执行,默认目标是
swanky-node或substrate-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:
- 把本文示例复制到你的项目里,确认
cargo contract build && cargo contract test全绿; - 视业务补充
remove_minter、pause等额外角色; - 继续补链上可观测性(事件、日志、指标),为后续的“存储布局与可升级模式”打底。
下篇我们会接着拆“智能合约存储套路”,把状态结构设计得可升级、不浪费 gas。