本文介绍了 Starknet Foundry 中用于 Cairo 智能合约测试的常用 cheatcode,包括 caller_address、block_timestamp、store、load 以及 revert 测试等,并对比了与 Solidity Foundry 类似功能的异同。
在 Foundry 中,“cheatcode” 是一种允许合约测试控制环境变量(例如,调用者地址、当前时间戳等)的机制。
在本文中,你将学习如何使用 Starknet Foundry 中最常用的 cheatcodes 来测试 Cairo 智能合约。
caller_address Cheatcodes在 Starknet 智能合约中,get_caller_address() 返回当前与合约中的函数交互的账户地址,类似于 Ethereum 中的 msg.sender。合约依赖它来进行访问控制、权限或其他自定义用途。例如,以下代码检查调用者是否为合约所有者,然后才允许执行:
// 获取谁在调用这个函数
let caller = get_caller_address();
assert(caller == self.owner.read(), 'Only owner');
在测试期间,当函数像上面的代码一样检查调用者地址时,我们需要控制 get_caller_address() 的返回值,以便在不使用实际账户(钱包地址)的情况下正确测试访问控制。这就是 caller_address cheatcodes 的用武之地。
Starknet Foundry 的 caller_address cheatcodes 允许我们通过模拟来自任何我们需要地址的调用来实现这一点。它们的工作方式与 Solidity Foundry 中的 prank 函数类似。可用的函数有:
Starknet Foundry caller_address cheatcodes |
它的作用 | Solidity Foundry 等效项 |
|---|---|---|
cheat_caller_address(target, caller_address, span) |
模拟目标合约的调用者,受 CheatSpan 限制 |
没有直接等效项 - Solidity 的 vm.prank(caller_address) 会全局影响下一次调用,而不是特定于目标 |
start_cheat_caller_address(target, caller_address) |
开始模拟目标合约的调用者 | 没有直接等效项 - Solidity 没有特定于目标的 pranking |
start_cheat_caller_address_global(caller_address) |
开始全局模拟调用者,包括目标合约及其调用的任何合约 | vm.startPrank(caller_address) |
stop_cheat_caller_address(target) |
停止模拟目标合约的调用者 | 没有直接等效项 |
stop_cheat_caller_address_global() |
停止全局调用者模拟 | vm.stopPrank() |
为了演示这些 caller_address cheatcodes 在实践中如何工作,初始化一个新的 Scarb 项目 (scarb new cheatcodes) 并选择 Starknet Foundry 作为测试运行器。
在 src/lib.cairo 文件中,有一个由 Scarb 生成的默认余额管理合约,它允许我们增加和检索合约存储中的余额。
更新此样板合约,以在 increase_balance() 函数中包含基于所有者的访问控制。更新后的合约将存储一个 owner 地址和一个只能由所有者修改的 balance。increase_balance() 函数将使用 get_caller_address() 来检查谁在调用它,并且只允许所有者继续。更新后的合约还将包含 get_owner() 函数来检查所有者的地址,这在编写测试时将非常有用。
复制下面的更新后的合约并将其粘贴到 src/lib.cairo 中:
use starknet::ContractAddress;
/// Interface representing `HelloContract`.
/// This interface allows modification and retrieval of the contract balance.
#[starknet::interface]
pub trait IHelloStarknet<TContractState> {
/// Increase contract balance.
fn increase_balance(ref self: TContractState, amount: u256);
/// Retrieve contract balance.
fn get_balance(self: @TContractState) -> u256;
fn get_owner(self: @TContractState) -> ContractAddress;
}
/// Simple contract for managing balance.
#[starknet::contract]
mod HelloStarknet {
use starknet::{ContractAddress, get_caller_address};
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
#[storage]
struct Storage {
owner: ContractAddress,
balance: u256,
}
#[constructor]
fn constructor(ref self: ContractState, owner: ContractAddress) {
// Set the owner when the contract is deployed
self.owner.write(owner);
}
#[abi(embed_v0)]
impl HelloStarknetImpl of super::IHelloStarknet<ContractState> {
fn increase_balance(ref self: ContractState, amount: u256) {
let caller = get_caller_address();
//NEWLY ADDED
//checks only the owner can increase balance
assert(caller == self.owner.read(), 'Only owner');
assert(amount != 0, 'Amount cannot be 0');
// Update the balance by adding the new amount
self.balance.write(self.balance.read() + amount);
}
fn get_balance(self: @ContractState) -> u256 {
self.balance.read()
}
fn get_owner(self: @ContractState) -> ContractAddress {
self.owner.read()
}
}
}
这种基于所有者的访问控制模式在 DeFi 协议中很常见,在这些协议中,特定的地址有权执行某些功能,例如提取资金。
由于 increase_balance() 仅限于合约的所有者,因此我们需要使用 caller_address cheatcode 来模拟来自所有者地址的调用。
cheat_caller_address 模拟地址cheat_caller_address cheatcode 允许我们在调用合约函数时模拟任何地址。这意味着我们可以使测试调用看起来像是来自特定地址,例如合约所有者,从而允许我们测试访问控制逻辑。
cheat_caller_address cheatcode 具有以下函数签名:
fn cheat_caller_address(target: ContractAddress, caller_address: ContractAddress, span: CheatSpan)
它接受三个参数:
target:应该看到模拟调用者的特定合约caller_address:要模拟的地址span:一个 CheatSpan 枚举,用于定义 cheat 应该持续多长时间。它有两个变体:
CheatSpan::Indefinite:Cheat 保持活动状态,直到手动停止CheatSpan::TargetCalls(n):将 cheat 应用于 n 个函数调用要在测试中使用 cheat_caller_address,请导航到项目目录中的 tests/test_contract.cairo。清除样板测试并更新导入以包含 cheat_caller_address 和 CheatSpan,如下所示:
use starknet::ContractAddress;
use snforge_std::{declare, ContractClassTrait, DeclareResultTrait, cheat_caller_address, CheatSpan};
为了了解 cheat_caller_address 在实践中是如何工作的,我们将创建两个测试:一个演示没有 cheat_caller_address cheatcode 的失败情况,另一个演示如何正确使用 cheatcode。
由于更新后的 HelloContract 构造函数现在需要一个所有者地址,因此我们需要在测试中部署合约时提供一个。我们将创建一个 deploy_contract 辅助函数,该函数将所有者地址作为参数传递给构造函数,以及一个 OWNER() 辅助函数,该函数返回一个可重用的模拟地址以进行测试。
然后,我们将导入与已部署合约交互所需的 dispatchers。总而言之,我们有以下内容:
use starknet::ContractAddress;
use snforge_std::{declare, ContractClassTrait, DeclareResultTrait, cheat_caller_address, CheatSpan};
//NEWLY ADDED BELOW//
use cheatcodes::IHelloStarknetDispatcher;
use cheatcodes::IHelloStarknetDispatcherTrait;
fn OWNER() -> ContractAddress {
'OWNER'.try_into().unwrap()
}
fn deploy_contract(name: ByteArray, owner: ContractAddress) -> ContractAddress {
let contract = declare(name).unwrap().contract_class(); // Declare the contract class
let constructor_args = array![owner.into()]; // Pass owner to constructor
let (contract_address, _) = contract.deploy(@constructor_args).unwrap(); // Deploy the contract and return its addres
contract_address
}
IHelloStarknetDispatcher 和 IHelloStarknetDispatcherTrait dispatchers 允许我们从测试中调用合约函数。
OWNER() 将字符串文字 'OWNER' 转换为在整个测试中可重用的 ContractAddress 类型。
deploy_contract 函数声明合约类,通过 constructor_args 将所有者地址传递给构造函数,并返回已部署合约的地址,以便我们与之交互。
测试 1:测试失败情况
第一个测试显示了当我们尝试在不使用 cheat_caller_address cheatcode 的情况下调用 increase_balance() 时会发生什么。我们将以 OWNER() 作为所有者部署合约,然后尝试增加余额。这将失败,因为测试环境的地址与合约中存储的所有者地址不同。
将此 test_environment_address_owner_check 测试代码添加到你的测试文件中:
use starknet::ContractAddress;
use snforge_std::{declare, ContractClassTrait, DeclareResultTrait, cheat_caller_address, CheatSpan};
use cheatcodes::IHelloStarknetDispatcher;
use cheatcodes::IHelloStarknetDispatcherTrait;
fn OWNER() -> ContractAddress {
'OWNER'.try_into().unwrap()
}
fn deploy_contract(name: ByteArray, owner: ContractAddress) -> ContractAddress {
let contract = declare(name).unwrap().contract_class();
let constructor_args = array![owner.into()];
let (contract_address, _) = contract.deploy(@constructor_args).unwrap();
contract_address
}
//NEWLY ADDED//
#[test]
fn test_environment_address_owner_check() {
// Deploy the HelloStarknet contract with OWNER() as the owner
let contract_address = deploy_contract("HelloStarknet", OWNER());
let dispatcher = IHelloStarknetDispatcher { contract_address };
// Verify the owner was set correctly during deployment
assert(dispatcher.get_owner() == OWNER(), 'Owner not set correctly');
// This call should fail because the test environment address != OWNER()
// The get_caller_address() inside increase_balance will return the environment address,
// which is not the OWNER(), so the owner check should fail
dispatcher.increase_balance(42);
}
运行 scarb test test_environment_address_owner_check。你应该看到此失败:

