Cheatcodes in Starknet Foundry
A "cheatcode" in Foundry is a mechanism that allows contract tests to control environment variables such as caller address, the current timestamp, and so on.
In this article, you will learn how to test Cairo smart contracts using Starknet Foundry’s most commonly used cheatcodes.
caller_address Cheatcodes
In Starknet smart contracts, get_caller_address() returns the address of the current account interacting with a function in the contract, similar to msg.sender in Ethereum. Contracts rely on it for access control, privileges, or custom uses. For example, the following code checks that the caller is the contract owner before allowing execution to proceed:
// Get who is calling this function
let caller = get_caller_address();
assert(caller == self.owner.read(), 'Only owner');
During testing, when a function checks the caller address like in the code above, we need to control what get_caller_address() returns to test that the access control works correctly, without using actual accounts (wallet addresses). This is where caller_address cheatcodes come in.
Starknet Foundry’s caller_address cheatcodes allow us to do this by simulating calls from any address we need. They work just like prank functions in Solidity Foundry. The available functions are:
Starknet Foundry caller_address cheatcodes |
What it does | Solidity Foundry equivalent |
|---|---|---|
cheat_caller_address(target, caller_address, span) |
Impersonates caller for a target contract, limited by CheatSpan |
No direct equivalent – Solidity’s vm.prank(caller_address) affects the next call globally, not target-specific |
start_cheat_caller_address(target, caller_address) |
Starts impersonating caller for a target contract | No direct equivalent – Solidity doesn’t have target-specific pranking |
start_cheat_caller_address_global(caller_address) |
Starts impersonating caller globally across all contracts which includes the target contract and any contracts it invokes | vm.startPrank(caller_address) |
stop_cheat_caller_address(target) |
Stops impersonating caller for a target contract | No direct equivalent |
stop_cheat_caller_address_global() |
Stops global caller impersonation | vm.stopPrank() |
To demonstrate how these caller_address cheatcodes work in practice, initialize a new Scarb project ( scarb new cheatcodes) and choose Starknet Foundry as the test runner.
In the src/lib.cairo file, there’s a default balance management contract generated by Scarb that allows us to increase and retrieve a balance from the contract’s storage.
Update this boilerplate contract to include owner-based access control in the increase_balance() function. The updated contract will store an owner address and a balance that can only be modified by the owner. The increase_balance() function will use get_caller_address() to check who’s calling it and only allow the owner to proceed. The updated contract will also include get_owner() function to check the owner’s address, which will be useful when writing tests.
Copy the updated contract below and paste it into 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()
}
}
}
This owner-based access control pattern is common in DeFi protocols where specific addresses have permission to perform certain functions like withdrawing funds.
Since increase_balance() is restricted to the contract’s owner, we need the caller_address cheatcode to simulate calls from the owner address.
Impersonating an address using cheat_caller_address
The cheat_caller_address cheatcode allows us to impersonate any address when calling contract functions. This means we can make the test calls appear as if they’re coming from the specific address, such as the contract owner, allowing us to test the access control logic.
The cheat_caller_address cheatcode has the following function signature:
fn cheat_caller_address(target: ContractAddress, caller_address: ContractAddress, span: CheatSpan)
It takes three parameters:
target: The specific contract that should see the impersonated callercaller_address: The address to impersonatespan: ACheatSpanenum that defines how long the cheat should last. It has two variants:CheatSpan::Indefinite: Cheat stays active until manually stoppedCheatSpan::TargetCalls(n): Apply cheat fornfunction calls
To use cheat_caller_address in your tests, navigate to tests/test_contract.cairo in your project directory. Clear the boilerplate tests and update the imports to include cheat_caller_address and CheatSpan as follows:
use starknet::ContractAddress;
use snforge_std::{declare, ContractClassTrait, DeclareResultTrait, cheat_caller_address, CheatSpan};
To see how cheat_caller_address works in practice, we’ll create two tests: one that demonstrates the failure case without cheat_caller_address cheatcode, and another that shows how to use the cheatcode correctly.
Since the updated HelloContract constructor now expects an owner address, we need to provide one when deploying the contract in our tests. We’ll create a deploy_contract helper function that takes the owner address as a parameter and passes it to the constructor, along with an OWNER() helper function that returns a reusable mock address for testing.
We’ll then import the dispatchers needed to interact with the deployed contract in the test. Altogether, we have the following:
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
}
The IHelloStarknetDispatcher and IHelloStarknetDispatcherTrait dispatchers allow us to call contract functions from the tests.
OWNER() converts the string literal 'OWNER' into a ContractAddress type that is reusable throughout our tests.
The deploy_contract function declares the contract class, passes the owner address to the constructor via constructor_args, and returns the deployed contract’s address for us to interact with.
Test 1: Testing the failure case
This first test shows what happens when we try to call increase_balance() without using thecheat_caller_address cheatcode. We’ll deploy the contract with OWNER() as the owner, then attempt to increase the balance. This will fail because the test environment’s address is different from the owner address stored in the contract.
Add this test_environment_address_owner_check test code into your test file:
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);
}
Run scarb test test_environment_address_owner_check. You should see this failure:

The failure occurs because when dispatcher.increase_balance(42) executes, the get_caller_address() function inside increase_balance() returns the test environment’s address, not OWNER(). Since the contract’s owner is set to OWNER(), the assertion assert(caller == self.owner.read(), 'Only owner') fails.
Test 2: Using the cheat_caller_address cheatcode
Now let’s see how cheat_caller_address solves this access control testing problem. Add the test_cheat_caller_address test to your test file as follows:
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
}
The cheat_caller_address(contract_address, OWNER(), CheatSpan::TargetCalls(1)) call overrides what get_caller_address() returns. It makes the contract believe that the next function call comes from OWNER() instead of the test environment.
When dispatcher.increase_balance(42) executes, get_caller_address() returns OWNER(), allowing the owner check to pass.
Run scarb test test_cheat_caller_address and you should see the test pass:

The CheatSpan::TargetCalls(1) parameter tells snforge to apply the caller cheat for only the next function call (increase_balance(42)). After that, the caller address returns to normal.
If we tried to call increase_balance() again without another cheat or increase the TargetCalls, it would fail because the caller would no longer be the owner.
Persistent caller Impersonation with start_cheat_caller_address and stop_cheat_caller_address
Unlike cheat_caller_address which requires a CheatSpan parameter to control duration, start_cheat_caller_address sets the caller address indefinitely for all subsequent calls until manually stopped with stop_cheat_caller_address.
start_cheat_caller_address requires two arguments: a target (the specific contract that should see the impersonated caller) and a caller_address (the address to impersonate), as shown below:
fn start_cheat_caller_address(target: ContractAddress, caller_address: ContractAddress
While stop_cheat_caller_address takes only the target to stop the impersonation for that specific contract:
fn stop_cheat_caller_address(target: ContractAddress)
To use these cheatcodes, update the snforge library imports to include the start_cheat_caller_address and stop_cheat_caller_address cheatcodes alongside the existing imports:
use snforge_std::{declare, ContractClassTrait, DeclareResultTrait, start_cheat_caller_address, stop_cheat_caller_address, cheat_caller_address, CheatSpan};
The following test shows how to use start_cheat_caller_address for persistent caller impersonation across multiple function calls:
#[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);
}
In test_persistent_caller_cheat(), we deploy the contract with OWNER() as the
stored owner, then call start_cheat_caller_address(contract_address, OWNER())
to begin impersonating the owner for all subsequent calls to that contract
Copy the test above into tests/test_contract.cairo and run it using scarb test test_persistent_caller_cheat.
All three calls to increase_balance will succeed because the cheat remains active
across all function calls. Each time the function checks get_caller_address(),
it returns OWNER() instead of the test environment’s address. The cheat stays
active until we explicitly call stop_cheat_caller_address(contract_address).

