Un “cheatcode” en Foundry es un mecanismo que permite a las pruebas de contratos controlar variables de entorno como la dirección del llamador (caller address), la marca de tiempo actual (current timestamp), y así sucesivamente.
En este artículo, aprenderás cómo probar contratos inteligentes en Cairo utilizando los cheatcodes más comúnmente utilizados de Starknet Foundry.
Cheatcodes de caller_address
En los contratos inteligentes de Starknet, get_caller_address() devuelve la dirección de la cuenta actual que interactúa con una función en el contrato, de manera similar a msg.sender en Ethereum. Los contratos dependen de ella para el control de acceso, privilegios o usos personalizados. Por ejemplo, el siguiente código verifica que el llamador sea el propietario del contrato (contract owner) antes de permitir que proceda la ejecución:
// Get who is calling this function
let caller = get_caller_address();
assert(caller == self.owner.read(), 'Only owner');
Durante las pruebas, cuando una función verifica la dirección del llamador como en el código anterior, necesitamos controlar qué devuelve get_caller_address() para probar que el control de acceso funciona correctamente, sin usar cuentas reales (direcciones de billetera). Aquí es donde entran los cheatcodes de caller_address.
Los cheatcodes de caller_address de Starknet Foundry nos permiten hacer esto simulando llamadas desde cualquier dirección que necesitemos. Funcionan igual que las funciones prank en Solidity Foundry. Las funciones disponibles son:
Cheatcodes de caller_address de Starknet Foundry |
Qué hace | Equivalente en Solidity Foundry |
|---|---|---|
cheat_caller_address(target, caller_address, span) |
Suplanta al llamador para un contrato objetivo, limitado por CheatSpan |
Sin equivalente directo (vm.prank(caller_address) de Solidity afecta la siguiente llamada globalmente, no específicamente a un objetivo) |
start_cheat_caller_address(target, caller_address) |
Comienza a suplantar al llamador para un contrato objetivo | Sin equivalente directo (Solidity no tiene suplantación específica de objetivo) |
start_cheat_caller_address_global(caller_address) |
Comienza a suplantar al llamador globalmente en todos los contratos, lo que incluye el contrato objetivo y cualquier contrato que este invoque | vm.startPrank(caller_address) |
stop_cheat_caller_address(target) |
Detiene la suplantación del llamador para un contrato objetivo | Sin equivalente directo |
stop_cheat_caller_address_global() |
Detiene la suplantación global del llamador | vm.stopPrank() |
Para demostrar cómo funcionan estos cheatcodes de caller_address en la práctica, inicializa un nuevo proyecto de Scarb (scarb new cheatcodes) y elige Starknet Foundry como el ejecutor de pruebas (test runner).
En el archivo src/lib.cairo, hay un contrato de gestión de saldo predeterminado generado por Scarb que nos permite incrementar y recuperar un saldo del almacenamiento (storage) del contrato.
Actualiza este contrato base para incluir control de acceso basado en el propietario en la función increase_balance(). El contrato actualizado almacenará una dirección owner y un balance que solo puede ser modificado por el propietario. La función increase_balance() usará get_caller_address() para verificar quién la está llamando y solo permitirá que el propietario proceda. El contrato actualizado también incluirá la función get_owner() para verificar la dirección del propietario, lo cual será útil al escribir pruebas.
Copia el contrato actualizado a continuación y pégalo en 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()
}
}
}
Este patrón de control de acceso basado en el propietario es común en los protocolos DeFi donde direcciones específicas tienen permiso para realizar ciertas funciones como retirar fondos.
Dado que increase_balance() está restringido al propietario del contrato, necesitamos el cheatcode caller_address para simular llamadas desde la dirección del propietario.
Suplantando una dirección usando cheat_caller_address
El cheatcode cheat_caller_address nos permite suplantar cualquier dirección al llamar a funciones del contrato. Esto significa que podemos hacer que las llamadas de prueba parezcan venir de la dirección específica, como el propietario del contrato, permitiéndonos probar la lógica de control de acceso.
El cheatcode cheat_caller_address tiene la siguiente firma de función:
fn cheat_caller_address(target: ContractAddress, caller_address: ContractAddress, span: CheatSpan)
Toma tres parámetros:
target: El contrato específico que debería ver al llamador suplantadocaller_address: La dirección a suplantarspan: Un enumCheatSpanque define cuánto tiempo debe durar el truco. Tiene dos variantes:CheatSpan::Indefinite: El truco permanece activo hasta que se detiene manualmenteCheatSpan::TargetCalls(n): Aplica el truco durantenllamadas a funciones
Para usar cheat_caller_address en tus pruebas, navega a tests/test_contract.cairo en el directorio de tu proyecto. Borra las pruebas base y actualiza las importaciones para incluir cheat_caller_address y CheatSpan de la siguiente manera:
use starknet::ContractAddress;
use snforge_std::{declare, ContractClassTrait, DeclareResultTrait, cheat_caller_address, CheatSpan};
Para ver cómo funciona cheat_caller_address en la práctica, crearemos dos pruebas: una que demuestra el caso de fallo sin el cheatcode cheat_caller_address, y otra que muestra cómo usar el cheatcode correctamente.
Dado que el constructor actualizado de HelloContract ahora espera una dirección de propietario, necesitamos proporcionar una al desplegar el contrato en nuestras pruebas. Crearemos una función auxiliar deploy_contract que tome la dirección del propietario como parámetro y la pase al constructor, junto con una constante OWNER que proporciona una dirección simulada (mock) reutilizable para las pruebas.
Luego importaremos los dispatchers necesarios para interactuar con el contrato desplegado en la prueba. En conjunto, tenemos lo siguiente:
use starknet::ContractAddress;
use snforge_std::{declare, ContractClassTrait, DeclareResultTrait, cheat_caller_address, CheatSpan};
//NEWLY ADDED BELOW//
use cheatcodes::IHelloStarknetDispatcher;
use cheatcodes::IHelloStarknetDispatcherTrait;
const 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
}
Los dispatchers IHelloStarknetDispatcher e IHelloStarknetDispatcherTrait nos permiten llamar a funciones del contrato desde las pruebas.
OWNER es una constante que convierte la cadena de texto 'OWNER' en un tipo ContractAddress que es reutilizable a lo largo de nuestras pruebas.
La función deploy_contract declara la clase del contrato, pasa la dirección del propietario al constructor a través de constructor_args, y devuelve la dirección del contrato desplegado para que interactuemos con ella.
Prueba 1: Probando el caso de fallo
Esta primera prueba muestra qué sucede cuando intentamos llamar a increase_balance() sin usar el cheatcode cheat_caller_address. Desplegaremos el contrato con OWNER como el propietario, luego intentaremos incrementar el saldo. Esto fallará porque la dirección del entorno de prueba es diferente de la dirección del propietario almacenada en el contrato.
Añade este código de prueba test_environment_address_owner_check en tu archivo de pruebas:
use starknet::ContractAddress;
use snforge_std::{declare, ContractClassTrait, DeclareResultTrait, cheat_caller_address, CheatSpan};
use cheatcodes::IHelloStarknetDispatcher;
use cheatcodes::IHelloStarknetDispatcherTrait;
const 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);
}
Ejecuta scarb test test_environment_address_owner_check. Deberías ver este fallo:

El fallo ocurre porque cuando se ejecuta dispatcher.increase_balance(42), la función get_caller_address() dentro de increase_balance() devuelve la dirección del entorno de prueba, no OWNER. Dado que el propietario del contrato está configurado como OWNER, la afirmación assert(caller == self.owner.read(), 'Only owner') falla.
Prueba 2: Usando el cheatcode cheat_caller_address
Ahora veamos cómo cheat_caller_address resuelve este problema de pruebas de control de acceso. Añade la prueba test_cheat_caller_address a tu archivo de pruebas de la siguiente manera:
use starknet::ContractAddress;
use snforge_std::{declare, ContractClassTrait, DeclareResultTrait, cheat_caller_address, CheatSpan};
use cheatcodes::IHelloStarknetDispatcher;
use cheatcodes::IHelloStarknetDispatcherTrait;
const 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
}
La llamada cheat_caller_address(contract_address, OWNER, CheatSpan::TargetCalls(1)) sobrescribe lo que devuelve get_caller_address(). Hace que el contrato crea que la siguiente llamada a la función proviene de OWNER en lugar del entorno de prueba.
Cuando se ejecuta dispatcher.increase_balance(42), get_caller_address() devuelve OWNER, permitiendo que la verificación del propietario sea exitosa.
Ejecuta scarb test test_cheat_caller_address y deberías ver que la prueba pasa:

El parámetro CheatSpan::TargetCalls(1) le dice a snforge que aplique el truco del llamador solo para la siguiente llamada a la función (increase_balance(42)). Después de eso, la dirección del llamador vuelve a la normalidad.
Si intentáramos llamar a increase_balance() nuevamente sin otro truco o incrementar las TargetCalls, fallaría porque el llamador ya no sería el propietario.
Suplantación persistente del llamador con start_cheat_caller_address y stop_cheat_caller_address
A diferencia de cheat_caller_address que requiere un parámetro CheatSpan para controlar la duración, start_cheat_caller_address establece la dirección del llamador de forma indefinida para todas las llamadas posteriores hasta que se detenga manualmente con stop_cheat_caller_address.
start_cheat_caller_address requiere dos argumentos: un target (el contrato específico que debería ver al llamador suplantado) y un caller_address (la dirección a suplantar), como se muestra a continuación:
fn start_cheat_caller_address(target: ContractAddress, caller_address: ContractAddress
Mientras que stop_cheat_caller_address toma solo el target para detener la suplantación de ese contrato específico:
fn stop_cheat_caller_address(target: ContractAddress)
Para usar estos cheatcodes, actualiza las importaciones de la biblioteca snforge para incluir los cheatcodes start_cheat_caller_address y stop_cheat_caller_address junto a las importaciones existentes:
use snforge_std::{declare, ContractClassTrait, DeclareResultTrait, start_cheat_caller_address, stop_cheat_caller_address, cheat_caller_address, CheatSpan};
La siguiente prueba muestra cómo usar start_cheat_caller_address para la suplantación persistente del llamador a través de múltiples llamadas a funciones:
#[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);
}
En test_persistent_caller_cheat(), desplegamos el contrato con OWNER como el propietario almacenado, luego llamamos a start_cheat_caller_address(contract_address, OWNER) para comenzar a suplantar al propietario en todas las llamadas posteriores a ese contrato.
Copia la prueba anterior en tests/test_contract.cairo y ejecútala usando scarb test test_persistent_caller_cheat.
Las tres llamadas a increase_balance tendrán éxito porque el truco permanece activo a través de todas las llamadas a funciones. Cada vez que la función verifica get_caller_address(), devuelve OWNER en lugar de la dirección del entorno de prueba. El truco permanece activo hasta que llamamos explícitamente a stop_cheat_caller_address(contract_address).

