En la Parte 1 de Componentes, aprendimos cómo crear y usar un componente dentro de un solo archivo. Construimos un CounterComponent desde cero e integramos su almacenamiento, eventos e implementaciones en nuestro contrato.
La mayoría de los componentes utilizados en contratos inteligentes provienen de bibliotecas externas. OpenZeppelin Contracts for Cairo proporciona componentes para propiedad (ownership), control de acceso, estándares de tokens y más, que se pueden importar en los contratos, de manera similar a OpenZeppelin Contracts for Solidity.
En este tutorial, aprenderás cómo importar y usar componentes de OpenZeppelin, en lugar de construir todo desde cero; entenderás las rutas de importación para componentes de crates externos; y usarás el OpenZeppelin Wizard para generar código boilerplate.
Configuración de Dependencias
Antes de que podamos importar componentes de OpenZeppelin, necesitamos agregar la biblioteca de OpenZeppelin Contracts como una dependencia en nuestro proyecto. Cairo utiliza Scarbs.xyz como su registro oficial de paquetes, similar a npm para JavaScript o crates.io para Rust.
Crea un nuevo proyecto scarb y navega a su directorio:
scarb new erc20_component
cd erc20_component
Abre el archivo Scarb.toml en el directorio de tu proyecto y agrega la siguiente entrada bajo la sección [dependencies]:
[dependencies]
starknet = "2.13.1"
openzeppelin = "2.0.0" //ADD THIS LINE

La sintaxis openzeppelin = "2.0.0" obtiene automáticamente el paquete desde Scarbs.xyz, el registro oficial de paquetes de Cairo. La versión “2.0.0” especifica qué lanzamiento de OpenZeppelin Contracts usar. Estamos usando v2.0.0, que es el último lanzamiento estable al momento de escribir este artículo. Consulta Scarbs.xyz for OpenZeppelin o la página de lanzamientos de OpenZeppelin Contracts for Cairo para ver la versión más reciente actual.
Ejecuta scarb build para descargar y compilar las dependencias. Una vez que la compilación sea exitosa, las dependencias estarán listas y podrás importar componentes de OpenZeppelin en tu contrato.
Creación de un Token ERC20 con OpenZeppelin Wizard
Construiremos un contrato de token ERC20 usando componentes de OpenZeppelin. Utilizando el OpenZeppelin Wizard, generaremos el código del contrato y luego explicaremos cómo se importan e integran los componentes.
Uso del OpenZeppelin Wizard
El OpenZeppelin Wizard es una herramienta interactiva basada en web que genera código boilerplate para contratos. En lugar de construir desde cero, nos permite seleccionar las características que queremos y produce el código completo del contrato listo para usar. Es una forma más rápida de implementar funcionalidades como Ownable, ERC20, ERC721 y más.
Nuestro token utilizará estos tres componentes:
- ERC20Component: Para la funcionalidad del token
- OwnableComponent: Para el control de acceso
- PausableComponent: Para pausar/reanudar transferencias de tokens
Ahora que entendemos lo que hace el OpenZeppelin Wizard, usémoslo para generar un contrato. El OpenZeppelin Wizard for Cairo está disponible en el subdominio Wizard del sitio web de OpenZeppelin. Ve a OpenZeppelin Wizard for Cairo en tu navegador y selecciona ‘ERC20’ como el tipo de contrato.
En la sección ‘SETTINGS’, cambia el nombre a tu nombre de token deseado y actualiza el símbolo. En la sección ‘FEATURES’, marca (☑️) Mintable y Pausable; Ownable se marca automáticamente.

