Una llamada entre contratos es cómo un contrato llama a la función pública de otro contrato. Un ejemplo común es un fondo de liquidez que llama a un contrato de token ERC-20 para transferir tokens hacia o desde el fondo.
En este artículo, aprenderás cómo funcionan las llamadas entre contratos en Starknet y cómo implementarlas en tus contratos inteligentes.
Formas de hacer llamadas entre contratos
Hay dos formas de hacer llamadas entre contratos en los contratos de Starknet:
- Usando un contract dispatcher
- Usando la syscall
call_contract_syscalldirectamente
Analicemos cada una de ellas.
1. Usando un contract dispatcher
Un dispatcher es un struct generado por el compilador que permite llamadas con seguridad de tipos (type-safe) a otros contratos. Envuelve un ContractAddress e implementa el trait que el compilador genera a partir de tu #[starknet::interface].
En Solidity, conviertes la dirección del contrato de destino a un tipo de interfaz para llamar a sus funciones. El dispatcher de Cairo funciona de manera similar, excepto que el compilador lo genera a partir de tu #[starknet::interface] y maneja la conversión (casting) por ti.
Cuando llamas a la función de otro contrato, simplemente la invocas en el dispatcher con los argumentos. Internamente, el dispatcher:
- calcula el selector de la función a partir del nombre de la función en tiempo de compilación
- serializa los argumentos de la función en valores
felt252 - usa
call_contract_syscallpara ejecutar la llamada con la dirección del contrato, el selector de la función y los argumentos serializados - y deserializa el
Span<felt252>devuelto de vuelta a los tipos esperados de Cairo
El siguiente diagrama muestra lo que sucede cuando el Contrato A llama a la función del Contrato B a través de un dispatcher:

A un alto nivel, realizas una llamada entre contratos de la misma manera que llamas a cualquier función regular. El dispatcher maneja el cálculo del selector, la serialización y la deserialización en segundo plano.
Para cada interfaz de contrato, el compilador genera varios dispatchers (consulta la lista completa aquí). Nos centraremos en:
- Contract dispatcher regular: realiza llamadas entre contratos y entra en pánico en caso de fallo.
- Contract dispatcher seguro (safe): realiza llamadas entre contratos y devuelve
Result<T, Array<felt252>>. Tu código puede entonces inspeccionar el resultado y manejar los fallos sin revertir toda la transacción. Sin embargo, hay casos que aún causan reversiones inmediatas que no pueden ser capturadas. Discutiremos estas limitaciones más adelante en el artículo.
Construyendo un contrato de banco simple para demostrar las llamadas entre contratos
Analizaremos un contrato de banco donde los usuarios pueden depositar y retirar RareTokens (nuestra implementación ERC-20 de un capítulo anterior). La configuración incluye dos contratos:
RareBank: el contrato bancario principal.RareToken: el contrato del token ERC-20.
El RareBank usará un dispatcher para llamar a las funciones en el contrato RareToken para depósitos y retiros.
Definamos tanto la interfaz del contrato del token IRareToken como la de IRareBank:
use starknet::ContractAddress;
// RareToken ERC20 Interface
#[starknet::interface]
pub trait IRareToken<TContractState> {
fn total_supply(self: @TContractState) -> u256;
fn balance_of(self: @TContractState, account: ContractAddress) -> u256;
fn allowance(self: @TContractState, owner: ContractAddress, spender: ContractAddress) -> u256;
fn transfer(ref self: TContractState, recipient: ContractAddress, amount: u256) -> bool;
fn transfer_from(ref self: TContractState, sender: ContractAddress, recipient: ContractAddress, amount: u256) -> bool;
fn approve(ref self: TContractState, spender: ContractAddress, amount: u256) -> bool;
fn name(self: @TContractState) -> ByteArray;
fn symbol(self: @TContractState) -> ByteArray;
fn decimals(self: @TContractState) -> u8;
fn mint(ref self: TContractState, recipient: ContractAddress, amount: u256) -> bool; // For testing
}
// RareBank Interface
#[starknet::interface]
pub trait IRareBank<TContractState> {
fn deposit(ref self: TContractState, amount: u256);
fn withdraw(ref self: TContractState, amount: u256);
fn get_balance(self: @TContractState, user: ContractAddress) -> u256;
}
Cuando defines una interfaz con #[starknet::interface], el compilador genera automáticamente tipos de dispatcher para ella. Estos están disponibles cuando importas la interfaz usando use super::{I..}.
En nuestro caso, definir IRareBank e IRareToken genera sus respectivos dispatchers mostrados en la animación a continuación:
Como se ve arriba, el compilador genera muchos tipos relacionados con los dispatchers, pero los más relevantes para nuestra discusión (resaltados en verde) son:
Para IRareBank:
IRareBankDispatcherpara llamadas de contratos regulares que entran en pánico ante erroresIRareBankSafeDispatcherpara llamadas que devuelvenResult<...>para el manejo de errores
Para IRareToken:
IRareTokenDispatcherpara llamadas regularesIRareTokenSafeDispatcherpara llamadas con manejo de errores
Los otros tipos generados (como Copy, Drop, Serde, etc.) son detalles de implementación que Cairo usa internamente para hacer que estos dispatchers funcionen correctamente.
Usando un contract dispatcher regular en el contrato del banco
Pasando a la implementación del contrato, aquí están las importaciones requeridas:
#[starknet::contract]
mod RareBank {
use super::{IRareTokenDispatcher, IRareTokenDispatcherTrait};
}
Importamos tanto el struct IRareTokenDispatcher como IRareTokenDispatcherTrait usando super:: porque se generan en el mismo módulo donde definimos nuestras interfaces. Aquí está el struct IRareTokenDispatcher que el compilador genera (resaltado en naranja):