Important note:
start_cheat_caller_addressis target-specific, meaning it only affects calls to the specified contract address. If you called a function on a different contract (contractB) while the cheat is active for contractA, contractB would see the normal test environment address, not the impersonated address. The cheat only applies to the contract specified in thetargetparameter.
Use start_cheat_caller_address when you need to make multiple consecutive calls as the same address to the specified contract.
Global caller Impersonation with start_cheat_caller_address_global and stop_cheat_caller_address_global
For testing interactions across multiple contracts, start_cheat_caller_address_global sets a universal caller address for all contract calls until explicitly stopped using stop_cheat_caller_address_global. It works similarly to Solidity’s startPrank/stopPrank from Foundry.
To use these global caller cheatcodes, add them to the existing snforge library imports:
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};
The test below uses this start_cheat_caller_address_global cheatcode to interact with two contracts using the same spoofed caller. We’ll deploy two separate instances of the HelloStarknet contract and make calls to both while impersonating the owner globally:
#[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();
}
Add the test code above to your test_contract.cairo file and run it with scarb test test_global_caller_cheat.
The test would pass because start_cheat_caller_address_global in the test_global_caller_cheat() test affects all contracts simultaneously. Both contracts (contract1 and contract2) sees the caller as OWNER(), so both operations succeed without needing separate cheats for each contract.
This global caller cheatcode is particularly useful for testing interactions between multiple contracts where all calls should originate from the same address. A practical example is staking protocols, where a user needs to interact with multiple contracts, approving tokens on an ERC-20 contract, then staking those tokens in a staking contract, using the same caller address. Using the global caller cheat ensures consistent caller identity across all these interconnected operations.
Just as caller_address cheatcodes let us control who calls contract functions, we also need a way to test contracts with time-dependent logic without waiting for actual time to pass. Many smart contracts include time-based restrictions such as withdrawal delays, vesting schedules, or cooldown periods. Testing these features would normally require waiting for real time to elapse, making tests impractical. Block timestamp cheatcodes solve this problem by letting us control
the contract’s perception of time.
block_timestamp Cheatcodes
block_timestamp cheatcodes make it possible to simulate time-based behavior without waiting for real time to elapse. The available functions for this cheatcode are:
Starknet foundry block_timestamp cheatcode |
What it does | Solidity foundry equivalent |
|---|---|---|
cheat_block_timestamp(target, timestamp, span) |
Sets block timestamp for a target contract, limited by CheatSpan |
No direct equivalent – vm.warp(timestamp) is global only |
start_cheat_block_timestamp(target, timestamp) |
Starts setting timestamp for a target contract | No direct equivalent |
start_cheat_block_timestamp_global(timestamp) |
Sets block timestamp globally across all contracts | vm.warp(timestamp) |
stop_cheat_block_timestamp(target) |
Stops timestamp modification for a target contract | No direct equivalent |
stop_cheat_block_timestamp_global() |
Stops global timestamp modification | Manually reset with vm.warp(original_timestamp) |
To illustrate how these block_timestamp cheatcodes work, we’ll modify the HelloStarknet contract to include time-locked functionality. The modified contract will include two new functions:
set_lock_time(duration)that allows the owner to set a time lock by callingget_block_timestamp()to get the current time and adding the duration to ittime_locked_withdrawal(amount)that allows the owner to withdraw funds, but only after the lock time has passed by checking if the current timestamp (get_block_timestamp()) is greater than or equal to the stored lock time.
Copy the updated code below and replace the contents of your src/lib.cairo file:
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);
}
}
}
With this time-locked contract in place, let’s see how to test it using block_timestamp cheatcodes.
Using cheat_block_timestamp cheatcode
The cheat_block_timestamp cheatcode warps the block timestamp for a specific contract for a controlled number of calls. Here’s the function signature:
fn cheat_block_timestamp(target: ContractAddress, timestamp: u64, span: CheatSpan)
The function takes three parameters:
target: The specific contract that should see the modified timestamptimestamp: The timestamp value to setspan: How many calls should see this timestamp
Note that in the test environment,
get_block_timestamp()returns 0 by default, so we can’t rely on it for timestamp assertions in our tests. Instead, we need to calculate and track timestamps manually based on the values we set with cheatcodes.
To test time-locked functionality, add the cheat_block_timestamp cheatcode to
your existing snforge library imports.
We’ll first show that time-locked withdrawals fail without any timestamp manipulation, then show how the cheatcodes enable us to bypass the time restriction:
#[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');
}
When you run scarb test test_time_locked_withdrawal_fails_without_cheat, this test will fail with a ‘Still locked’ error, proving that the time lock mechanism works correctly.

