本文详细介绍了Starknet中的系统调用(syscalls),包括存储读写、跨合约调用、部署合约、事件日志、区块哈希、执行上下文、类哈希、库调用、合约升级、Keccak-256、SHA-256、L1消息发送以及meta_tx_v0。每个syscall都与Solidity中的对应操作进行对比,并提供了完整的函数签名、参数说明和代码示例。文章还解释了Cairo中存储地址的哈希计算方式,以及如何通过低层级syscall访问任意存储槽。
掌握 Cairo
在 Solidity 中,像读写存储、合约间调用或发送消息等底层操作,是通过使用 Yul 操作码(如 call、sload 和 sstore)在内联汇编中直接执行的。这些操作码绕过了 Solidity 的高级抽象。
Cairo 通过系统调用(syscalls)引入了类似的概念。系统调用是从 Starknet 合约到 Starknet OS 的底层调用,用于执行普通 Cairo 代码无法自行完成的操作,例如调用其他合约、部署合约、发出事件、读取执行上下文或访问存储。
在本文中,我们将介绍不同的可用系统调用以及它们在 Starknet 合约中的使用方法。
以下列表包含了当前所有可用的 Starknet 系统调用,以及它们在 Solidity 中最接近的对应物(如适用)。
storage_read_syscall 和 storage_write_syscall 分别对应 Solidity 汇编中的 sload 和 sstore。get_block_hash_syscall 对应 blockhash(),返回给定区块的哈希值。call_contract_syscall 可类比于合约调用中的 address.call()。deploy_syscall 类似于 create2,用于在可预测地址部署合约。emit_event_syscall 类似于 event + emit。两者都记录数据以供链下索引。keccak_syscall 对应 keccak256。get_class_hash_at_syscall 类似于 address.codehash,返回合约字节码的哈希值。library_call_syscall 类似于 delegatecall,在当前合约的执行上下文中执行另一个合约的代码。replace_class_syscall、get_execution_info_syscall、get_execution_info_v2_syscall、send_message_to_l1_syscall、sha256_process_block_syscall 和 meta_tx_v0_syscall 在 Solidity 中没有直接对应物。接下来,让我们看看上述系统调用在实践中是如何使用的。
与 Cairo 的 .read() 和 .write() 方法不同(它们只能读写 Storage 结构中声明的存储变量),storage_read_syscall 和 storage_write_syscall 直接读写原始存储槽。它们不需要声明的存储变量。如果你知道某个槽的存储地址,就可以直接读取或写入该地址。
storage_read_syscall 函数签名如下:
fn storage_read_syscall(
address_domain: u32, address: StorageAddress,
) -> SyscallResult<felt252>;
该系统调用接受两个参数:
address_domain:决定存储操作使用的数据可用性模式。目前只支持域 0,所以现在总是传递 0。address:要读取的存储地址(槽),类型为 StorageAddress。storage_read_syscall 返回一个 felt252 值。SyscallResult 只是 Cairo 中用于系统调用的 Result 类型,因此你可以使用 .unwrap_syscall() 解包(失败时会 panic 并回滚),或者显式匹配它自行处理错误而不回滚。
storage_write_syscall 函数签名如下:
fn storage_write_syscall(
address_domain: u32, address: StorageAddress, value: felt252,
) -> SyscallResult<()>;
该系统调用接受三个参数:address_domain、address 和
value:要写入存储地址的值(felt252)。它返回 SyscallResult<()>,其中 () 表示成功时没有值,重要的是成功或失败。
storage_read_syscall 和 storage_write_syscall 都围绕一个关键参数 address。在我们有效使用它们之前,需要了解 Cairo 如何计算这些存储地址。
在 Solidity 中,每个存储变量在编译时被分配一个顺序槽号:第一个声明的变量获得槽 0,第二个获得 1,以此类推。EVM 通过槽号识别变量。我们可以在汇编中使用 .slot 直接获取变量的槽:
contract Hello {
uint256 public a; // slot 0
uint256 public b; // slot 1
function getSlot() public {
assembly {
let x := b.slot
}
}
}
这里,b.slot 解析为 1,因为 b 是第二个声明的变量。
这种 Solidity 槽号分配模型在升级合约时会成为问题。如果合约的新版本移除或重新排序了存储变量,槽号就会改变,新变量最终会读取属于旧版本中不同变量的数据。
在 Cairo 中,存储地址是从变量名派生出来的。Cairo 计算每个存储变量名的 sn_keccak 哈希,并将其结果作为存储地址。sn_keccak 是以太坊 keccak256 的 Starknet 变体,截断为前 250 位。
下面是上述 Solidity 合约的 Cairo 等价实现。它使用 selector! 宏计算变量的存储槽。接下来的解释将分解代码的相关部分。
use starknet::storage_access::StorageAddress;
#[starknet::interface]
pub trait IHelloStarknet<TContractState> {
fn get_slot(self: @TContractState) -> StorageAddress;
}
#[starknet::contract]
mod HelloStarknet {
// *** 导入 *** //
use starknet::storage_access::{
StorageAddress,
storage_address_from_base,
storage_base_address_from_felt252,
};
#[storage]
struct Storage {
a: u256,
b: u256
}
#[abi(embed_v0)]
impl HelloStarknetImpl of super::IHelloStarknet<ContractState> {
fn get_slot(self: @ContractState) -> StorageAddress {
// `selector!` 宏可用于计算 {sn_keccak}。
let selector_in_felt = selector!("b");
// 将 `felt252` 转换为 `StorageBaseAddress` 类型
let slot_base = storage_base_address_from_felt252(selector_in_felt);
// 将 `StorageBaseAddress` 转换为 `StorageAddress` 类型
storage_address_from_base(slot_base)
}
}
}
以下是对上述代码的逐步解释:
// *** 导入 *** //
use starknet::storage_access::{
StorageAddress,
storage_address_from_base,
storage_base_address_from_felt252,
};
这些辅助函数允许我们将原始的 felt252 转换为存储读写系统调用所需的有效 StorageAddress 类型。
get_slot 函数中,selector!("b") 将 sn_keccak("b") 计算为 felt252:// `selector!` 宏可用于计算 {sn_keccak}。
let selector_in_felt = selector!("b");
storage_base_address_from_felt252 方法将结果转换为 StorageBaseAddress:// 将 `felt252` 转换为 `StorageBaseAddress` 类型
let slot_base = storage_base_address_from_felt252(selector_in_felt);
felt252 只是一个数字,Cairo 不会自动将数字视为存储地址。将其转换为 StorageBaseAddress 不会改变底层值。相反,它改变了 Cairo 对该值的处理方式,即现在 Cairo 知道这个数字应该用作存储地址,而不是普通整数。换句话说,转换并不修改数据本身,而是为其分配一个存储地址类型,以便在存储操作中使用。
storage_address_from_base 将该基础地址转换为 StorageAddress。StorageAddress 是存储系统调用期望的类型。// 将 `StorageBaseAddress` 转换为 `StorageAddress` 类型
storage_address_from_base(slot_base)
StorageAddress 是存储读写系统调用所需的确切格式。可以将其视为地址的最终可用形式。这是 Cairo 明确区分“只是一个数字”和“准备好在系统调用中使用的有效存储地址”的方式。
现在我们知道如何计算声明变量的存储槽了,接下来就是读取和写入一个槽。
b下面的合约扩展了前面的示例,增加了两个新函数:
b,两者都直接使用系统调用:
use starknet::storage_access::StorageAddress;
#[starknet::interface]
pub trait IHelloStarknet<TContractState> {
fn get_slot(self: @TContractState) -> StorageAddress;
// *** 新添加的函数 *** //
fn write_to_b_low_level(ref self: TContractState);
fn read_from_b_low_level(self: @TContractState) -> felt252;
}
#[starknet::contract]
mod HelloStarknet {
use starknet::storage_access::{
storage_address_from_base,
storage_base_address_from_felt252,
StorageAddress,
};
// *** 新添加的导入 *** //
use starknet::syscalls::{
storage_read_syscall,
storage_write_syscall
};
use starknet::SyscallResultTrait;
#[storage]
struct Storage {
a: u256,
b: u256
}
#[abi(embed_v0)]
impl HelloStarknetImpl of super::IHelloStarknet<ContractState> {
// *** 对变量 `b` 的底层写入 *** //
fn write_to_b_low_level(ref self: ContractState) {
// 获取变量 `b` 的槽
let slot = self.get_slot();
// 执行系统调用,将值 4 写入变量 `b`
let _ = storage_write_syscall(0, slot, 4);
}
// *** 对变量 `b` 的底层读取 *** //
fn read_from_b_low_level(self: @ContractState) -> felt252 {
// 获取变量 `b` 的槽
let slot = self.get_slot();
// 执行系统调用,从变量 `b` 读取值
// `.unwrap_syscall()` 解包系统调用结果,失败时 panic
storage_read_syscall(0, slot).unwrap_syscall()
}
fn get_slot(self: @ContractState) -> StorageAddress {
let selector_in_felt = selector!("b");
let slot_base = storage_base_address_from_felt252(selector_in_felt);
storage_address_from_base(slot_base)
}
}
}
以下是对上述代码相关部分的详细解释:
IHelloStarknet 接口中添加了两个函数:一个用于写入存储变量 b,另一个用于读取它。// *** 新添加的函数 *** //
fn write_to_b_low_level(ref self: TContractState);
fn read_from_b_low_level(self: @TContractState) -> felt252;
SyscallResultTrait(用于解包系统调用结果)。// *** 新添加的导入 *** //
use starknet::syscalls::{
storage_read_syscall,
storage_write_syscall
};
use starknet::SyscallResultTrait;
添加了两个新的导入。
starknet::syscalls 导入 storage_read_syscall 和 storage_write_syscall。这些是底层系统调用,我们将直接使用它们来读取和写入存储,而不是使用 Cairo 的高级 .read() 和 .write() 方法。starknet 导入 SyscallResultTrait,用于在系统调用返回的结果上调用 .unwrap_syscall()。b 的存储槽并将其传递给 storage_write_syscall,以将值 4 写入该槽。// *** 对变量 `b` 的底层写入 *** //
fn write_to_b_low_level(ref self: ContractState) {
// 获取变量 `b` 的槽
let slot = self.get_slot();
// 执行系统调用,将值 4 写入变量 `b`
let _ = storage_write_syscall(0, slot, 4);
}
storage_read_syscall 以读取值,返回一个 Result。对其调用 .unwrap_syscall() 将产生原始的 felt252 值。// *** 对变量 `b` 的底层读取 *** //
fn read_from_b_low_level(self: @ContractState) -> felt252 {
// 获取变量 `b` 的槽
let slot = self.get_slot();
// 执行系统调用,从变量 `b` 读取值
// `.unwrap_syscall()` 解包系统调用结果,失败时 panic
storage_read_syscall(0, slot).unwrap_syscall()
}
到目前为止,我们只读取和写入了在 Storage 结构中显式声明的存储变量。但如果你需要访问一个从未声明的存储槽呢?例如,为了调试而检查原始存储、构建代理合约,或与其他合约的存储布局交互。
这就是任意槽访问变得有用的地方。我们不再通过 selector!() 从变量名派生槽,而是直接将槽值作为原始 felt252 提供。
转换过程与我们之前介绍的相同:将原始 felt252 转换为 StorageBaseAddress,然后转换为 StorageAddress。唯一的区别在于槽值的来源。
下面的合约直接读取和写入存储槽 1:
#[starknet::interface]
pub trait IHelloStarknet<TContractState> {
fn write_to_slot1_low_level(ref self: TContractState);
fn read_from_slot1_low_level(self: @TContractState) -> felt252;
}
#[starknet::contract]
mod HelloStarknet {
// 导入
use starknet::syscalls::{
storage_read_syscall,
storage_write_syscall
};
use starknet::storage_access::{
storage_address_from_base,
storage_base_address_from_felt252
};
use starknet::SyscallResultTrait; // 用于 .unwrap_syscall()
#[storage]
struct Storage {}
#[abi(embed_v0)]
impl HelloStarknetImpl of super::IHelloStarknet<ContractState> {
// 对槽 1 的底层写入
fn write_to_slot1_low_level(ref self: ContractState) {
// 将 `felt252` 转换为 `StorageBaseAddress` 类型
let slot_base = storage_base_address_from_felt252(1);
// 将 `StorageBaseAddress` 转换为 `StorageAddress` 类型
let slot = storage_address_from_base(slot_base);
// 执行系统调用,将值 4 写入槽 1
let _ = storage_write_syscall(0, slot, 4);
}
// 对槽 1 的底层读取
fn read_from_slot1_low_level(self: @ContractState) -> felt252 {
// 将 `felt252` 转换为 `StorageBaseAddress` 类型
let slot_base = storage_base_address_from_felt252(1);
// 将 `StorageBaseAddress` 转换为 `StorageAddress` 类型
let slot = storage_address_from_base(slot_base);
// 执行系统调用,从槽 1 读取值
// `.unwrap_syscall()` 解包系统调用结果,失败时 panic
storage_read_syscall(0, slot).unwrap_syscall()
}
}
}
在这个例子中,在读取和写入存储地址(槽)1 之前,我们将值 1 从 felt252 转换为 StorageBaseAddress 类型,然后转换为两种系统调用中存储地址所需的 StorageAddress 类型。
call_contract_syscall 用于执行低层级的跨合约调用,类似于 Solidity 中的 address.call()。其函数签名如下:
fn call_contract_syscall(
address: ContractAddress,
entry_point_selector: felt252,
calldata: Span<felt252>,
) -> SyscallResult<Span<felt252>>;
它接受三个参数:
address:被调用合约的地址。entry_point_selector:被调用函数的 selector,通过 selector!() 宏从函数名派生而来。calldata:包含传递给函数的参数的 Span<felt252>。该系统调用返回一个 Span<felt252>,因为在系统调用级别,Cairo 不知道被调用函数的返回类型——它可能返回单个值、元组、结构体,或者什么都不返回。
为了统一处理所有这些情况,返回数据被序列化为一个扁平的 felt252 值序列。例如,如果被调用函数返回一个 u256(在内部表示为两个 u128 值(低和高)),结果将以两个 felt252 元素的形式出现在 span 中。然后由调用者负责将返回的 span 反序列化回预期的类型。
let result: u256 = u256 {
low: ret_data_low_felt252,
high: ret_data_high_felt252,
};
下面的代码演示了如何在实践中使用 call_contract_syscall 调用另一个合约的 transfer 函数。注意如何使用 selector!() 宏从函数名派生函数 selector,以及如何将 calldata 作为 Span<felt252> 传递:
use starknet::ContractAddress;
#[starknet::interface]
pub trait IHelloStarknet<TContractState> {
fn call_something(ref self: TContractState, target: ContractAddress, calldata: Span<felt252>);
}
#[starknet::contract]
mod HelloStarknet {
// 导入
use starknet::syscalls::call_contract_syscall;
use starknet::SyscallResultTrait; // 用于 .unwrap_syscall()
use starknet::ContractAddress;
#[storage]
struct Storage {}
#[abi(embed_v0)]
impl HelloStarknetImpl of super::IHelloStarknet<ContractState> {
fn call_something(
ref self: ContractState,
target: ContractAddress,
calldata: Span<felt252>,
) {
// `transfer(felt252,u128)` 的 selector
let selector = selector!("transfer");
// 调用 `target` 合约
let _ = call_contract_syscall(target, selector, calldata).unwrap_syscall();
}
}
}
在 call_something 函数中,selector! 宏将函数名 "transfer" 哈希为一个 felt252 值,该值唯一标识目标合约中的入口点(与 Solidity 中的函数 selector 概念相同,其中 keccak256("transfer(address,uint256)") 产生一个 4 字节的 selector)。一旦我们有了 selector 和 calldata,就将它们与目标地址一起传递给 call_contract_syscall 来执行实际调用。
与 Solidity 的 address.call()(返回一个布尔值指示成功或失败,允许调用者优雅地处理)不同,call_contract_syscall 不提供这种选项。如果被调用的合约因任何原因回滚,失败会立即传播,整个交易都会回滚——无法在链上捕获或恢复。在设计依赖跨合约调用的合约时,这是一个重要的区别。
deploy_syscall 是 Cairo 创建合约的底层方式,类似于 Solidity 的 create2。它在可预测的地址部署合约。
deploy_syscall 的函数签名如下:
fn deploy_syscall(
class_hash: ClassHash,
contract_address_salt: felt252,
calldata: Span<felt252>,
deploy_from_zero: bool,
) -> SyscallResult<(ContractAddress, Span<felt252>)>;
该系统调用接受以下参数:
class_hash:要部署的合约类的标识符,类型为 ClassHash。contract_address_salt:用于确定性地计算已部署合约地址的盐值。类型为 felt252。calldata:包含构造函数参数的 Span<felt252>。deploy_from_zero:一个 bool,指示合约地址是使用调用者地址还是 0 作为部署者地址计算。deploy_syscall 返回一个元组 (ContractAddress, Span<felt252>)。第一个元素是新部署合约的地址。第二个元素是一个 felt252 的 span,表示来自构造函数返回的数据。与 call_contract_syscall 一样,构造函数返回值被展平为一个连续的 felts 序列,从而可以处理多种返回值和不同类型的返回值。
以下代码部署了一个带有接受初始值的构造函数的计数器合约示例:
use starknet::ClassHash;
#[starknet::interface]
pub trait IHelloStarknet<TContractState> {
fn deploy_counter(ref self: TContractState, class_hash: ClassHash, initial: felt252);
}
#[starknet::contract]
mod HelloStarknet {
// 导入
use starknet::ClassHash;
use starknet::SyscallResultTrait; // 用于 .unwrap_syscall()
use starknet::syscalls::deploy_syscall;
#[storage]
struct Storage {}
#[abi(embed_v0)]
impl HelloStarknetImpl of super::IHelloStarknet<ContractState> {
fn deploy_counter(ref self: ContractState, class_hash: ClassHash, initial: felt252) {
// 准备构造函数 calldata 作为 Span<felt252>
let mut calldata: Array<felt252> = ArrayTrait::new();
calldata.append(initial);
let calldata_span = calldata.span();
// 用于确定性地址的盐值
let salt: felt252 = 123;
// 部署合约
let (_contract_address, _) = deploy_syscall(class_hash, salt, calldata_span, false)
.unwrap_syscall();
}
}
}
deploy_counter 函数将构造函数参数序列化为 Span<felt252>,然后调用 deploy_syscall,传入 class hash、盐值和构造函数 calldata。
emit_event_syscall 是在 Starknet 上记录事件的底层原语。它的作用与底层 Solidity 中的 logN() 相同,其中 N 是主题数量。但与 EVM 中日志操作码最多支持 4 个索引主题不同,Starknet 支持多达 50 个键(主题)。
emit_event_syscall 的函数签名如下:
fn emit_event_syscall(
keys: Span<felt252>, data: Span<felt252>,
) -> SyscallResult<()>;
它接受两个参数:
keys:包含用于过滤和搜索事件的索引主题的 Span<felt252>(相当于 Solidity 事件中的 indexed 参数)。data:包含非索引事件数据的 Span<felt252>。emit_event_syscall 返回 SyscallResult<()>:成功时值为 (),失败时返回错误但不会停止执行,允许优雅地处理失败。
考虑一个记录具有 2 个主题的事件的 Solidity 合约。不要太担心代码在做什么,直接跳到 **FOCUS HERE** 注释:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Hello {
// 要发出的事件
event MyEvent(address indexed from, uint256 amount);
function emitMyEvent(bytes32 t0, bytes32 t1) external {
assembly {
// 将非索引数据(amount = 4)写入内存位置 0x00
mstore(0x00, 4)
// **** 关注此处 *****
// _____data_____ _____keys_____
// | | | |
// log2(memPtr, memSize, topic0, topic1)
log2(0x00, 0x20, t0, t1)
}
}
}
上面代码中的 log2 操作码:
0x00 读取 0x20 字节(memSize)的数据:这是事件数据(非索引)。t0 和 t1 作为两个主题(已索引)。从而将非索引数据与主题分开。
下面是使用 emit_event_syscall 的 Cairo 等价实现:
#[starknet::interface]
pub trait IHelloStarknet<TContractState> {
fn emit_my_event(ref self: TContractState, amount: felt252);
}
#[starknet::contract]
mod HelloStarknet {
use starknet::get_caller_address;
use starknet::SyscallResultTrait; // 用于 .unwrap_syscall()
use starknet::syscalls::emit_event_syscall;
#[storage]
struct Storage {}
#[abi(embed_v0)]
impl HelloStarknetImpl of super::IHelloStarknet<ContractState> {
fn emit_my_event(ref self: ContractState, amount: felt252) {
// event MyEvent(address indexed from, uint256 amount);
let t0: felt252 = selector!("MyEvent");
let t1: felt252 = get_caller_address().into(); // 类似 msg.sender
// ---- 键(主题) ----
let mut keys: Array<felt252> = ArrayTrait::new();
keys.append(t0); // topic0 = 事件 selector
keys.append(t1); // topic1 = 已索引的 "from"
// ---- 数据(非索引) ----
let mut data: Array<felt252> = ArrayTrait::new();
data.append(amount);
// 底层系统调用(类似汇编)
// __keys___ ___data___
// | | | |
emit_event_syscall(keys.span(), data.span()).unwrap_syscall();
}
}
}
注意 emit_event_syscall 如何将键和数据明确分开为两个 Span<felt252> 参数,类似于 log2 如何为数据获取内存指针和大小,以及为主题获取单独的参数。
在 Solidity 中,使用内置的 blockhash(blockNumber) 获取区块哈希很简单(仅适用于最近的 256 个区块)。Cairo 通过 get_block_hash_syscall 暴露了类似的功能,但具有不同的可用性约束。
该系统调用可用于范围在 first_v0_12_0_block(网络升级到 Starknet v0.12.0 后产生的第一个 Starknet 区块号)到 current_block - 10 之间的区块号。存在 10 个区块的延迟,因为较新的区块必须最终确定后才能安全地公开其哈希值,这意味着你不能查询最近 10 个区块的哈希。查询该范围之外的任何区块都会抛出 BLOCK_NUMBER_OUT_OF_RANGE 错误。
get_block_hash_syscall 的函数签名如下:
fn get_block_hash_syscall(
block_number: u64,
) -> SyscallResult<felt252>;
它接受一个 u64 类型的参数 block_number,并返回一个表示所请求区块哈希的 felt252。
这是一个检索给定区块号哈希的简单合约:
#[starknet::interface]
pub trait IHelloStarknet<TContractState> {
fn get_block_hash(self: @TContractState, block_number: u64) -> felt252;
}
#[starknet::contract]
mod HelloStarknet {
// 导入
use starknet::SyscallResultTrait; // 用于 .unwrap_syscall()
use starknet::syscalls::get_block_hash_syscall;
#[storage]
struct Storage {}
#[abi(embed_v0)]
impl HelloStarknetImpl of super::IHelloStarknet<ContractState> {
fn get_block_hash(self: @ContractState, block_number: u64) -> felt252 {
// 调用系统调用并解包结果
// 如果 `block_number` 超出有效范围,将抛出异常
let block_hash: felt252 = get_block_hash_syscall(block_number).unwrap_syscall();
// 返回区块哈希作为 felt252
block_hash
}
}
}
在 Solidity 中,诸如 block.timestamp、msg.sender、tx.origin 等值可作为 内置全局变量 使用。
Cairo 不提供这些值的内置全局变量。相反,你必须通过调用 get_execution_info_syscall 系统调用来检索它们。
get_execution_info_syscall 的函数签名如下:
fn get_execution_info_syscall() ->
SyscallResult<Box<starknet::info::ExecutionInfo>>
它不接受参数,返回一个包装在 SyscallResult 中的 Box<ExecutionInfo> 结构体。该结构体包含调用者地址、区块信息、交易信息和其他执行上下文数据的字段,如下所示:

返回的结构体(Box<ExecutionInfo>)可以被解构以提取感兴趣的特定字段,例如调用者地址、区块信息、交易信息等。
以下代码示例从执行上下文中检索调用者地址和区块时间戳:
#[starknet::interface]
pub trait IHelloStarknet<TContractState> {
fn context_info(self: @TContractState);
}
#[starknet::contract]
mod HelloStarknet {
// 导入
use starknet::SyscallResultTrait; // 用于 .unwrap_syscall()
use starknet::syscalls::get_execution_info_syscall;
#[storage]
struct Storage {}
#[abi(embed_v0)]
impl HelloStarknetImpl of super::IHelloStarknet<ContractState> {
fn context_info(self: @ContractState) {
// 调用系统调用
let info = get_execution_info_syscall().unwrap_syscall();
// 提取发送者和时间戳
let (_sender, _timestamp) = (info.caller_address, info.block_info.block_timestamp);
}
}
}
context_info 函数调用 get_execution_info_syscall() 以检索执行上下文。它直接从返回的结构体(info.caller_address)访问 caller_address,并从嵌套的 block_info 字段(info.block_info.block_timestamp)访问 block_timestamp。
注意:为简单起见,此示例提取了值但未返回或使用它们。在实际合约中,你通常会返回这些值,或将其用于访问控制、计时逻辑或其他逻辑。
这与获取执行上下文类似,只是将 TxInfo 字段替换为 v2::TxInfo,后者既包含原始交易字段,也包含在较新交易版本中引入的额外字段。
下面是包含新 v2::TxInfo 的 ExecutionInfo 结构体图片:

v2::TxInfo 中较新的字段(红色高亮)是为了支持 V3 交易而添加的。它们描述了交易允许使用多少网络资源、如何支付费用、某些交易数据存储在哪里,以及交易是否包含账户部署数据。
resource_bounds:设置交易允许消耗的资源限制。你可以将其视为预算:交易声明它愿意支付多少网络工作。在 V3 中,这是描述交易费用的一部分。tip:附加在交易上的额外金额,用于激励更快的交易处理,通过优先处理它们。paymaster_data:包含 paymaster(交易发送者之外的支付交易费用的账户)地址以及要发送给 paymaster 的额外数据。此额外数据不由协议固定,它存在以便 paymaster 可以强制执行自己的赞助交易逻辑。nonce_data_availability_mode:指定交易 nonce 的数据可用性模式。换句话说,它指示交易的 nonce 数据应该在 L1(以太坊)还是 L2(Starknet)上可用。
fee_data_availability_mode:指定用于支付交易费用的账户余额的数据可用性模式。与上面的 nonce 字段一样,它决定与费用相关的账户数据应该在 L1 还是 L2 上可用。account_deployment_data:当你希望将账户合约部署和交易执行合并为单个操作时使用此字段。它包含部署参数(class hash、地址盐值和构造函数 calldata)。当填充时,它会在执行交易之前部署账户合约。当为空时,它会跳过部署并从现有账户合约执行。以下代码示例展示了如何从执行上下文(v2)中检索交易 tip:
#[starknet::interface]
pub trait IHelloStarknet<TContractState> {
fn context_info(self: @TContractState);
}
#[starknet::contract]
mod HelloStarknet {
// 导入
use starknet::syscalls::{get_execution_info_v2_syscall};
use starknet::SyscallResultTrait; // 用于 .unwrap_syscall()
#[storage]
struct Storage {}
#[abi(embed_v0)]
impl HelloStarknetImpl of super::IHelloStarknet<ContractState> {
fn context_info(self: @ContractState) {
// 调用系统调用
let info = get_execution_info_v2_syscall().unwrap_syscall();
// 提取 tip
let _tip = info.tx_info.tip;
}
}
}
get_class_hash_at_syscall在 Starknet 中,每个已部署的合约都与一个 class hash 关联,这是表示其编译合约类的唯一标识符(类似于 EVM 中的字节码哈希)。
get_class_hash_at_syscall 用于检索给定地址的任何合约的 class hash。这类似于 Solidity 中的 address.codehash,它返回某个地址的字节码哈希。
get_class_hash_at_syscall 的函数签名如下:
fn get_class_hash_at_syscall(contract_address: ContractAddress) ->
SyscallResult<ClassHash>
它接受一个参数 contract_address(已部署合约的地址),并返回一个表示该地址合约 class hash 的 ClassHash。
以下代码示例使用 get_class_hash_at_syscall 检索并返回合约的 class hash:
use starknet::{ContractAddress, ClassHash};
#[starknet::interface]
pub trait IHelloStarknet<TContractState> {
fn get_class_hash(self: @TContractState, target: ContractAddress) -> ClassHash;
}
#[starknet::contract]
mod HelloStarknet {
// 导入
use starknet::{ContractAddress, ClassHash};
use starknet::SyscallResultTrait; // 用于 .unwrap_syscall()
use starknet::syscalls::get_class_hash_at_syscall;
#[storage]
struct Storage {}
#[abi(embed_v0)]
impl HelloStarknetImpl of super::IHelloStarknet<ContractState> {
fn get_class_hash(self: @ContractState, target: ContractAddress) -> ClassHash {
// 检索并返回 `target` 处合约的 class hash。
get_class_hash_at_syscall(target).unwrap_syscall()
}
}
}
在 Solidity 中,使用 delegatecall 操作码在调用者的存储上下文中执行另一个合约的代码。Cairo 通过 library_call 系统调用提供了类似的机制,该机制在保持调用者存储上下文的同时,调用在另一个 class hash(合约编译代码的标识符)中定义的函数。
这意味着尽管执行的代码来自目标合约,但任何存储的读写都会影响调用合约的存储,而不是目标合约本身的存储。
library_call_syscall 的函数签名如下:
fn library_call_syscall(
class_hash: ClassHash, function_selector: felt252, calldata: Span<felt252>,
) -> SyscallResult<Span<felt252>>;
它接受以下参数:
class_hash:包含要调用函数的类的哈希(合约代码哈希)。类型为 ClassHash。function_selector:要执行函数的 selector。类型为 felt252。calldata:传递给该函数的参数数组。类型为 Span<felt252>。它返回被调用函数的返回数据,包装在 SyscallResult 中以处理可能的错误。
下面的代码示例展示了如何使用 library_call_syscall:
use starknet::ClassHash;
#[starknet::interface]
pub trait IHelloStarknet<TContractState> {
fn lib_call(
ref self: TContractState,
class_hash: ClassHash,
selector: felt252,
calldata: Span<felt252>
);
}
#[starknet::contract]
mod HelloStarknet {
// 导入
use starknet::ClassHash;
use starknet::SyscallResultTrait; // 用于 .unwrap_syscall()
use starknet::syscalls::library_call_syscall;
#[storage]
struct Storage {}
#[abi(embed_v0)]
impl HelloStarknetImpl of super::IHelloStarknet<ContractState> {
fn lib_call(ref self: ContractState, class_hash: ClassHash, selector: felt252, calldata: Span<felt252>) {
// 调用系统调用
let _ = library_call_syscall(class_hash, selector, calldata).unwrap_syscall();
}
}
}
与 call_contract_syscall 一样,如果 library_call_syscall 失败(例如,目标函数回滚或 class hash 无效),整个交易将回滚。
library_call_syscall 存储布局就像 Solidity 中的 delegatecall 一样,Cairo 中的 library_call_syscall 在调用合约的存储上下文中执行目标合约中的函数。区别在于这些存储位置是如何寻址的。
在 Solidity 中,存储是基于槽的:
在 Cairo 中,存储是基于名称的:
sn_keccak("variable_name") 派生的。这意味着在使用 library_call_syscall 时,调用合约和目标合约必须为任何共享状态使用相同的存储变量名。如果不这样做,目标合约将哈希一个不同的名称,计算出一个不同的地址,最终读取或写入错误的存储位置。
在以太坊中,合约升级通常通过代理模式完成。代理持有所有存储,而实现合约包含逻辑。要升级,代理会更新其委托调用的实现合约地址。
Starknet 采用不同的方法。合约可以直接使用 replace_class_syscall 原生升级自身,而不是通过代理路由。
该系统调用用于将其当前的 class hash(运行时代码)替换为新的 class hash,同时保持其存储不变。合约的“主体”可以替换,而其“内存”(存储)保持不变。
replace_class_syscall 的函数签名如下:
fn replace_class_syscall(
class_hash: ClassHash,
) -> SyscallResult<()>;
它接受一个参数 class_hash(替换当前实现的新实现的 class hash,类型 ClassHash),成功时什么也不返回。
下面的代码示例展示了如何使用它:
use starknet::ClassHash;
#[starknet::interface]
pub trait IHelloStarknet<TContractState> {
fn upgrade(ref self: TContractState, class_hash: ClassHash);
}
#[starknet::contract]
mod HelloStarknet {
// 导入
use starknet::ClassHash;
use starknet::SyscallResultTrait; // 用于 .unwrap_syscall()
use starknet::syscalls::replace_class_syscall;
#[storage]
struct Storage {}
#[abi(embed_v0)]
impl HelloStarknetImpl of super::IHelloStarknet<ContractState> {
fn upgrade(ref self: ContractState, class_hash: ClassHash) {
// 访问控制逻辑,以便只有授权方可升级
// 调用系统调用
let _ = replace_class_syscall(class_hash).unwrap_syscall();
}
}
}
在实践中,直接调用 replace_class_syscall 而不加保护是不安全的。升级会改变合约的逻辑,因此应该以某种方式保护。一种方法是确保只有所有者才能升级合约。
升级合约前需要注意的事项:
replace_class_syscall 后,当前从旧类执行的代码将继续运行完毕。新类代码将从下一次调用此合约的交易开始使用,或者如果在此合约之后(替换发生后)在同一交易中通过 call_contract_syscall 再次调用此合约,则新代码将立即生效。keccak_syscall 提供对 Cairo 中 keccak 哈希函数的直接访问。其函数签名如下:
fn keccak_syscall(
input: Span<u64>,
) -> SyscallResult<u256>;
它接受一个参数 input,即要哈希的数据,作为 64 位小端序单词的 Span<u64>,并返回一个 u256 哈希值(小端序格式)。
小端和大端格式示例:假设有一个 4 字节的数字
0x12345678。
- 大端序(最高有效字节在前):
12 34 56 78- 小端序(最低有效字节在前):
78 56 34 12所以在小端序中,“最后”一个字节排在第一位。
输入必须根据 Cairo 版本的 keccak “pad10*1” 规则进行预填充:
0x01。0x00 字节,以达到所需的块大小。0x80 以指示填充结束。填充后,总长度必须是 1088 位的倍数(即 17 × u64 字),否则将出现运行时错误,提示“Invalid input length”:

为了说明输入数据构建方式的差异,我们将先查看 Solidity 版本,然后将其与 Cairo 的方法进行比较。
下面的底层 Solidity 合约返回预映像 "hello" 的 Keccak 哈希:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract KeccakExample {
function hashHello() external pure returns (bytes32 hash) {
assembly {
// "hello" 的 ASCII 码长度为 5 字节 (0x68656c6c6f)
// 将字节存储在内存中
mstore(0x00, 0x68656c6c6f)
// 对内存中最后 5 个字节计算 keccak256
// keccak256(memory_ptr, length)
hash := keccak256(0x1b, 0x05)
}
}
}
在汇编块内部,mstore 将输入值 hello 写入一个完整的 32 字节内存字中,右对齐,这意味着前 27 个字节为零,最后 5 个字节包含 "hello",如下所示:
// ______________前 27 个字节为零______________ _hello__
// | | | |
0x 000000000000000000000000000000000000000000000000000000 68656c6c6f
由于这种布局,调用 keccak256(0x1b, 0x05) 从偏移量 0x1b(27)开始哈希,恰好哈希 5 个字节,仅针对 "hello" 字节,忽略零填充。很简单,对吧?
Cairo 的等价实现需要更深入的研究,因为 keccak_syscall 期望其输入根据 Keccak pad10*1 规则预填充为 1088 位(17 × 64 位字)的倍数。
在哈希之前,"hello" 的 ASCII 字节(0x68656c6c6f)按如下方式转换:
0x01。0x68 65 6c 6c 6f 01
零填充——用 0x00 字节填充,直到总长度为 136 字节(1088 位 ÷ 8)。
最终位——追加一个单字节 0x80 以标记填充结束。
0x68 65 6c 6c 6f 01 ...00... 80
绿色部分表示实际消息(要哈希的预映像)。红色部分标记了 Keccak pad10*1 规则所需的起始
0x01和结束0x80填充字节。蓝色部分显示了填补间隙的零填充字节,使总长度达到完整的 1088 位块。
填充后,总长度必须是 1088 位(136 字节)的倍数。
在此示例中,消息只有 5 个字节长,因此填充后的块扩展到完整的 136 字节。如果预映像更大,比如 154 字节,你将需要两个 136 字节的块(总共 272 字节),因为 154 字节不能放入一个 136 字节的块中。
现在消息(hello)已填充至 136 字节,下一步是将其分割为 17 个小端序 u64 字(17 × 64 位 = 1088 位),然后这些字成为 keccak_syscall 的输入 span。
上述 Solidity 示例的 Cairo 等价实现:
#[starknet::interface]
pub trait IHelloStarknet<TContractState> {
fn hasher(ref self: TContractState) -> u256;
}
#[starknet::contract]
mod HelloStarknet {
// 导入
use starknet::SyscallResultTrait; // 用于 .unwrap_syscall()
use starknet::syscalls::keccak_syscall;
#[storage]
struct Storage {}
#[abi(embed_v0)]
impl HelloStarknetImpl of super::IHelloStarknet<ContractState> {
fn hasher(ref self: ContractState) -> u256 {
// 将输入编码为 span<u64>
let mut input: Array<u64> = ArrayTrait::new();
input.append(0x0000016f6c6c6568);
input.append(0x0000000000000000);
input.append(0x0000000000000000);
input.append(0x0000000000000000);
input.append(0x0000000000000000);
input.append(0x0000000000000000);
input.append(0x0000000000000000);
input.append(0x0000000000000000);
input.append(0x0000000000000000);
input.append(0x0000000000000000);
input.append(0x0000000000000000);
input.append(0x0000000000000000);
input.append(0x0000000000000000);
input.append(0x0000000000000000);
input.append(0x0000000000000000);
input.append(0x0000000000000000);
input.append(0x8000000000000000);
// 调用系统调用
keccak_syscall(input.span()).unwrap_syscall()
}
}
}
keccak_syscall 返回一个小端序的 u256。
为了直接与 Solidity 的 bytes32 结果比较,我们必须将其转换为大端序十六进制格式,这将在后面的章节(哈希函数)中介绍。
总结
在 Cairo 中使用 keccak_syscall 时:
u64 字提供。bytes32,则反转结果字节为大端序。与 Solidity 通过预编译提供 SHA-256 哈希算法一样,Starknet 也通过系统调用提供该功能。两者都提供相同的哈希函数,但抽象级别不同。
sha256 作为 EVM 预编译暴露,接受任意长度的输入,函数自动处理正确的填充并在底层处理所有需要的块。结果以大端序的 bytes32 值返回。sha256_process_block_syscall 可用,它在更低的级别上运行:
Sha256StateHandle,表示为盒子化的八个 32 位字数组。sha256_process_block_syscall 的函数签名如下:
fn sha256_process_block_syscall(
state: Sha256StateHandle,
input: Box<[u32; 16]>
) -> SyscallResult<core::sha256::Sha256StateHandle>;
它接受两个参数:
state:一个 Sha256StateHandle,表示内部哈希状态(八个 32 位字,[u32; 8])。对于第一个块,该状态被初始化为 SHA-256 初始化常量。处理每个块后,更新后的状态成为新的内部状态,并传递给后续调用。input:一个盒子化的 16 字数组([u32; 16]),每个字为 32 位。这表示要哈希的消息的一个 512 位块(64 字节)。短于 64 字节的输入必须根据 SHA-256 标准手动填充:
0x80,0x00),以及例如,消息 hello 的填充输入将是:

消息 cat 的填充输入将是:

它返回:
SyscallResult<Sha256StateHandle>:处理块后更新的内部状态。如果还有更多块,这个新状态应输入到下一轮,或者在必要时被视为最终摘要(在应用 SHA-256 填充之后)。为什么不能直接调用 sha256_process_block
在撰写本文时,用户合约不能直接调用 sha256_process_block 系统调用,因为其状态 Handle 声明为受限可见性:
/// SHA-256 哈希的状态 Handle。
pub(crate) extern type Sha256StateHandle;
pub(crate) 可见性意味着 Sha256StateHandle 仅在核心库的 crate 内部可访问,用户合约无法使用。换句话说,尽管系统调用存在,但其状态 Handle 类型未公开暴露,阻止了我们直接与其交互。
尽管如此,核心库确实提供了在内部处理此问题的高级函数:
compute_sha256_byte_arraycompute_sha256_u32_array然而,我们不会在本文中介绍这些,因为我们的重点专门放在系统调用上。
到目前为止,我们已经研究了 Starknet 内部使用的系统调用。但是,Starknet 合约也可以与以太坊(L1)通信。这是通过 send_message_to_l1_syscall 完成的,它允许 Starknet(L2)合约向 L1 合约发送消息或传递数据。
实际用例包括将代币桥接到以太坊、通知 L1 合约在 Starknet 上发生的事件,或完成跨链操作。
send_message_to_l1_syscall 的函数签名如下:
fn send_message_to_l1_syscall(
to_address: felt252, payload: Span<felt252>,
) -> SyscallResult<()>;
它接受两个参数:
to_address:目标 L1 合约的 20 字节以太坊地址(作为 felt252)。payload:表示消息的 Span<felt252>。它可以包含 L1 合约能理解的任何编码数据(函数选择器、参数等)。它什么也不返回。
需要注意的是,系统调用本身并不直接调用以太坊合约上的函数。相反,流程通过部署在 L1 上的 Starknet 核心合约工作:
L2 上的 Cairo 合约调用 send_message_to_l1_syscall,将有效负载推送到消息桥接器中。
在 L1 上,目标 Solidity 合约(你在 to_address 中指定的地址)通过调用 Starknet 核心合约(一个部署在以太坊上的 Solidity 合约,它验证 Starknet 状态更新、维护 L2 ↔ L1 消息队列,并暴露 L1 合约用来向 Starknet 发送和从 Starknet 接收消息的函数)中的函数 consumeMessageFromL2 来消耗消息:
consumeMessageFromL2(l2_sender, payload)
l2_sender:发送消息的 Starknet L2 地址(作为 uint256)。
send_message_to_l1_syscall 的确切 L2 合约地址。uint256,具有与 L2 felt 地址相同的数值(无需额外编码)。msg.sender 是否与目标地址匹配。因此,你必须从 to_address 中指定的确切 L1 合约地址调用 consumeMessageFromL2。从 EOA 或不同的合约调用将导致交易回滚。payload:L2 合约发送的确切单词数组(作为 uint256[])——顺序相同、值相同、长度相同。然后,consumeMessageFromL2 函数将消息标记为已消耗,防止多次处理(重放保护)。
Solidity 合约随后可以触发任何后续逻辑——例如更新状态、铸造代币或调用其他函数。
让我们创建一个简单的 Cairo 合约,向 L1 发送消息,然后测试它确实向 L1 发送了消息。
我们首先创建一个新的 Scarb 项目,命名为 l1_msg:
scarb new l1_msg
然后将 lib.cairo 中的生成代码替换为下面的代码。此合约定义一个函数 greetings_to_l1,向 L1 发送消息 Hello from RareSkills!。
#[starknet::interface]
pub trait IHelloStarknet<TContractState> {
fn greetings_to_l1(ref self: TContractState);
}
#[starknet::contract]
mod HelloStarknet {
// 导入
use starknet::syscalls::send_message_to_l1_syscall;
use starknet::SyscallResultTrait; // 用于 .unwrap_syscall()
#[storage]
struct Storage {}
#[abi(embed_v0)]
impl HelloStarknetImpl of super::IHelloStarknet<ContractState> {
fn greetings_to_l1(ref self: ContractState) {
// payload 必须是 Span<felt252>
let mut payload: Array<felt252> = ArrayTrait::new();
payload.append('Hello from RareSkills!'); // 你想发送的任何数据
// 调用系统调用
send_message_to_l1_syscall(0x1234, payload.span()).unwrap_syscall();
}
}
}
这里我们向接收者的 L1 地址 0x1234 发送消息 Hello from RareSkills!。
现在为了测试 Cairo 合约正确地向 L1 发送消息,我们将使用 snforge_std 库中的辅助函数。导航到 tests/test_contract.cairo 并将代码替换为以下内容:
use snforge_std::{
ContractClassTrait, DeclareResultTrait, declare,
// ======== 3 个新辅助函数 =========
spy_messages_to_l1,
MessageToL1,
MessageToL1SpyAssertionsTrait,
};
use starknet::{ContractAddress, EthAddress};
use l1_msg::{IHelloStarknetDispatcher, IHelloStarknetDispatcherTrait};
fn deploy_contract(name: ByteArray) -> ContractAddress {
let contract = declare(name).unwrap().contract_class();
let (address, _) = contract.deploy(@ArrayTrait::new()).unwrap();
address
}
#[test]
fn test_send_message_to_l1() {
let main_contract_addr = deploy_contract("HelloStarknet");
let main_dispatcher = IHelloStarknetDispatcher { contract_address: main_contract_addr };
// 开始监控 L2->L1 消息
let mut spy = spy_messages_to_l1();
// 调用 Cairo 函数
let _ = main_dispatcher.greetings_to_l1();
// 选择任意 L1 地址(EthAddress 在底层包装了一个 felt252)
let to: EthAddress = 0x1234.try_into().unwrap();
// 构建我们期望的消息
let expected_payload = array!['Hello from RareSkills!'];
// 断言消息已发送(从我们的合约发送到预期的 L1 地址并带有 payload)
spy.assert_sent(
@array![\
(main_contract_addr, MessageToL1 { to_address: to, payload: expected_payload }),\
],
);
}
测试使用 spy_messages_to_l1() 作为 L2 → L1 消息队列的观察者,调用 Cairo 合约函数 greetings_to_l1,然后断言消息已成功从合约发送到预期的 L1 地址并带有正确的 payload。
练习:
scarb test 运行测试并确认通过。expected_payload 修改为 Hi from RareSkills!。meta_tx_v0_syscall 是 Starknet v0.14.0 中引入的向后兼容系统调用。
Starknet 交易 经历了好几个版本,v0 是第一个,v3 是当前版本。当 v0、v1 和 v2 交易被弃用以支持 v3 时,为 v0 交易格式编写的账户合约便不能再被直接调用。
该系统调用弥补了这一差距,允许 v3 交易调用一个为期望 v0 交易格式而编写的账户合约。
meta_tx_v0_syscall 的函数签名如下:
fn meta_tx_v0_syscall(
address: ContractAddress,
entry_point_selector: felt252,
calldata: Span<felt252>,
signature: Span<felt252>,
) -> SyscallResult<Span<felt252>>;
该系统调用接受以下参数:
address:目标版本 0 账户的合约地址。entry_point_selector:目标账户上要调用的函数的 selector。在 v0 中,这也是通过 sn_keccak(function_name) 计算得出的。calldata:要传递给函数的参数,作为 felt252 值的 span。signature:作为 felt252 值 span 的交易签名。与 v3 交易(签名会自动从交易上下文派生)不同,这里必须显式计算并传递签名。生成方法:invoke_v0_tx_hash = h(
"invoke",
version,
contract_address,
entry_point_selector,
h(calldata),
max_fee,
chain_id
);
(r, s) 签名值,然后将它们作为 signature span 传递。它返回被调用函数的返回数据作为 Span<felt252>。
当你调用 meta_tx_v0_syscall 时,Starknet 会修改执行上下文,使其看起来像一个旧风格的 v0 交易:
signature 参数传入的签名。下图显示了在调用系统调用前后每个字段的具体变化:

这些更改不仅适用于被调用的合约,也适用于它内部调用的任何合约。它确保整个调用链在“v0 兼容模式”下运行。
注意:系统调用文档明确说明它“只应用于允许支持旧的版本 0 账户合约,不应为其他目的使用。”如果你需要调用较新版本的账户合约,请始终使用
call_contract_syscall。
- 原文链接: rareskills.io/post/cairo...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!
作者暂未设置收款二维码