Starknet中的系统调用

本文详细介绍了Starknet中的系统调用(syscalls),包括存储读写、跨合约调用、部署合约、事件日志、区块哈希、执行上下文、类哈希、库调用、合约升级、Keccak-256、SHA-256、L1消息发送以及meta_tx_v0。每个syscall都与Solidity中的对应操作进行对比,并提供了完整的函数签名、参数说明和代码示例。文章还解释了Cairo中存储地址的哈希计算方式,以及如何通过低层级syscall访问任意存储槽。

掌握 Cairo

Starknet 中的系统调用

在 Solidity 中,像读写存储、合约间调用或发送消息等底层操作,是通过使用 Yul 操作码(如 callsloadsstore)在内联汇编中直接执行的。这些操作码绕过了 Solidity 的高级抽象。

Cairo 通过系统调用(syscalls)引入了类似的概念。系统调用是从 Starknet 合约到 Starknet OS 的底层调用,用于执行普通 Cairo 代码无法自行完成的操作,例如调用其他合约、部署合约、发出事件、读取执行上下文或访问存储。

在本文中,我们将介绍不同的可用系统调用以及它们在 Starknet 合约中的使用方法。

系统调用及其 Solidity 对应物

以下列表包含了当前所有可用的 Starknet 系统调用,以及它们在 Solidity 中最接近的对应物(如适用)。

  • storage_read_syscallstorage_write_syscall 分别对应 Solidity 汇编中的 sloadsstore
  • 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_syscallget_execution_info_syscallget_execution_info_v2_syscallsend_message_to_l1_syscallsha256_process_block_syscallmeta_tx_v0_syscall 在 Solidity 中没有直接对应物。

接下来,让我们看看上述系统调用在实践中是如何使用的。

用于读写存储的系统调用

与 Cairo 的 .read().write() 方法不同(它们只能读写 Storage 结构中声明的存储变量),storage_read_syscallstorage_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_domainaddress

  • value:要写入存储地址的值(felt252)。

它返回 SyscallResult<()>,其中 () 表示成功时没有值,重要的是成功或失败。

storage_read_syscallstorage_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)
        }

    }
}

以下是对上述代码的逐步解释:

  1. 我们导入所需的类型和辅助函数:
// *** 导入 *** //
use starknet::storage_access::{
    StorageAddress,
    storage_address_from_base,
    storage_base_address_from_felt252,
};

这些辅助函数允许我们将原始的 felt252 转换为存储读写系统调用所需的有效 StorageAddress 类型。

  1. get_slot 函数中,selector!("b")sn_keccak("b") 计算为 felt252
// `selector!` 宏可用于计算 {sn_keccak}。
let selector_in_felt = selector!("b");
  1. 使用 storage_base_address_from_felt252 方法将结果转换为 StorageBaseAddress
// 将 `felt252` 转换为 `StorageBaseAddress` 类型
let slot_base = storage_base_address_from_felt252(selector_in_felt);

felt252 只是一个数字,Cairo 不会自动将数字视为存储地址。将其转换为 StorageBaseAddress 不会改变底层值。相反,它改变了 Cairo 对该值的处理方式,即现在 Cairo 知道这个数字应该用作存储地址,而不是普通整数。换句话说,转换并不修改数据本身,而是为其分配一个存储地址类型,以便在存储操作中使用。

  1. 然后使用 storage_address_from_base 将该基础地址转换为 StorageAddressStorageAddress 是存储系统调用期望的类型。
// 将 `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)
        }
    }
}

以下是对上述代码相关部分的详细解释:

  1. IHelloStarknet 接口中添加了两个函数:一个用于写入存储变量 b,另一个用于读取它。
// *** 新添加的函数 *** //
fn write_to_b_low_level(ref self: TContractState);
fn read_from_b_low_level(self: @TContractState) -> felt252;
  1. 存储系统调用和 SyscallResultTrait(用于解包系统调用结果)。
// *** 新添加的导入 *** //
use starknet::syscalls::{
    storage_read_syscall,
    storage_write_syscall
};

use starknet::SyscallResultTrait;