Nota importante:
start_cheat_caller_addresses específico del objetivo, lo que significa que solo afecta las llamadas a la dirección del contrato especificada. Si llamaras a una función en un contrato diferente (contractB) mientras el truco está activo para contractA, contractB vería la dirección normal del entorno de prueba, no la dirección suplantada. El truco solo se aplica al contrato especificado en el parámetrotarget.
Usa start_cheat_caller_address cuando necesites realizar múltiples llamadas consecutivas como la misma dirección al contrato especificado.
Suplantación global del llamador con start_cheat_caller_address_global y stop_cheat_caller_address_global
Para probar interacciones a través de múltiples contratos, start_cheat_caller_address_global establece una dirección de llamador universal para todas las llamadas de contratos hasta que se detiene explícitamente usando stop_cheat_caller_address_global. Funciona de manera similar a startPrank/stopPrank de Solidity de Foundry.
Para usar estos cheatcodes globales del llamador, añádelos a las importaciones existentes de la biblioteca 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};
La prueba a continuación utiliza este cheatcode start_cheat_caller_address_global para interactuar con dos contratos usando el mismo llamador falsificado. Desplegaremos dos instancias separadas del contrato HelloStarknet y realizaremos llamadas a ambas mientras suplantamos al propietario globalmente:
#[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();
}
Añade el código de prueba anterior a tu archivo test_contract.cairo y ejecútalo con scarb test test_global_caller_cheat.
La prueba pasaría porque start_cheat_caller_address_global en la prueba test_global_caller_cheat() afecta a todos los contratos simultáneamente. Ambos contratos (contract1 y contract2) ven al llamador como OWNER, por lo que ambas operaciones tienen éxito sin necesidad de trucos separados para cada contrato.
Este cheatcode global del llamador es particularmente útil para probar interacciones entre múltiples contratos donde todas las llamadas deben originarse desde la misma dirección. Un ejemplo práctico son los protocolos de staking, donde un usuario necesita interactuar con múltiples contratos, aprobando tokens en un contrato ERC-20, y luego haciendo staking de esos tokens en un contrato de staking, usando la misma dirección del llamador. El uso del cheatcode global del llamador garantiza una identidad del llamador consistente en todas estas operaciones interconectadas.
Al igual que los cheatcodes caller_address nos permiten controlar quién llama a las funciones del contrato, también necesitamos una forma de probar contratos con lógica dependiente del tiempo sin esperar a que pase el tiempo real. Muchos contratos inteligentes incluyen restricciones basadas en el tiempo, como retrasos en los retiros, programas de adquisición de derechos o períodos de enfriamiento. Probar estas características normalmente requeriría esperar a que transcurra el tiempo real, lo que hace que las pruebas sean poco prácticas. Los cheatcodes de marca de tiempo del bloque (block timestamp cheatcodes) resuelven este problema al permitirnos controlar la percepción del tiempo del contrato.
Cheatcodes de block_timestamp
Los cheatcodes block_timestamp hacen posible simular un comportamiento basado en el tiempo sin esperar a que transcurra el tiempo real. Las funciones disponibles para este cheatcode son:
Cheatcode de block_timestamp de Starknet Foundry |
Qué hace | Equivalente en Solidity Foundry |
|---|---|---|
cheat_block_timestamp(target, timestamp, span) |
Establece la marca de tiempo del bloque para un contrato objetivo, limitado por CheatSpan |
Sin equivalente directo (vm.warp(timestamp) es solo global) |
start_cheat_block_timestamp(target, timestamp) |
Comienza a establecer la marca de tiempo para un contrato objetivo | Sin equivalente directo |
start_cheat_block_timestamp_global(timestamp) |
Establece la marca de tiempo del bloque globalmente en todos los contratos | vm.warp(timestamp) |
stop_cheat_block_timestamp(target) |
Detiene la modificación de la marca de tiempo para un contrato objetivo | Sin equivalente directo |
stop_cheat_block_timestamp_global() |
Detiene la modificación global de la marca de tiempo | Se restablece manualmente con vm.warp(original_timestamp) |
Para ilustrar cómo funcionan estos cheatcodes de block_timestamp, modificaremos el contrato HelloStarknet para incluir una funcionalidad de bloqueo de tiempo. El contrato modificado incluirá dos funciones nuevas:
set_lock_time(duration)que permite al propietario establecer un bloqueo de tiempo llamando aget_block_timestamp()para obtener el tiempo actual y sumándole la duracióntime_locked_withdrawal(amount)que permite al propietario retirar fondos, pero solo después de que haya pasado el tiempo de bloqueo verificando si la marca de tiempo actual (get_block_timestamp()) es mayor o igual al tiempo de bloqueo almacenado.
Copia el código actualizado a continuación y reemplaza el contenido de tu archivo 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);
}
}
}
Con este contrato de bloqueo de tiempo en su lugar, veamos cómo probarlo usando los cheatcodes block_timestamp.
Usando el cheatcode cheat_block_timestamp
El cheatcode cheat_block_timestamp altera la marca de tiempo del bloque para un contrato específico durante un número controlado de llamadas. Aquí está la firma de la función:
fn cheat_block_timestamp(target: ContractAddress, timestamp: u64, span: CheatSpan)
La función toma tres parámetros:
target: El contrato específico que debería ver la marca de tiempo modificadatimestamp: El valor de la marca de tiempo a establecerspan: Cuántas llamadas deberían ver esta marca de tiempo
Nota que en el entorno de prueba,
get_block_timestamp()devuelve 0 por defecto, por lo que no podemos depender de ello para las afirmaciones de marca de tiempo en nuestras pruebas. En su lugar, necesitamos calcular y rastrear las marcas de tiempo manualmente en función de los valores que establecemos con los cheatcodes.
Para probar la funcionalidad de bloqueo de tiempo, añade el cheatcode cheat_block_timestamp a tus importaciones de la biblioteca snforge existentes.
Primero mostraremos que los retiros con bloqueo de tiempo fallan sin ninguna manipulación de la marca de tiempo, y luego mostraremos cómo los cheatcodes nos permiten eludir la restricción de tiempo:
#[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');
}
Cuando ejecutes scarb test test_time_locked_withdrawal_fails_without_cheat, esta prueba fallará con un error ‘Still locked’, demostrando que el mecanismo de bloqueo de tiempo funciona correctamente.

