En Component Part 1, aprendimos cómo crear y usar un componente dentro de un contrato, y demostramos que los componentes se comportan como contratos abstractos en Solidity. En Component Part 2, aprendimos a crear un contrato de token usando componentes preconstruidos de OpenZeppelin.
Hasta ahora, hemos utilizado componentes a nivel de contrato, donde el contrato importa componentes y llama a sus funciones. Pero, ¿qué pasa si queremos construir un componente que utilice la funcionalidad de otros componentes?
Por ejemplo, consideremos un Staking Component que pueda ser reutilizado en múltiples contratos. Cuando los usuarios hacen stake o unstake, el componente necesita transferir tokens hacia y desde sus cuentas. También necesita asegurar que solo el propietario del contrato pueda actualizar las tasas de recompensa. Ambos requisitos implican llamar a otros componentes. La interacción de componente a componente hace posible que el Staking Component llame a dos componentes separados para manejar estos requisitos.
El siguiente diagrama muestra el Staking Contract integrando tres componentes: Staking Component maneja la lógica de staking y depende tanto del ERC20 Component (para transferencias de tokens) como del Ownable Component (para asegurar que solo el propietario del contrato pueda actualizar las tasas de recompensa):

Al final de este capítulo, aprenderás cómo:
- Llamar a componentes directamente desde otros componentes
- Gestionar dependencias de componentes dentro de un contrato
- Especificar qué funciones del componente exponer en el ABI del contrato
- Inicializar componentes durante el despliegue del contrato
Cómo los Componentes Llaman a Otros Componentes
Un componente debe centrarse en un área de responsabilidad, como la gestión de tokens, el control de acceso o la lógica de staking. Cuando un componente necesita usar funcionalidades de otros componentes, declaras esas dependencias en la firma de implementación del componente. Una vez declaradas, el componente puede llamar a funciones de esas dependencias. Por ejemplo, un componente de staking podría llamar a funciones de transferencia de un componente ERC20 y a verificaciones de propiedad de un componente ownable, sin que el contrato necesite coordinar estas interacciones.
Así es como se ve el flujo de interacción de componente a componente cuando un usuario hace stake de 100 tokens:

En este tutorial, integraremos (embeberemos) el ERC20 Component y el Staking Component en el mismo contrato. Este enfoque embebido es principalmente para demostrar la interacción de componente a componente. En producción, los contratos de staking típicamente aceptan una dirección de token externa e interactúan con ese contrato de token separado usando despachadores (contract dispatchers). Explicaremos el enfoque del token externo y sus diferencias al final del artículo.
Construyendo el Staking Component
Construiremos un Staking Component que dependa del ERC20 Component y del Ownable Component de OpenZeppelin para ver cómo funciona la interacción de componente a componente en la práctica, y luego lo integraremos en un Staking Contract.
Nuestro Staking Component proporcionará la siguiente funcionalidad:
- Hacer stake de tokens: Los usuarios pueden hacer stake de tokens para ganar recompensas. Cuando un usuario hace stake, los tokens se transfieren de su balance al contrato.
- Hacer unstake de tokens: Los usuarios pueden hacer unstake de sus tokens en cualquier momento (sin período de bloqueo). Al hacer unstake, los tokens en stake se devuelven al usuario junto con las recompensas acumuladas.
- Establecer tasas de recompensa: Solo el propietario del contrato puede actualizar la tasa de recompensa, la cual determina cuántos tokens de recompensa ganan los usuarios por token en stake a lo largo del tiempo.
Configuración del Proyecto
Crea un nuevo proyecto de scarb y navega a su directorio:
scarb new component_component
cd component_component
Añadiendo Dependencias de Componentes
Para usar los componentes ERC20 y Ownable de OpenZeppelin, añade la dependencia de OpenZeppelin a tu archivo Scarb.toml bajo [dependencies]:

Interfaz de Staking
La siguiente interfaz define las funciones específicas de staking que el Staking Component implementará:
use starknet::ContractAddress;
#[starknet::interface]
pub trait IStaking<TContractState> {
// User functions
fn stake(ref self: TContractState, amount: u256);
fn unstake(ref self: TContractState, amount: u256);
fn claim_rewards(ref self: TContractState);
// View functions
fn get_staked_balance(self: @TContractState, user: ContractAddress) -> u256;
fn get_total_staked(self: @TContractState) -> u256;
fn calculate_rewards(self: @TContractState, user: ContractAddress) -> u256;
fn get_reward_rate(self: @TContractState) -> u256;
// Admin function
fn set_reward_rate(ref self: TContractState, rate: u256);
}
Añade la interfaz a tu archivo src/lib.cairo, y luego añade la siguiente estructura del componente debajo de ella:
#[starknet::component]
pub mod StakingComponent {
// Component implementation will go here
}
Configuración del Storage
Cada componente define su estructura de almacenamiento (storage) para mantener el registro de su estado. Para el StakingComponent, necesitamos registrar cuánto ha puesto en stake cada usuario, cuándo se calcularon sus recompensas por última vez, la cantidad total en stake en el contrato, las recompensas acumuladas de los usuarios y la tasa de recompensa. Vamos a definir la estructura del storage dentro del StakingComponent:
#[starknet::component]
pub mod StakingComponent {
use starknet::ContractAddress;
use starknet::storage::Map;
#[storage]
pub struct Storage {
staked_balances: Map<ContractAddress, u256>,
total_staked: u256,
reward_rate: u256,
last_update_time: Map<ContractAddress, u64>,
accumulated_rewards: Map<ContractAddress, u256>,
}
}
Aquí está lo que representa cada campo del storage:
staked_balances: Un mapeo que rastrea cuántos tokens ha puesto en stake cada usuario. La clave es la dirección del usuario, y el valor es su cantidad en stake.total_staked: La cantidad total de tokens en stake de todos los usuarios en el contrato.reward_rate: La cantidad de tokens de recompensa acumulados por token en stake por segundo (escalado por 1,000,000). Esto puede ser actualizado por el propietario del contrato.last_update_time: Un mapeo de la dirección del usuario a la marca de tiempo (timestamp) de su última actualización de recompensas.accumulated_rewards: Un mapeo que rastrea las recompensas totales que cada usuario ha acumulado pero aún no ha reclamado.
Una nota sobre el storage de componentes y el nombrado de variables
Recuerda del Component Part 1 que el atributo #[substorage(v0)] permite al contrato que usa el componente acceder al estado de ese componente.
Al integrar múltiples componentes, si dos componentes definen variables de storage con nombres idénticos, el compilador de Cairo emitirá una advertencia sobre una posible colisión:
warn: The path `component_a.variable_name` collides with existing path `component_b.variable_name`.
Puedes suprimir esta advertencia con #[allow(starknet::colliding_storage_paths)], pero esto no evita la colisión; solo silencia la advertencia. Ambas variables apuntarán a la misma ubicación de almacenamiento.
Esta es la razón por la que OpenZeppelin añade prefijos a las variables de storage en sus componentes (ERC20_total_supply, Ownable_owner, etc.). Los prefijos aseguran que incluso cuando se usan múltiples componentes juntos, sus variables de storage tengan nombres únicos y no colisionen.
Por lo tanto, al construir componentes, usa prefijos descriptivos o nombres que sean poco probables de entrar en conflicto con las variables de storage de otros componentes.
Declaración de Eventos
Para rastrear cuándo los usuarios hacen stake y unstake de tokens, añade las siguientes definiciones de eventos al StakingComponent:
#[event]
#[derive(Drop, starknet::Event)]
pub enum Event {
Staked: Staked,
Unstaked: Unstaked
}
#[derive(Drop, starknet::Event)]
pub struct Staked {
pub user: ContractAddress,
pub amount: u256,
}
#[derive(Drop, starknet::Event)]
pub struct Unstaked {
pub user: ContractAddress,
pub amount: u256,
}
Staked registra la dirección del usuario y la cantidad cuando hacen stake de sus tokens. Unstaked hace lo mismo cuando un usuario hace unstake.
Implementando la Interfaz de Staking
Con las variables de estado y los eventos en su lugar, ahora podemos implementar la interfaz IStaking que definimos anteriormente. Comenzaremos creando stubs de funciones vacíos para que nuestro código compile, luego implementaremos cada función una por una.
Añade el siguiente bloque de implementación al StakingComponent:
#[embeddable_as(StakingImpl)]
impl StakingImplImpl<
TContractState,
+HasComponent<TContractState>,
+Drop<TContractState>,
> of super::IStaking<ComponentState<TContractState>> {
fn stake(ref self: ComponentState<TContractState>, amount: u256) {
// Implementation will go here
}
fn unstake(ref self: ComponentState<TContractState>, amount: u256) {
// Implementation will go here
}
fn claim_rewards(ref self: ComponentState<TContractState>) {
// Implementation will go here
}
fn get_staked_balance(
self: @ComponentState<TContractState>, user: ContractAddress
) -> u256 {
0 // Placeholder
}
fn get_total_staked(self: @ComponentState<TContractState>) -> u256 {
0 // Placeholder
}
fn calculate_rewards(
self: @ComponentState<TContractState>, user: ContractAddress
) -> u256 {
0 // Placeholder
}
fn set_reward_rate(ref self: ComponentState<TContractState>, rate: u256) {
// Implementation will go here
}
fn get_reward_rate(self: @ComponentState<TContractState>) -> u256 {
0 // Placeholder
}
}
El atributo #[embeddable_as(StakingImpl)] le indica a Cairo que esta implementación debe estar disponible para ser embebida en contratos.
Declarando Dependencias de Componentes
Como se mencionó anteriormente, StakingComponent debe declarar ERC20Component y OwnableComponent como dependencias en su firma de implementación para llamar a sus funciones directamente.
Añade las importaciones de ERC20Component, OwnableComponent y starknet al módulo StakingComponent:
use openzeppelin::access::ownable::OwnableComponent;
use openzeppelin::token::erc20::ERC20Component;
use starknet::{get_caller_address, get_contract_address};
A continuación, declara estos componentes como dependencias en la firma de implementación:
#[embeddable_as(StakingImpl)]
impl StakingImplImpl
TContractState,
+HasComponent<TContractState>,
+Drop<TContractState>,
impl ERC20: ERC20Component::HasComponent<TContractState>,//ADD THIS LINE
impl Ownable: OwnableComponent::HasComponent<TContractState>,//ADD THIS LINE
> of super::IStaking<ComponentState<TContractState>> {
// Functions here
}
Las líneas impl ERC20: ERC20Component::HasComponent<TContractState> e impl Ownable: OwnableComponent::HasComponent<TContractState> le indican a Cairo que cualquier contrato que use el StakingComponent también debe incluir estos componentes.
Aquí está el código completo de StakingComponent hasta este punto:
use starknet::ContractAddress;
#[starknet::interface]
pub trait IStaking<TContractState> {
// User functions
fn stake(ref self: TContractState, amount: u256);
fn unstake(ref self: TContractState, amount: u256);
fn claim_rewards(ref self: TContractState);
// View functions
fn get_staked_balance(self: @TContractState, user: ContractAddress) -> u256;
fn get_total_staked(self: @TContractState) -> u256;
fn calculate_rewards(self: @TContractState, user: ContractAddress) -> u256;
fn get_reward_rate(self: @TContractState) -> u256;
// Admin function
fn set_reward_rate(ref self: TContractState, rate: u256);
}
#[starknet::component]
pub mod StakingComponent {
use openzeppelin::access::ownable::OwnableComponent;
use openzeppelin::token::erc20::ERC20Component;
use starknet::storage::{
Map, StorageMapReadAccess, StorageMapWriteAccess, StoragePointerReadAccess,
StoragePointerWriteAccess,
};
use starknet::{ContractAddress, get_caller_address, get_contract_address};
#[storage]
pub struct Storage {
staked_balances: Map<ContractAddress, u256>,
total_staked: u256,
reward_rate: u256,
last_update_time: Map<ContractAddress, u64>,
accumulated_rewards: Map<ContractAddress, u256>,
}
#[event]
#[derive(Drop, starknet::Event)]
pub enum Event {
Staked: Staked,
Unstaked: Unstaked,
}
#[derive(Drop, starknet::Event)]
pub struct Staked {
pub user: ContractAddress,
pub amount: u256,
}
#[derive(Drop, starknet::Event)]
pub struct Unstaked {
pub user: ContractAddress,
pub amount: u256,
}
#[embeddable_as(StakingImpl)]
impl StakingImplImpl<
TContractState,
+HasComponent<TContractState>,
+Drop<TContractState>,
impl ERC20: ERC20Component::HasComponent<TContractState>,
impl Ownable: OwnableComponent::HasComponent<TContractState>,
> of super::IStaking<ComponentState<TContractState>> {
fn stake(
ref self: ComponentState<TContractState>, amount: u256,
) { // Implementation will go here
}
fn unstake(
ref self: ComponentState<TContractState>, amount: u256,
) { // Implementation will go here
}
fn claim_rewards(ref self: ComponentState<TContractState>) {// Implementation will go here
}
fn get_staked_balance(
self: @ComponentState<TContractState>, user: ContractAddress,
) -> u256 {
0 // Placeholder
}
fn get_total_staked(self: @ComponentState<TContractState>) -> u256 {
0 // Placeholder
}
fn calculate_rewards(self: @ComponentState<TContractState>, user: ContractAddress) -> u256 {
0 // Placeholder
}
fn set_reward_rate(
ref self: ComponentState<TContractState>, rate: u256,
) { // Implementation will go here
}
fn get_reward_rate(self: @ComponentState<TContractState>) -> u256 {
0 // Placeholder
}
}
}
Implementando la función stake
La función stake transfiere tokens del usuario al contrato, actualiza su balance en stake y registra la marca de tiempo (timestamp) para el cálculo de recompensas:
fn stake(ref self: ComponentState<TContractState>, amount: u256) {
assert(amount > 0, 'Amount must be greater than 0');
let caller = get_caller_address();
let contract_address = get_contract_address();
// Transfer tokens from caller to contract
let mut erc20 = get_dep_component_mut!(ref self, ERC20);
erc20._transfer(caller, contract_address, amount);
// Update staking balances
let current_stake = self.staked_balances.read(caller);
self.staked_balances.write(caller, current_stake + amount);
let total = self.total_staked.read();
self.total_staked.write(total + amount);
self.emit(Staked { user: caller, amount });
}
La función comienza validando que la cantidad de staking sea mayor que cero, luego recupera la dirección del llamador (caller) y la dirección del contrato. Transfiere tokens del llamador al contrato, actualiza el balance en stake del llamador y la cantidad total en stake, y emite un evento Staked.
Dónde ocurre la interacción de componente a componente
Nota estas líneas:
let mut erc20 = get_dep_component_mut!(ref self, ERC20);
erc20._transfer(caller, contract_address, amount);
Aquí es donde ocurre la interacción de componente a componente. La macro get_dep_component_mut! recupera una referencia mutable al ERC20Component, lo que nos permite llamar a su función _transfer para mover tokens del usuario al contrato. Analicemos cómo funciona esto:
- La macro:
get_dep_component_mut!nos da una referencia mutable a un componente del que dependemos. Esto nos permite llamar a sus funciones internas. - Los parámetros:
ref selfse refiere al estado del componenteERC20es el nombre de la dependencia que declaramos en la firma de implementación
- Por qué necesitamos la macro: Dentro de
StakingComponent, no podemos llamar directamente aself.erc20._transfer(...)porque el storage de cada componente se mantiene separado dentro del contrato. La macroget_dep_component_mut!nos obtiene una referencia alERC20Componentpara que podamos llamar a sus funciones.
Podrías preguntarte por qué usamos _transfer en lugar de la función convencional transfer_from. Como se mencionó en la introducción, este tutorial utiliza una arquitectura de token embebida donde el token y la lógica de staking son parte del mismo contrato. Esto afecta a qué método de transferencia usamos. Lo explicaremos en más detalle y lo compararemos con hacer stake de tokens externos más adelante en el artículo.
Después de la transferencia, actualizamos el balance en stake del usuario leyendo su stake actual, añadimos la nueva cantidad y la volvemos a escribir. También actualizamos la cantidad total en stake y emitimos un evento Staked para registrar esta acción.
Si intentas compilar el código en este punto, notarás que _transfer arroja un error. Al pasar el cursor sobre él, verás:
Method `_transfer` not found on type `openzeppelin_token::erc20::erc20::ERC20Comp
onent::ComponentState::<TContractState>`. Did you import the correct trait and impl?
Este error ocurre porque _transfer es una función interna del ERC20Component, y no hemos importado el trait que la implementa. Para solucionar esto, añade la siguiente importación en la parte superior del módulo StakingComponent:
use openzeppelin::token::erc20::ERC20Component::InternalTrait as ERC20InternalTrait;
Esta importación nos da acceso a las funciones internas del ERC20Component como _transfer y _mint. Si compilas de nuevo, encontrarás otro error que dice:
Trait has no implementation in context: openzeppelin_token::erc20::erc20::ERC20Co
mponent::InternalTrait::<TContractState, ImplVarId(34881), ImplVarId(34882)> ….
Esto ocurre porque el ERC20Component requiere una implementación de su ERC20HooksTrait. Este trait define hooks que pueden ejecutarse antes y después de las transferencias de tokens. Dado que no necesitamos hooks personalizados para nuestro contrato de staking, usaremos la implementación vacía proporcionada por OpenZeppelin.
Actualiza la importación de ERC20 para incluir ERC20HooksEmptyImpl:
use openzeppelin::token::erc20::{ERC20Component, ERC20HooksEmptyImpl};
Ahora el código debería compilar exitosamente, y la función _transfer funcionará como se espera.
Sin embargo, la implementación de la función stake está incompleta. Antes de actualizar el stake de un usuario, primero necesitamos calcular sus recompensas acumuladas. Vamos a crear funciones auxiliares (helper) internas para manejar los cálculos de recompensas.
Funciones auxiliares internas para el cálculo de recompensas
Los componentes pueden tener funciones internas que solo son accesibles dentro del propio componente o por el contrato que lo utiliza, como _transfer en el ERC20Component. Estas funciones no son parte de la interfaz pública y no aparecerán en el ABI del contrato.
Las definimos usando el atributo #[generate_trait], el cual genera automáticamente un trait para contener las funciones internas. Añade el siguiente bloque de implementación interna debajo de la implementación principal para manejar los cálculos y actualizaciones de recompensas:
#[generate_trait]
pub impl InternalImpl<
TContractState,
+HasComponent<TContractState>,
+Drop<TContractState>,
impl ERC20: ERC20Component::HasComponent<TContractState>,
impl Ownable: OwnableComponent::HasComponent<TContractState>,
> of InternalTrait<TContractState> {
// Internal functions will go here
}
Antes de implementar las funciones auxiliares de cálculo de recompensas, necesitamos una función inicializadora para configurar el estado inicial del componente con una tasa de recompensa inicial cuando se despliega el contrato:
#[generate_trait]
pub impl InternalImpl<
TContractState,
+HasComponent<TContractState>,
+Drop<TContractState>,
impl ERC20: ERC20Component::HasComponent<TContractState>,
impl Ownable: OwnableComponent::HasComponent<TContractState>,
> of InternalTrait<TContractState> {
//NEWLY ADDED BELOW //
fn initializer(ref self: ComponentState<TContractState>, initial_reward_rate: u256) {
self.reward_rate.write(initial_reward_rate);
}
//other internal function will go here
}
Entendiendo los inicializadores de componentes
A veces los componentes necesitan ejecutar una lógica de inicialización solo una vez. En Solidity, esto es posible con un constructor. Aunque los contratos de Cairo también soportan constructores, los componentes no.
En su lugar, los componentes usan inicializadores: funciones regulares que manejan la configuración cuando el contrato es desplegado. El framework no impone una ejecución única, por lo que la convención es llamar a los inicializadores solo desde el constructor del contrato. Dado que los constructores se ejecutan solo una vez durante el despliegue, esto asegura que los inicializadores también se llamen solo una vez.
En este caso, los componentes Ownable y ERC20 de OpenZeppelin, junto con nuestro StakingComponent personalizado, todos proporcionan funciones inicializadoras llamadas initializer. Dado que es una función regular, su nombre puede ser arbitrario. El nombre initializer es una convención.
Estos inicializadores serán llamados desde el constructor del contrato cuando construyamos el StakingContract más adelante en el artículo. Olvidar llamar al inicializador de un componente dejará su estado sin inicializar, rompiendo la lógica del contrato o creando vulnerabilidades de seguridad.
Un fragmento del inicializador del componente Ownable en OpenZeppelin se ve así:

El inicializador Ownable establece la dirección del propietario inicial, por lo que nuestro constructor debe recibir una dirección de propietario como parámetro.
Calculando Recompensas Pendientes
Con el inicializador en su lugar, podemos implementar las funciones auxiliares de cálculo de recompensas. La función _calculate_pending_rewards calcula cuántas recompensas ha ganado un usuario basándose en su cantidad en stake y el tiempo transcurrido desde su última actualización:
fn _calculate_pending_rewards(
self: @ComponentState<TContractState>, user: ContractAddress,
) -> u256 {
let staked = self.staked_balances.read(user);
if staked == 0 {
return self.accumulated_rewards.read(user);
}
let last_update = self.last_update_time.read(user);
let current_time = get_block_timestamp();
if last_update == 0 {
return 0;
}
let time_elapsed = current_time - last_update;
let reward_rate = self.reward_rate.read();
// Calculate new rewards: staked_amount * reward_rate * time_elapsed
let new_rewards = (staked * reward_rate * time_elapsed.into()) / 1000000;
let accumulated = self.accumulated_rewards.read(user);
accumulated + new_rewards
}
La función primero verifica si el usuario tiene algún token en stake. Si no tiene, devuelve sus recompensas acumuladas de stakes anteriores.
Luego recupera cuándo se actualizaron por última vez las recompensas del usuario y el block timestamp actual. Si el usuario nunca ha hecho stake antes (su last_update_time es 0), la función devuelve 0 ya que aún no hay recompensas que calcular.
La función calcula el tiempo transcurrido desde la última actualización y recupera la tasa de recompensa actual. El cálculo de la recompensa utiliza esta fórmula: staked_amount * reward_rate * time_elapsed / 1000000. Dividimos por 1,000,000 porque Cairo no tiene números de punto flotante. La tasa de recompensa se escala por 1,000,000 para representar valores fraccionarios como enteros.
Luego, la función suma cualquier recompensa previamente acumulada a las recompensas recién calculadas y devuelve el total.
Necesitamos importar get_block_timestamp en la parte superior del módulo junto con las importaciones existentes:
use starknet::{ContractAddress, get_block_timestamp, get_caller_address, get_contract_address};
Ahora implementemos la función update_rewards que usa _calculate_pending_rewards para calcular cualquier recompensa pendiente para un usuario, actualiza sus recompensas acumuladas y la marca de tiempo de la última actualización:
fn update_rewards(ref self: ComponentState<TContractState>, user: ContractAddress) {
let pending = self._calculate_pending_rewards(user);
self.accumulated_rewards.write(user, pending);
self.last_update_time.write(user, get_block_timestamp());
}
Con update_rewards implementado, podemos regresar y completar la función stake añadiendo la actualización de recompensas antes de cambiar el stake del usuario.
Completando la función stake()
Actualiza la función stake para incluir la llamada a update_rewards:
fn stake(ref self: ComponentState<TContractState>, amount: u256) {
assert(amount > 0, 'Amount must be greater than 0');
let caller = get_caller_address();
let contract_address = get_contract_address();
// Update rewards before changing stake
self.update_rewards(caller); //ADD THIS LINE
// Transfer tokens from caller to contract
let mut erc20 = get_dep_component_mut!(ref self, ERC20);
erc20._transfer(caller, contract_address, amount);
// Update staking balances
let current_stake = self.staked_balances.read(caller);
self.staked_balances.write(caller, current_stake + amount);
let total = self.total_staked.read();
self.total_staked.write(total + amount);
self.emit(Staked { user: caller, amount });
}
La adición es self.update_rewards(caller) que se llama antes de que cambiemos el balance en stake del usuario. Esto asegura que las recompensas se calculen basándose en el stake anterior del usuario antes de que se añadan los nuevos tokens. Sin esto, los usuarios perderían las recompensas ganadas en su stake anterior.
Aquí está el código completo de StakingComponent hasta este punto:
use starknet::ContractAddress;
#[starknet::interface]
pub trait IStaking<TContractState> {
// User functions
fn stake(ref self: TContractState, amount: u256);
fn unstake(ref self: TContractState, amount: u256);
fn claim_rewards(ref self: TContractState);
// View functions
fn get_staked_balance(self: @TContractState, user: ContractAddress) -> u256;
fn get_total_staked(self: @TContractState) -> u256;
fn calculate_rewards(self: @TContractState, user: ContractAddress) -> u256;
fn get_reward_rate(self: @TContractState) -> u256;
// Admin function
fn set_reward_rate(ref self: TContractState, rate: u256);
}
#[starknet::component]
pub mod StakingComponent {
use openzeppelin::access::ownable::OwnableComponent;
use openzeppelin::token::erc20::ERC20Component::InternalTrait as ERC20InternalTrait;
use openzeppelin::token::erc20::{ERC20Component, ERC20HooksEmptyImpl};
use starknet::storage::{
Map, StorageMapReadAccess, StorageMapWriteAccess, StoragePointerReadAccess,
StoragePointerWriteAccess,
};
use starknet::{ContractAddress, get_block_timestamp, get_caller_address, get_contract_address};
#[storage]
pub struct Storage {
staked_balances: Map<ContractAddress, u256>,
total_staked: u256,
reward_rate: u256,
last_update_time: Map<ContractAddress, u64>,
accumulated_rewards: Map<ContractAddress, u256>,
}
#[event]
#[derive(Drop, starknet::Event)]
pub enum Event {
Staked: Staked,
Unstaked: Unstaked,
}
#[derive(Drop, starknet::Event)]
pub struct Staked {
pub user: ContractAddress,
pub amount: u256,
}
#[derive(Drop, starknet::Event)]
pub struct Unstaked {
pub user: ContractAddress,
pub amount: u256,
}
#[embeddable_as(StakingImpl)]
impl StakingImplImpl<
TContractState,
+HasComponent<TContractState>,
+Drop<TContractState>,
impl ERC20: ERC20Component::HasComponent<TContractState>,
impl Ownable: OwnableComponent::HasComponent<TContractState>,
> of super::IStaking<ComponentState<TContractState>> {
/// @notice Stakes tokens into the contract and update rewards
/// @param amount The amount of tokens to stake
fn stake(ref self: ComponentState<TContractState>, amount: u256) {
assert(amount > 0, 'Amount must be greater than 0');
let caller = get_caller_address();
let contract_address = get_contract_address();
// Update rewards before changing stake
self.update_rewards(caller);
// This transfers from caller to contract without needing approval
let mut erc20 = get_dep_component_mut!(ref self, ERC20);
erc20._transfer(caller, contract_address, amount);
// Update staking balances
let current_stake = self.staked_balances.read(caller);
self.staked_balances.write(caller, current_stake + amount);
let total = self.total_staked.read();
self.total_staked.write(total + amount);
self.emit(Staked { user: caller, amount });
}
fn unstake(
ref self: ComponentState<TContractState>, amount: u256,
) { // Implementation will go here
}
fn claim_rewards(ref self: ComponentState<TContractState>) { // Implementation will go here
}
fn get_staked_balance(
self: @ComponentState<TContractState>, user: ContractAddress,
) -> u256 {
0 // Placeholder
}
fn get_total_staked(self: @ComponentState<TContractState>) -> u256 {
0 // Placeholder
}
fn calculate_rewards(self: @ComponentState<TContractState>, user: ContractAddress) -> u256 {
0 // Placeholder
}
fn set_reward_rate(
ref self: ComponentState<TContractState>, rate: u256,
) { // Implementation will go here
}
fn get_reward_rate(self: @ComponentState<TContractState>) -> u256 {
0 // Placeholder
}
}
#[generate_trait]
pub impl InternalImpl<
TContractState,
+HasComponent<TContractState>,
+Drop<TContractState>,
impl ERC20: ERC20Component::HasComponent<TContractState>,
impl Ownable: OwnableComponent::HasComponent<TContractState>,
> of InternalTrait<TContractState> {
//NEWLY ADDED BELOW //
fn initializer(ref self: ComponentState<TContractState>, initial_reward_rate: u256) {
self.reward_rate.write(initial_reward_rate);
}
fn _calculate_pending_rewards(
self: @ComponentState<TContractState>, user: ContractAddress,
) -> u256 {
let staked = self.staked_balances.read(user);
if staked == 0 {
return self.accumulated_rewards.read(user);
}
let last_update = self.last_update_time.read(user);
let current_time = get_block_timestamp();
if last_update == 0 {
return 0;
}
let time_elapsed = current_time - last_update;
let reward_rate = self.reward_rate.read();
// Calculate new rewards: staked_amount * reward_rate * time_elapsed
let new_rewards = (staked * reward_rate * time_elapsed.into()) / 1000000;
let accumulated = self.accumulated_rewards.read(user);
accumulated + new_rewards
}
fn update_rewards(ref self: ComponentState<TContractState>, user: ContractAddress) {
let pending = self._calculate_pending_rewards(user);
self.accumulated_rewards.write(user, pending);
self.last_update_time.write(user, get_block_timestamp());
}
}
}
Implementando la función unstake
La función unstake permite a los usuarios retirar sus tokens en stake del contrato. Vamos a implementarla:
fn unstake(ref self: ComponentState<TContractState>, amount: u256) {
let caller = get_caller_address();
let current_stake = self.staked_balances.read(caller);
assert(amount > 0, 'Amount must be greater than 0');
assert(current_stake >= amount, 'Insufficient staked balance');
// Update rewards before changing stake
self.update_rewards(caller);
// Update staking balances
self.staked_balances.write(caller, current_stake - amount);
let total = self.total_staked.read();
self.total_staked.write(total - amount);
// Transfer tokens back to user using ERC20Component
let mut erc20 = get_dep_component_mut!(ref self, ERC20);
let contract_address = get_contract_address();
erc20._transfer(contract_address, caller, amount);
self.emit(Unstaked { user: caller, amount });
}
La función primero recupera la dirección del llamador y su balance en stake actual. Luego valida la cantidad y confirma que el usuario tiene suficientes tokens en stake.
Al igual que en la función stake, llamamos a self.update_rewards(caller) antes de modificar el balance en stake del usuario. Esto asegura que las recompensas se calculen basándose en su stake antes del retiro.
Después de actualizar las recompensas, la función disminuye el balance en stake del usuario y la cantidad total en stake. Luego transfiere los tokens del contrato de vuelta al usuario usando la función _transfer del ERC20Component y emite un evento Unstaked.
Implementando la función claim rewards
La función claim_rewards permite a los usuarios reclamar sus recompensas acumuladas mientras mantienen sus tokens en stake en el contrato:
fn claim_rewards(ref self: ComponentState<TContractState>) {
let caller = get_caller_address();
self.update_rewards(caller);
let rewards = self.accumulated_rewards.read(caller);
assert(rewards > 0, 'No rewards to claim');
// Reset accumulated rewards
self.accumulated_rewards.write(caller, 0);
// Transfer reward tokens to user
let mut erc20 = get_dep_component_mut!(ref self, ERC20);
let contract_address = get_contract_address();
erc20._transfer(contract_address, caller, rewards);
}
La función claim_rewards primero actualiza las recompensas del usuario para asegurar que se calculen todas las recompensas pendientes. Luego lee las recompensas totales acumuladas del usuario.
La función valida que el usuario tenga recompensas por reclamar, restablece sus recompensas acumuladas a cero y transfiere los tokens de recompensa al usuario usando _transfer.
Implementando la función set reward rate
La función set_reward_rate permite al propietario del contrato actualizar la tasa de recompensa:
fn set_reward_rate(ref self: ComponentState<TContractState>, rate: u256) {
// Check ownership using OwnableComponent
let ownable = get_dep_component!(@self, Ownable);
ownable.assert_only_owner();
self.reward_rate.write(rate);
}
Esta función también demuestra otra interacción de componente a componente. Usamos get_dep_component!(@self, Ownable) para acceder al OwnableComponent y llamar a su función assert_only_owner. Esta función generará un panic si el llamador no es el propietario del contrato, evitando que usuarios no autorizados cambien la tasa de recompensa.
Nota que usamos get_dep_component! aquí (sin _mut) en lugar de get_dep_component_mut! que usamos con el ERC20Component. La diferencia es:
get_dep_component_mut!proporciona una referencia mutable: úsalo cuando necesites modificar el estado del componente (como al transferir tokens)get_dep_component!proporciona una referencia inmutable: úsalo cuando solo necesites leer datos o llamar a funciones que no modifican el estado (como al verificar la propiedad)
Dado que assert_only_owner solo lee la dirección del propietario y no modifica el estado del OwnableComponent, usamos la versión inmutable.
Si compilas el código ahora, obtendrás un error:
Method 'assert_only_owner' not found on type '@openzeppelin_access::ownable::owna
ble::OwnableComponent::ComponentState::<TContractState>'. Consider importing one
of the following traits: 'OwnableComponent::InternalTrait'
Esto significa que necesitamos importar el trait interno del OwnableComponent en la parte superior del módulo:
use openzeppelin::access::ownable::OwnableComponent::InternalTrait as OwnableInternalTrait;
Después de que la verificación de propiedad pasa, la función actualiza la tasa de recompensa en el storage.
Implementando las funciones view
Necesitamos implementar cuatro funciones view (de vista):
get_staked_balancedevuelve cuántos tokens ha puesto en stake un usuario específico,get_total_stakeddevuelve la cantidad total en stake entre todos los usuarios,calculate_rewardsmuestra cuántas recompensas puede reclamar un usuario, yget_reward_ratedevuelve la tasa de recompensa actual.
fn get_staked_balance(
self: @ComponentState<TContractState>, user: ContractAddress
) -> u256 {
self.staked_balances.read(user)
}
fn get_total_staked(self: @ComponentState<TContractState>) -> u256 {
self.total_staked.read()
}
fn calculate_rewards(
self: @ComponentState<TContractState>, user: ContractAddress
) -> u256 {
self._calculate_pending_rewards(user)
}
fn get_reward_rate(self: @ComponentState<TContractState>) -> u256 {
self.reward_rate.read()
}
Estas implementaciones leen desde el storage y devuelven los valores solicitados. La función calculate_rewards usa la función interna _calculate_pending_rewards para calcular las recompensas.
Aquí está el código completo del StakingComponent:
use starknet::ContractAddress;
#[starknet::interface]
pub trait IStaking<TContractState> {
// User functions
fn stake(ref self: TContractState, amount: u256);
fn unstake(ref self: TContractState, amount: u256);
fn claim_rewards(ref self: TContractState);
// View functions
fn get_staked_balance(self: @TContractState, user: ContractAddress) -> u256;
fn get_total_staked(self: @TContractState) -> u256;
fn calculate_rewards(self: @TContractState, user: ContractAddress) -> u256;
fn get_reward_rate(self: @TContractState) -> u256;
// Admin function
fn set_reward_rate(ref self: TContractState, rate: u256);
}
#[starknet::component]
pub mod StakingComponent {
use openzeppelin::access::ownable::OwnableComponent;
use openzeppelin::access::ownable::OwnableComponent::InternalTrait as OwnableInternalTrait;
use openzeppelin::token::erc20::ERC20Component::InternalTrait as ERC20InternalTrait;
use openzeppelin::token::erc20::{ERC20Component, ERC20HooksEmptyImpl};
use starknet::storage::{
Map, StorageMapReadAccess, StorageMapWriteAccess, StoragePointerReadAccess,
StoragePointerWriteAccess,
};
use starknet::{ContractAddress, get_block_timestamp, get_caller_address, get_contract_address};
#[storage]
pub struct Storage {
staked_balances: Map<ContractAddress, u256>,
total_staked: u256,
reward_rate: u256,
last_update_time: Map<ContractAddress, u64>,
accumulated_rewards: Map<ContractAddress, u256>,
}
#[event]
#[derive(Drop, starknet::Event)]
pub enum Event {
Staked: Staked,
Unstaked: Unstaked
}
#[derive(Drop, starknet::Event)]
pub struct Staked {
pub user: ContractAddress,
pub amount: u256,
}
#[derive(Drop, starknet::Event)]
pub struct Unstaked {
pub user: ContractAddress,
pub amount: u256,
}
#[embeddable_as(StakingImpl)]
impl StakingImplImpl<
TContractState,
+HasComponent<TContractState>,
+Drop<TContractState>,
impl ERC20: ERC20Component::HasComponent<TContractState>,
impl Ownable: OwnableComponent::HasComponent<TContractState>,
> of super::IStaking<ComponentState<TContractState>> {
/// @notice Stakes tokens into the contract and update rewards
/// @param amount The amount of tokens to stake
fn stake(ref self: ComponentState<TContractState>, amount: u256) {
assert(amount > 0, 'Amount must be greater than 0');
let caller = get_caller_address();
let contract_address = get_contract_address();
// Update rewards before changing stake
self.update_rewards(caller);
// This transfers from caller to contract without needing approval
let mut erc20 = get_dep_component_mut!(ref self, ERC20);
erc20._transfer(caller, contract_address, amount);
// Update staking balances
let current_stake = self.staked_balances.read(caller);
self.staked_balances.write(caller, current_stake + amount);
let total = self.total_staked.read();
self.total_staked.write(total + amount);
self.emit(Staked { user: caller, amount });
}
/// @notice Unstakes tokens and transfers them back to user
/// @param amount The amount of tokens to unstake
fn unstake(ref self: ComponentState<TContractState>, amount: u256) {
let caller = get_caller_address();
let current_stake = self.staked_balances.read(caller);
assert(amount > 0, 'Amount must be greater than 0');
assert(current_stake >= amount, 'Insufficient staked balance');
// Update rewards before changing stake
self.update_rewards(caller);
// Update staking balances
self.staked_balances.write(caller, current_stake - amount);
let total = self.total_staked.read();
self.total_staked.write(total - amount);
// Transfer tokens back to user using ERC20Component
let mut erc20 = get_dep_component_mut!(ref self, ERC20);
let contract_address = get_contract_address();
erc20._transfer(contract_address, caller, amount);
self.emit(Unstaked { user: caller, amount });
}
/// @notice Claims accumulated rewards for the caller
fn claim_rewards(ref self: ComponentState<TContractState>) {
let caller = get_caller_address();
self.update_rewards(caller);
let rewards = self.accumulated_rewards.read(caller);
assert(rewards > 0, 'No rewards to claim');
// Reset accumulated rewards
self.accumulated_rewards.write(caller, 0);
// Transfer reward tokens to user
let mut erc20 = get_dep_component_mut!(ref self, ERC20);
let contract_address = get_contract_address();
erc20._transfer(contract_address, caller, rewards);
}
/// @notice Returns the staked balance of a specific user
/// @param user The address of the user
/// @return The amount of tokens staked by the user
fn get_staked_balance(
self: @ComponentState<TContractState>, user: ContractAddress,
) -> u256 {
self.staked_balances.read(user)
}
/// @notice Returns the total amount of tokens staked in the contract
/// @return The total staked amount
fn get_total_staked(self: @ComponentState<TContractState>) -> u256 {
self.total_staked.read()
}
/// @notice Calculates the pending rewards for a user
/// @param user The address of the user
/// @return The amount of pending rewards
fn calculate_rewards(self: @ComponentState<TContractState>, user: ContractAddress) -> u256 {
self._calculate_pending_rewards(user)
}
/// @notice Sets the reward rate (only callable by owner)
/// @param rate The new reward rate per second
fn set_reward_rate(ref self: ComponentState<TContractState>, rate: u256) {
// Check ownership using OwnableComponent
let ownable = get_dep_component!(@self, Ownable);
ownable.assert_only_owner();
self.reward_rate.write(rate);
}
/// @notice Returns the current reward rate
/// @return The reward rate per second
fn get_reward_rate(self: @ComponentState<TContractState>) -> u256 {
self.reward_rate.read()
}
}
#[generate_trait]
pub impl InternalImpl<
TContractState,
+HasComponent<TContractState>,
+Drop<TContractState>,
impl ERC20: ERC20Component::HasComponent<TContractState>,
impl Ownable: OwnableComponent::HasComponent<TContractState>,
> of InternalTrait<TContractState> {
// Initializes the staking component with an initial reward rate
fn initializer(ref self: ComponentState<TContractState>, initial_reward_rate: u256) {
self.reward_rate.write(initial_reward_rate);
}
// Updates the accumulated rewards for a user
fn update_rewards(ref self: ComponentState<TContractState>, user: ContractAddress) {
let pending = self._calculate_pending_rewards(user);
self.accumulated_rewards.write(user, pending);
self.last_update_time.write(user, get_block_timestamp());
}
// Calculates pending rewards based on staked amount and time elapsed
fn _calculate_pending_rewards(
self: @ComponentState<TContractState>, user: ContractAddress,
) -> u256 {
let staked = self.staked_balances.read(user);
if staked == 0 {
return self.accumulated_rewards.read(user);
}
let last_update = self.last_update_time.read(user);
let current_time = get_block_timestamp();
if last_update == 0 {
return 0;
}
let time_elapsed = current_time - last_update;
let reward_rate = self.reward_rate.read();
// Calculate new rewards: staked_amount * reward_rate * time_elapsed
let new_rewards = (staked * reward_rate * time_elapsed.into()) / 1000000;
let accumulated = self.accumulated_rewards.read(user);
accumulated + new_rewards
}
}
}
Ahora hemos completado el StakingComponent, que contiene toda la lógica de staking que necesitamos. El componente maneja hacer stake y unstake de tokens, calcular y reclamar recompensas, y gestionar la tasa de recompensa.
Sin embargo, un componente por sí solo no puede ser desplegado. Necesitamos crear un contrato que integre nuestro StakingComponent junto con el ERC20Component y el OwnableComponent. Este contrato servirá como el contrato inteligente desplegable con el que los usuarios interactuarán.
Construyendo el Staking Contract
El StakingContract:
- Incluirá el
StakingComponentque acabamos de construir - Incluirá el
ERC20Component(para el token de staking) - Incluirá el
OwnableComponent(para el control de acceso) - Inicializará los tres componentes con los parámetros necesarios
- Expondrá las funciones que queremos que los usuarios puedan llamar
Vamos a construir el StakingContract que reúne todos estos componentes.
Importando Dependencias
Nota: Para este tutorial, estamos construyendo tanto el StakingComponent como el StakingContract en el mismo archivo lib.cairo. En un proyecto más grande, podrías organizarlos en archivos separados (por ejemplo, staking_component.cairo y staking_contract.cairo), pero mantener todo en un solo archivo hace que sea más fácil de seguir.
Primero, necesitamos importar todos los componentes que estaremos usando:
#[starknet::contract]
mod StakingContract {
use openzeppelin::access::ownable::OwnableComponent;
use openzeppelin::token::erc20::{ERC20Component, ERC20HooksEmptyImpl};
use starknet::{ContractAddress, get_contract_address};
use super::StakingComponent;
}
Importamos el OwnableComponent y el ERC20Component de OpenZeppelin, junto con el StakingComponent que acabamos de crear.
Declarando Componentes
A continuación, declaramos los tres componentes que usará nuestro contrato:
// Component declarations
component!(path: StakingComponent, storage: staking, event: StakingEvent);
component!(path: ERC20Component, storage: erc20, event: ERC20Event);
component!(path: OwnableComponent, storage: ownable, event: OwnableEvent);
Cada macro component! declara un componente y especifica:
- La ruta del componente (qué componente usar)
- El nombre en storage (dónde se guardará el storage de este componente)
- El nombre del evento (cómo llamar a los eventos de este componente)
Configurando el ERC20Component
El ERC20Component requiere que implementemos su trait ImmutableConfig. Este trait configura valores que están fijos en tiempo de compilación en lugar de estar guardados en el storage del contrato. El valor DECIMALS del ERC20 nunca cambia después del despliegue, lo que lo hace ideal para este patrón.
// ERC20 Mixin configuration
impl ERC20ImmutableConfig of ERC20Component::ImmutableConfig {
const DECIMALS: u8 = 18;
}
Esto configura el token para usar 18 decimales, lo cual es estándar para la mayoría de los tokens.
Exponiendo funciones del componente
Ahora necesitamos decidir qué funciones de cada componente deben ser públicamente accesibles. Usamos el atributo #[abi(embed_v0)] para exponer las implementaciones:
// Expose staking functions publicly
#[abi(embed_v0)]
impl StakingImpl = StakingComponent::StakingImpl<ContractState>;
// Expose ERC20 functions publicly
#[abi(embed_v0)]
impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl<ContractState>;
// Expose Ownable functions publicly
#[abi(embed_v0)]
impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl<ContractState>;
Estas líneas hacen que todas las funciones de staking, las funciones de ERC-20 (como transfer, balance_of), y las funciones de propiedad (como transfer_ownership) estén disponibles en la interfaz pública (ABI) del contrato.
También necesitamos hacer que las implementaciones internas estén disponibles para que los componentes puedan llamar a las funciones internas de los demás:
// Internal implementations
impl StakingInternalImpl = StakingComponent::InternalImpl<ContractState>;
impl ERC20InternalImpl = ERC20Component::InternalImpl<ContractState>;
impl OwnableInternalImpl = OwnableComponent::InternalImpl<ContractState>;
Estructura del storage
Cada componente requiere su propio espacio de storage. Declaramos el storage para los tres componentes:
#[storage]
struct Storage {
#[substorage(v0)]
staking: StakingComponent::Storage,
#[substorage(v0)]
erc20: ERC20Component::Storage,
#[substorage(v0)]
ownable: OwnableComponent::Storage,
}
El atributo #[substorage(v0)] le indica a Cairo que cada campo contiene la estructura de storage de un componente.
Eventos
De manera similar, necesitamos agregar los eventos de todos los componentes:
#[event]
#[derive(Drop, starknet::Event)]
pub enum Event {
#[flat]
StakingEvent: StakingComponent::Event,
#[flat]
ERC20Event: ERC20Component::Event,
#[flat]
OwnableEvent: OwnableComponent::Event,
}
Constructor
El constructor inicializa los tres componentes cuando se despliega el contrato:
#[constructor]
fn constructor(
ref self: ContractState,
owner: ContractAddress,
token_name: ByteArray,
token_symbol: ByteArray,
initial_reward_rate: u256,
initial_supply: u256,
) {
// Initialize ownership
self.ownable.initializer(owner);
// Initialize the ERC20 token
self.erc20.initializer(token_name, token_symbol);
// Mint initial supply to the contract for rewards
self.erc20.mint(get_contract_address(), initial_supply);
// Initialize staking with reward rate
self.staking.initializer(initial_reward_rate);
}
El constructor toma los parámetros necesarios para los tres componentes: la dirección del propietario, el nombre y símbolo del token, la tasa de recompensa inicial y un suministro inicial de tokens. Inicializa cada componente y acuña (mints) el suministro inicial al propio contrato. Esto asegura que el contrato tenga tokens disponibles para pagar como recompensas cuando los usuarios hagan stake y reclamen.
Añadiendo una función mint
Añadimos una función adicional para permitir que el propietario acuñe (mint) tokens:
#[generate_trait]
#[abi(per_item)]
impl ExternalImpl of ExternalTrait {
#[external(v0)]
fn mint(ref self: ContractState, recipient: ContractAddress, amount: u256) {
self.ownable.assert_only_owner();
self.erc20.mint(recipient, amount);
}
}
Esta función verifica que el llamador sea el propietario y luego acuña tokens para el destinatario especificado.
Aquí está el StakingContract completo:
#[starknet::contract]
mod StakingContract {
use openzeppelin::access::ownable::OwnableComponent;
use openzeppelin::token::erc20::{ERC20Component, ERC20HooksEmptyImpl};
use starknet::{ContractAddress, get_contract_address};
use super::StakingComponent;
// Component declarations
component!(path: StakingComponent, storage: staking, event: StakingEvent);
component!(path: ERC20Component, storage: erc20, event: ERC20Event);
component!(path: OwnableComponent, storage: ownable, event: OwnableEvent);
// ERC20 Mixin configuration
impl ERC20ImmutableConfig of ERC20Component::ImmutableConfig {
const DECIMALS: u8 = 18;
}
// Expose staking functions publicly
#[abi(embed_v0)]
impl StakingImpl = StakingComponent::StakingImpl<ContractState>;
// Expose ERC20 functions publicly
#[abi(embed_v0)]
impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl<ContractState>;
// Expose Ownable functions publicly
#[abi(embed_v0)]
impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl<ContractState>;
// Internal implementations
impl StakingInternalImpl = StakingComponent::InternalImpl<ContractState>;
impl ERC20InternalImpl = ERC20Component::InternalImpl<ContractState>;
impl OwnableInternalImpl = OwnableComponent::InternalImpl<ContractState>;
#[storage]
struct Storage {
#[substorage(v0)]
staking: StakingComponent::Storage,
#[substorage(v0)]
erc20: ERC20Component::Storage,
#[substorage(v0)]
ownable: OwnableComponent::Storage,
}
#[event]
#[derive(Drop, starknet::Event)]
pub enum Event {
#[flat]
StakingEvent: StakingComponent::Event,
#[flat]
ERC20Event: ERC20Component::Event,
#[flat]
OwnableEvent: OwnableComponent::Event,
}
#[constructor]
fn constructor(
ref self: ContractState,
owner: ContractAddress,
token_name: ByteArray,
token_symbol: ByteArray,
initial_reward_rate: u256,
initial_supply: u256,
) {
// Initialize ownership
self.ownable.initializer(owner);
// Initialize the ERC20 token
self.erc20.initializer(token_name, token_symbol);
// Mint initial supply to the contract for rewards
self.erc20.mint(get_contract_address(), initial_supply);
// Initialize staking with reward rate
self.staking.initializer(initial_reward_rate);
}
#[generate_trait]
#[abi(per_item)]
impl ExternalImpl of ExternalTrait {
#[external(v0)]
fn mint(ref self: ContractState, recipient: ContractAddress, amount: u256) {
self.ownable.assert_only_owner();
self.erc20.mint(recipient, amount);
}
}
}
Por qué usamos _transfer del ERC20Component en lugar de transfer_from
Si bien transfer_from es la función estándar de ERC-20 para transferencias de tokens aprobadas, no funciona correctamente en llamadas de componente a componente dentro del mismo contrato.
En un contrato de staking típico, el contrato de staking y el token son contratos separados. Cuando un usuario hace stake de tokens STRK:
- El usuario aprueba el contrato de staking:
token.approve(staking_contract, amount) - El contrato de staking llama a:
token.transfer_from(user, staking_contract, amount) - Dentro del contrato del token,
transfer_fromllama aget_caller_address()el cual devuelve la dirección del contrato de staking - El token verifica:
allowances[user][staking_contract] >= amount - Si es aprobado, los tokens se transfieren
Esto funciona porque get_caller_address() dentro del contrato del token devuelve el contrato de staking (el llamador externo), lo cual coincide con la aprobación.
Qué sucede en las llamadas de componente a componente
En nuestra arquitectura embebida, el StakingComponent y el ERC20Component son parte del mismo contrato. Cuando el StakingComponent llama al ERC20Component, es una llamada interna, no una llamada externa.
El comportamiento importante a notar es que get_caller_address() mantiene la dirección original del llamador externo incluso cuando se llama desde dentro de un componente. Cuando un usuario llama a stake():
- La llamada externa entra al contrato:
get_caller_address()devuelve el Usuario - Se ejecuta StakingComponent.stake():
get_caller_address()devuelve el Usuario - Se ejecuta ERC20Component.transfer_from():
get_caller_address()todavía devuelve el Usuario
Por lo tanto, transfer_from verifica allowances[user][user] en lugar de allowances[user][contract], lo cual no funcionará.
Las llamadas a componentes son despachos (dispatches) internos, no llamadas a contratos externos, por lo que no hay una dirección de contrato intermedia en la cadena de llamadas.
Usando _transfer para llamadas a componentes
La función interna _transfer omite el mecanismo de allowance y transfiere tokens directamente:
fn stake(ref self: ComponentState<TContractState>, amount: u256) {
let mut erc20 = get_dep_component_mut!(ref self, ERC20);
erc20._transfer(caller, contract_address, amount);
// No allowance check needed
}
Esto funciona porque, como se mencionó anteriormente, nuestro contrato es el token; el mismo contrato controla tanto los balances y transferencias de tokens (ERC20Component) como la lógica de staking (StakingComponent). La función _transfer omite el mecanismo de aprobación por completo, por lo que los usuarios no necesitan llamar a approve() antes de hacer stake.
Haciendo stake de tokens externos
En un contrato de staking típico, harías stake de tokens externos en lugar de usar un ERC20Component embebido. Con este patrón estándar, usas el despachador del token externo (IERC20Dispatcher { contract_address: token_address }) en la función stake(), llamas a transfer_from en ese contrato externo, y el mecanismo de aprobación funciona correctamente porque es una verdadera llamada externa.
transfer_from está diseñado para llamadas externas de contrato a contrato, no para llamadas de componente a componente dentro del mismo contrato. Cuando los componentes interactúan dentro de un solo contrato, usa funciones internas como _transfer que no dependen de get_caller_address() para las verificaciones de autorización. El contexto de ejecución y la semántica del llamador difieren entre las interacciones con contratos externos y las interacciones con componentes internos, incluso cuando implementan la misma interfaz.
Conclusión
La interacción de componente a componente nos permite construir lógica reutilizable que depende de otros componentes. Al declarar dependencias explícitamente, un componente puede llamar directamente a las funciones de otro en lugar de enrutar las llamadas a través del contrato.
En este tutorial, construimos un StakingComponent que declara dependencias en el ERC20Component y el OwnableComponent. Este patrón de declarar y llamar a componentes dependientes es fundamental para construir contratos inteligentes modulares y componibles.
Como se mencionó anteriormente, el enfoque de token embebido que usamos en este tutorial es principalmente para fines de demostración; los contratos de staking en producción típicamente usan el patrón de token externo.
Los componentes funcionan mejor cuando cada uno maneja una responsabilidad específica. Cuando la lógica necesita interactuar con múltiples componentes, declara esas dependencias explícitamente para mantener el código organizado y reutilizable.