Copia el código en la parte superior derecha y pégalo en el archivo src/lib.cairo en el directorio de tu proyecto. El código generado debería verse similar al siguiente contrato con todas las importaciones necesarias, declaraciones de componentes, estructura de almacenamiento, eventos, constructor y funciones personalizadas (pause, unpause y mint):
// SPDX-License-Identifier: MIT
// Compatible with OpenZeppelin Contracts for Cairo ^2.0.0
#[starknet::contract]
mod RareToken {
use openzeppelin::access::ownable::OwnableComponent;
use openzeppelin::security::pausable::PausableComponent;
use openzeppelin::token::erc20::{DefaultConfig as ERC20DefaultConfig, ERC20Component};
use starknet::ContractAddress;
component!(path: ERC20Component, storage: erc20, event: ERC20Event);
component!(path: PausableComponent, storage: pausable, event: PausableEvent);
component!(path: OwnableComponent, storage: ownable, event: OwnableEvent);
// External
#[abi(embed_v0)]
impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl<ContractState>;
#[abi(embed_v0)]
impl PausableImpl = PausableComponent::PausableImpl<ContractState>;
#[abi(embed_v0)]
impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl<ContractState>;
// Internal
impl ERC20InternalImpl = ERC20Component::InternalImpl<ContractState>;
impl PausableInternalImpl = PausableComponent::InternalImpl<ContractState>;
impl OwnableInternalImpl = OwnableComponent::InternalImpl<ContractState>;
#[storage]
struct Storage {
#[substorage(v0)]
erc20: ERC20Component::Storage,
#[substorage(v0)]
pausable: PausableComponent::Storage,
#[substorage(v0)]
ownable: OwnableComponent::Storage,
}
#[event]
#[derive(Drop, starknet::Event)]
enum Event {
#[flat]
ERC20Event: ERC20Component::Event,
#[flat]
PausableEvent: PausableComponent::Event,
#[flat]
OwnableEvent: OwnableComponent::Event,
}
#[constructor]
fn constructor(ref self: ContractState, owner: ContractAddress) {
self.erc20.initializer("RareToken", "RTK");
self.ownable.initializer(owner);
}
impl ERC20HooksImpl of ERC20Component::ERC20HooksTrait<ContractState> {
fn before_update(
ref self: ERC20Component::ComponentState<ContractState>,
from: ContractAddress,
recipient: ContractAddress,
amount: u256,
) {
let contract_state = self.get_contract();
contract_state.pausable.assert_not_paused();
}
}
#[generate_trait]
#[abi(per_item)]
impl ExternalImpl of ExternalTrait {
#[external(v0)]
fn pause(ref self: ContractState) {
self.ownable.assert_only_owner();
self.pausable.pause();
}
#[external(v0)]
fn unpause(ref self: ContractState) {
self.ownable.assert_only_owner();
self.pausable.unpause();
}
#[external(v0)]
fn mint(ref self: ContractState, recipient: ContractAddress, amount: u256) {
self.ownable.assert_only_owner();
self.erc20.mint(recipient, amount);
}
}
}
Sin mucho esfuerzo, hemos generado un contrato completamente funcional con características mintable, pausable y de control de acceso.
Con el código generado a mano, desglosaremos cómo se importan e integran los componentes de OpenZeppelin en el contrato.
Entendiendo el Código Generado
Al trabajar con componentes, se requieren tres pasos:
- importar el componente,
- vincular tu contrato a él usando el macro
component!, y - incrustar las implementaciones del componente para exponer sus funciones en tu contrato
Veamos cómo funciona esto en nuestro contrato RareToken generado.
Paso 1: Importando Componentes
El primer paso es importar los componentes. Las declaraciones de importación resaltadas en el código a continuación traen el OwnableComponent, PausableComponent y ERC20Component al alcance del contrato, haciendo que su funcionalidad esté disponible para su uso:

Paso 2: Vinculando Componentes con el Macro component!
Después de importar los componentes requeridos, los componentes se configuran (vinculan) en el contrato usando el macro component!:

El macro component! declara cómo nuestro contrato se conectará a cada componente. Toma tres argumentos:
path: La ruta al componente (lo que fue importado). En este caso:ERC20Component,PausableComponentyOwnableComponentstorage: El nombre de la variable de almacenamiento en el contrato que apunta al almacenamiento del componente. Para acceder al almacenamiento de un componente, necesitas una variable en la estructura de almacenamiento de tu contrato que haga referencia al almacenamiento del componente