Ahora veamos una prueba que “viaja en el tiempo” usando cheat_block_timestamp para simular que ha pasado suficiente tiempo para que un retiro con bloqueo de tiempo tenga éxito.
#[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');
}
En el código anterior, desplegamos el contrato, añadimos 1000 al saldo del propietario y establecimos un bloqueo de 1 hora con set_lock_time(3600).
Llamar a cheat_block_timestamp(contract_address, future_timestamp, CheatSpan::TargetCalls(1)) hace que el contrato piense que han pasado 2 horas (7200 segundos). Cuando time_locked_withdrawal() verifica la hora actual usando get_block_timestamp(), ve 7200 segundos, que es mayor que lock_until (3600), por lo que el retiro es exitoso.
let future_timestamp = 7200; // 2 hours later
cheat_block_timestamp(contract_address, future_timestamp, CheatSpan::TargetCalls(1));
El parámetro CheatSpan::TargetCalls(1) significa que solo la siguiente llamada a la función
(time_locked_withdrawal) verá esta marca de tiempo modificada.
cheat_block_timestamp simula la progresión del tiempo sin retrasos reales, permitiéndonos probar la lógica dependiente del tiempo instantáneamente.
Usando el cheatcode start_cheat_block_timestamp
A diferencia de cheat_block_timestamp que requiere un parámetro CheatSpan para controlar la duración, start_cheat_block_timestamp hace que el contrato objetivo vea la marca de tiempo simulada para todas las llamadas posteriores hasta que se detenga manualmente. Aquí está la firma de la función:
fn start_cheat_block_timestamp(target: ContractAddress, timestamp: u64)
Actualiza las importaciones de la biblioteca snforge para incluir los cheatcodes start_cheat_block_timestamp y stop_cheat_block_timestamp junto con los existentes, para que podamos ver cómo funcionan:
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};
Considera este código de prueba a continuación que muestra cómo avanzar en el tiempo reiniciando los trucos de marca de tiempo para probar la funcionalidad de bloqueo de tiempo usando start_cheat_block_timestamp y 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);
}
En test_start_cheat_block_timestamp(), comenzamos estableciendo una marca de tiempo específica (start_time) que todas las llamadas del contrato verán, luego configuramos el estado del contrato agregando saldo y creando un bloqueo de tiempo.
Para simular el paso del tiempo, detenemos el truco de la marca de tiempo actual y comenzamos uno nuevo con future_time (2 horas después), lo que permite que el retiro tenga éxito porque el contrato ahora ve la marca de tiempo posterior.
Para actualizar la marca de tiempo con start_cheat_block_timestamp, debemos detener el truco actual y comenzar uno nuevo, simulando efectivamente la progresión del tiempo al cambiar de start_time a future_time.
Usar start_cheat_block_timestamp es útil cuando necesitas que ocurran múltiples operaciones exactamente en el mismo tiempo simulado antes de avanzar el tiempo para acciones posteriores. De manera similar a start_cheat_caller_address, este cheatcode es específico del objetivo; solo afecta las llamadas realizadas a la dirección del contrato especificada. Si necesitas establecer el tiempo de manera diferente para interacciones con múltiples instancias de contratos distintas en la misma prueba, deberías usar el cheatcode start_cheat_block_timestamp_global en su lugar.
En todos los escenarios cubiertos por los cheatcodes caller_address y block_timestamp, las pruebas requieren verificar que las funciones funcionen correctamente cuando se cumplen las condiciones y que fallen cuando deberían hacerlo. Aquí es donde debemos asegurar que el contrato se revierta (revert) adecuadamente.
Esperando un revert (reversión)
Al probar que una función debería fallar bajo ciertas condiciones, Starknet Foundry proporciona el atributo #[should_panic] que es similar a vm.expectRevert() en el Foundry de Solidity. El atributo en sí no es un cheatcode, pero funciona con otros cheatcodes para probar escenarios de fallo:
#[should_panic(expected: ('Still locked',))]
El atributo #[should_panic] le dice al framework de pruebas:
- Esperar que esta prueba cause pánico (panic); si no entra en pánico, la prueba falla
- Esperar un mensaje de error específico; el pánico debe contener el mensaje exacto ‘Still locked’
- La prueba solo pasa si entra en pánico correctamente; tanto el pánico como el mensaje de error deben coincidir
Cuando una prueba con #[should_panic] pasa, confirma que la función entró en pánico como se esperaba. Es importante incluir el parámetro expected con el mensaje de error correcto para verificar que la prueba falle por la razón prevista.
Aquí hay un ejemplo básico de prueba de reversión (revert test) que verifica que los retiros con bloqueo de tiempo fallan cuando se intentan antes de que expire el período de bloqueo:
#[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);
}
Ejecuta scarb test test_time_locked_withdrawal_fails_too_early para probar el código:

Dado que el entorno de prueba comienza en la marca de tiempo 0 y establecimos un bloqueo de 3600 segundos, el intento de retiro choca con la línea assert(get_block_timestamp() >= self.lock_until.read(), 'Still locked') y entra en pánico.
El atributo #[should_panic(expected: ('Still locked',))] le dice al framework de pruebas que este pánico es esperado y la prueba debe pasar cuando ocurra.
No necesitamos stop_cheat_caller_address en la prueba test_time_locked_withdrawal_fails_too_early porque entra en pánico antes de alcanzar cualquier código de limpieza.
Usando Safe Dispatcher (Despachador Seguro)
A veces queremos verificar el error sin hacer que nuestra prueba entre en pánico. Para esto, podemos usar el “Safe Dispatcher”.
El Safe Dispatcher es una variante generada automáticamente de nuestro dispatcher de contrato que devuelve Result<T, Array<felt252>> en lugar de entrar en pánico directamente.
Cuando definimos una interfaz de contrato como IHelloStarknet, el compilador genera muchos elementos relacionados con el dispatcher, pero los principales relevantes para las pruebas son:
- Regular Dispatcher (
IHelloStarknetDispatchereIHelloStarknetDispatcherTrait): Entra en pánico en caso de errores - Safe Dispatcher (
IHelloStarknetSafeDispatchereIHelloStarknetSafeDispatcherTrait): Devuelve tipos Result
Usa el Safe Dispatcher cuando necesites:
- Examinar el mensaje de error exacto
- Probar múltiples condiciones de error en una sola prueba
- Manejar errores programáticamente sin entrar en pánico
El siguiente ejemplo de prueba usa Safe Dispatcher para verificar el control de acceso asegurando que las llamadas no autorizadas fallen y devuelvan el mensaje de error correcto. Importa los Safe Dispatchers (IHelloStarknetSafeDispatcher e IHelloStarknetSafeDispatcherTrait) para permitir la interacción con el contrato:
const USER: ContractAddress = 'USER'.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 who is NOT the owner
start_cheat_caller_address(contract_address, USER);
// 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);
}
En la prueba test_non_owner_error_with_safe_dispatcher anterior, cuando USER_1 intenta incrementar el saldo, el safe dispatcher devuelve éxito (Ok) o fracaso (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
},
};
Si la llamada tiene éxito inesperadamente, la prueba falla con ‘Should have panicked’ porque los que no son propietarios no deberían tener acceso. Si falla como se espera, verificamos que el mensaje de error sea exactamente ‘Only owner’ comprobando el primer elemento del array panic_data.