添加了两个新的导入。

  • 首先,从 starknet::syscalls 导入 storage_read_syscallstorage_write_syscall。这些是底层系统调用,我们将直接使用它们来读取和写入存储,而不是使用 Cairo 的高级 .read().write() 方法。
  • 其次,从 starknet 导入 SyscallResultTrait,用于在系统调用返回的结果上调用 .unwrap_syscall()
  1. 在写入函数中,计算 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);
}
  1. 在读取函数中,计算相同的槽并将其传递给 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 之前,我们将值 1felt252 转换为 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)的数据:这是事件数据(非索引)。
  • 使用 t0t1 作为两个主题(已索引)。

从而将非索引数据与主题分开。

下面是使用 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.timestampmsg.sendertx.origin 等值可作为 内置全局变量 使用。

Cairo 不提供这些值的内置全局变量。相反,你必须通过调用 get_execution_info_syscall 系统调用来检索它们。

get_execution_info_syscall 的函数签名如下:

fn get_execution_info_syscall() ->
    SyscallResult<Box<starknet::info::ExecutionInfo>>

它不接受参数,返回一个包装在 SyscallResult 中的 Box<ExecutionInfo> 结构体。该结构体包含调用者地址、区块信息、交易信息和其他执行上下文数据的字段,如下所示:

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

注意:为简单起见,此示例提取了值但未返回或使用它们。在实际合约中,你通常会返回这些值,或将其用于访问控制、计时逻辑或其他逻辑。

获取执行上下文 v2

这与获取执行上下文类似,只是将 TxInfo 字段替换为 v2::TxInfo,后者既包含原始交易字段,也包含在较新交易版本中引入的额外字段。

下面是包含新 v2::TxInfoExecutionInfo 结构体图片:

ExecutionInfo v2 结构体图片

v2::TxInfo 中较新的字段(红色高亮)是为了支持 V3 交易而添加的。它们描述了交易允许使用多少网络资源、如何支付费用、某些交易数据存储在哪里,以及交易是否包含账户部署数据。

  • resource_bounds:设置交易允许消耗的资源限制。你可以将其视为预算:交易声明它愿意支付多少网络工作。在 V3 中,这是描述交易费用的一部分。
  • tip:附加在交易上的额外金额,用于激励更快的交易处理,通过优先处理它们。
  • paymaster_data:包含 paymaster(交易发送者之外的支付交易费用的账户)地址以及要发送给 paymaster 的额外数据。此额外数据不由协议固定,它存在以便 paymaster 可以强制执行自己的赞助交易逻辑。
  • nonce_data_availability_mode:指定交易 nonce 的数据可用性模式。换句话说,它指示交易的 nonce 数据应该在 L1(以太坊)还是 L2(Starknet)上可用。
    • L1 数据可用性模式为 0(默认)。
    • L2 数据可用性模式为 1。
  • 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;
        }
    }
}

获取 Class Hash - 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()
        }

    }
}

library_call 系统调用

在 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 时,调用合约和目标合约必须为任何共享状态使用相同的存储变量名。如果不这样做,目标合约将哈希一个不同的名称,计算出一个不同的地址,最终读取或写入错误的存储位置。

升级合约 - replace_class 系统调用

在以太坊中,合约升级通常通过代理模式完成。代理持有所有存储,而实现合约包含逻辑。要升级,代理会更新其委托调用的实现合约地址。

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 而不加保护是不安全的。升级会改变合约的逻辑,因此应该以某种方式保护。一种方法是确保只有所有者才能升级合约。

升级合约前需要注意的事项:

  • 存储被保留,只有逻辑被替换。
  • 在以太坊中,升级需要跨版本维护相同的顺序存储槽布局。在 Cairo 中,由于存储槽是从变量名派生的,新类必须为存储变量使用相同的变量名,以避免读取或写入错误的存储位置。
  • 调用 replace_class_syscall 后,当前从旧类执行的代码将继续运行完毕。新类代码将从下一次调用此合约的交易开始使用,或者如果在此合约之后(替换发生后)在同一交易中通过 call_contract_syscall 再次调用此合约,则新代码将立即生效。

Keccak-256 系统调用

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” 规则进行预填充:

  1. 起始标记:在预映像之后立即追加一个单字节 0x01
  2. 零填充:添加所需数量的 0x00 字节,以达到所需的块大小。
  3. 结束标记:追加最后一个字节 0x80 以指示填充结束。

填充后,总长度必须是 1088 位的倍数(即 17 × u64 字),否则将出现运行时错误,提示“Invalid input length”:

显示“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)按如下方式转换:

  1. 追加起始位——在消息之后立即添加一个单字节 0x01