En el ejemplo anterior, se utilizaron los nombres de almacenamiento erc20, pausable y ownable. Estos nombres pueden personalizarse, pero deben coincidir con lo que está declarado en la estructura de almacenamiento del contrato.
Como se discutió en la Parte 1 de Componentes, cada campo de almacenamiento está anotado con #[substorage(v0)] para indicar que hace referencia al almacenamiento de un componente.
3. event: El nombre de la variante de evento en el contrato que apunta a los eventos del componente.
En la captura de pantalla a continuación, nota cómo los nombres de eventos resaltados en la parte superior (líneas 11-13) corresponden a las variantes de evento resaltadas en la parte inferior (líneas 42, 44, 46). El parámetro event en el macro component! (por ejemplo, ERC20Event) se mapea al nombre de la variante en el enum de eventos del contrato.

En este caso, se utilizaron ERC20Event, PausableEvent y OwnableEvent. Al igual que los nombres de almacenamiento, estos pueden ser cualquier cosa, pero deben coincidir con lo que está declarado en el enum de eventos del contrato.
El atributo #[flat] aplicado a cada variante de evento es importante aquí. Recuerda de la sección “Using #[flat] attribute” del capítulo de Eventos que el atributo #[flat] cambia cómo se calculan los selectores de eventos.
Sin #[flat], los eventos del componente incluyen un ID de componente como la primera clave, y todos los eventos de una variante de componente comparten el mismo selector calculado a partir del nombre de la variante de enum externa. Por ejemplo, ambos eventos Transfer y Approval de ERC20Component usarían starknetKeccak("ERC20Event") como su selector, haciendo imposible distinguir entre diferentes tipos de eventos basándose únicamente en el selector.
Con #[flat], el prefijo del ID de componente se elimina, y cada evento usa el nombre de su propia estructura para el selector: starknetKeccak("Transfer"), starknetKeccak("Approval"). Esto permite un filtrado de eventos preciso y coincide con la estructura de eventos estándar esperada por herramientas e indexadores externos.
Paso 3: Implementaciones de Componentes
Ahora veamos las implementaciones de componentes en el código generado. Hay dos tipos: externas e internas. Las implementaciones externas se pueden llamar desde fuera del contrato, mientras que las internas solo se pueden usar dentro del contrato.
El código generado incluye tres implementaciones externas que exponen la funcionalidad de los componentes:
// External
#[abi(embed_v0)]
impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl<ContractState>;
#[abi(embed_v0)]
impl PausableImpl = PausableComponent::PausableImpl<ContractState>;
#[abi(embed_v0)]
impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl<ContractState>;
El atributo #[abi(embed_v0)] hace que estas implementaciones sean accesibles públicamente; sus funciones se pueden llamar desde fuera del contrato. Desglosaremos cada implementación.
ERC20MixinImpl combina toda la funcionalidad necesaria de ERC20 en un solo paquete:
ERC20Impl: tiene las funciones principales comotransfer,approve,balance_ofERC20MetadataImpl: tiene funciones de Metadatos comoname,symbol,decimalsERC20CamelImpl: tiene las versiones de funciones en formato Camel-case para compatibilidad (por ejemplo,balanceOf,totalSupply)
Usar el mixin ERC20 nos ahorra tener que incrustar cada implementación por separado.
Además del mixin ERC20, el contrato incrusta otras dos implementaciones externas:
PausableImplproporcionapause()para detener las operaciones del contrato,unpause()para reanudarlas, yis_paused()para verificar el estado actual de pausaOwnableMixinImplproporcionaowner()para ver el propietario actual,transfer_ownership()para transferir la propiedad a una nueva dirección, yrenounce_ownership()para eliminar al propietario por completo
El código generado también incluye estas implementaciones internas:
// Internal
impl ERC20InternalImpl = ERC20Component::InternalImpl<ContractState>;
impl PausableInternalImpl = PausableComponent::InternalImpl<ContractState>;
impl OwnableInternalImpl = OwnableComponent::InternalImpl<ContractState>;
Nota que las implementaciones anteriores no tienen #[abi(embed_v0)], eso es porque no son invocables públicamente desde fuera del contrato.
Constructor
El constructor configura el nombre y el símbolo del token a través del inicializador del componente ERC20 y establece el propietario del contrato a través del inicializador del componente Ownable.
#[constructor]
fn constructor(
ref self: ContractState,
name: ByteArray,
symbol: ByteArray,
fixed_supply: u256,
recipient: ContractAddress,
owner: ContractAddress
) {
self.erc20.initializer(name, symbol);
self.erc20.mint(recipient, fixed_supply);
self.ownable.initializer(owner);
}
Cada inicializador solo puede ser llamado una vez, bloqueando estas configuraciones después del despliegue.
Hooks de ERC20
Los hooks son funciones que se ejecutan automáticamente antes o después de ciertas operaciones. El componente ERC20 proporciona un ERC20HooksTrait que te permite agregar lógica que se ejecuta durante las transferencias de tokens.
El hook before_update
El código generado contiene un hook before_update que verifica si el contrato está pausado antes de cualquier operación de token:
impl ERC20HooksImpl of ERC20Component::ERC20HooksTrait<ContractState> {
fn before_update(
ref self: ERC20Component::ComponentState<ContractState>,
from: ContractAddress,
recipient: ContractAddress,
amount: u256,
) {
let contract_state = self.get_contract();
contract_state.pausable.assert_not_paused();
}
}
La función before_update se ejecuta antes de cualquier cambio en el balance del token (transferencias, mints o burns). En esta implementación:
self.get_contract()recupera el estado del contratocontract_state.pausable.assert_not_paused()verifica si el contrato está pausado- Si está pausado, la transacción se revierte; si no, la transferencia procede
Así es como funciona la característica pausable; al verificar el estado de pausa antes de cada operación de token, el contrato puede detener todas las transferencias cuando está pausado.
Hooks de Before y After Update
Sin implementar el hook before_update en el código generado, el componente pausable existiría en el contrato pero en realidad no afectaría las transferencias de tokens.
El ERC20HooksTrait también incluye un hook after_update que se ejecuta después de que se completa una operación de token. Aunque no se usa en este contrato, podrías implementarlo para agregar lógica personalizada que se ejecute después de transferencias, mints o burns.
Exponiendo Funciones Internas de Componentes
Algunas funciones de componentes como pause() y mint() son internas; existen dentro de los componentes pero no son accesibles públicamente. El código generado crea funciones wrapper públicas que exponen estas operaciones mientras añaden control de acceso solo para el propietario:
#[generate_trait]
#[abi(per_item)]
impl ExternalImpl of ExternalTrait {
#[external(v0)]
fn pause(ref self: ContractState) {
self.ownable.assert_only_owner();
self.pausable.pause();
}
#[external(v0)]
fn unpause(ref self: ContractState) {
self.ownable.assert_only_owner();
self.pausable.unpause();
}
#[external(v0)]
fn mint(ref self: ContractState, recipient: ContractAddress, amount: u256) {
self.ownable.assert_only_owner();
self.erc20.mint(recipient, amount);
}
}
El atributo #[generate_trait] genera automáticamente la interfaz ExternalTrait a partir de esta implementación, para que no tengas que escribir la definición del trait manualmente.
El atributo #[abi(per_item)] marca cada función individualmente para la generación del ABI, y cuando se combina con #[external(v0)] en cada función, las convierte en parte de la interfaz pública del contrato. El v0 en #[external(v0)] especifica la versión del ABI.
Cómo Funcionan las Funciones Wrapper
Cada función sigue el mismo patrón: verificar propiedad, luego ejecutar la operación. Por ejemplo, pause() llama a self.ownable.assert_only_owner() para verificar que el llamador sea el propietario, luego llama a self.pausable.pause() para pausar el contrato; si el llamador no es el propietario, la transacción se revierte.
De manera similar, unpause() verifica la propiedad y luego reanuda el contrato, mientras que mint() verifica la propiedad y luego acuña nuevos tokens a la dirección del destinatario especificada utilizando self.erc20.mint().
Sin estas funciones wrapper, las funciones internas de los componentes como pause(), unpause() y mint() existirían, pero el propietario/desplegador no podría interactuar con ellas desde fuera del contrato.
Probando el Contrato
Ahora que tenemos el contrato del token configurado, escribamos algunas pruebas. Nos enfocaremos en probar las características personalizadas que agregamos: pause(), unpause() y mint() con sus controles de acceso.
Configuración del archivo de prueba
Navega a tests/test_contract.cairo en el directorio de tu proyecto. Borra las pruebas generadas con el boilerplate, dejando solo las importaciones básicas:
use starknet::ContractAddress;
use snforge_std::{declare, ContractClassTrait, DeclareResultTrait};
Para interactuar con las funciones estándar ERC-20 en nuestras pruebas, necesitamos importar la interfaz ERC-20 y su trait dispatcher de OpenZeppelin:
use starknet::ContractAddress;
use snforge_std::{declare, ContractClassTrait, DeclareResultTrait};
// NEWLY ADDED//
use openzeppelin::token::erc20::interface::{IERC20Dispatcher, IERC20DispatcherTrait};
El IERC20Dispatcher nos permite llamar a las funciones estándar ERC-20 como transfer, balance_of y total_supply en nuestro contrato.
Recuerda que el contrato generado usó el atributo #[generate_trait] para crear automáticamente traits para las funciones personalizadas (pause, unpause, mint). Estos traits no fueron escritos explícitamente en el contrato, por lo que para llamar a estas funciones en pruebas, se necesita una definición de interfaz manual como se muestra a continuación:
use starknet::ContractAddress;
use snforge_std::{declare, ContractClassTrait, DeclareResultTrait};
use openzeppelin::token::erc20::interface::{IERC20Dispatcher, IERC20DispatcherTrait};
// NEWLY ADDED //
// Define the interface for our custom functions
#[starknet::interface]
trait IRareToken<TContractState> {
fn pause(ref self: TContractState);
fn unpause(ref self: TContractState);
fn mint(ref self: TContractState, recipient: ContractAddress, amount: u256);
fn decimals(self: @TContractState) -> u8;
}
La interfaz IRareToken en el código anterior expone las funciones personalizadas en el entorno de prueba. El atributo #[starknet::interface] genera el dispatcher (IRareTokenDispatcher) y el trait dispatcher (IRareTokenDispatcherTrait) que se usarán para interactuar con esas funciones.
Necesitamos direcciones consistentes para las pruebas. Define constantes para proporcionar direcciones de prueba en lugar de crear nuevas cada vez:
const OWNER: ContractAddress = 'OWNER'.try_into().unwrap();
const USER: ContractAddress = 'USER'.try_into().unwrap();
const RECIPIENT: ContractAddress = 'RECIPIENT'.try_into().unwrap();
Estas constantes convierten los literales de cadena en direcciones de contrato.
Ahora necesitamos una función auxiliar para desplegar nuestro contrato de token en el entorno de pruebas. Agrega la función deploy_token en test_contract.cairo:
use starknet::ContractAddress;
use snforge_std::{declare, ContractClassTrait, DeclareResultTrait};
use openzeppelin::token::erc20::interface::{IERC20Dispatcher, IERC20DispatcherTrait};
#[starknet::interface]
trait IRareToken<TContractState> {
fn pause(ref self: TContractState);
fn unpause(ref self: TContractState);
fn mint(ref self: TContractState, recipient: ContractAddress, amount: u256);
fn decimals(self: @TContractState) -> u8;
}
const OWNER: ContractAddress = 'OWNER'.try_into().unwrap();
const USER: ContractAddress = 'USER'.try_into().unwrap();
const RECIPIENT: ContractAddress = 'RECIPIENT'.try_into().unwrap();
// NEWLY ADDED //
fn deploy_token() -> (ContractAddress, IERC20Dispatcher, IRareTokenDispatcher) {
let contract = declare("RareToken").unwrap().contract_class();
let mut constructor_args = array![OWNER.into()];
let (contract_address, _) = contract.deploy(@constructor_args).unwrap();
let token = IERC20Dispatcher { contract_address };
let rare_token = IRareTokenDispatcher { contract_address };
(contract_address, token, rare_token)
}
deploy_token usa declare("RareToken").unwrap().contract_class() para declarar el contrato RareToken y recuperar su clase de contrato, que carga el código compilado del contrato.
A continuación, prepara los argumentos del constructor con array![OWNER.into()], lo cual crea un array que contiene la dirección del propietario.