El struct del dispatcher contiene la dirección del contrato de destino (que apuntará al contrato RareToken), y el compilador genera el IRareTokenDispatcherTrait correspondiente que contiene todas las firmas de funciones a las que podemos llamar desde la interfaz IRareToken:
trait IRareTokenDispatcherTrait<T> {
fn total_supply(self: T) -> u256;
fn balance_of(self: T, account: ContractAddress) -> u256;
fn allowance(self: T, owner: ContractAddress, spender: ContractAddress) -> u256;
fn transfer(self: T, recipient: ContractAddress, amount: u256) -> bool;
fn transfer_from(self: T, sender: ContractAddress, recipient: ContractAddress, amount: u256) -> bool;
fn approve(self: T, spender: ContractAddress, amount: u256) -> bool;
fn name(self: T) -> ByteArray;
fn symbol(self: T) -> ByteArray;
fn decimals(self: T) -> u8;
fn mint(self: T, recipient: ContractAddress, amount: u256) -> bool;
}
// The compiler also generates this implementation
impl IRareTokenDispatcherImpl of IRareTokenDispatcherTrait<IRareTokenDispatcher> {
fn transfer(self: IRareTokenDispatcher, recipient: ContractAddress, amount: u256) -> bool {
//logic goes in
}
// ... other function implementations
}
Ten en cuenta que todas las firmas de las funciones coinciden exactamente con lo que definimos en nuestra interfaz IRareToken, pero el parámetro self cambia de @TContractState o ref TContractState a simplemente T.
El parámetro de tipo genérico T permite que el mismo trait se reutilice para diferentes tipos de dispatcher. Dado que el compilador genera múltiples variantes de dispatcher a partir de tu interfaz (como IRareTokenDispatcher y IRareTokenSafeDispatcher), usar T significa que una sola definición de trait puede servir para todas estas variantes. Cuando usas un dispatcher específico, el compilador sustituye T por ese tipo concreto.
El self del dispatcher es T, el genérico que contiene la dirección del contrato de destino en lugar de TContractState. A diferencia de la implementación de un contrato, no tiene acceso al estado del contrato. En su lugar, traduce las llamadas a funciones en llamadas entre contratos a la dirección que contiene.
Para cada función en la implementación del trait, el compilador genera código que:
- serializa los argumentos de la función en calldata (un array de valores
felt252) - realiza una llamada de contrato de bajo nivel usando
call_contract_syscallcon la dirección del contrato, el selector de la función y la calldata serializada - deserializa el valor devuelto en el tipo de retorno esperado
Este es el proceso de “traducción” que mencionamos antes: el dispatcher convierte las llamadas a funciones de alto nivel de Cairo en syscalls de bajo nivel, las ejecuta y convierte los resultados de vuelta a los tipos de Cairo.
Aquí está la implementación completa del contrato del banco. El constructor configura el banco con un owner y la dirección del contrato RareToken. deposit() acepta depósitos de tokens del usuario, withdraw() transfiere los tokens de vuelta al usuario y get_balance() devuelve el saldo bancario actual de un usuario:
use starknet::ContractAddress;
// RareToken ERC20 Interface - defines functions we can call on the token contract
#[starknet::interface]
pub trait IRareToken<TContractState> {
fn total_supply(self: @TContractState) -> u256;
fn balance_of(self: @TContractState, account: ContractAddress) -> u256;
fn allowance(self: @TContractState, owner: ContractAddress, spender: ContractAddress) -> u256;
fn transfer(ref self: TContractState, recipient: ContractAddress, amount: u256) -> bool;
fn transfer_from(ref self: TContractState, sender: ContractAddress, recipient: ContractAddress, amount: u256) -> bool;
fn approve(ref self: TContractState, spender: ContractAddress, amount: u256) -> bool;
fn name(self: @TContractState) -> ByteArray;
fn symbol(self: @TContractState) -> ByteArray;
fn decimals(self: @TContractState) -> u8;
fn mint(ref self: TContractState, recipient: ContractAddress, amount: u256) -> bool; // For testing
}
// RareBank Interface - defines the bank's functions
#[starknet::interface]
pub trait IRareBank<TContractState> {
fn deposit(ref self: TContractState, amount: u256);
fn withdraw(ref self: TContractState, amount: u256);
fn get_balance(self: @TContractState, user: ContractAddress) -> u256;
}
// RareBank Contract - manages RareToken deposits and withdrawals
#[starknet::contract]
mod RareBank {
use starknet::{ContractAddress, get_caller_address, get_contract_address};
use starknet::storage::{
StoragePointerReadAccess, StoragePointerWriteAccess,
Map, StoragePathEntry
};
// import the generated dispatcher and trait for cross contract calls
use super::{IRareTokenDispatcher, IRareTokenDispatcherTrait};
#[storage]
struct Storage {
owner: ContractAddress,
rare_token: ContractAddress, // address of the RareToken contract we'll interact with
balances: Map<ContractAddress, u256>, // maps user addresses to their bank balances
}
#[event]
#[derive(Drop, starknet::Event)]
pub enum Event {
DepositSuccessful: DepositSuccessful,
WithdrawSuccessful: WithdrawSuccessful,
}
#[derive(Drop, starknet::Event)]
struct DepositSuccessful {
user: ContractAddress,
amount: u256
}
#[derive(Drop, starknet::Event)]
struct WithdrawSuccessful {
user: ContractAddress,
amount: u256
}
// constructor sets up the bank with owner and RareToken contract address
#[constructor]
fn constructor(ref self: ContractState, owner: ContractAddress, rare_token_address: ContractAddress) {
assert!(owner != 0.try_into().unwrap(), "address zero detected");
assert!(rare_token_address != 0.try_into().unwrap(), "address zero detected");
self.owner.write(owner);
self.rare_token.write(rare_token_address); // store the token contract address
}
// Implements the IRareBank interface and makes functions externally callable
#[abi(embed_v0)]
impl RareBankImpl of super::IRareBank<ContractState> {
fn deposit(ref self: ContractState, amount: u256) {
assert!(amount > 0, "can't deposit zero amount");
let caller = get_caller_address();
let this_contract = get_contract_address();
let rare_token_address = self.rare_token.read(); // get the stored token address
// create dispatcher instance pointing to the RareToken contract
let rare_token = IRareTokenDispatcher { contract_address: rare_token_address };
// cross contract call: transfer tokens from caller to this bank contract
// this calls the transfer_from function on the RareToken contract
// note: caller must have approved this contract to spend at least `amount` tokens
let success = rare_token.transfer_from(caller, this_contract, amount);
assert!(success, "transfer failed");
// update the caller's balance in our bank's storage
let prev_balance = self.balances.entry(caller).read();
self.balances.entry(caller).write(prev_balance + amount);
// emit DepositSuccessful event
self.emit(DepositSuccessful { user: caller, amount });
}
fn withdraw(ref self: ContractState, amount: u256) {
let caller = get_caller_address();
let rare_token_address = self.rare_token.read();
assert!(rare_token_address != 0.try_into().unwrap(), "RareToken not set");
// check if caller has sufficient balance in the bank
let user_balance = self.balances.entry(caller).read();
assert!(user_balance >= amount, "insufficient funds");
// update balance first
self.balances.entry(caller).write(user_balance - amount);
// create dispatcher instance pointing to the RareToken contract
let rare_token = IRareTokenDispatcher { contract_address: rare_token_address };
// cross contract call: transfer tokens from bank back to caller
// this calls the transfer function on the RareToken contract
let success = rare_token.transfer(caller, amount);
assert!(success, "transfer failed");
// emit WithdrawSuccessful event
self.emit(WithdrawSuccessful { user: caller, amount });
}
// view function to check user's balance in the bank
fn get_balance(self: @ContractState, user: ContractAddress) -> u256 {
self.balances.entry(user).read()
}
}
}
Observando las líneas a continuación de la función deposit(), aquí se muestra cómo RareBank usa un dispatcher para llamar a transfer_from en el contrato RareToken para transferir tokens del llamador al banco:
// create a dispatcher instance pointing to the *RareToken* contract
let rare_token = IRareTokenDispatcher { contract_address: rare_token_address };
// use the dispatcher to call transfer_from on the *RareToken* contract
let success = rare_token.transfer_from(caller, this_contract, amount);
El equivalente en Solidity sería:
// cast the address to the interface type
// transferFrom returns true on success, reverts on failure
IERC20 rareToken = IERC20(rareTokenAddress);
bool success = rareToken.transferFrom(msg.sender, address(this), amount);
Tanto Cairo como Solidity utilizan el mismo enfoque: envolver una dirección de contrato con la definición de una interfaz para realizar llamadas con seguridad de tipos (type-safe) a contratos externos. La sintaxis difiere ligeramente (Solidity envuelve usando IERC20(address) mientras que Cairo usa la inicialización de un struct), pero el concepto subyacente es idéntico.
Cómo funciona el Dispatcher en la función deposit()
Esto es lo que sucede en el código deposit() de Cairo:
- Crear instancia del dispatcher: Instanciamos
IRareTokenDispatchercon la dirección del contrato RareToken - Llamar a la función: Cuando llamamos a
rare_token.transfer_from(caller, this_contract, amount), el dispatcher (IRareTokenDispatcherTrait) maneja la traducción - Proceso de traducción:
- El dispatcher serializa los argumentos (
caller,this_contract,amount) en un array defelt252 - Calcula el selector de la función (un hash
felt252) a partir del nombre de la función usando
selector!("transfer_from") - Llama a
call_contract_syscallcon:- La dirección del contrato RareToken
- El selector de la función calculado
- Los argumentos serializados
- Deserializa el array
felt252devuelto de vuelta en un resultadobool
- El dispatcher serializa los argumentos (
Una vez que la llamada entre contratos tiene éxito, el estado del contrato RareToken se actualiza (los tokens se transfieren del llamador al banco), y el contrato del banco continúa con su propia lógica:
// update the user's balance in the bank's storage
let prev_balance = self.balances.entry(caller).read();
self.balances.entry(caller).write(prev_balance + amount);
// emit DepositSuccessful event
self.emit(DepositSuccessful { user: caller, amount });
El mismo patrón se aplica en la función withdraw():
let rare_token = IRareTokenDispatcher { contract_address: rare_token_address };
let success = rare_token.transfer(caller, amount);
Usando el contract dispatcher seguro (safe) para el manejo de errores
A diferencia del contract dispatcher regular, cuando llamas a una función usando un dispatcher seguro, no obtienes el resultado directamente. En su lugar, obtienes un tipo Result que puede ser:
Ok(value): la llamada tuvo éxito y devolvió un valorErr(error_data): la llamada falló y devolvió información de error
Esto te permite manejar los errores por ti mismo. Usas match para manejar ambos casos y decidir qué hacer cuando ocurren errores.
Ampliando RareBank para usar el dispatcher seguro
Creemos la función withdraw_safe(), una versión que usa el dispatcher seguro para manejar errores del contrato RareToken. Cuando llamamos a rare_token.transfer() y falla (devuelve un error), en lugar de entrar en pánico y revertir toda la transacción, restauraremos el saldo bancario del usuario y emitiremos un evento de error.
Importa los tipos de dispatcher seguro de RareToken (IRareTokenSafeDispatcher y IRareTokenSafeDispatcherTrait) junto con las importaciones de dispatcher existentes:
use super::{
IRareTokenDispatcher, IRareTokenDispatcherTrait,
IRareTokenSafeDispatcher, IRareTokenSafeDispatcherTrait, //NEWLY ADDED
};
A continuación, define un nuevo evento para capturar los fallos de retiro (WithdrawFailed):
#[event]
#[derive(Drop, starknet::Event)]
pub enum Event {
DepositSuccessful: DepositSuccessful,
WithdrawSuccessful: WithdrawSuccessful,
WithdrawFailed: WithdrawFailed, // New event
}
#[derive(Drop, starknet::Event)]
struct WithdrawFailed {
user: ContractAddress,
amount: u256,
error: Array<felt252>,
}
Actualiza la interfaz IRareBank para incluir la nueva función:
#[starknet::interface]
pub trait IRareBank<TContractState> {
fn deposit(ref self: TContractState, amount: u256);
fn withdraw(ref self: TContractState, amount: u256);
fn withdraw_safe(ref self: TContractState, amount: u256); // ADD THIS
fn get_balance(self: @TContractState, user: ContractAddress) -> u256;
}
Ahora implementa la función withdraw_safe. El atributo #[feature("safe_dispatcher")] es obligatorio cuando se usan dispatchers seguros. Sin él, obtendrás una advertencia del compilador sobre el uso de una característica inestable. El atributo habilita explícitamente el uso de dispatchers seguros para esta función:
#[feature("safe_dispatcher")]
fn withdraw_safe(ref self: ContractState, amount: u256) {
let caller = get_caller_address();
let rare_token_address = self.rare_token.read();
// check if caller has sufficient balance in the bank
let user_balance = self.balances.entry(caller).read();
assert!(user_balance >= amount, "insufficient funds");
// update balance first
self.balances.entry(caller).write(user_balance - amount);
// CREATE SAFE DISPATCHER INSTANCE
let rare_token = IRareTokenSafeDispatcher { contract_address: rare_token_address };
match rare_token.transfer(caller, amount) {
Result::Ok(_) => {
// Transfer succeeded - RareToken always returns true on success
self.emit(WithdrawSuccessful { user: caller, amount });
},
Result::Err(error) => {
// Transfer panicked - restore balance and emit error
self.balances.entry(caller).write(user_balance);
self.emit(WithdrawFailed { user: caller, amount, error });
},
}
}
La función withdraw_safe comienza comprobando el saldo del usuario, luego deduce la cantidad del retiro. Creamos un dispatcher seguro apuntando al contrato RareToken y llamamos a transfer().
La declaración match maneja el Result devuelto:
Result::Ok(_): La función de transferencia se ejecutó con éxito. La funcióntransferdel contrato RareToken siempre devuelvetruecuando tiene éxito; en cambio, todos los casos de error hacen que la función entre en pánico.Result::Err(error): La función de transferencia entró en pánico. Esto sucede cuando:- El remitente tiene un saldo insuficiente (
assert(sender_prev_balance >= amount)falla) - La verificación de la transacción falla
- Cualquier otra aserción en la función de transferencia falla
- El remitente tiene un saldo insuficiente (
Cuando capturamos el error, restauramos el saldo bancario del usuario de vuelta a user_balance (la cantidad antes de deducir el retiro) y emitimos WithdrawFailed con los detalles del error.
Cuándo los dispatchers seguros siguen revirtiendo
Aunque los contract dispatchers seguros manejan muchos escenarios de error durante las llamadas entre contratos, ciertos fallos a nivel del sistema provocan que toda la transacción se revierta inmediatamente en lugar de devolver un Result::Err. Estos incluyen:
- llamar a un contrato que no existe en la dirección especificada
- errores lanzados por contratos en Cairo Zero, que no soportan el manejo de errores con
Result - cuando el contrato llamado intenta internamente desplegar un contrato con parámetros no válidos
- cuando el contrato llamado intenta internamente actualizarse usando un hash de clase inexistente
Estos son fallos a nivel del sistema que los dispatchers seguros no pueden capturar. Los contract dispatchers seguros solo capturan errores de la ejecución normal del contrato, como aserciones fallidas o reversiones explícitas.
En nuestro ejemplo withdraw_safe, si rare_token_address apunta a un contrato inexistente, la transacción se revertirá inmediatamente cuando llamemos a rare_token.transfer(), incluso con un dispatcher seguro. De manera similar, si estuvieras llamando a un contrato de fábrica que intenta desplegar con un hash de clase inválido, eso también causaría una reversión inmediata.
Nota: Se espera que estas limitaciones se solucionen en futuras versiones de Starknet.
2. Usando la syscall call_contract_syscall directamente
Cairo usa call_contract_syscall para realizar llamadas entre contratos. Si bien los contract dispatchers usan esta syscall internamente, podemos llamarla directamente cuando necesitamos control manual sobre la serialización y deserialización.
Para usar call_contract_syscall, necesitamos importar lo siguiente:
use starknet::{SyscallResultTrait, syscalls};
SyscallResultTrait proporciona el método .unwrap_syscall() para extraer resultados de las operaciones de syscall, y el módulo syscalls contiene la función call_contract_syscall que usaremos para realizar la llamada de bajo nivel.
Ejemplo de implementación con call_contract_syscall
El siguiente código muestra cómo nuestra función de depósito usa call_contract_syscall directamente para ejecutar la llamada entre contratos:
fn deposit_with_direct_syscall(ref self: ContractState, amount: u256) {
assert!(amount > 0, "can't deposit zero amount");
let caller = get_caller_address();
let this_contract = get_contract_address();
let rare_token_address = self.rare_token.read();
// manually serialize function arguments into felt252 array
let mut call_data: Array<felt252> = array![];
Serde::serialize(@caller, ref call_data); // sender
Serde::serialize(@this_contract, ref call_data); // recipient
Serde::serialize(@amount, ref call_data); // amount
// === MAKE THE DIRECT SYSCALL === //
let mut res = syscalls::call_contract_syscall(
rare_token_address,
selector!("transfer_from"),
call_data.span(),
).unwrap_syscall();
// manually deserialize the response
let success: bool = Serde::<bool>::deserialize(ref res).unwrap();
assert!(success, "transfer failed");
// update balance and emit event
let prev_balance = self.balances.entry(caller).read();
self.balances.entry(caller).write(prev_balance + amount);
self.emit(DepositSuccessful { user: caller, amount });
}
call_contract_syscall requiere tres parámetros:
let mut res = syscalls::call_contract_syscall(
rare_token_address,
selector!("transfer_from"),
call_data.span(),
).unwrap_syscall();
- Dirección del contrato (
rare_token_address): el contrato de destino a llamar - Selector de función: calculado mediante
selector!("function_name") - Calldata: argumentos de la función serializados como
Span<felt252>
Debemos manejar manualmente la serialización de los argumentos usando Serde::serialize() y la deserialización de las respuestas usando Serde::deserialize().
deposit_with_direct_syscall() hace exactamente lo mismo que hace deposit() en nuestro contract dispatcher regular.
Método recomendado para las llamadas entre contratos
No se recomienda usar call_contract_syscall directamente para interacciones estándar entre contratos, ya que:
- Requiere serialización/deserialización manual
- Carece de comprobación de tipos en tiempo de compilación
- Es más propenso a errores de serialización que los dispatchers
En su lugar, usa el enfoque de contract dispatcher:
let rare_token = IRareTokenDispatcher { contract_address: rare_token_address };
let success = rare_token.transfer_from(caller, this_contract, amount);
Dado que el contract dispatcher maneja la serialización y la comprobación de tipos automáticamente, las syscalls directas solo deben usarse cuando los dispatchers no puedan cumplir con tus requisitos específicos.
Conclusión
Hemos cubierto las formas principales de realizar llamadas entre contratos en Starknet: los contract dispatchers (regulares y seguros) y las syscalls directas.
Los contract dispatchers son el enfoque recomendado para la mayoría de los casos de uso. Usa dispatchers regulares cuando las llamadas deban tener éxito. Usa dispatchers seguros cuando necesites manejar fallos sin revertir toda la transacción, teniendo en cuenta que algunos fallos a nivel de sistema aún pueden causar reversiones inmediatas, como hemos discutido. Las syscalls directas atienden necesidades especializadas que requieren control explícito de la serialización.
Al construir con llamadas entre contratos, siempre valida las entradas, maneja los errores con cuidado y ten en cuenta las reversiones o los contratos maliciosos para mantener tus contratos seguros.