0x68 65 6c 6c 6f 01
  1. 零填充——用 0x00 字节填充,直到总长度为 136 字节(1088 位 ÷ 8)。

  2. 最终位——追加一个单字节 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 时:

  • 手动使用 pad10*1 规则将预映像填充为 1088 位块的倍数。
  • 将填充后的消息作为 17 个小端序 u64 字提供。
  • 如果需要与 Solidity 兼容的 bytes32,则反转结果字节为大端序。

Sha-256 系统调用

与 Solidity 通过预编译提供 SHA-256 哈希算法一样,Starknet 也通过系统调用提供该功能。两者都提供相同的哈希函数,但抽象级别不同。

  • 在 Solidity 中,sha256 作为 EVM 预编译暴露,接受任意长度的输入,函数自动处理正确的填充并在底层处理所有需要的块。结果以大端序的 bytes32 值返回。
  • 在 Cairo 中,SHA-256 哈希通过 sha256_process_block_syscall 可用,它在更低的级别上运行:
    • 它一次只操作一个 512 位(64 字节)块。
    • 它接受一个初始的 SHA-256 状态和输入,以计算下一个 SHA-256 状态。
    • 输出返回输入的下一个 SHA-256 状态,作为一个小端序的 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),以及
    • 在最后一个字节中追加(消息长度 * 8),以完成完整的 512 位块。

例如,消息 hello 的填充输入将是:

调用 sha256_process_block_syscall 时 “hello” 单词的填充输入

消息 cat 的填充输入将是:

调用 sha256_process_block_syscall 时 “hello” 单词的填充输入

它返回:

  • 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_array
  • compute_sha256_u32_array

然而,我们不会在本文中介绍这些,因为我们的重点专门放在系统调用上。

向以太坊 L1 发送消息的系统调用

到目前为止,我们已经研究了 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 核心合约工作:

  1. L2 上的 Cairo 合约调用 send_message_to_l1_syscall,将有效负载推送到消息桥接器中。

  2. 在 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 合约地址。
    • 在 L1 上将其表示为 uint256,具有与 L2 felt 地址相同的数值(无需额外编码)。
    • Starknet 核心合约会验证 msg.sender 是否与目标地址匹配。因此,你必须从 to_address 中指定的确切 L1 合约地址调用 consumeMessageFromL2。从 EOA 或不同的合约调用将导致交易回滚。
  • payload:L2 合约发送的确切单词数组(作为 uint256[])——顺序相同、值相同、长度相同。
  1. 然后,consumeMessageFromL2 函数将消息标记为已消耗,防止多次处理(重放保护)。

  2. 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。

练习:

  1. 使用 scarb test 运行测试并确认通过。
  2. 将测试中的 expected_payload 修改为 Hi from RareSkills!
  3. 重新运行测试,观察当 payload 不匹配时断言如何失败。

meta_tx_v0 系统调用

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 交易(签名会自动从交易上下文派生)不同,这里必须显式计算并传递签名。生成方法:
  1. 使用 Pedersen 哈希交易数据,得到版本 0 交易哈希:
invoke_v0_tx_hash = h(
    "invoke",
    version,
    contract_address,
    entry_point_selector,
    h(calldata),
    max_fee,
    chain_id
);
  1. 使用账户的私钥对生成的哈希进行签名,得到 (r, s) 签名值,然后将它们作为 signature span 传递。

它返回被调用函数的返回数据作为 Span<felt252>

该系统调用的内部工作原理

当你调用 meta_tx_v0_syscall 时,Starknet 会修改执行上下文,使其看起来像一个旧风格的 v0 交易:

  1. 签名被替换为你通过 signature 参数传入的签名。
  2. 调用者变为地址 0(OS/协议本身)。
  3. 交易版本变为 0(假装是一个旧风格交易)。
  4. 交易哈希重新计算为版本 0 交易哈希。

下图显示了在调用系统调用前后每个字段的具体变化:

显示 meta_tx_v0 系统调用后 v3 转换为 v0 交易的图片

这些更改不仅适用于被调用的合约,也适用于它内部调用的任何合约。它确保整个调用链在“v0 兼容模式”下运行。

注意:系统调用文档明确说明它“只应用于允许支持旧的版本 0 账户合约,不应为其他目的使用。”如果你需要调用较新版本的账户合约,请始终使用 call_contract_syscall

  • 原文链接: rareskills.io/post/cairo...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
RareSkills
RareSkills
https://www.rareskills.io/