El constructor espera un parámetro (la dirección del propietario), por lo que lo convertimos en un felt252 utilizando .into() en la prueba. El nombre del token “RareToken” y el símbolo “RTK” ya están codificados directamente en el constructor del contrato.
Una vez que los argumentos están listos, contract.deploy(@constructor_args).unwrap() despliega el contrato y devuelve la dirección del contrato. Con el contrato desplegado, creamos dos dispatchers para la misma dirección de contrato: IERC20Dispatcher para las funciones estándar ERC-20 e IRareTokenDispatcher para las funciones personalizadas como pause(), unpause() y mint().
La función devuelve una tupla que contiene la dirección del contrato y ambos dispatchers, dándonos todo lo que necesitamos para interactuar con el contrato desplegado en nuestras pruebas.
Probando pause() para prevenir transferencias
La función pause detiene todas las operaciones del token, lo cual es útil durante incidentes de seguridad o mantenimiento.
Importa start_cheat_caller_address y stop_cheat_caller_address junto con otras importaciones de snforge_std para permitirnos suplantar diferentes direcciones al llamar a funciones del contrato:
use snforge_std::{declare, ContractClassTrait, DeclareResultTrait, start_cheat_caller_address,stop_cheat_caller_address};
Ahora escribamos una prueba que verifique que las transferencias se bloqueen cuando el contrato está pausado:
#[test]
fn test_pause_prevents_transfer() {
let (contract_address, token, rare_token) = deploy_token();
// Get token decimals for proper amount calculation
let token_decimal = rare_token.decimals();
let amount_to_mint: u256 = 10000 * token_decimal.into();
// Mint tokens to USER
start_cheat_caller_address(contract_address, OWNER);
rare_token.mint(USER, amount_to_mint);
// Pause the contract
rare_token.pause();
stop_cheat_caller_address(contract_address);
// Try to transfer - should fail when paused
start_cheat_caller_address(contract_address, USER);
token.transfer(RECIPIENT, 100 * token_decimal.into()); // This should panic
}
La prueba comienza desplegando el contrato mediante deploy_token(), lo que devuelve la dirección del contrato y los dispatchers que necesitamos para interactuar con el contrato. Luego recuperamos los decimales del token usando rare_token.decimals(). Los tokens ERC-20 típicamente usan 18 decimales, así que multiplicar 10000 * 10^18 nos da 10,000 tokens.
A continuación, usamos start_cheat_caller_address para suplantar al OWNER y acuñar tokens para el USER. Mientras seguimos actuando como OWNER, llamamos a pause() para activar la función pause(), luego usamos stop_cheat_caller_address para restablecer la dirección del llamador a su valor predeterminado.
Con el contrato ahora pausado, suplantamos al USER usando de nuevo start_cheat_caller_address e intentamos transferir tokens al RECIPIENT. Esta transferencia debería fallar porque el contrato está pausado, que es exactamente lo que queremos verificar.
Cuando ejecutes scarb test test_pause_prevents_transfer, deberías ver este error en tu terminal:

El contrato rechaza correctamente la transferencia porque está pausado. El mensaje de error proviene del componente Pausable de OpenZeppelin. Si revisas el código fuente del componente Pausable de OpenZeppelin, verás que este es el error exacto que se lanza cuando se intentan operaciones en un contrato pausado:
fn assert_not_paused(self: @ComponentState<TContractState>) {
assert(!self.is_paused(), Errors::PAUSED);
}
Podemos mejorar la prueba utilizando el atributo #[should_panic] para indicar explícitamente que esperamos que la prueba entre en pánico. Esto hace que la prueba pase cuando entra en pánico con el error esperado:
#[test]
#[should_panic(expected: ('Pausable: paused',))]
fn test_pause_prevents_transfer() {
let (contract_address, token, rare_token) = deploy_token();
// Get token decimals for proper amount calculation
let token_decimal = rare_token.decimals();
let amount_to_mint: u256 = 10000 * token_decimal.into();
// Mint tokens to USER
start_cheat_caller_address(contract_address, OWNER);
rare_token.mint(USER, amount_to_mint);
// Pause the contract
rare_token.pause();
stop_cheat_caller_address(contract_address);
// Try to transfer - should fail when paused
start_cheat_caller_address(contract_address, USER);
token.transfer(RECIPIENT, 100 * token_decimal.into()); // This should panic
}
El atributo #[should_panic(expected: ('Pausable: paused',))] le dice al framework de pruebas:
- Esta prueba debe entrar en pánico
- El pánico debe contener el mensaje de error
'Pausable: paused'
Si la prueba no entra en pánico o entra en pánico con un error diferente, la prueba fallará. Ahora cuando ejecutes scarb test test_pause_prevents_transfer, deberías ver que la prueba pasa con éxito.
Probando unpause() para permitir transferencias
Después de pausar un contrato, necesitas la capacidad de reanudar las operaciones normales. Esta prueba verifica que después de reanudar, las transferencias de tokens funcionen como se espera:
#[test]
fn test_unpause_allows_transfer() {
let (contract_address, token, rare_token) = deploy_token();
// Get token decimals for proper amount calculation
let token_decimal = rare_token.decimals();
let amount_to_mint: u256 = 1000 * token_decimal.into();
// Mint tokens to USER
start_cheat_caller_address(contract_address, OWNER);
rare_token.mint(USER, amount_to_mint);
// Pause then unpause the contract
rare_token.pause();
rare_token.unpause();
stop_cheat_caller_address(contract_address);
// Transfer should now succeed
start_cheat_caller_address(contract_address, USER);
token.transfer(RECIPIENT, 100 * token_decimal.into());
// Verify the transfer worked*
assert!(token.balance_of(USER) == 900 * token_decimal.into(), "User balance incorrect");
assert!(token.balance_of(RECIPIENT) == 100 * token_decimal.into(), "Recipient balance incorrect");
}
Comenzamos desplegando el contrato y obteniendo los decimales del token, luego acuñamos 1,000 tokens al USER como el OWNER. La diferencia clave en esta prueba es que pausamos el contrato y lo reanudamos inmediatamente mientras aún actuamos como OWNER. Después de llamar a stop_cheat_caller_address, cambiamos para suplantar al USER e intentamos una transferencia de 100 tokens al RECIPIENT.
Como el contrato ya no está pausado, la transferencia debería tener éxito. Verificamos esto comprobando los balances: el USER debería tener 900 tokens restantes (1000 - 100), y el RECIPIENT debería haber recibido 100 tokens. El macro assert! confirma que estos balances son correctos, asegurando que la función unpause restablezca adecuadamente las operaciones normales del contrato.
Ejecuta la prueba con scarb test test_unpause_allows_transfer y debería pasar, confirmando que el mecanismo de pausa se puede activar y desactivar exitosamente.
Probando Control de Acceso para pause()
Funciones como pause() que pueden detener las operaciones del contrato necesitan un control de acceso adecuado. Solo el propietario del contrato debería poder pausar el contrato. Esta prueba verifica que los que no son propietarios no puedan pausar:
#[test]
#[should_panic(expected: ('Caller is not the owner',))]
fn test_only_owner_can_pause() {
let (contract_address, _token, rare_token) = deploy_token();
// Try to pause as non-owner - should panic
start_cheat_caller_address(contract_address, USER);
rare_token.pause();
// no need to stop cheat since it doesn't reach here
}
Esta prueba es sencilla pero importante. Desplegamos el contrato, luego inmediatamente intentamos llamar a pause() como USER (no como propietario). El atributo #[should_panic(expected: ('Caller is not the owner',))] le dice al framework de pruebas que esperamos que esto falle con un mensaje de error específico.
Cuando se llama a rare_token.pause(), internamente desencadena self.ownable.assert_only_owner() del componente Ownable. Como el USER no es el propietario, esta aserción falla y la transacción se revierte con el error “Caller is not the owner” como se espera.
Ejecuta la prueba con scarb test test_only_owner_can_pause y debería pasar, confirmando que nuestro control de acceso funciona correctamente.
Aquí está el archivo de pruebas que hemos construido:
use starknet::ContractAddress;
use snforge_std::{declare, ContractClassTrait, DeclareResultTrait, start_cheat_caller_address, stop_cheat_caller_address};
use openzeppelin::token::erc20::interface::{IERC20Dispatcher, IERC20DispatcherTrait};
#[starknet::interface]
trait IRareToken<TContractState> {
fn pause(ref self: TContractState);
fn unpause(ref self: TContractState);
fn mint(ref self: TContractState, recipient: ContractAddress, amount: u256);
fn decimals(self: @TContractState) -> u8;
}
const OWNER: ContractAddress = 'OWNER'.try_into().unwrap();
const USER: ContractAddress = 'USER'.try_into().unwrap();
const RECIPIENT: ContractAddress = 'RECIPIENT'.try_into().unwrap();
fn deploy_token() -> (ContractAddress, IERC20Dispatcher, IRareTokenDispatcher) {
let contract = declare("RareToken").unwrap().contract_class();
let mut constructor_args = array![OWNER.into()];
let (contract_address, _) = contract.deploy(@constructor_args).unwrap();
let token = IERC20Dispatcher { contract_address };
let rare_token = IRareTokenDispatcher { contract_address };
(contract_address, token, rare_token)
}
#[test]
#[should_panic(expected: ('Pausable: paused',))]
fn test_pause_prevents_transfer() {
let (contract_address, token, rare_token) = deploy_token();
// Get token decimals for proper amount calculation
let token_decimal = rare_token.decimals();
let amount_to_mint: u256 = 10000 * token_decimal.into();
// Mint tokens to USER
start_cheat_caller_address(contract_address, OWNER);
rare_token.mint(USER, amount_to_mint);
// Pause the contract
rare_token.pause();
stop_cheat_caller_address(contract_address);
// Try to transfer - should fail when paused
start_cheat_caller_address(contract_address, USER);
token.transfer(RECIPIENT, 100 * token_decimal.into()); // This should panic
}
#[test]
fn test_unpause_allows_transfer() {
let (contract_address, token, rare_token) = deploy_token();
// Get token decimals for proper amount calculation
let token_decimal = rare_token.decimals();
let amount_to_mint: u256 = 1000 * token_decimal.into();
// Mint tokens to USER
start_cheat_caller_address(contract_address, OWNER);
rare_token.mint(USER, amount_to_mint);
// Pause then unpause the contract
rare_token.pause();
rare_token.unpause();
stop_cheat_caller_address(contract_address);
// Transfer should now succeed
start_cheat_caller_address(contract_address, USER);
token.transfer(RECIPIENT, 100 * token_decimal.into());
// Verify the transfer worked
assert!(token.balance_of(USER) == 900 * token_decimal.into(), "User balance incorrect");
assert!(token.balance_of(RECIPIENT) == 100 * token_decimal.into(), "Recipient balance incorrect");
}
#[test]
#[should_panic(expected: ('Caller is not the owner',))]
fn test_only_owner_can_pause() {
let (contract_address, _token, rare_token) = deploy_token();
// Try to pause as non-owner - should panic
start_cheat_caller_address(contract_address, USER);
rare_token.pause();
}
Tarea: La biblioteca OpenZeppelin ERC-20 soporta la quema de tokens (burn), pero esta función es interna. Tu tarea es:
- Exponer la función burn en el contrato agregando una función wrapper pública similar a cómo se expone
mint() - El burn debe provenir de
get_caller_address() - Escribir pruebas para la funcionalidad de burn:
- Probar que un usuario puede hacer burn de sus propios tokens
- Probar que hacer burn disminuye el balance del usuario
- Probar que hacer burn disminuye el suministro total (total supply)
- Probar que el burn no puede ocurrir cuando el contrato está pausado
- Probar que un usuario no puede hacer burn de más tokens de los que tiene
Este artículo es parte de una serie de tutoriales sobre Cairo Programming on Starknet