访问控制定义了谁可以调用特定函数或修改合约行为。本文将解释 Cairo 如何使用 assert 宏来实现访问控制。
回顾 Solidity 中的访问控制
在 Solidity 中,modifiers 是一种将行为包装在函数周围的简洁方式。它们通常用于访问控制。考虑以下合约定义了一个 onlyOwner 修饰符,该修饰符确保只有合约所有者才能调用 callMe 函数:
// SPDX-License-Identifier: MIT
pragma solidity =0.8.30;
contract SomeContract {
address owner;
constructor() {
owner = msg.sender;
}
// THE `ONLYOWNER` MODIFIER
modifier onlyOwner() {
require(msg.sender == owner, "Not the owner");
_;
}
function callMe() public onlyOwner {
// callMe logic
}
}
修饰符允许你通过将前置条件移动到其他地方来保持主函数逻辑的清晰,就像我们在上面的 onlyOwner 修饰符中所做的那样。
Cairo 没有修饰符 —— Cairo 如何进行访问控制
在 Cairo 中,没有 modifier 关键字。相反,我们定义一个常规函数来执行检查(假设为 only_owner),并在 call_me 函数内部调用它。
下面的代码展示了这样的示例:
构造函数将调用者的地址(get_caller_address() 类似于 Solidity 的 msg.sender)赋值给 owner 变量。
#[starknet::contract]
mod SomeContract {
// import the required functions from the starknet core library
use starknet::ContractAddress;
use starknet::get_caller_address;
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
#[storage]
struct Storage {
owner: ContractAddress,
}
#[constructor]
fn constructor(ref self: ContractState) {
self.owner.write(get_caller_address());
}
#[generate_trait]
impl Internal of InternalTrait {
fn only_owner(self: @ContractState) {
let caller = get_caller_address();
let stored_owner = self.owner.read();
// ENSURES THE CALLER IS THE OWNER OR REVERT
assert(caller == stored_owner, 'Not owner');
}
}
#[abi(embed_v0)]
impl SomeContractImpl of super::ISomeContract<ContractState> {
// CALL_ME FUNCTION
fn call_me(ref self: ContractState) {
self.only_owner();
// callMe logic
}
}
}
这个 Cairo 版本通过限制对 call_me 函数的访问,模仿了 Solidity 的模式。它通过断言调用者的地址与合约状态中存储的 owner 匹配,来确保只有所有者才能调用它。
assert(caller == stored_owner, 'Not owner');
assert 函数的行为类似于 Solidity 的 require,如果条件失败,它将停止执行并回滚交易。更好的是,Cairo 提供了另一个名为 assert! 的函数,它支持格式化的错误消息,使其更具表现力。
assert 与 assert!
虽然 assert 函数和 assert! 宏(! 用于区分宏和函数)具有相同的目的,即确保条件为真,但它们在报告错误的方式上有所不同。
assert:
第一个参数 condition 是一个布尔表达式。如果它为 false,程序将使用单引号中的固定错误消息引发 panic。
assert(condition, 'static error message');
assert!:
- 第一个参数
condition是一个布尔表达式。如果它为false,程序将使用第二个参数引发 panic。 - 第二个参数是双引号中的格式化字符串。
assert!(condition, "Formatted error: {}", variable);
格式化字符串中的 {} 代表什么
在格式化字符串中,{} 是一个占位符。当代码运行时,variable 的值会被转换为字符串并插入到 {} 出现的位置。
可以把它看作是填空:
let name = "Alice";
println!("Hello, {}", name);
// Prints: Hello, Alice
我们可以有多个占位符:
println!("x = {}, y = {}", x, y);
顺序很重要:每个 {} 都会被字符串后面对应的参数所填充。
这为开发者在调试或处理错误时提供了更大的灵活性。你可以在消息中包含运行时值,而不是使用静态字符串,这是 Solidity 的 require 无法直接支持的。
推荐的方法是使用
assert!,即使在生产环境中也是如此。
assert! 中支持的类型
并非所有类型都可以在 assert! 消息中使用。只有实现了 core::fmt::Display trait 的类型才能用于 assert! 消息格式化。Display trait 定义了类型在使用 {} 格式说明符时如何转换为字符串表示。这些类型包括:
ByteArrayboolNonZero<T>(对于任何本身实现了Display的T)- 所有原生整数类型(
felt252、u8、u16、u32、u64、u128、u256以及有符号变体,如果存在的话) @T(上述任何Display类型的引用)
例如,像 felt252 这样的类型是可以的,但自定义结构体或像 ContractAddress 这样的类型将引发错误,因为它们没有实现 Display trait。
如果你尝试这样做:
let caller: ContractAddress = get_caller_address();
// ❌ This will fail to compile
assert!(caller == owner, "Caller was: {}", caller);
你会看到类似如下的错误:
Trait has no implementation in context: core::fmt::Display::<core::starknet::contract_address::ContractAddress>
为了解决这个问题,如果你只需要数字表示,你可以将该地址转换为 felt252:
let caller: ContractAddress = get_caller_address();
let caller_felt: felt252 = caller.into();
// ✅ This works, assuming the `owner` variable is of type felt252 too
assert!(caller_felt == owner, "Caller was: {}", caller_felt);
因此,虽然 assert! 为你提供了富有表现力的错误处理能力,但在格式化消息时请务必牢记类型要求。
练习: 编写一个 Cairo 函数,接收两个数字 n 和 d,并返回它们的除法结果。如果 d 为零,该函数应回滚并显示消息:“n is not divisible by d”(在错误中包含 n 和 d 的实际值)。提示:使用 assert! 函数。要解答 safe_divide 练习,请克隆此 repo。
本文是关于 Starknet 上的 Cairo 编程 教程系列的一部分