De esta manera podemos verificar tanto que la función falla como que falla con el mensaje de error correcto.
Escribiendo en el Almacenamiento (Storage)
El cheatcode store nos permite escribir valores directamente en las ranuras de almacenamiento (storage slots) del contrato durante las pruebas, sin invocar las funciones del contrato o ejecutar su flujo lógico habitual. Esto significa que podemos eludir verificaciones, validaciones, control de acceso y otras transiciones de estado que normalmente ocurrirían a través de llamadas a funciones. Esto es particularmente útil para configurar estados de contrato específicos o probar casos extremos (edge cases) sin pasar por llamadas a funciones regulares.
En el contrato HelloStarknet, tenemos el almacenamiento (storage) como se muestra a continuación:
#[storage]
struct Storage {
owner: ContractAddress,
balance: u256,
lock_until: u64,
}
Cada variable de almacenamiento tiene una ranura única en la que podemos escribir directamente usando el cheatcode store.
El cheatcode store
fn store(target: ContractAddress, storage_address: felt252, serialized_value: Span<felt252>)
Toma tres parámetros:
target: La dirección del contrato a modificarstorage_address: La ubicación de la ranura de almacenamiento (calculada usandomap_entry_address)serialized_value: El valor a almacenar, convertido a array defelt252
Encontrando Direcciones de Almacenamiento
Para usar el cheatcode store, primero debemos calcular la dirección exacta de almacenamiento para la variable que queremos modificar. Usaremos map_entry_address para calcular la ubicación de almacenamiento.
Importa tanto store como map_entry_address de la biblioteca snforge_std junto con los existentes:
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};
Así es como encontramos la dirección para la variable balance:
let balance_storage_addr = map_entry_address(
map_selector: selector!("balance"),
keys: array![].span()
);
La función map_entry_address calcula la dirección exacta de la ranura de almacenamiento para una variable. Toma dos parámetros:
map_selector: Un identificador de almacenamiento que identifica de forma única la variable de almacenamientokeys: Un array de claves utilizadas para acceder al almacenamiento mapeado
En nuestro ejemplo:
selector!("balance")convierte el nombre de la variable de almacenamiento (balance) en el identificador de almacenamiento requeridokeys: array![].span()es un array vacío porquebalancees una variable de almacenamiento simple, no un mapeo (mapping). Sibalancefuera un mapeo comoLegacyMap<ContractAddress, u256>, pasaríamos la clave de la dirección aquí
El resultado, balance_storage_addr, es la dirección de la ranura de almacenamiento que ahora podemos pasar al cheatcode store.
Para variables de almacenamiento simples como balance (no mapeos), también puedes usar la sintaxis más corta:
let balance_storage_addr = selector!("balance");
Cuándo usar cuál:
- Usa
selector!()solo para variables de almacenamiento simples (comou256,felt252,bool) - Usa
map_entry_address()para:- Tipos
LegacyMap(proporciona la clave del mapa en el arraykeys) - Arrays (proporciona el índice en el array
keys) - Cualquier tipo de almacenamiento donde necesites especificar claves o índices
- Variables simples (usando claves vacías:
array![].span()) - aunqueselector!()es más corto para este caso
- Tipos
Ambos métodos calculan la dirección exacta de almacenamiento donde el contrato almacena la variable, lo que nos permite escribir nuevos valores directamente en esa ubicación.
Serializando Valores
Diferentes tipos de datos necesitan diferentes formatos de serialización:
- Para
ContractAddress: un solofelt252
let serialized_owner = array![OWNER.into()];
- Para
u64: un solofelt252
let timestamp: u64 = 1641070800;
let serialized_timestamp = array![timestamp.into()];
- Para
u256(nuestro tipo de balance): necesita las partes baja y alta (low and high) porqueu256es más grande de lo que un solofelt252puede contener.
let balance: u256 = 5000;
let serialized_balance = array![balance.low.into(), balance.high.into()];
La siguiente prueba demuestra que las escrituras en almacenamiento eluden todo el control de acceso; no se necesita ninguna llamada a increase_balance() o verificación de propiedad. Modificaremos directamente el saldo del contrato HelloStarknet a 5000 sin invocar ninguna función del contrato:
#[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');
}
Leyendo directamente desde el almacenamiento con el cheatcode load
En lugar de usar las funciones del contrato para verificar los valores almacenados, podemos usar el cheatcode load para leer directamente desde el almacenamiento. Aquí está la firma de la función:
fn load(target: ContractAddress, storage_address: felt252, size: felt252) -> Array<felt252>
Toma tres parámetros:
target: La dirección del contrato desde donde leerstorage_address: La ubicación de la ranura de almacenamiento para leersize: Número de valoresfelt252a leer
Importa load desde snforge_std. Aquí hay una prueba que escribe un saldo usando store y lo lee de vuelta usando 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');
}
Nota que usamos 2 como el tamaño a cargar (size to load):
let stored_data = load(contract_address, balance_storage_addr, 2);
Esto se debe a que balance es de tipo u256 y en Cairo, los valores u256 se serializan como 2 valores felt252; uno que contiene los 128 bits inferiores y otro que contiene los 128 bits superiores, como se mencionó anteriormente. Es por esto que necesitamos leer 2 felts y reconstruir el valor completo de u256. Si el valor almacenado fuera de tipo u512, estaríamos cargando 4 valores felt252.
Tanto store como load proporcionan acceso directo al almacenamiento, lo cual es útil para configurar escenarios de prueba específicos rápidamente y probar cómo funciona tu contrato bajo varias condiciones de estado.
Comprobando si se Emitió un Evento
Starknet Foundry también proporciona el cheatcode spy_events para capturar y verificar que se emitieron eventos específicos durante la ejecución del contrato. Las funciones principales proporcionadas por el cheatcode incluyen:
spy_events()- Comenzar a capturar eventosget_events()- Recuperar eventos capturados- Utilidades de filtrado de eventos y aserciones
Para obtener ejemplos detallados y una cobertura completa de las pruebas de eventos con cheatcodes, consulta nuestro artículo sobre Eventos en Starknet.
Conclusión
Este artículo cubrió algunos cheatcodes principales para pruebas de contratos inteligentes en Cairo: caller_address, block_timestamp, store, load y pruebas de reversión (revert testing) con #[should_panic], y el uso del safe dispatcher. Estas funciones proporcionan capacidades de suplantación de llamador, manipulación de marcas de tiempo, acceso directo al almacenamiento y verificación de errores.
De manera similar a los cheatcodes dentro del framework de pruebas de Solidity Foundry, los cheatcodes de Starknet Foundry ofrecen una funcionalidad comparable con una sintaxis adaptada para la arquitectura de Cairo. Los conceptos centrales de las pruebas siguen siendo consistentes en ambos ecosistemas.
Para cheatcodes adicionales, consulta el libro de Starknet Foundry.
Este artículo es parte de una serie de tutoriales sobre Programación en Cairo en Starknet