Now let’s see a test that "time travels" using cheat_block_timestamp to simulate
enough time passing for a time-locked withdrawal to succeed.
#[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');
}
In the above code, we deployed the contract, added 1000 to the owner’s balance, and set a 1-hour lock with set_lock_time(3600).
Calling cheat_block_timestamp(contract_address, future_timestamp, CheatSpan::TargetCalls(1)) makes the contract think 2 hours (7200 seconds) have passed. When time_locked_withdrawal() checks the current time using get_block_timestamp(), it sees 7200 seconds, which is greater than lock_until (3600), so the withdrawal succeeds.
let future_timestamp = 7200; // 2 hours later
cheat_block_timestamp(contract_address, future_timestamp, CheatSpan::TargetCalls(1));
The CheatSpan::TargetCalls(1) parameter means only the next function call
(time_locked_withdrawal) will see this modified timestamp.
cheat_block_timestamp simulates time progression without actual delays, letting us test time-dependent logic instantly.
Using start_cheat_block_timestamp cheatcode
Unlike cheat_block_timestamp which requires a CheatSpan parameter to control duration, start_cheat_block_timestamp makes the target contract see the simulated timestamp for all subsequent calls until it is manually stopped. Here’s the function signature:
fn start_cheat_block_timestamp(target: ContractAddress, timestamp: u64)
Update the snforge library imports to include the start_cheat_block_timestamp and stop_cheat_block_timestamp cheatcodes along with the existing ones, so we can see how they work:
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};
Consider this test code below that shows how to advance time by restarting timestamp cheats to test time-locked functionality using start_cheat_block_timestamp and 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
// move 2 hours forward (7200 seconds)
let future_time = start_time + 7200; // New time: 1754439529 + 7200 = 1754446729
// Stop the current timestamp cheat
stop_cheat_block_timestamp(contract_address);
// Start a new timestamp cheat with the future time
// This simulates 2 hours passing (future_time > lock_until, so withdrawal allowed)
start_cheat_block_timestamp(contract_address, future_time);
// Withdrawal succeeds because get_block_timestamp() returns future_time (1754446729)
// which is greater than lock_until (1754443129)
dispatcher.time_locked_withdrawal(100);
// confirm the withdrawal was successful
assert(dispatcher.get_balance() == 900, 'Withdrawal failed');
// stop both cheats
stop_cheat_caller_address(contract_address);
stop_cheat_block_timestamp(contract_address);
}
In test_start_cheat_block_timestamp(), we begin by setting a specific timestamp (start_time) that all contract calls will see, then set up the contract state by adding balance and creating a time lock.
To simulate time passing, we stop the current timestamp cheat and start a new one with future_time (2 hours later), which allows the withdrawal to succeed because the contract now sees the later timestamp.
To update the timestamp with start_cheat_block_timestamp, we must stop the current cheat and start a new one, effectively simulating time progression by changing from start_time to future_time.
Using start_cheat_block_timestamp is useful when you need multiple operations to occur at the exact same simulated time before advancing time for subsequent actions. Similar to start_cheat_caller_address, this cheatcode is target-specific; it only affects calls made to the specified contract address. If you need to set the time differently for interactions with multiple, distinct contract instances in the same test, you should use the start_cheat_block_timestamp_global cheatcode instead.
In all scenarios covered by the caller_address and block_timestamp cheatcodes, testing requires verifying that functions work correctly when conditions are met and that they fail when they should. This is where we need to ensure the contract reverts properly.
Expecting a revert
When testing that a function should fail under certain conditions, Starknet Foundry provides the #[should_panic] attribute which is similar to vm.expectRevert() in solidity’s foundry. The attribute itself is not a cheatcode itself, but works with other cheatcodes to test failure scenarios:
#[should_panic(expected: ('Still locked',))]
The #[should_panic] attribute tells the test framework:
- Expect this test to cause panic; if it doesn’t panic, the test fails
- Expect specific error message; the panic must contain the exact message ‘Still locked’
- Test passes only if it panics correctly; both the panic and error message must match
When a #[should_panic] test passes, it confirms the function panicked as expected. It’s important to include the expected parameter with the correct error message to verify the test fails for the intended reason.
Here’s a basic revert test example that verifies that time-locked withdrawals fail when attempted before the lock period expires:
#[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());
// Set up the contract state
dispatcher.increase_balance(1000);
dispatcher.set_lock_time(3600); // Lock for 1 hour from timestamp 0
dispatcher.time_locked_withdrawal(100);
}
Run scarb test test_time_locked_withdrawal_fails_too_early to test the code:

Since the test environment starts at timestamp 0 and we set a 3600-second lock, the withdrawal attempt hits the assert(get_block_timestamp() >= self.lock_until.read(), 'Still locked') line and panic.
The #[should_panic(expected: ('Still locked',))] attribute tells the test framework that this panic is expected and the test should pass when it occurs.
We didn’t need stop_cheat_caller_address in the test_time_locked_withdrawal_fails_too_early test because it panics before reaching any cleanup code.
Using Safe Dispatcher
Sometimes we want to check the error without making our test panic. For this, we can use the "Safe Dispatcher."
Safe Dispatcher is an automatically generated variant of our contract dispatcher that returns Result<T, Array<felt252>> instead of panicking directly.
When we define a contract interface like IHelloStarknet, the compiler generates many dispatcher-related items, but the main ones relevant for testing are:
- Regular Dispatcher (
IHelloStarknetDispatcher) andIHelloStarknetDispatcherTrait): Panics on errors - Safe Dispatcher (
IHelloStarknetSafeDispatcherandIHelloStarknetSafeDispatcherTrait): Returns Result types
Use Safe Dispatcher when you need to:
- Examine exact error message
- Test multiple error conditions in one test
- Handle errors programmatically without panicking
The following test example uses Safe Dispatcher to verify access control by ensuring that unauthorized calls both fail and return the correct error message. Import the Safe Dispatchers (IHelloStarknetSafeDispatcher and IHelloStarknetSafeDispatcherTrait ) to enable contract interaction:
use cheatcodes::IHelloStarknetSafeDispatcher; //import SafeDispatcher
use cheatcodes::IHelloStarknetSafeDispatcherTrait; //import SafeDispatcherTrait
Then, add the following code to test/test_contract.cairo to test the safe_dispatcher feature:
fn USER_1() -> ContractAddress {
'USER_1'.try_into().unwrap()
}
#[test]
#[feature("safe_dispatcher")]
fn test_non_owner_error_with_safe_dispatcher() {
// Deploy the HelloStarknet contract with OWNER() as the owner
let contract_address = deploy_contract("HelloStarknet", OWNER());
// Use the safe dispatcher variant to handle errors gracefully
let safe_dispatcher = IHelloStarknetSafeDispatcher { contract_address };
// Impersonate USER_1() who is NOT the owner
start_cheat_caller_address(contract_address, USER_1());
// Call increase_balance - this will fail but return a Result instead of panicking
match safe_dispatcher.increase_balance(100) {
// If the call succeeds, the test should fail because non-owners shouldn't have access
Result::Ok(_) => core::panic_with_felt252('Should have panicked'),
// If the call fails (expected), confirm we get the correct error message
Result::Err(panic_data) => {
// Check that the first element of panic_data contains our expected error message
assert(*panic_data.at(0) == 'Only owner', 'Wrong error message');
},
};
// stop the caller impersonation
stop_cheat_caller_address(contract_address);
}
In the test_non_owner_error_with_safe_dispatcher test above, when USER_1() tries to increase balance, the safe dispatcher returns either success (Ok) or failure (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
},
};
If the call succeeds unexpectedly, the test fails with ‘Should have panicked’ because non-owners shouldn’t have access. If it fails as expected, we verify the error message is exactly ‘Only owner’ by checking the first element of the panic_data array.