失败的原因是当 dispatcher.increase_balance(42) 执行时,increase_balance() 函数内的 get_caller_address() 返回测试环境的地址,而不是 OWNER()。由于合约的所有者设置为 OWNER(),因此断言 assert(caller == self.owner.read(), 'Only owner') 失败。
测试 2:使用 cheat_caller_address cheatcode
现在让我们看看 cheat_caller_address 如何解决此访问控制测试问题。将 test_cheat_caller_address 测试添加到你的测试文件中,如下所示:
use starknet::ContractAddress;
use snforge_std::{declare, ContractClassTrait, DeclareResultTrait, cheat_caller_address, CheatSpan};
use cheatcodes::IHelloStarknetDispatcher;
use cheatcodes::IHelloStarknetDispatcherTrait;
fn OWNER() -> ContractAddress {
'OWNER'.try_into().unwrap()
}
fn deploy_contract(name: ByteArray, owner: ContractAddress) -> ContractAddress {
let contract = declare(name).unwrap().contract_class();
let constructor_args = array![owner.into()];
let (contract_address, _) = contract.deploy(@constructor_args).unwrap();
contract_address
}
//NEWLY ADDED//
#[test]
fn test_cheat_caller_address() {
// Deploy the HelloStarknet contract with OWNER() as the owner
let contract_address = deploy_contract("HelloStarknet", OWNER());
let dispatcher = IHelloStarknetDispatcher { contract_address };
// Verify the owner was set correctly during deployment
assert(dispatcher.get_owner() == OWNER(), 'Owner not set correctly');
// cheat caller address to be the owner
cheat_caller_address(contract_address, OWNER(), CheatSpan::TargetCalls(1));
dispatcher.increase_balance(42); // This function call uses the cheat
assert(dispatcher.get_balance() == 42, 'Balance not 42');
// The cheat has expired after 1 call (CheatSpan::TargetCalls(1))
// Any subsequent calls would fail the owner check
}
cheat_caller_address(contract_address, OWNER(), CheatSpan::TargetCalls(1)) 调用会覆盖 get_caller_address() 的返回值。它使合约认为下一个函数调用来自 OWNER() 而不是测试环境。
当 dispatcher.increase_balance(42) 执行时,get_caller_address() 返回 OWNER(),从而使所有者检查通过。
运行 scarb test test_cheat_caller_address,你应该看到测试通过:

CheatSpan::TargetCalls(1) 参数告诉 snforge 仅对下一个函数调用 (increase_balance(42)) 应用调用者 cheat。之后,调用者地址恢复正常。
如果我们尝试在没有另一个 cheat 的情况下再次调用 increase_balance() 或增加 TargetCalls,它将失败,因为调用者将不再是所有者。
start_cheat_caller_address 和 stop_cheat_caller_address 进行持久调用者模拟与需要 CheatSpan 参数来控制持续时间的 cheat_caller_address 不同,start_cheat_caller_address 会无限期地为所有后续调用设置调用者地址,直到使用 stop_cheat_caller_address 手动停止。
start_cheat_caller_address 需要两个参数:一个 target(应该看到模拟调用着的特定合约)和一个 caller_address(要模拟的地址),如下所示:
fn start_cheat_caller_address(target: ContractAddress, caller_address: ContractAddress
虽然 stop_cheat_caller_address 仅采用 target 来停止对该特定合约的模拟:
fn stop_cheat_caller_address(target: ContractAddress)
要使用这些 cheatcodes,请更新 snforge 库导入,以包含 start_cheat_caller_address 和 stop_cheat_caller_address cheatcodes 以及现有的导入:
use snforge_std::{declare, ContractClassTrait, DeclareResultTrait, start_cheat_caller_address, stop_cheat_caller_address, cheat_caller_address, CheatSpan};
以下测试显示了如何使用 start_cheat_caller_address 对多个函数调用进行持久调用者模拟:
#[test]
fn test_persistent_caller_cheat() {
let contract_address = deploy_contract("HelloStarknet", OWNER());
let dispatcher = IHelloStarknetDispatcher { contract_address };
// Start impersonating OWNER() for all calls to this specific contract until we explicitly stop it
start_cheat_caller_address(contract_address, OWNER());
// multiple calls will all use OWNER() as caller
dispatcher.increase_balance(10);
dispatcher.increase_balance(2);
dispatcher.increase_balance(45);
assert(dispatcher.get_balance() == 57, 'Balance should be 57');
// Stop the caller impersonation
stop_cheat_caller_address(contract_address);
}
在 test_persistent_caller_cheat() 中,我们以 OWNER() 作为
存储的所有者部署合约,然后调用 start_cheat_caller_address(contract_address, OWNER())
以开始模拟对该合约的所有后续调用的所有者
将上面的测试复制到 tests/test_contract.cairo 中,并使用 scarb test test_persistent_caller_cheat 运行它。
对 increase_balance 的所有三个调用都将成功,因为 cheat 在
所有函数调用中都保持活动状态。每次该函数检查 get_caller_address() 时,
它都会返回 OWNER() 而不是测试环境的地址。Cheat 保持
活动状态,直到我们显式调用 stop_cheat_caller_address(contract_address)。

重要提示:
start_cheat_caller_address是特定于目标的,这意味着它仅影响对指定合约地址的调用。如果在 cheat 对 contractA 处于活动状态时在另一个合约(contractB)上调用了一个函数,则 contractB 将看到正常的测试环境地址,而不是模拟地址。Cheat 仅适用于target参数中指定的合约。
当需要以同一地址对指定合约进行多个连续调用时,请使用 start_cheat_caller_address。
start_cheat_caller_address_global 和 stop_cheat_caller_address_global 进行全局调用者模拟为了测试跨多个合约的交互,start_cheat_caller_address_global 会为所有合约调用设置一个通用的调用者地址,直到使用 stop_cheat_caller_address_global 显式停止。它的工作方式与 Foundry 中的 Solidity 的 startPrank/stopPrank 类似。
要使用这些全局调用者 cheatcodes,请将它们添加到现有的 snforge 库导入中:
use snforge_std::{declare, ContractClassTrait, DeclareResultTrait, start_cheat_caller_address, stop_cheat_caller_address, cheat_caller_address, CheatSpan, start_cheat_caller_address_global, stop_cheat_caller_address_global};
下面的测试使用此 start_cheat_caller_address_global cheatcode 来使用相同的欺骗调用者与两个合约进行交互。我们将部署 HelloStarknet 合约的两个单独实例,并在全局模拟所有者时对两者进行调用:
#[test]
fn test_global_caller_cheat() {
// Deploy two separate instances of the HelloStarknet contract
// Both contracts have OWNER() as their owner
let contract1 = deploy_contract("HelloStarknet", OWNER());
let contract2 = deploy_contract("HelloStarknet", OWNER());
// Create dispatchers to interact with each contract
let dispatcher1 = IHelloStarknetDispatcher { contract_address: contract1 };
let dispatcher2 = IHelloStarknetDispatcher { contract_address: contract2 };
// Start global caller impersonation - affects ALL contracts
// Every contract call will now appear to come from OWNER()
start_cheat_caller_address_global(OWNER());
// Both calls succeed because both contracts see OWNER() as the caller
dispatcher1.increase_balance(100);
dispatcher2.increase_balance(200);
// Confirm each contract has the correct balance
assert(dispatcher1.get_balance() == 100, 'Contract1 balance wrong');
assert(dispatcher2.get_balance() == 200, 'Contract2 balance wrong');
// Stop the global cheat
stop_cheat_caller_address_global();
}
将上面的测试代码添加到你的 test_contract.cairo 文件中,并使用 scarb test test_global_caller_cheat 运行它。
该测试将通过,因为 test_global_caller_cheat() 中的 start_cheat_caller_address_global 同时影响所有合约。两个合约(contract1 和 contract2)都将调用者视为 OWNER(),因此两个操作都成功,而无需为每个合约单独的 cheats。
此全局调用者 cheatcode 对于测试多个合约之间的交互特别有用,在这些交互中,所有调用都应来自同一地址。一个实际的例子是 staking 协议,其中用户需要与多个合约进行交互,在 ERC-20 合约上批准 tokens,然后在 staking 合约中 staking 这些 tokens,使用相同的调用者地址。使用全局调用者 cheat 可确保所有这些相互关联的操作具有一致的调用者身份。
正如 caller_address cheatcodes 让我们控制谁调用合约函数一样,我们还需要一种方法来测试具有时间相关逻辑的合约,而无需等待实际时间过去。许多智能合约都包含基于时间的限制,例如提款延迟、归属计划或冷却期。测试这些功能通常需要等待真实的时间过去,这使得测试不切实际。区块时间戳 cheatcodes 通过让我们控制合约的时间感知来解决此问题。
block_timestamp Cheatcodesblock_timestamp cheatcodes 使模拟基于时间的行为成为可能,而无需等待真实时间过去。此 cheatcode 的可用函数有:
Starknet foundry block_timestamp cheatcode |
它的作用 | Solidity foundry 等效项 |
|---|---|---|
cheat_block_timestamp(target, timestamp, span) |
为目标合约设置区块时间戳,受 CheatSpan 限制 |
没有直接等效项 - vm.warp(timestamp) 仅是全局的 |
start_cheat_block_timestamp(target, timestamp) |
开始为目标合约设置时间戳 | 没有直接等效项 |
start_cheat_block_timestamp_global(timestamp) |
在所有合约中全局设置区块时间戳 | vm.warp(timestamp) |
stop_cheat_block_timestamp(target) |
停止对目标合约的时间戳修改 | 没有直接等效项 |
stop_cheat_block_timestamp_global() |
停止全局时间戳修改 | 使用 vm.warp(original_timestamp) 手动重置 |
为了说明这些 block_timestamp cheatcodes 的工作方式,我们将修改 HelloStarknet 合约以包含时间锁定功能。修改后的合约将包含两个新函数:
set_lock_time(duration),它允许所有者通过调用
get_block_timestamp() 来获取当前时间并将持续时间添加到其中来设置时间锁定time_locked_withdrawal(amount),它允许所有者提取资金,但前提是锁定时间已经过去,方法是检查当前时间戳 (get_block_timestamp()) 是否大于或等于存储的锁定时间。复制下面的更新后的代码并替换你的 src/lib.cairo 文件的内容:
use starknet::ContractAddress;
/// Interface representing `HelloContract`.
/// This interface allows modification and retrieval of the contract balance with time-locked functionality.
#[starknet::interface]
pub trait IHelloStarknet<TContractState> {
fn increase_balance(ref self: TContractState, amount: u256);
fn get_balance(self: @TContractState) -> u256;
fn get_owner(self: @TContractState) -> ContractAddress;
// NEWLY ADDED
fn time_locked_withdrawal(ref self: TContractState, amount: u256);
fn set_lock_time(ref self: TContractState, duration: u64);
}
#[starknet::contract]
mod HelloStarknet {
use starknet::{ContractAddress, get_caller_address, get_block_timestamp};
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
#[storage]
struct Storage {
owner: ContractAddress,
balance: u256,
lock_until: u64,
}
#[constructor]
fn constructor(ref self: ContractState, owner: ContractAddress) {
// Set the owner when the contract is deployed
self.owner.write(owner);
}
#[abi(embed_v0)]
impl HelloStarknetImpl of super::IHelloStarknet<ContractState> {
fn increase_balance(ref self: ContractState, amount: u256) {
// Get who is calling this function
let caller = get_caller_address();
// Only owner can increase balance
assert(caller == self.owner.read(), 'Only owner');
assert(amount != 0, 'Amount cannot be 0');
// Add the amount to current balance
self.balance.write(self.balance.read() + amount);
}
fn get_balance(self: @ContractState) -> u256 {
self.balance.read()
}
fn get_owner(self: @ContractState) -> ContractAddress {
self.owner.read()
}
// NEWLY ADDED: Time lock functionality
fn set_lock_time(ref self: ContractState, duration: u64) {
let caller = get_caller_address();
// Only owner can set lock time
assert(caller == self.owner.read(), 'Only owner');
assert(duration > 0, 'Duration must be positive');
// Set lock_until = current timestamp + duration
self.lock_until.write(get_block_timestamp() + duration);
}
// NEWLY ADDED: Time-locked withdrawal function
fn time_locked_withdrawal(ref self: ContractState, amount: u256) {
let caller = get_caller_address();
// Only owner can withdraw
assert(caller == self.owner.read(), 'Only owner');
// Check if enough time has passed since lock was set
// This is the key time-based check we'll test with cheatcodes
assert(get_block_timestamp() >= self.lock_until.read(), 'Still locked');
// Ensure sufficient balance for withdrawal
assert(amount <= self.balance.read(), 'Insufficient balance');
// Subtract the withdrawal amount from balance
self.balance.write(self.balance.read() - amount);
}
}
}
有了这个时间锁定的合约,让我们看看如何使用 block_timestamp cheatcodes 来测试它。
cheat_block_timestamp cheatcodecheat_block_timestamp cheatcode 会扭曲特定合约的区块时间戳,以便在受控数量的调用中进行。这是函数签名:
fn cheat_block_timestamp(target: ContractAddress, timestamp: u64, span: CheatSpan)
该函数接受三个参数:
target:应该看到修改后的时间戳的特定合约timestamp:要设置的时间戳值span:应该看到此时间戳的调用次数请注意,在测试环境中,
get_block_timestamp()默认返回 0,因此我们不能依赖它来断言测试中的时间戳。相反,我们需要根据我们使用 cheatcodes 设置的值手动计算和跟踪时间戳。
要测试时间锁定功能,请将 cheat_block_timestamp cheatcode 添加到
你现有的 snforge 库导入中。
我们将首先表明,在没有任何时间戳操作的情况下,时间锁定的提款会失败,然后表明 cheatcodes 如何使我们能够绕过时间限制:
#[test]
fn test_time_locked_withdrawal_fails_without_cheat() {
let contract_address = deploy_contract("HelloStarknet", OWNER());
let dispatcher = IHelloStarknetDispatcher { contract_address };
// Set up as owner for initial state
start_cheat_caller_address(contract_address, OWNER());
// Set up the contract state
dispatcher.increase_balance(1000);
// Set 1-hour lock: lock_until = current_time + 3600
dispatcher.set_lock_time(3600);
// Try to withdraw immediately without advancing time
// This will cause the test to fail with "Still locked" error when you run scarb test
dispatcher.time_locked_withdrawal(100);
// This assertion will never be reached because the withdrawal above fails
assert(dispatcher.get_balance() == 900, 'Withdrawal should fail');
}
当你运行 scarb test test_time_locked_withdrawal_fails_without_cheat 时,此测试将失败,并显示“仍然锁定”错误,这证明了时间锁定机制正常工作。

现在让我们看一个使用 cheat_block_timestamp“时间旅行”以模拟
足够的时间过去,以便时间锁定的提款成功的测试。
#[test]
fn test_cheat_block_timestamp() {
let contract_address = deploy_contract("HelloStarknet", OWNER());
let dispatcher = IHelloStarknetDispatcher { contract_address };
// Set up initial state: we need 2 owner calls (increase_balance + set_lock_time)
cheat_caller_address(contract_address, OWNER(), CheatSpan::TargetCalls(2));
// Add 1000 to the balance (first owner call)
dispatcher.increase_balance(1000); // Balance: 0 + 1000 = 1000
// Set a 1-hour time lock from current timestamp (second owner call)
dispatcher.set_lock_time(3600); // Lock until: current_time + 3600 seconds
// "Time travel" to 2 hours in the future (7200 seconds from block 0)
let future_timestamp = 7200;
cheat_block_timestamp(contract_address, future_timestamp, CheatSpan::TargetCalls(1));
// Need to impersonate owner again for the withdrawal call
cheat_caller_address(contract_address, OWNER(), CheatSpan::TargetCalls(1));
// This withdrawal succeeds because get_block_timestamp() now returns 7200 which is > lock_until (3600)
dispatcher.time_locked_withdrawal(100); // Balance: 1000 - 100 = 900
// confirm the withdrawal was successful
assert(dispatcher.get_balance() == 900, 'Withdrawal failed');
}
在上面的代码中,我们部署了合约,将 1000 添加到所有者的余额中,并使用 set_lock_time(3600) 设置了 1 小时的锁定。
调用 cheat_block_timestamp(contract_address, future_timestamp, CheatSpan::TargetCalls(1)) 会使合约认为已经过去了 2 小时(7200 秒)。当 time_locked_withdrawal() 使用 get_block_timestamp() 检查当前时间时,它会看到 7200 秒,这大于 lock_until (3600),因此提款成功。
let future_timestamp = 7200; // 2 hours later
cheat_block_timestamp(contract_address, future_timestamp, CheatSpan::TargetCalls(1));
CheatSpan::TargetCalls(1) 参数意味着只有下一个函数调用
(time_locked_withdrawal) 才会看到此修改后的时间戳。
cheat_block_timestamp 模拟时间推移,而无需实际延迟,从而使我们能够立即测试时间相关的逻辑。
start_cheat_block_timestamp cheatcode与需要 CheatSpan 参数来控制持续时间的 cheat_block_timestamp 不同,start_cheat_block_timestamp 使目标合约看到模拟时间戳,以便在手动停止之前进行所有后续调用。这是函数签名:
fn start_cheat_block_timestamp(target: ContractAddress, timestamp: u64)
更新 snforge 库导入以包含 start_cheat_block_timestamp 和 stop_cheat_block_timestamp cheatcodes 以及现有的 cheatcodes,以便我们可以了解它们的工作方式:
use snforge_std::{declare, ContractClassTrait, DeclareResultTrait, start_cheat_caller_address, stop_cheat_caller_address, cheat_caller_address, CheatSpan, start_cheat_caller_address_global, stop_cheat_caller_address_global, cheat_block_timestamp, start_cheat_block_timestamp, stop_cheat_block_timestamp};
考虑下面的测试代码,它显示了如何通过重新启动时间戳 cheats 来推进时间,以使用 start_cheat_block_timestamp 和 stop_cheat_block_timestamp 测试时间锁定功能:
#[test]
fn test_start_cheat_block_timestamp() {
let contract_address = deploy_contract("HelloStarknet", OWNER());
let dispatcher = IHelloStarknetDispatcher { contract_address };
// Set a specific starting timestamp (August 6th, 2025)
let start_time = 1754439529;
start_cheat_caller_address(contract_address, OWNER());
// Set up the contract state
dispatcher.increase_balance(1000);
// Make all contract calls see this timestamp until we change it
start_cheat_block_timestamp(contract_address, start_time);
// Set 1-hour lock: lock_until = start_time + 3600
dispatcher.set_lock_time(3600); // Lock until: 1754439529 + 3600 = 1754443129
// 向前移动 2 小时 (7200 秒) let future_time = start_time + 7200; // 新时间:1754439529 + 7200 = 1754446729
// 停止当前时间戳欺骗
stop_cheat_block_timestamp(contract_address);
// 以未来时间开始新的时间戳欺骗
// 这模拟了 2 小时过去 (future_time > lock_until,因此允许提款)
start_cheat_block_timestamp(contract_address, future_time);
// 提款成功,因为 get_block_timestamp() 返回 future_time (1754446729)
// 大于 lock_until (1754443129)
dispatcher.time_locked_withdrawal(100);
// 确认提款成功
assert(dispatcher.get_balance() == 900, 'Withdrawal failed');
// 停止两个欺骗
stop_cheat_caller_address(contract_address);
stop_cheat_block_timestamp(contract_address);
}
在 `test_start_cheat_block_timestamp()` 中,我们首先设置一个特定的时间戳 (`start_time`),所有合约调用都将看到该时间戳,然后通过添加余额和创建时间锁来设置合约状态。
为了模拟时间的流逝,我们停止当前的时间戳欺骗,并使用 `future_time`(2 小时后)开始一个新的时间戳欺骗,这使得提款能够成功,因为合约现在看到的是稍后的时间戳。
要使用 `start_cheat_block_timestamp` 更新时间戳,我们必须停止当前的欺骗并开始一个新的欺骗,从而通过从 `start_time` 更改为 `future_time` 来有效地模拟时间进程。
当你需要在为后续操作推进时间之前,在完全相同的模拟时间内发生多个操作时,使用 `start_cheat_block_timestamp` 会很有用。与 `start_cheat_caller_address` 类似,此作弊码是特定于目标的;它仅影响对指定合约地址的调用。如果你需要在同一测试中为与多个不同的合约实例的交互设置不同的时间,则应改用 `start_cheat_block_timestamp_global` 作弊码。
在 `caller_address` 和 `block_timestamp` 作弊码涵盖的所有场景中,测试都需要验证函数在满足条件时是否正常工作,以及在应该失败时是否失败。 这就是我们需要确保合约正确 revert 的地方。
## 期望 revert
在测试函数在某些条件下是否应该失败时,Starknet Foundry 提供了 `#[should_panic]` 属性,该属性类似于 solidity Foundry 中的 `vm.expectRevert()`。该属性本身不是作弊码,而是与其他作弊码一起用于测试失败场景:
```rust
#[should_panic(expected: ('Still locked',))]
#[should_panic] 属性告诉测试框架:
当 #[should_panic] 测试通过时,它会确认该函数按预期 panic。 务必包含带有正确错误消息的 expected 参数,以验证测试是否因预期原因而失败。
这是一个基本的 revert 测试示例,用于验证在锁定期到期之前尝试进行时间锁定提款会失败:
#[test]
#[should_panic(expected: ('Still locked',))]
fn test_time_locked_withdrawal_fails_too_early() {
let contract_address = deploy_contract("HelloStarknet", OWNER());
let dispatcher = IHelloStarknetDispatcher { contract_address };
start_cheat_caller_address(contract_address, OWNER());
// 设置合约状态
dispatcher.increase_balance(1000);
dispatcher.set_lock_time(3600); // 从时间戳 0 开始锁定 1 小时
dispatcher.time_locked_withdrawal(100);
}
运行 scarb test test_time_locked_withdrawal_fails_too_early 来测试代码:

由于测试环境从时间戳 0 开始,并且我们设置了 3600 秒的锁定,因此提款尝试会命中 assert(get_block_timestamp() >= self.lock_until.read(), 'Still locked') 行并 panic。
#[should_panic(expected: ('Still locked',))] 属性告诉测试框架,此 panic 是预期的,并且测试在发生时应该通过。
在 test_time_locked_withdrawal_fails_too_early 测试中,我们不需要 stop_cheat_caller_address,因为它在到达任何清理代码之前就 panic 了。
有时我们想检查错误,而无需让我们的测试 panic。 为此,我们可以使用“Safe Dispatcher”。
Safe Dispatcher 是我们的合约 dispatcher 的自动生成的变体,它返回 Result<T, Array<felt252>> 而不是直接 panic。
当我们定义像 IHelloStarknet 这样的合约接口时,编译器会生成许多与 dispatcher 相关的项,但与测试相关的主要项是:
IHelloStarknetDispatcher) 和 IHelloStarknetDispatcherTrait): 在错误时 PanicIHelloStarknetSafeDispatcher和 IHelloStarknetSafeDispatcherTrait): 返回Result类型当你需要以下内容时,请使用 Safe Dispatcher:
以下测试示例使用 Safe Dispatcher 来验证访问控制,方法是确保未经授权的调用失败并返回正确的错误消息。 导入 Safe Dispatcher (IHelloStarknetSafeDispatcher 和 IHelloStarknetSafeDispatcherTrait) 以启用合约交互:
use cheatcodes::IHelloStarknetSafeDispatcher; //导入SafeDispatcher
use cheatcodes::IHelloStarknetSafeDispatcherTrait; //导入SafeDispatcherTrait
然后,将以下代码添加到 test/test_contract.cairo 以测试 safe_dispatcher 功能:
fn USER_1() -> ContractAddress {
'USER_1'.try_into().unwrap()
}
#[test]
#[feature("safe_dispatcher")]
fn test_non_owner_error_with_safe_dispatcher() {
// 使用 OWNER() 作为所有者部署 HelloStarknet 合约
let contract_address = deploy_contract("HelloStarknet", OWNER());
// 使用 safe dispatcher 变体来优雅地处理错误
let safe_dispatcher = IHelloStarknetSafeDispatcher { contract_address };
// 模拟不是所有者的 USER_1()
start_cheat_caller_address(contract_address, USER_1());
// 调用 increase_balance - 这将失败,但会返回 Result 而不是 panic
match safe_dispatcher.increase_balance(100) {
// 如果调用成功,则测试应失败,因为非所有者不应具有访问权限
Result::Ok(_) => core::panic_with_felt252('Should have panicked'),
// 如果调用失败(预期),请确认我们收到了正确的错误消息
Result::Err(panic_data) => {
// 检查 panic_data 的第一个元素是否包含我们期望的错误消息
assert(*panic_data.at(0) == 'Only owner', 'Wrong error message');
},
};
// 停止调用者模拟
stop_cheat_caller_address(contract_address);
}
在上面的 test_non_owner_error_with_safe_dispatcher 测试中,当 USER_1() 尝试增加余额时,safe dispatcher 返回成功 (Ok) 或失败 (Err):
match safe_dispatcher.increase_balance(100) {
Result::Ok(_) => core::panic_with_felt252('Should have panicked'), //success
Result::Err(panic_data) => {
assert(*panic_data.at(0) == 'Only owner', 'Wrong error message'); //failure
},
};
如果调用意外成功,则测试将因“Should have panicked”而失败,因为非所有者不应具有访问权限。 如果它按预期失败,我们会通过检查 panic_data 数组的第一个元素来验证错误消息是否完全是“Only owner”。

这样,我们可以验证该函数是否失败,以及它是否因正确的错误消息而失败。
store 作弊码允许我们在测试期间将值直接写入合约存储槽,而无需调用合约的函数或执行其通常的逻辑流程。 这意味着我们可以绕过通常通过函数调用发生的检查、验证、访问控制和其他状态转换。 这对于设置特定的合约状态或测试极端情况而无需通过常规函数调用特别有用。
在 HelloStarknet 合约中,我们的存储如下所示:
#[storage]
struct Storage {
owner: ContractAddress,
balance: u256,
lock_until: u64,
}
每个存储变量都有一个唯一的槽,我们可以使用 store 作弊码直接写入该槽。
store 作弊码fn store(target: ContractAddress, storage_address: felt252, serialized_value: Span<felt252>)
它接受三个参数 :
target:要修改的合约地址storage_address:存储槽位置(使用 map_entry_address 计算)serialized_value:要存储的值,转换为 felt252 数组要使用 store 作弊码,我们必须首先计算我们要修改的变量的确切存储地址。我们将使用 map_entry_address 来计算存储位置。
从 snforge_std 库导入 store 和 map_entry_address 以及现有库:
use snforge_std::{declare, ContractClassTrait, DeclareResultTrait, start_cheat_caller_address, stop_cheat_caller_address, cheat_caller_address, CheatSpan, start_cheat_caller_address_global, stop_cheat_caller_address_global, cheat_block_timestamp, start_cheat_block_timestamp, stop_cheat_block_timestamp, store, map_entry_address};
以下是我们如何找到 balance 变量的地址:
let balance_storage_addr = map_entry_address(
map_selector: selector!("balance"),
keys: array![].span()
);
"balance" 是我们合约的 Storage struct 中的存储变量名称map_entry_address 计算存储“balance”的确切内存地址selector!("balance") 将存储变量名称 (balance) 转换为存储标识符。keys: array![].span() 为空,因为 balance 是一个简单的存储变量,而不是一个映射。结果 balance_storage_addr 是我们现在可以传递给store作弊码的存储槽地址。
对于像 balance (非映射)这样的简单存储变量,你也可以使用较短的语法:
let balance_storage_addr = selector!("balance");
何时使用哪个:
u256、felt252、bool)使用 selector!()将 map_entry_address() 用于:
LegacyMap 类型(在 keys 数组中提供映射键)keys 数组中提供索引)array![].span())– 虽然selector!() 在这种情况下更短两种方法都计算合约存储变量的确切存储地址,从而允许我们将新值直接写入该位置。
不同的数据类型需要不同的序列化格式:
ContractAddress – 单个 felt252let serialized_owner = array![OWNER().into()];
u64 – 单个 felt252let timestamp: u64 = 1641070800;
let serialized_timestamp = array![timestamp.into()];
u256 (我们的余额类型)– 需要低位和高位部分,因为 u256 大于单个 felt252 可以容纳的内容。let balance: u256 = 5000;
let serialized_balance = array![balance.low.into(), balance.high.into()];
以下测试表明,存储写入绕过了所有访问控制;不需要 increase_balance() 调用或所有权检查。 我们将直接将 HelloStarknet 合约的余额修改为 5000,而无需调用任何合约函数:
#[test]
fn test_store_balance_directly() {
let contract_address = deploy_contract("HelloStarknet", OWNER());
let dispatcher = IHelloStarknetDispatcher { contract_address };
//计算存储 "balance" 变量的存储地址
let balance_storage_addr = map_entry_address(
map_selector: selector!("balance"),
keys: array![].span()
);
// 直接写入存储的值
let new_balance: u256 = 5000;
// 将 u256 序列化为低位和高位部分 (u256 = {low: u128, high: u128})
// 在 Cairo 中,u256 值被序列化为 2 个 felt252 值 - 一个用于较低的 128 位,一个用于较高的 128 位
let serialized_value = array![new_balance.low.into(), new_balance.high.into()];
// 在直接存储写入之前检查余额
let balance_before = dispatcher.get_balance();
assert(balance_before == 0, 'Initial balance should be 0');
// 直接写入存储
store(contract_address, balance_storage_addr, serialized_value.span());
assert(dispatcher.get_balance() == 5000, 'Direct storage write failed');
}
load 作弊码直接从存储中读取我们可以使用 load 作弊码直接从存储中读取,而不是使用合约函数来验证存储的值。 这是函数签名:
fn load(target: ContractAddress, storage_address: felt252, size: felt252) -> Array<felt252>
它接受三个参数:
felt252 值的数量从 snforge_std 导入 load。 这是一个使用 store 写入余额并使用 load 读回来的测试:
#[test]
fn test_load_balance_directly() {
let contract_address = deploy_contract("HelloStarknet", OWNER());
// 计算存储 "balance" 变量的存储地址
let balance_storage_addr = selector!("balance");
// 直接写入存储的值
let new_balance: u256 = 5000;
// 将 u256 序列化为低位和高位部分 (u256 = {low: u128, high: u128})
// 在 Cairo 中,u256 值被序列化为 2 个 felt252 值 - 一个用于较低的 128 位,一个用于较高的 128 位
let serialized_value = array![new_balance.low.into(), new_balance.high.into()];
// 直接写入存储
store(contract_address, balance_storage_addr, serialized_value.span());
// 从余额存储槽读取原始存储数据
let stored_data = load(contract_address, balance_storage_addr, 2);
// 从存储数据数组中提取低位和高位部分
let stored_balance_low = *stored_data.at(0);
let stored_balance_high = *stored_data.at(1);
// 从其低位和高位分量重建 u256
let stored_balance: u256 = u256 {
low: stored_balance_low.try_into().unwrap(),
high: stored_balance_high.try_into().unwrap(),
};
// 确认直接读取的存储值与我们期望的余额匹配
assert(stored_balance == 5000, 'Direct storage read failed');
}
请注意,我们使用了 2 作为要加载的大小:
let stored_data = load(contract_address, balance_storage_addr, 2);
这是因为“balance”的类型为 u256,并且在 Cairo 中,u256 值被序列化为 2 个 felt252 值; 如前所述,一个包含较低的 128 位,另一个包含较高的 128 位。 这就是我们需要读取 2 个 felts 值并重建完整的 u256 值的原因。 如果存储的值的类型为 u512,我们将加载 4 个 felt252 值。
store 和 load 都提供直接存储访问,这有助于快速设置特定的测试场景并测试你的合约在各种状态条件下如何工作。
Starknet Foundry 还提供了 spy_events 作弊码来捕获和验证在合约执行期间是否发出了特定事件。 该作弊码提供的主要功能包括:
spy_events() – 开始捕获事件get_events() – 检索捕获的事件有关使用作弊码进行事件测试的详细示例和全面介绍,请参阅我们关于 Starknet 中的事件的文章。
本文介绍了一些用于 Cairo 智能合约测试的主要作弊码:caller_address、block_timestamp、store、load,以及使用 #[should_panic] 进行还原测试,以及使用 safe dispatcher。 这些函数提供调用者模拟、时间戳操作、直接存储访问和错误验证功能。
与 Solidity Foundry 测试框架中的作弊码类似,Starknet Foundry 作弊码提供类似的功能,语法适用于 Cairo 的架构。 核心测试概念在两个生态系统中保持一致。
有关其他作弊码,请参阅 starknet foundry 书籍。
本文是 Starknet 上的 Cairo 编程 系列教程的一部分
- 原文链接: rareskills.io/post/cairo...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!