This way we can verify both that the function fails and that it fails with the correct error message.
Writing to Storage
The store cheatcode lets us directly write values to contract storage slots during testing, without invoking the contract’s functions or executing its usual logic flow. This means we can bypass checks, validations, access control, and other state transitions that would normally occur through function calls. This is particularly useful for setting up specific contract states or testing edge cases without going through regular function calls.
In the HelloStarknet contract, we have the storage as shown below:
#[storage]
struct Storage {
owner: ContractAddress,
balance: u256,
lock_until: u64,
}
Each storage variable has a unique slot that we can write to directly using the store cheatcode.
The store cheatcode
fn store(target: ContractAddress, storage_address: felt252, serialized_value: Span<felt252>)
It takes three parameters:
target: The contract address to modifystorage_address: The storage slot location (calculated usingmap_entry_address)serialized_value: The value to store, converted to felt252 array
Finding Storage Addresses
To use the store cheatcode, we must first calculate the exact storage address for the variable we want to modify. We will use map_entry_address to calculate the storage location.
Import both store and map_entry_address from snforge_std library along with the existing ones:
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};
Here’s how we find the address for the balance variable:
let balance_storage_addr = map_entry_address(
map_selector: selector!("balance"),
keys: array![].span()
);
"balance"is the storage variable name from our contract’s Storage structmap_entry_addresscalculates the exact memory address where "balance" is storedselector!("balance")converts the storage variable name (balance) into a storage identifier.keys: array![].span()is empty becausebalanceis a simple storage variable, not a mapping.
The result, balance_storage_addr, is the storage slot address we can now pass to the store cheatcode.
For simple storage variables like balance (non-mappings), you can also use the shorter syntax:
let balance_storage_addr = selector!("balance");
When to use which:
- Use
selector!()only for simple storage variables (likeu256,felt252,bool) - Use
map_entry_address()for:LegacyMaptypes (provide the map key in thekeysarray)- Arrays (provide the index in the
keysarray) - Any storage type where you need to specify keys or indices
- Simple variables (using empty keys:
array![].span()) – thoughselector!()is shorter for this case
Both methods calculate the exact storage address where the contract stores the variable, allowing us to write new values directly to that location.
Serializing Values
Different data types need different serialization formats:
- For
ContractAddress– singlefelt252
let serialized_owner = array![OWNER().into()];
- For
u64– singlefelt252
let timestamp: u64 = 1641070800;
let serialized_timestamp = array![timestamp.into()];
- For
u256(our balance type) – needs low and high parts becauseu256is larger than what a singlefelt252can hold.
let balance: u256 = 5000;
let serialized_balance = array![balance.low.into(), balance.high.into()];
The following test demonstrates that storage writes bypass all access control; no increase_balance() call or ownership check is needed. We’ll directly modify the balance of the HelloStarknet contract to 5000 without invoking any contract function:
#[test]
fn test_store_balance_directly() {
let contract_address = deploy_contract("HelloStarknet", OWNER());
let dispatcher = IHelloStarknetDispatcher { contract_address };
//calculate the storage address where the "balance" variable is stored
let balance_storage_addr = map_entry_address(
map_selector: selector!("balance"),
keys: array![].span()
);
// value to write directly to storage
let new_balance: u256 = 5000;
// Serialize u256 into low and high parts (u256 = {low: u128, high: u128})
// In Cairo, u256 values are serialized as 2 felt252 values - one for lower 128 bits, one for upper 128 bits
let serialized_value = array![new_balance.low.into(), new_balance.high.into()];
// Check balance before direct storage write
let balance_before = dispatcher.get_balance();
assert(balance_before == 0, 'Initial balance should be 0');
// write directly to storage
store(contract_address, balance_storage_addr, serialized_value.span());
assert(dispatcher.get_balance() == 5000, 'Direct storage write failed');
}
Reading directly from storage with the load cheatcode
Instead of using contract functions to verify stored values, we can use the load cheatcode to read directly from storage. Here’s the function signature:
fn load(target: ContractAddress, storage_address: felt252, size: felt252) -> Array<felt252>
It takes three parameters:
- target: The contract address to read from
- storage_address: The storage slot location to read
- size: Number of
felt252values to read
Import load from snforge_std. Here’s a test that writes a balance using store and reads it back using load:
#[test]
fn test_load_balance_directly() {
let contract_address = deploy_contract("HelloStarknet", OWNER());
// Calculate the storage address where the "balance" variable is stored
let balance_storage_addr = selector!("balance");
// Value to write directly to storage
let new_balance: u256 = 5000;
// Serialize u256 into low and high parts (u256 = {low: u128, high: u128})
// In Cairo, u256 values are serialized as 2 felt252 values - one for lower 128 bits, one for upper 128 bits
let serialized_value = array![new_balance.low.into(), new_balance.high.into()];
// Write directly to storage
store(contract_address, balance_storage_addr, serialized_value.span());
// Read the raw storage data from the balance storage slot
let stored_data = load(contract_address, balance_storage_addr, 2);
// Extract the low and high parts from the storage data array
let stored_balance_low = *stored_data.at(0);
let stored_balance_high = *stored_data.at(1);
// Reconstruct the u256 from its low and high components
let stored_balance: u256 = u256 {
low: stored_balance_low.try_into().unwrap(),
high: stored_balance_high.try_into().unwrap(),
};
// Confirm that the directly read storage value matches our expected balance
assert(stored_balance == 5000, 'Direct storage read failed');
}
Notice that we used 2 as the size to load:
let stored_data = load(contract_address, balance_storage_addr, 2);
This is because ‘balance’ is of type u256 and in Cairo, u256 values are serialized as 2 felt252 values; one containing the lower 128 bits and another containing the upper 128 bits as mentioned earlier. This is why we need to read 2 felts and reconstruct the complete u256 value. If the value stored were of type u512, we would be loading 4 felt252 values.
Both store and load provide direct storage access useful for setting up specific test scenarios quickly and testing how your contract works under various state conditions.
Checking if an Event Was Emitted
Starknet Foundry also provides the spy_events cheatcode to capture and verify that specific events were emitted during contract execution. The main functions provided by the cheatcode include:
spy_events()– Start capturing eventsget_events()– Retrieve captured events- Event filtering and assertion utilities
For detailed examples and comprehensive coverage of event testing with cheatcodes, see our article on Events in Starknet.
Conclusion
This article covered some primary cheatcodes for Cairo smart contract testing: caller_address, block_timestamp, store, load, and revert testing with #[should_panic], and using safe dispatcher. These functions provide caller impersonation, timestamp manipulation, direct storage access, and error verification capabilities.
Similar to the cheatcodes within Solidity Foundry’s testing framework, Starknet Foundry cheatcodes offer comparable functionality with syntax adapted for Cairo’s architecture. The core testing concepts remain consistent across both ecosystems.
For additional cheatcodes, refer to the starknet foundry book.
This article is part of a tutorial series on Cairo Programming on Starknet