Los tokens ERC-20 en Starknet funcionan de la misma manera que en Ethereum. De hecho, STRK (el token de tarifas de Starknet) es en sí mismo un token ERC-20; no existe un token “nativo” especial a nivel de protocolo.
Tanto ETH como STRK en Starknet existen como contratos ERC-20 estándar, al igual que cualquier otro token que uno cree.
En este tutorial, aprenderás a construir y probar un contrato de token ERC-20 en Starknet. El tutorial asume familiaridad con el estándar ERC-20, pero explica cada paso de implementación y la sintaxis de Cairo sobre la marcha.
La forma preferida de crear tokens ERC-20 es utilizar la biblioteca OpenZeppelin. Esto se cubrirá en un próximo tutorial sobre “Componentes” (Components). El propósito de este tutorial es unir todo lo que hemos aprendido anteriormente.
Configuración del Proyecto
Crea un nuevo proyecto con scarb y navega hasta el directorio:
scarb new erc20
cd erc20
Interfaz del Contrato
La interfaz ERC-20 define el plano que todo token fungible debe seguir. Especifica las funciones requeridas para verificar saldos de tokens, transferir tokens, administrar permisos de gasto y recuperar metadatos del token.
Todos los tokens ERC-20 en Starknet implementan la siguiente interfaz en Cairo:
use starknet::{ContractAddress};
#[starknet::interface]
pub trait IERC20<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;
}
Esta interfaz refleja el estándar ERC-20 de Ethereum, pero utiliza sintaxis y convenciones específicas de Cairo. En la siguiente sección, veremos en qué se diferencia de Solidity.
Cómo difiere la sintaxis de la interfaz ERC-20 de Cairo respecto a Solidity
Referencias de estado (State References): Nota que en la interfaz IERC20 de Cairo anterior, el self: @TContractState es para funciones de vista (view) y ref self: TContractState es para funciones que cambian el estado. El símbolo @ crea una instantánea de solo lectura del estado del contrato, mientras que ref permite modificaciones de estado. Por ejemplo, verificar el saldo de STRK usa @ (solo vista), pero transferir STRK usa ref (modifica saldos).
El <TContractState> es un tipo genérico que permite que esta misma interfaz funcione con la disposición de almacenamiento (storage layout) de cualquier contrato ERC-20.
Tipos: Cairo utiliza u256 para las cantidades de tokens (similar a uint256 de Solidity) y ContractAddress en lugar del tipo address de Ethereum. Las funciones name y symbol de la interfaz del token devuelven un ByteArray en lugar de un string.
Estas funciones implementan los mismos saldos, transferencias, asignaciones (allowances) y metadatos que el estándar ERC-20 de Ethereum, difiriendo solo en la sintaxis.
Construyendo el Contrato del Token ERC-20
Construiremos el contrato ERC-20 paso a paso en Cairo, comenzando con la estructura básica y añadiendo funcionalidad gradualmente, mientras probamos las funciones principales sobre la marcha.
En el archivo src/lib.cairo, crea un módulo de contrato vacío y una interfaz sobre la que construiremos:
#[starknet::interface]
pub trait IERC20<TContractState> {
// We'll add functions here as we implement them
}
#[starknet::contract]
pub mod ERC20 {}
Configuración del Storage
A continuación, definiremos las variables de almacenamiento que contendrán saldos, asignaciones (allowances), metadatos y datos de propiedad. Importaremos ContractAddress de Starknet para los tipos de dirección y Map para la versión de Cairo de los mapeos de almacenamiento. Las variables de almacenamiento rastrearán:
balances: cuántos tokens posee cada direcciónallowances: cuánto puede gastar cada dirección del saldo de otra direccióntoken_name,symbol,decimalson metadatos estándar de ERC-20total_supply: total de tokens en circulaciónowner: la dirección del propietario del contrato
#[starknet::interface]
pub trait IERC20<TContractState> {
// We'll add functions here as we implement them
}
#[starknet::contract]
pub mod ERC20 {
use starknet::ContractAddress;
use starknet::storage::Map;
#[storage]
pub struct Storage {
// Maps each account address to their token balance
balances: Map<ContractAddress, u256>,
// Maps (owner, spender) pairs to approved spending amounts
allowances: Map<(ContractAddress, ContractAddress), u256>,
// Token metadata
token_name: ByteArray,
symbol: ByteArray,
decimal: u8,
// Total number of tokens that exist
total_supply: u256,
// Address that can mint new tokens
owner: ContractAddress,
}
}
Así es como se comparan los mapeos con Solidity:
mapping(address => uint256) balances;
mapping(address => mapping(address => uint256)) allowances;
Cairo utiliza tuplas (ContractAddress, ContractAddress) para mapeos anidados en lugar de la estructura anidada de Solidity:
balances: Map<ContractAddress, u256>, // owner -> amount
allowances: Map<(ContractAddress, ContractAddress), u256>, // (owner, spender) -> amount
Crearemos un “Rare Token” con el símbolo “RST”. Dado que el nombre, el símbolo y los decimales normalmente no cambian, los estableceremos en el constructor. También importamos StoragePointerWriteAccess para habilitar el acceso de escritura al almacenamiento:
#[starknet::interface]
pub trait IERC20<TContractState> {
// We'll add functions here as we implement them
}
#[starknet::contract]
pub mod ERC20 {
use starknet::ContractAddress;
use starknet::storage::{Map, StoragePointerWriteAccess};
#[storage]
pub struct Storage {
balances: Map<ContractAddress, u256>,
allowances: Map<(ContractAddress, ContractAddress), u256>, // (owner, spender) -> amount
token_name: ByteArray,
symbol: ByteArray,
decimal: u8,
total_supply: u256,
owner: ContractAddress,
}
//NEWLY ADDED
#[constructor]
fn constructor(ref self: ContractState, owner: ContractAddress) {
// Set the token's metadata
self.token_name.write("Rare Token");
self.symbol.write("RST");
self.decimal.write(18);
// Set owner
self.owner.write(owner); // Usually the deployer's address
}
}
El constructor inicializa el token con el nombre “Rare Token”, símbolo “RST”, 18 decimales (estándar para la mayoría de los tokens) y la dirección del propietario. El parámetro ref self: ContractState nos permite modificar el almacenamiento del contrato.
Quizás te preguntes por qué pasamos la dirección del propietario como parámetro en lugar de usar
get_caller_address()para establecer automáticamente al desplegador como propietario.
Esta elección de diseño es intencional y se relaciona con la forma en que funciona el despliegue de contratos en Starknet. Al desplegar un contrato que utilizaget_caller_address()en su constructor, el Universal Deployer Contract (UDC) despliega el contrato, no tu cuenta directamente. Por lo tanto,get_caller_address()devuelve la dirección del UDC, no la dirección de tu cuenta. El UDC se explica en detalle en el capítulo “Desplegando Contratos en Starknet” más adelante en esta serie.
Declaración de Eventos
Agrega los siguientes eventos después de la sección de almacenamiento para rastrear transferencias y aprobaciones:
// Define the events that this contract can emit
#[event]
#[derive(Drop, starknet::Event)]
pub enum Event {
Transfer: Transfer, // Emitted when tokens are transferred
Approval: Approval, // Emitted when spending approval is granted
}
// Event emitted whenever tokens are transferred between addresses
#[derive(Drop, starknet::Event)]
pub struct Transfer {
#[key] // Indexed field (can be filtered when querying events)
from: ContractAddress, // Address sending the tokens
#[key] // Indexed field (can be filtered when querying events)
to: ContractAddress, // Address receiving the tokens
amount: u256, // Number of tokens transferred
}
// Event emitted when an owner approves a spender to use their tokens
#[derive(Drop, starknet::Event)]
pub struct Approval {
#[key] // Indexed field (can be filtered when querying events)
owner: ContractAddress, // Address that owns the tokens
#[key] // Indexed field (can be filtered when querying events)
spender: ContractAddress, // Address approved to spend the tokens
value: u256, // Amount approved for spending
}
El enum Event contiene todos los eventos que el contrato puede emitir: Transfer y Approval.
- Los eventos
Transferrastrean los movimientos de tokens con las direccionesfromytomás elamount(cantidad). - Los eventos
Approvaltambién rastrean los permisos de gasto con elowner(propietario) que otorga el permiso, elspender(gastador) que lo recibe y elvalue(valor) aprobado.
Los parámetros están indexados para que podamos consultar fácilmente transferencias desde direcciones específicas o aprobaciones para propietarios particulares.
Implementación del Contrato
Ahora implementemos las funciones del contrato. Dado que los contratos externos y los usuarios necesitan interactuar con nuestro token ERC20, debemos hacer que nuestra implementación pueda ser llamada desde fuera del contrato. Hacemos esto agregando el atributo #[abi(embed_v0)], el cual integra la implementación en el ABI del contrato:
#[abi(embed_v0)]
impl ERC20Impl of super::IERC20<ContractState> {
// Implementation functions go here
}
ERC20Impl implementa la interfaz IERC20 que definimos anteriormente, con ContractState representando el almacenamiento del contrato.
Funciones de Metadatos: name, symbol y decimals
Las funciones de metadatos devuelven detalles básicos del token como el nombre, el símbolo y la precisión decimal. Comencemos implementando las funciones.
Agrega las firmas de sus funciones a la interfaz:
#[starknet::interface]
pub trait IERC20<TContractState> {
fn name(self: @TContractState) -> ByteArray;
fn symbol(self: @TContractState) -> ByteArray;
fn decimals(self: @TContractState) -> u8;
}
Luego implementa las funciones dentro del bloque ERC20Impl:
#[abi(embed_v0)]
impl ERC20Impl of super::IERC20<ContractState> {
// Returns the full name of the token
fn name(self: @ContractState) -> ByteArray {
self.token_name.read()
}
// Returns the token's symbol/ticker
fn symbol(self: @ContractState) -> ByteArray {
self.symbol.read()
}
// Returns the number of decimal places for the token
fn decimals(self: @ContractState) -> u8 {
self.decimal.read()
}
// other functions goes here
}
Cada función lee un valor almacenado que se estableció en el constructor durante la inicialización del contrato. Actualiza las importaciones dentro del módulo del contrato para incluir StoragePointerReadAccess y así habilitar estas lecturas:
use starknet::storage::{
Map, StoragePointerWriteAccess, StoragePointerReadAccess
};
Aquí está el código completo hasta este punto:
#[starknet::interface]
pub trait IERC20<TContractState> {
fn name(self: @TContractState) -> ByteArray;
fn symbol(self: @TContractState) -> ByteArray;
fn decimals(self: @TContractState) -> u8;
}
#[starknet::contract]
pub mod ERC20 {
use starknet::ContractAddress;
use starknet::storage::{Map, StoragePointerReadAccess, StoragePointerWriteAccess};
#[storage]
pub struct Storage {
balances: Map<ContractAddress, u256>,
allowances: Map<(ContractAddress, ContractAddress), u256>,
token_name: ByteArray,
symbol: ByteArray,
decimal: u8,
total_supply: u256,
owner: ContractAddress,
}
#[event]
#[derive(Drop, starknet::Event)]
pub enum Event {
Transfer: Transfer,
Approval: Approval,
}
#[derive(Drop, starknet::Event)]
pub struct Transfer {
#[key]
from: ContractAddress,
#[key]
to: ContractAddress,
amount: u256,
}
#[derive(Drop, starknet::Event)]
pub struct Approval {
#[key]
owner: ContractAddress,
#[key]
spender: ContractAddress,
value: u256,
}
#[constructor]
fn constructor(ref self: ContractState, owner: ContractAddress) {
self.token_name.write("Rare Token");
self.symbol.write("RST");
self.decimal.write(18);
self.owner.write(owner);
}
#[abi(embed_v0)]
impl ERC20Impl of super::IERC20<ContractState> {
// Returns the full name of the token
fn name(self: @ContractState) -> ByteArray {
self.token_name.read()
}
// Returns the token's symbol/ticker
fn symbol(self: @ContractState) -> ByteArray {
self.symbol.read()
}
// Returns the number of decimal places for the token
fn decimals(self: @ContractState) -> u8 {
self.decimal.read()
}
}
}
Configuración de Pruebas
Navega a test/test_contract.cairo en el directorio de tu proyecto. Borra las pruebas predeterminadas (boilerplate), dejando solo las importaciones básicas:
use starknet::ContractAddress;
use snforge_std::{declare, ContractClassTrait, DeclareResultTrait};
El constructor del contrato requiere una dirección de propietario (owner) como parámetro:
#[constructor]
fn constructor(ref self: ContractState, owner: ContractAddress) {
self.token_name.write("Rare Token");
self.symbol.write("RST");
self.decimal.write(18);
self.owner.write(owner);
}
Dado que el constructor espera una dirección de propietario, necesitamos proporcionar una al desplegar el contrato en nuestras pruebas. Para manejar esto, crea una función auxiliar deploy_contract que tome la dirección del propietario como parámetro y se la pase al constructor.
Además, importa el dispatcher en la prueba para interactuar con el contrato desplegado, por lo que en conjunto tenemos:
use starknet::ContractAddress;
use snforge_std::{declare, ContractClassTrait, DeclareResultTrait};
//NEWLY ADDED BELOW//
use erc20::IERC20Dispatcher;
use erc20::IERC20DispatcherTrait;
// Helper function to deploy the ERC20 contract with a specified owner
fn deploy_contract(name: ByteArray, owner: ContractAddress) -> ContractAddress {
let contract = declare(name).unwrap().contract_class(); // Declare the contract class
let constructor_args = array![owner.into()]; // Pass owner to constructor
let (contract_address, _) = contract.deploy(@constructor_args).unwrap(); // Deploy the contract and return its address
contract_address
}
Los dispatchers IERC20Dispatcher y IERC20DispatcherTrait nos permiten llamar a las funciones del contrato desde las pruebas.
La función deploy_contract declara la clase del contrato, pasa la dirección del propietario al constructor a través de constructor_args, y devuelve la dirección del contrato desplegado para que podamos interactuar con él.
Dado que cada prueba requiere un despliegue de contrato, define una constante OWNER para proporcionar una dirección de propietario de prueba consistente en lugar de crear una nueva cada vez:
const OWNER: ContractAddress = 'OWNER'.try_into().unwrap();
De esta manera, cada prueba puede simplemente llamar a deploy_contract("ERC20", OWNER) para desplegar el contrato con una dirección de propietario consistente.
Probando la Inicialización del Constructor
El siguiente paso es confirmar que el constructor inicializa los metadatos correctamente. La prueba a continuación despliega el contrato, llama a sus funciones de metadatos (name(), symbol(), decimal()) y verifica los valores devueltos:
use starknet::ContractAddress;
use snforge_std::{declare, ContractClassTrait, DeclareResultTrait};
use erc20::IERC20Dispatcher;
use erc20::IERC20DispatcherTrait;
fn deploy_contract(name: ByteArray, owner: ContractAddress) -> ContractAddress {
let contract = declare(name).unwrap().contract_class();
let constructor_args = array![owner.into()];
let (contract_address, _) = contract.deploy(@constructor_args).unwrap();
contract_address
}
const OWNER: ContractAddress = 'OWNER'.try_into().unwrap();
// NEWLY ADDED BELOW
#[test]
fn test_token_constructor() {
// Deploy the ERC20 contract with OWNER as the owner
let contract_address = deploy_contract("ERC20", OWNER);
// Create a dispatcher to interact with the deployed contract
let erc20_token = IERC20Dispatcher { contract_address };
// Retrieve token metadata from the contract
let token_name = erc20_token.name();
let token_symbol = erc20_token.symbol();
let token_decimal = erc20_token.decimals();
// Verify that the constructor set the correct values
assert(token_name == "Rare Token", 'Wrong token name');
assert(token_symbol == "RST", 'Wrong token symbol');
assert(token_decimal == 18, 'Wrong token decimal');
}
Después de desplegar el contrato en test_token_constructor, creamos una instancia de IERC20Dispatcher con la dirección del contrato desplegado para interactuar con el contrato. Luego llamamos a cada función de metadatos y afirmamos (assert) que el nombre del token, el símbolo y los decimales coinciden con lo que se estableció en el constructor. Si algún valor no coincide, la prueba fallará con el mensaje de error correspondiente.
Ejecuta scarb test test_token_constructor para confirmar que la prueba pasa. También puedes probar con valores incorrectos para ver los errores esperados.
Implementando total_supply
Para rastrear cuántos tokens existen, incluiremos una función de suministro total en la interfaz y la implementaremos para leer y devolver el número total de tokens creados.
Agrega la firma de la función a la interfaz:
#[starknet::interface]
pub trait IERC20<TContractState> {
fn name(self: @TContractState) -> ByteArray;
fn symbol(self: @TContractState) -> ByteArray;
fn decimals(self: @TContractState) -> u8;
//NEWLY ADDED
fn total_supply(self: @TContractState) -> u256;
}
Luego impleméntala en el contrato:
#[abi(embed_v0)]
impl ERC20Impl of super::IERC20<ContractState> {
// ...previous functions....
fn total_supply(self: @ContractState) -> u256 {
// Read the total_supply value from contract storage
self.total_supply.read()
}
}
Para probar la función total_supply, primero necesitamos acuñar (mint) tokens y luego confirmar que el suministro total refleja la cantidad acuñada. Por lo tanto, necesitamos implementar la función para acuñar tokens.
Implementando mint
Sin mint, no puede existir ningún token, ya que todos los saldos comienzan en cero.
La función mint no está en la especificación ERC-20, pero es necesaria para crear tokens y aumentar el suministro total.
Agrégala a la interfaz:
use starknet::ContractAddress;
#[starknet::interface]
pub trait IERC20<TContractState> {
fn name(self: @TContractState) -> ByteArray;
fn symbol(self: @TContractState) -> ByteArray;
fn decimals(self: @TContractState) -> u8;
fn total_supply(self: @TContractState) -> u256;
//NEWLY ADDED
fn mint(ref self: TContractState, recipient: ContractAddress, amount: u256) -> bool;
}
Importamos ContractAddress ya que la función mint la utiliza como tipo de parámetro.
Luego implementa la función mint en el contrato:
#[abi(embed_v0)]
impl ERC20Impl of super::IERC20<ContractState> {
// ....previous functions.....//
fn mint(ref self: ContractState, recipient: ContractAddress, amount: u256) -> bool {
// Get the address of whoever is calling this function
let caller = get_caller_address();
// Only the contract owner is allowed to mint new tokens
assert(caller == self.owner.read(), 'Call not owner');
// Read current values before updating
let previous_total_supply = self.total_supply.read();
let previous_balance = self.balances.entry(recipient).read();
// Increase total supply by the minted amount
self.total_supply.write(previous_total_supply + amount);
// Add the minted tokens to recipient's balance
self.balances.entry(recipient).write(previous_balance + amount);
// Emit transfer from zero address
let zero_address: ContractAddress = 0.try_into().unwrap();
self.emit(Transfer { from: zero_address, to: recipient, amount });
true // Return success
}
}
mint toma una dirección de destinatario y una cantidad como parámetros. Solo el propietario del contrato puede llamar a esta función, que es la razón por la que se verifica caller == owner.
Cuando se acuñan tokens, tanto el suministro total como el saldo del destinatario aumentan en la cantidad especificada.
Recuerda de Solidity que los tokens recién acuñados siempre aparecen como transferencias desde la dirección cero porque se crean de la nada. Seguimos el mismo patrón aquí, emitiendo un evento Transfer desde la dirección cero al destinatario.
let zero_address: ContractAddress = 0.try_into().unwrap();
self.emit(Transfer { from: zero_address, to: recipient, amount });
Importamos StoragePathEntry porque usamos .entry() para acceder a las claves del Map, lo que crea una ruta a entradas de mapeo específicas, y también get_caller_address para obtener la dirección del llamador actual.
Actualiza las importaciones:
use starknet::storage::{
Map, StoragePathEntry, StoragePointerReadAccess, StoragePointerWriteAccess,
};
use starknet::{ContractAddress, get_caller_address};
Aquí está el código completo hasta este punto:
use starknet::ContractAddress;
#[starknet::interface]
pub trait IERC20<TContractState> {
fn name(self: @TContractState) -> ByteArray;
fn symbol(self: @TContractState) -> ByteArray;
fn decimals(self: @TContractState) -> u8;
fn total_supply(self: @TContractState) -> u256;
fn mint(ref self: TContractState, recipient: ContractAddress, amount: u256) -> bool;
}
#[starknet::contract]
pub mod ERC20 {
use starknet::storage::{
Map, StoragePathEntry, StoragePointerReadAccess, StoragePointerWriteAccess,
};
use starknet::{ContractAddress, get_caller_address};
#[storage]
pub struct Storage {
balances: Map<ContractAddress, u256>,
allowances: Map<(ContractAddress, ContractAddress), u256>,
token_name: ByteArray,
symbol: ByteArray,
decimal: u8,
total_supply: u256,
owner: ContractAddress,
}
#[event]
#[derive(Drop, starknet::Event)]
pub enum Event {
Transfer: Transfer,
Approval: Approval,
}
#[derive(Drop, starknet::Event)]
pub struct Transfer {
#[key]
from: ContractAddress,
#[key]
to: ContractAddress,
amount: u256,
}
#[derive(Drop, starknet::Event)]
pub struct Approval {
#[key]
owner: ContractAddress,
#[key]
spender: ContractAddress,
value: u256,
}
#[constructor]
fn constructor(ref self: ContractState, owner: ContractAddress) {
self.token_name.write("Rare Token");
self.symbol.write("RST");
self.decimal.write(18);
self.owner.write(owner);
}
#[abi(embed_v0)]
impl ERC20Impl of super::IERC20<ContractState> {
fn name(self: @ContractState) -> ByteArray {
self.token_name.read()
}
fn symbol(self: @ContractState) -> ByteArray {
self.symbol.read()
}
fn decimals(self: @ContractState) -> u8 {
self.decimal.read()
}
fn total_supply(self: @ContractState) -> u256 {
self.total_supply.read()
}
fn mint(ref self: ContractState, recipient: ContractAddress, amount: u256) -> bool {
let caller = get_caller_address();
assert(caller == self.owner.read(), 'Call not owner');
let previous_total_supply = self.total_supply.read();
let previous_balance = self.balances.entry(recipient).read();
self.total_supply.write(previous_total_supply + amount);
self.balances.entry(recipient).write(previous_balance + amount);
let zero_address: ContractAddress = 0.try_into().unwrap();
self.emit(Transfer { from: zero_address, to: recipient, amount });
true
}
}
}
Probando total_supply
Dado que solo el propietario del contrato puede acuñar tokens y las pruebas no se ejecutan como el propietario de forma predeterminada, es necesario suplantar (impersonate) la dirección del propietario.
Usaremos cheat_caller_address para cambiar temporalmente quién cree el contrato que lo está llamando, eludiendo las comprobaciones de control de acceso en el contrato. Establece CheatSpan::TargetCalls(1) para aplicar este truco (cheat) solo a la siguiente llamada de función (mint()).
Importa cheat_caller_address y CheatSpan de snforge_std, y agrega una función auxiliar para generar una dirección de destinatario de prueba para recibir los tokens acuñados, de modo que terminemos con:
use starknet::ContractAddress;
use snforge_std::{
declare, ContractClassTrait, DeclareResultTrait,
cheat_caller_address, CheatSpan
};
use erc20::IERC20Dispatcher;
use erc20::IERC20DispatcherTrait;
fn deploy_contract(name: ByteArray, owner: ContractAddress) -> ContractAddress {
let contract = declare(name).unwrap().contract_class();
let constructor_args = array![owner.into()];
let (contract_address, _) = contract.deploy(@constructor_args).unwrap();
contract_address
}
const OWNER: ContractAddress = 'OWNER'.try_into().unwrap();
// NEWLY ADDED
// Test recipient address constant
const TOKEN_RECIPIENT: ContractAddress = 'RECIPIENT'.try_into().unwrap();
Ahora escribe la prueba:
#[test]
fn test_total_supply() {
// Deploy the contract
let contract_address = deploy_contract("ERC20", OWNER);
// Create dispatcher to interact with the contract
let erc20_token = IERC20Dispatcher { contract_address };
// Calculate mint amount: 1000 tokens adjusted for decimals
let token_decimal = erc20_token.decimals();
let mint_amount = 1000 * token_decimal.into();
// Impersonate the owner for the next function call (mint)
cheat_caller_address(contract_address, OWNER, CheatSpan::TargetCalls(1));
erc20_token.mint(TOKEN_RECIPIENT, mint_amount);
// Get the total supply
let supply = erc20_token.total_supply();
// Verify total supply matches the minted amount
assert(supply == mint_amount, 'Incorrect Supply');
}
La prueba test_total_supply despliega el contrato y calcula la cantidad de acuñación multiplicando 1000 tokens por los lugares decimales (18). Antes de llamar a mint, cheat_caller_address establece que el llamador sea la dirección del propietario, permitiendo que la acuñación eluda la verificación assert(caller == owner). Después de acuñar a una dirección de destinatario, la prueba recupera el suministro total y verifica que sea igual a la cantidad acuñada.
Agrega la prueba al archivo test_contract.cairo, luego ejecuta scarb test test_total_supply para ver que pasa correctamente.
Implementando la transferencia de tokens
La función transfer maneja el movimiento de tokens desde el llamador a un destinatario. Primero, agrega la firma de la función a la interfaz:
use starknet::ContractAddress;
#[starknet::interface]
pub trait IERC20<TContractState> {
fn name(self: @TContractState) -> ByteArray;
fn symbol(self: @TContractState) -> ByteArray;
fn decimals(self: @TContractState) -> u8;
fn total_supply(self: @TContractState) -> u256;
fn mint(ref self: TContractState, recipient: ContractAddress, amount: u256) -> bool;
//NEWLY ADDED
fn transfer(ref self: TContractState, recipient: ContractAddress, amount: u256) -> bool;
}
Ahora, implementa la función transfer dentro del bloque ERC20Impl:
#[abi(embed_v0)]
impl ERC20Impl of super::IERC20<ContractState> {
//.....previous functions.....//
fn transfer(ref self: ContractState, recipient: ContractAddress, amount: u256) -> bool {
// Get the address of whoever called this function
let sender = get_caller_address();
// Read current balances for both sender and recipient
let sender_prev_balance = self.balances.entry(sender).read();
let recipient_prev_balance = self.balances.entry(recipient).read();
// Check if sender has enough tokens to transfer
assert(sender_prev_balance >= amount, 'Insufficient amount');
// Update balances: subtract from sender, add to recipient
self.balances.entry(sender).write(sender_prev_balance - amount);
self.balances.entry(recipient).write(recipient_prev_balance + amount);
// Verify the transfer worked correctly
assert(
self.balances.entry(recipient).read() > recipient_prev_balance,
'Transaction failed',
);
// Emit an event to log this Transfer
self.emit(Transfer { from: sender, to: recipient, amount });
true // Return success
}
}
}
Supongamos que Alice tiene 100 RareTokens y quiere enviar 30 a Bob, quien tiene 50. La función verifica si Alice tiene suficiente (100 >= 30), actualiza el saldo de Alice a 70, y actualiza el saldo de Bob a 80. Luego confirma que el saldo de Bob aumentó y emite un evento Transfer con from: Alice, to: Bob y amount: 30 para registrar esta transacción, y devuelve true para señalar que se completó con éxito.
Para probar la función de transferencia, el contrato necesita una forma de verificar el saldo de cada cuenta en puntos específicos.
Implementando balance_of
Agreguemos balance_of para consultar los saldos de tokens. Agrega la firma de la función a la interfaz:
use starknet::ContractAddress;
#[starknet::interface]
pub trait IERC20<TContractState> {
fn name(self: @TContractState) -> ByteArray;
fn symbol(self: @TContractState) -> ByteArray;
fn decimals(self: @TContractState) -> u8;
fn total_supply(self: @TContractState) -> u256;
fn mint(ref self: TContractState, recipient: ContractAddress, amount: u256) -> bool;
fn transfer(ref self: TContractState, recipient: ContractAddress, amount: u256) -> bool;
//NEWLY ADDED
fn balance_of(self: @TContractState, account: ContractAddress) -> u256;
}
Luego impleméntala en el contrato:
#[abi(embed_v0)]
impl ERC20Impl of super::IERC20<ContractState> {
//.....previous functions.....//
fn balance_of(self: @ContractState, account: ContractAddress) -> u256 {
// Use .entry() to access the specific account's balance in the Map
let balance = self.balances.entry(account).read();
balance
}
}
Para verificar el saldo de RareToken de una cuenta, balance_of(account_address) busca la dirección en el mapeo de balances y devuelve el valor correspondiente.
Probando transfer
Para probar la función transfer, primero necesitamos tokens en una cuenta, luego verificar que la transferencia mueva los tokens correctamente del remitente al destinatario. Acuñaremos tokens al propietario, luego transferiremos algunos a un destinatario y verificaremos ambos saldos.
Dado que tanto acuñar como transferir requieren el permiso del propietario, usaremos start_cheat_caller_address para suplantar al propietario durante múltiples llamadas consecutivas hasta que se detenga explícitamente con stop_cheat_caller_address.
Importa start_cheat_caller_address y stop_cheat_caller_address de snforge_std junto con las otras importaciones:
use snforge_std::{
declare, ContractClassTrait, DeclareResultTrait,
cheat_caller_address, CheatSpan,
start_cheat_caller_address, stop_cheat_caller_address
};
Ahora aquí está la prueba:
#[test]
fn test_transfer() {
// Deploy the contract
let contract_address = deploy_contract("ERC20", OWNER);
let erc20_token = IERC20Dispatcher { contract_address };
// Get token decimals for proper amount calculation
let token_decimal = erc20_token.decimals();
// Define amounts: 10,000 tokens to mint, 5,000 to transfer
let amount_to_mint: u256 = 10000 * token_decimal.into();
let amount_to_transfer: u256 = 5000 * token_decimal.into();
// Start impersonating the owner for multiple calls
start_cheat_caller_address(contract_address, OWNER);
// Mint tokens to the owner
erc20_token.mint(OWNER, amount_to_mint);
// Verify the mint was successful
assert(erc20_token.balance_of(OWNER) == amount_to_mint, 'Incorrect minted amount');
// Track recipient's balance before transfer
let receiver_previous_balance = erc20_token.balance_of(TOKEN_RECIPIENT);
// Transfer tokens from owner to recipient
erc20_token.transfer(TOKEN_RECIPIENT, amount_to_transfer);
// Stop impersonating the owner
stop_cheat_caller_address(contract_address);
// Verify sender's balance decreased correctly
assert(erc20_token.balance_of(OWNER) < amount_to_mint, 'Sender balance not reduced');
assert(erc20_token.balance_of(OWNER) == amount_to_mint - amount_to_transfer, 'Wrong sender balance');
// Verify recipient's balance increased correctly
assert(erc20_token.balance_of(TOKEN_RECIPIENT) > receiver_previous_balance, 'Recipient balance unchanged');
assert(erc20_token.balance_of(TOKEN_RECIPIENT) == amount_to_transfer, 'Wrong recipient amount');
}
Después de desplegar el contrato en test_transfer(), la prueba calcula las cantidades: 10,000 tokens para acuñar y 5,000 para la transferencia. Comienza suplantando al propietario con start_cheat_caller_address, lo que permite acuñar tokens en la cuenta del propietario. Una vez que la acuñación tiene éxito, la prueba registra el saldo del destinatario antes de realizar la transferencia.
Luego, la prueba transfiere 5,000 tokens al destinatario y detiene la suplantación. Las afirmaciones finales (assertions) verifican ambos lados de la transacción: que el saldo del propietario disminuyó exactamente en 5,000 tokens, y que el saldo del destinatario aumentó en la misma cantidad. Esto confirma que transfer mueve correctamente los tokens entre cuentas.
Agrega la prueba al archivo test_contract.cairo, luego ejecuta scarb test test_transfer para verificar que pasa.
Probando saldo insuficiente para transferir
Probemos que transfer rechaza adecuadamente los intentos de transferir más tokens de los que posee el remitente.
Modifica la llamada transfer en nuestra prueba para intentar transferir 11,000 tokens en lugar de 5000:
erc20_token.transfer(TOKEN_RECIPIENT, 11000 * token_decimal.into());
Cuando ejecutemos scarb test test_transfer, la prueba debería fallar con este error:

Esto confirma que el contrato está funcionando correctamente, está previniendo la transferencia de más tokens de los que posee el remitente, desencadenando la verificación assert(sender_prev_balance >= amount, 'Insufficient amount') en la función transfer.
Para mantener la prueba pasando, vuelve a cambiar la cantidad a
amount_to_transfer(5,000 tokens o cualquier cantidad menor o igual al saldo del propietario de 10,000)
Alternativa: Crear una prueba dedicada para el caso de fallo
En lugar de modificar la prueba existente, agrega esta prueba usando #[should_panic] a test_contract.cairo:
#[test]
#[should_panic(expected: ('Insufficient amount',))]
fn test_transfer_insufficient_balance() {
// Deploy the contract
let contract_address = deploy_contract("ERC20", OWNER);
let erc20_token = IERC20Dispatcher { contract_address };
let token_decimal = erc20_token.decimals();
// Define amounts: only 5,000 tokens minted, but attempting to transfer 10,000
let mint_amount: u256 = 5000 * token_decimal.into();
let transfer_amount: u256 = 10000 * token_decimal.into();
// Start impersonating the owner
start_cheat_caller_address(contract_address, OWNER);
// Mint only 5,000 tokens to the owner
erc20_token.mint(OWNER, mint_amount);
// Verify the mint was successful
assert(erc20_token.balance_of(OWNER) == mint_amount, 'Mint failed');
// Attempt to transfer more than balance (10,000 tokens when only 5,000 exist)
// This should panic with 'Insufficient amount'
erc20_token.transfer(TOKEN_RECIPIENT, transfer_amount);
// Stop impersonating the owner
stop_cheat_caller_address(contract_address);
}
Esta prueba verifica que la transferencia falla al intentar enviar más tokens de los que posee el remitente. El propietario solo tiene 5,000 tokens pero intenta transferir 10,000, desencadenando la verificación assert(sender_prev_balance >= amount, 'Insufficient amount') en la función transfer. El atributo #[should_panic] le dice al framework de pruebas que se espera que esta prueba entre en pánico (panic) con el mensaje de error específico 'Insufficient amount'.
Ejecuta scarb test test_transfer_insufficient_balance para verificar que pasa.
Implementando allowance
La función allowance verifica cuánto se le permite gastar a una dirección en nombre de otra. Agreguemos la firma de la función a la interfaz:
use starknet::ContractAddress;
#[starknet::interface]
pub trait IERC20<TContractState> {
// ... previous functions ...
//NEWLY ADDED
fn allowance(self: @TContractState, owner: ContractAddress, spender: ContractAddress) -> u256;
}
Luego impleméntala en el contrato:
#[abi(embed_v0)]
impl ERC20Impl of super::IERC20<ContractState> {
//....previous functions....//
fn allowance(
self: @ContractState, owner: ContractAddress, spender: ContractAddress,
) -> u256 {
// Access the allowances Map using a tuple key (owner, spender)
self.allowances.entry((owner, spender)).read()
}
Por ejemplo, para ver cuántos RareTokens puede gastar Bob de la cuenta de Alice, llamarías a allowance(Alice, Bob).
Implementando approve
La función approve otorga permiso de gasto al establecer cuánto puede retirar alguien (gastador o spender) del saldo de una cuenta (propietario u owner).
Agrega la firma de la función a la interfaz:
use starknet::ContractAddress;
#[starknet::interface]
pub trait IERC20<TContractState> {
// ... previous functions ...
//NEWLY ADDED
fn approve(ref self: TContractState, spender: ContractAddress, amount: u256) -> bool;
}
Luego impleméntala en el contrato:
#[abi(embed_v0)]
impl ERC20Impl of super::IERC20<ContractState> {
//.....previous functions.....//
fn approve(ref self: ContractState, spender: ContractAddress, amount: u256) -> bool {
// Get the address of whoever is giving the approval (owner)
let caller = get_caller_address();
// Set the allowance: how much the spender can spend on behalf of the caller (owner)
self.allowances.entry((caller, spender)).write(amount);
// Emit an event to log this Approval
self.emit(Approval { owner: caller, spender, value: amount });
true // Return success
}
}
En la línea self.allowances.entry((caller, spender)).write(amount), spender se refiere a la dirección a la que el caller (llamador) le está otorgando la asignación. El caller (obtenido de get_caller_address()) le está dando permiso al spender para gastar una cierta cantidad de tokens de su cuenta.
Por lo tanto, caller es el propietario de los tokens, y spender es alguien que ha sido aprobado por el propietario para gastar una cierta cantidad de tokens en su nombre. Esto crea la entrada allowances[(owner, spender)] = amount que transfer_from verificará y utilizará más adelante.
Probando approve
Probemos que la función approve establece correctamente la asignación (allowance) y que se puede consultar después:
#[test]
fn test_approve() {
let contract_address = deploy_contract("ERC20", OWNER);
let erc20_token = IERC20Dispatcher { contract_address };
let token_decimal = erc20_token.decimals();
let mint_amount: u256 = 10000 * token_decimal.into();
let approval_amount: u256 = 5000 * token_decimal.into();
// Start impersonating the owner
start_cheat_caller_address(contract_address, OWNER);
// Mint tokens to the owner first
erc20_token.mint(OWNER, mint_amount);
// Verify mint succeeded
assert(erc20_token.balance_of(OWNER) == mint_amount, 'Mint failed');
// Owner approves the recipient to spend tokens
erc20_token.approve(TOKEN_RECIPIENT, approval_amount);
// Stop impersonating the owner
stop_cheat_caller_address(contract_address);
// Verify the allowance was set
assert(erc20_token.allowance(OWNER, TOKEN_RECIPIENT) > 0, 'Incorrect allowance');
assert(erc20_token.allowance(OWNER, TOKEN_RECIPIENT) == approval_amount, 'Wrong allowance amount');
}
La prueba despliega el contrato y define dos cantidades: 10,000 tokens para acuñar y 5,000 tokens para la aprobación. Utilizando start_cheat_caller_address, la prueba suplanta al propietario durante múltiples llamadas consecutivas.
Primero, la prueba acuña 10,000 tokens al propietario y verifica que la acuñación haya tenido éxito. Luego, mientras aún suplanta al propietario, llama a approve para otorgar al destinatario permiso para gastar 5,000 tokens del saldo del propietario. Después de detener la suplantación, la prueba verifica dos cosas: primero, que existe una asignación (mayor que 0), y segundo, que la cantidad de asignación coincide exactamente con lo que se aprobó (5,000 tokens). Estas afirmaciones confirman que approve almacena correctamente el permiso de gasto en el mapeo allowances.
Agrega la prueba a tu archivo test_contract.cairo, luego ejecuta scarb test test_approve para verificar que pasa.
Implementando transferencias delegadas: transfer_from
Ahora, implementemos transfer_from que mueve tokens de una dirección a otra utilizando permisos de gasto preaprobados.
Actualiza la interfaz para incluir la firma de la función:
#[starknet::interface]
pub trait IERC20<TContractState> {
// ... previous functions ...
fn transfer_from(
ref self: TContractState, sender: ContractAddress, recipient: ContractAddress, amount: u256,
) -> bool;
}
Implementación de transfer_from:
#[abi(embed_v0)]
impl ERC20Impl of super::IERC20<ContractState> {
//.....previous functions.....//
fn transfer_from(ref self: ContractState, sender: ContractAddress, recipient: ContractAddress, amount: u256) -> bool {
// Get the address of whoever is calling this function (the spender)
let spender = get_caller_address();
// Read current allowance: how much the spender is allowed to spend from sender's account
let spender_allowance = self.allowances.entry((sender, spender)).read();
// Read current balances for both sender and recipient
let sender_balance = self.balances.entry(sender).read();
let recipient_balance = self.balances.entry(recipient).read();
// Check if the transfer amount doesn't exceed the approved allowance
assert(amount <= spender_allowance, 'amount exceeds allowance');
// Check if sender has enough tokens to transfer
assert(amount <= sender_balance, 'amount exceeds balance');
// Update allowance: reduce by the amount being spent
self.allowances.entry((sender, spender)).write(spender_allowance - amount);
// Update balances: subtract from sender, add to recipient
self.balances.entry(sender).write(sender_balance - amount);
self.balances.entry(recipient).write(recipient_balance + amount);
// Emit an event to log this Transfer
self.emit(Transfer { from: sender, to: recipient, amount });
true // Return success
}
}
En el código anterior, el spender (obtenido de get_caller_address()) ejecuta la transferencia, el sender es el propietario del token, y el recipient (destinatario) recibe los tokens. La función verifica que el spender tenga asignación suficiente leyendo allowances[(sender, spender)], y luego reduce la asignación en la cantidad transferida.
Sin restar lo gastado, el spender tendría poder de gasto ilimitado.
Considera este ejemplo que muestra cómo approve y transfer_from trabajan en conjunto:
Alice llama a approve(Bob, 50) para permitir que Bob gaste 50 de sus RareTokens. Luego Bob puede usar transfer_from(Alice, Charlie, 30) para mover 30 tokens de la cuenta de Alice a Charlie, dejando a Bob con 20 de asignación restante.
Este patrón de aprobar y luego retirar es la forma en que los protocolos DeFi, los DEX (exchanges descentralizados) y otros contratos inteligentes interactúan con los tokens de los usuarios.
Probando transfer_from
La prueba de transfer_from requiere tres partes: un propietario con tokens, un gastador con aprobación y una dirección de destinatario.
Dado que el gastador utiliza su aprobación para mover tokens de la cuenta del propietario al destinatario, tanto el propietario como el gastador necesitan ser suplantados en diferentes etapas de la prueba:
#[test]
fn test_transfer_from() {
// Deploy the contract
let contract_address = deploy_contract("ERC20", OWNER);
let erc20_token = IERC20Dispatcher { contract_address };
let token_decimal = erc20_token.decimals();
// Define amounts: 10,000 tokens to mint, 5,000 to approve and transfer
let mint_amount: u256 = 10000 * token_decimal.into();
let transfer_amount: u256 = 5000 * token_decimal.into();
// Start impersonating the owner
start_cheat_caller_address(contract_address, OWNER);
// Mint tokens to the owner
erc20_token.mint(OWNER, mint_amount);
// Verify mint succeeded
assert(erc20_token.balance_of(OWNER) == mint_amount, 'Mint failed');
let spender:ContractAddress = 'SPENDER'.try_into().unwrap();
// Owner approves SPENDER to spend tokens on their behalf
erc20_token.approve(spender, transfer_amount);
// Stop impersonating owner
stop_cheat_caller_address(contract_address);
// Verify the allowance was set correctly
assert(erc20_token.allowance(OWNER, spender) == transfer_amount, 'Approval failed');
// Track balances before transfer
let owner_balance_before = erc20_token.balance_of(OWNER);
let recipient_balance_before = erc20_token.balance_of(TOKEN_RECIPIENT);
let allowance_before = erc20_token.allowance(OWNER, spender);
// Now impersonate the SPENDER to call transfer_from
cheat_caller_address(contract_address, spender, CheatSpan::TargetCalls(1));
erc20_token.transfer_from(OWNER, TOKEN_RECIPIENT, transfer_amount);
// Verify owner's balance decreased
assert(erc20_token.balance_of(OWNER) == owner_balance_before - transfer_amount, 'Owner balance wrong');
// Verify recipient's balance increased
assert(erc20_token.balance_of(TOKEN_RECIPIENT) == recipient_balance_before + transfer_amount, 'Recipient balance wrong');
// Verify allowance decreased
assert(erc20_token.allowance(OWNER, spender) == allowance_before - transfer_amount, 'Allowance not reduced');
}
test_transfer_from valida el patrón completo de aprobar y gastar. La prueba comienza suplantando al propietario para acuñar 10,000 tokens y aprobar a un gastador para usar 5,000 de ellos. Después de detener la suplantación del propietario, verifica que la aprobación se haya establecido correctamente.
A continuación, la prueba captura el estado actual: el saldo del propietario, el saldo del destinatario y la asignación del gastador. Luego, suplanta al gastador y llama a transfer_from para mover 5,000 tokens del propietario al destinatario.
// Now impersonate the SPENDER to call transfer_from
cheat_caller_address(contract_address, spender, CheatSpan::TargetCalls(1));
erc20_token.transfer_from(OWNER, TOKEN_RECIPIENT, transfer_amount);
Las afirmaciones finales verifican tres actualizaciones: el saldo del propietario disminuyó en 5,000, el saldo del destinatario aumentó en 5,000, y la asignación del gastador se redujo en 5,000. Estas verificaciones confirman que transfer_from maneja correctamente las transferencias delegadas y actualiza adecuadamente las asignaciones.
Agrega la prueba al archivo test_contract.cairo, luego ejecuta scarb test test_transfer_from para verificar que pasa.
Probando Asignación Insuficiente
Probemos que transfer_from rechaza adecuadamente los intentos de gastar más de la cantidad aprobada. Si un gastador intenta transferir más tokens de los que se le aprobaron, la transacción debería fallar.
Modifica la llamada transfer_from en nuestra prueba para intentar transferir 6,000 tokens en lugar de los 5,000 aprobados:
// Attempt to transfer more than approved (6,000 instead of 5,000)
cheat_caller_address(contract_address, spender, CheatSpan::TargetCalls(1));
erc20_token.transfer_from(OWNER, TOKEN_RECIPIENT, 6000 * token_decimal.into());
Cuando ejecutemos scarb test test_transfer_from, la prueba debería fallar con este error:

Este error confirma que el contrato atrapa el intento de gasto no autorizado. El gastador solo fue aprobado para 5,000 tokens, por lo que intentar transferir 6,000 desencadena la verificación assert(amount <= spender_allowance, 'amount exceeds allowance') en la función transfer_from.
Vuelve a cambiar la cantidad a transfer_amount (5,000 tokens) para mantener la prueba pasando.
Alternativa: Crear una prueba dedicada para el caso de fallo
En lugar de modificar la prueba existente, podemos crear una prueba separada que espere el fallo utilizando el atributo #[should_panic]:
#[test]
#[should_panic(expected: ('amount exceeds allowance',))]
fn test_transfer_from_insufficient_allowance() {
// Deploy the contract
let contract_address = deploy_contract("ERC20", OWNER);
let erc20_token = IERC20Dispatcher { contract_address };
let token_decimal = erc20_token.decimals();
// Define amounts: 10,000 tokens to mint, 5,000 to approve
let mint_amount: u256 = 10000 * token_decimal.into();
let approval_amount: u256 = 5000 * token_decimal.into();
// Start impersonating the owner
start_cheat_caller_address(contract_address, OWNER);
// Mint tokens to the owner
erc20_token.mint(OWNER, mint_amount);
let spender: ContractAddress = 'SPENDER'.try_into().unwrap();
// Owner approves SPENDER to spend 5,000 tokens
erc20_token.approve(spender, approval_amount);
// Stop impersonating owner
stop_cheat_caller_address(contract_address);
// Attempt to transfer more than approved (6,000 instead of 5,000)
// This should panic with 'amount exceeds allowance'
cheat_caller_address(contract_address, spender, CheatSpan::TargetCalls(1));
erc20_token.transfer_from(OWNER, TOKEN_RECIPIENT, 6000 * token_decimal.into());
}
Nuevamente, el atributo #[should_panic] le dice al framework de pruebas que se espera que esta prueba falle con el mensaje de error específico 'amount exceeds allowance'. Cuando agregues esta prueba a tu archivo test_contract.cairo, y luego ejecutes scarb test test_transfer_from_insufficient_allowance, esta prueba pasará porque el pánico (panic) ocurrió como se esperaba.
Probando Saldo Insuficiente
También podemos probar el caso donde un gastador tiene asignación suficiente pero el propietario no tiene suficientes tokens. Agrega esta prueba a test_contract.cairo:
#[test]
#[should_panic(expected: ('amount exceeds balance',))]
fn test_transfer_from_insufficient_balance() {
// Deploy the contract
let contract_address = deploy_contract("ERC20", OWNER);
let erc20_token = IERC20Dispatcher { contract_address };
let token_decimal = erc20_token.decimals();
// Define amounts: only 1,000 tokens minted, but 2,000 approved and attempted
let mint_amount: u256 = 1000 * token_decimal.into();
let approval_amount: u256 = 2000 * token_decimal.into();
let transfer_amount: u256 = 2000 * token_decimal.into();
// Start impersonating the owner
start_cheat_caller_address(contract_address, OWNER);
// Mint only 1,000 tokens to the owner
erc20_token.mint(OWNER, mint_amount);
let spender: ContractAddress = 'SPENDER'.try_into().unwrap();
// Owner approves SPENDER to spend 2,000 tokens (more than balance)
erc20_token.approve(spender, approval_amount);
// Stop impersonating owner
stop_cheat_caller_address(contract_address);
// Spender has sufficient allowance but owner doesn't have enough balance
// This should panic with 'amount exceeds balance'
cheat_caller_address(contract_address, spender, CheatSpan::TargetCalls(1));
erc20_token.transfer_from(OWNER, TOKEN_RECIPIENT, transfer_amount);
}
La prueba test_transfer_from_insufficient_balance anterior verifica que incluso con asignación suficiente, la transferencia falla si el propietario del token no tiene suficiente saldo. El gastador está aprobado para 2,000 tokens, pero el propietario solo tiene 1,000, desencadenando la verificación assert(amount <= sender_balance, 'amount exceeds balance').
Ejecuta scarb test test_transfer_from_insufficient_balance para verificar que pasa.
Aquí está el contrato ERC-20 completo:
use starknet::ContractAddress;
#[starknet::interface]
pub trait IERC20<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 purposes
}
#[starknet::contract]
pub mod ERC20 {
use starknet::{ContractAddress, get_caller_address};
use starknet::storage::{
Map, StoragePointerWriteAccess, StoragePointerReadAccess, StoragePathEntry,
};
#[storage]
pub struct Storage {
balances: Map<ContractAddress, u256>,
allowances: Map<
(ContractAddress, ContractAddress), u256,
>, // (owner, spender) -> amount, amount>
token_name: ByteArray,
symbol: ByteArray,
decimal: u8,
total_supply: u256,
owner: ContractAddress,
}
#[event]
#[derive(Drop, starknet::Event)]
pub enum Event {
Transfer: Transfer,
Approval: Approval,
}
#[derive(Drop, starknet::Event)]
pub struct Transfer {
#[key]
from: ContractAddress,
#[key]
to: ContractAddress,
amount: u256,
}
#[derive(Drop, starknet::Event)]
pub struct Approval {
#[key]
owner: ContractAddress,
#[key]
spender: ContractAddress,
value: u256,
}
#[constructor]
fn constructor(ref self: ContractState, owner: ContractAddress) {
self.token_name.write("Rare Token");
self.symbol.write("RST");
self.decimal.write(18);
self.owner.write(owner);
}
#[abi(embed_v0)]
impl ERC20Impl of super::IERC20<ContractState> {
fn total_supply(self: @ContractState) -> u256 {
self.total_supply.read()
}
fn balance_of(self: @ContractState, account: ContractAddress) -> u256 {
let balance = self.balances.entry(account).read();
balance
}
fn name(self: @ContractState) -> ByteArray {
self.token_name.read()
}
fn symbol(self: @ContractState) -> ByteArray {
self.symbol.read()
}
fn decimals(self: @ContractState) -> u8 {
self.decimal.read()
}
fn allowance(
self: @ContractState, owner: ContractAddress, spender: ContractAddress,
) -> u256 {
let allowance = self.allowances.entry((owner, spender)).read();
allowance
}
fn transfer(ref self: ContractState, recipient: ContractAddress, amount: u256) -> bool {
let sender = get_caller_address();
let sender_prev_balance = self.balances.entry(sender).read();
let recipient_prev_balance = self.balances.entry(recipient).read();
assert(sender_prev_balance >= amount, 'Insufficient amount');
self.balances.entry(sender).write(sender_prev_balance - amount);
self.balances.entry(recipient).write(recipient_prev_balance + amount);
assert(
self.balances.entry(recipient).read() > recipient_prev_balance,
'Transaction failed',
);
self.emit(Transfer { from: sender, to: recipient, amount });
true
}
fn transfer_from(
ref self: ContractState,
sender: ContractAddress,
recipient: ContractAddress,
amount: u256,
) -> bool {
let spender = get_caller_address();
let spender_allowance = self.allowances.entry((sender, spender)).read();
let sender_balance = self.balances.entry(sender).read();
let recipient_balance = self.balances.entry(recipient).read();
assert(amount <= spender_allowance, 'amount exceeds allowance');
assert(amount <= sender_balance, 'amount exceeds balance');
self.allowances.entry((sender, spender)).write(spender_allowance - amount);
self.balances.entry(sender).write(sender_balance - amount);
self.balances.entry(recipient).write(recipient_balance + amount);
self.emit(Transfer { from: sender, to: recipient, amount });
true
}
fn approve(ref self: ContractState, spender: ContractAddress, amount: u256) -> bool {
let caller = get_caller_address();
self.allowances.entry((caller, spender)).write(amount);
self.emit(Approval { owner: caller, spender, value: amount });
true
}
fn mint(ref self: ContractState, recipient: ContractAddress, amount: u256) -> bool {
let caller = get_caller_address();
assert(caller == self.owner.read(), 'Call not owner');
let previous_total_supply = self.total_supply.read();
let previous_balance = self.balances.entry(recipient).read();
self.total_supply.write(previous_total_supply + amount);
self.balances.entry(recipient).write(previous_balance + amount);
let zero_address: ContractAddress = 0.try_into().unwrap();
self.emit(Transfer { from: zero_address, to: recipient, amount });
true
}
}
}
A continuación se muestra la prueba completa:
use erc20::{IERC20Dispatcher, IERC20DispatcherTrait};
use snforge_std::{
CheatSpan, ContractClassTrait, DeclareResultTrait, cheat_caller_address, declare,
start_cheat_caller_address, stop_cheat_caller_address,
};
use starknet::ContractAddress;
fn deploy_contract(name: ByteArray, owner: ContractAddress) -> ContractAddress {
let contract = declare(name).unwrap().contract_class();
let constructor_args = array![owner.into()];
let (contract_address, _) = contract.deploy(@constructor_args).unwrap();
contract_address
}
const OWNER: ContractAddress = 'OWNER'.try_into().unwrap();
const TOKEN_RECIPIENT: ContractAddress = 'RECIPIENT'.try_into().unwrap();
#[test]
fn test_token_constructor() {
let contract_address = deploy_contract("ERC20", OWNER);
let erc20_token = IERC20Dispatcher { contract_address };
let token_name = erc20_token.name();
let token_symbol = erc20_token.symbol();
let token_decimal = erc20_token.decimals();
assert(token_name == "Rare Token", 'Wrong token name');
assert(token_symbol == "RST", 'Wrong token symbol');
assert(token_decimal == 18, 'Wrong token decimal');
}
#[test]
fn test_total_supply() {
let contract_address = deploy_contract("ERC20", OWNER);
let erc20_token = IERC20Dispatcher { contract_address };
let token_decimal = erc20_token.decimals();
let mint_amount = 1000 * token_decimal.into();
// cheat caller address to be the owner
cheat_caller_address(contract_address, OWNER, CheatSpan::TargetCalls(1));
erc20_token.mint(TOKEN_RECIPIENT, mint_amount);
let supply = erc20_token.total_supply();
assert(supply == mint_amount, 'Incorrect Supply');
}
#[test]
fn test_transfer() {
let contract_address = deploy_contract("ERC20", OWNER);
let erc20_token = IERC20Dispatcher { contract_address };
let token_decimal = erc20_token.decimals();
let amount_to_mint: u256 = 10000 * token_decimal.into();
let amount_to_transfer: u256 = 5000 * token_decimal.into();
// Start impersonating the owner for multiple calls
start_cheat_caller_address(contract_address, OWNER);
erc20_token.mint(OWNER, amount_to_mint);
assert(erc20_token.balance_of(OWNER) == amount_to_mint, 'Incorrect minted amount');
let receiver_previous_balance = erc20_token.balance_of(TOKEN_RECIPIENT);
erc20_token.transfer(TOKEN_RECIPIENT, amount_to_transfer);
stop_cheat_caller_address(contract_address);
assert(erc20_token.balance_of(OWNER) < amount_to_mint, 'Sender balance not reduced');
assert(
erc20_token.balance_of(OWNER) == amount_to_mint - amount_to_transfer,
'Wrong sender balance',
);
assert(
erc20_token.balance_of(TOKEN_RECIPIENT) > receiver_previous_balance,
'Recipient balance unchanged',
);
assert(
erc20_token.balance_of(TOKEN_RECIPIENT) == amount_to_transfer, 'Wrong recipient amount',
);
}
#[test]
#[should_panic(expected: ('Insufficient amount',))]
fn test_transfer_insufficient_balance() {
// Deploy the contract
let contract_address = deploy_contract("ERC20", OWNER);
let erc20_token = IERC20Dispatcher { contract_address };
let token_decimal = erc20_token.decimals();
// Define amounts: only 5,000 tokens minted, but attempting to transfer 10,000
let mint_amount: u256 = 5000 * token_decimal.into();
let transfer_amount: u256 = 10000 * token_decimal.into();
// Start impersonating the owner
start_cheat_caller_address(contract_address, OWNER);
// Mint only 5,000 tokens to the owner
erc20_token.mint(OWNER, mint_amount);
// Verify the mint was successful
assert(erc20_token.balance_of(OWNER) == mint_amount, 'Mint failed');
// Attempt to transfer more than balance (10,000 tokens when only 5,000 exist)
// This should panic with 'Insufficient amount'
erc20_token.transfer(TOKEN_RECIPIENT, transfer_amount);
// Stop impersonating the owner
stop_cheat_caller_address(contract_address);
}
#[test]
fn test_approve() {
let contract_address = deploy_contract("ERC20", OWNER);
let erc20_token = IERC20Dispatcher { contract_address };
let token_decimal = erc20_token.decimals();
let mint_amount: u256 = 10000 * token_decimal.into();
let approval_amount: u256 = 5000 * token_decimal.into();
// Start impersonating the owner
start_cheat_caller_address(contract_address, OWNER);
// Mint tokens to the owner first
erc20_token.mint(OWNER, mint_amount);
// Verify mint succeeded
assert(erc20_token.balance_of(OWNER) == mint_amount, 'Mint failed');
// Owner approves the recipient to spend tokens
erc20_token.approve(TOKEN_RECIPIENT, approval_amount);
// Stop impersonating the owner
stop_cheat_caller_address(contract_address);
// Verify the allowance was set
assert(erc20_token.allowance(OWNER, TOKEN_RECIPIENT) > 0, 'Incorrect allowance');
assert(erc20_token.allowance(OWNER, TOKEN_RECIPIENT) == approval_amount, 'Wrong allowance amount');
}
#[test]
fn test_transfer_from() {
let contract_address = deploy_contract("ERC20", OWNER);
let erc20_token = IERC20Dispatcher { contract_address };
let token_decimal = erc20_token.decimals();
let mint_amount: u256 = 10000 * token_decimal.into();
let transfer_amount: u256 = 5000 * token_decimal.into();
start_cheat_caller_address(contract_address, OWNER);
erc20_token.mint(OWNER, mint_amount);
assert(erc20_token.balance_of(OWNER) == mint_amount, 'Mint failed');
let spender: ContractAddress = 'SPENDER'.try_into().unwrap();
erc20_token.approve(spender, transfer_amount);
stop_cheat_caller_address(contract_address);
assert(erc20_token.allowance(OWNER, spender) == transfer_amount, 'Approval failed');
let owner_balance_before = erc20_token.balance_of(OWNER);
let recipient_balance_before = erc20_token.balance_of(TOKEN_RECIPIENT);
let allowance_before = erc20_token.allowance(OWNER, spender);
cheat_caller_address(contract_address, spender, CheatSpan::TargetCalls(1));
erc20_token.transfer_from(OWNER, TOKEN_RECIPIENT, 5000 * token_decimal.into());
assert(
erc20_token.balance_of(OWNER) == owner_balance_before - transfer_amount,
'Owner balance wrong',
);
assert(
erc20_token.balance_of(TOKEN_RECIPIENT) == recipient_balance_before + transfer_amount,
'Recipient balance wrong',
);
assert(
erc20_token.allowance(OWNER, spender) == allowance_before - transfer_amount,
'Allowance not reduced',
);
}
#[test]
#[should_panic(expected: ('amount exceeds allowance',))]
fn test_transfer_from_insufficient_allowance() {
// Deploy the contract
let contract_address = deploy_contract("ERC20", OWNER);
let erc20_token = IERC20Dispatcher { contract_address };
let token_decimal = erc20_token.decimals();
// Define amounts: 10,000 tokens to mint, 5,000 to approve
let mint_amount: u256 = 10000 * token_decimal.into();
let approval_amount: u256 = 5000 * token_decimal.into();
// Start impersonating the owner
start_cheat_caller_address(contract_address, OWNER);
// Mint tokens to the owner
erc20_token.mint(OWNER, mint_amount);
let spender: ContractAddress = 'SPENDER'.try_into().unwrap();
// Owner approves SPENDER to spend 5,000 tokens
erc20_token.approve(spender, approval_amount);
// Stop impersonating owner
stop_cheat_caller_address(contract_address);
// Attempt to transfer more than approved (6,000 instead of 5,000)
// This should panic with 'amount exceeds allowance'
cheat_caller_address(contract_address, spender, CheatSpan::TargetCalls(1));
erc20_token.transfer_from(OWNER, TOKEN_RECIPIENT, 6000 * token_decimal.into());
}
#[test]
#[should_panic(expected: ('amount exceeds balance',))]
fn test_transfer_from_insufficient_balance() {
// Deploy the contract
let contract_address = deploy_contract("ERC20", OWNER);
let erc20_token = IERC20Dispatcher { contract_address };
let token_decimal = erc20_token.decimals();
// Define amounts: only 1,000 tokens minted, but 2,000 approved and attempted
let mint_amount: u256 = 1000 * token_decimal.into();
let approval_amount: u256 = 2000 * token_decimal.into();
let transfer_amount: u256 = 2000 * token_decimal.into();
// Start impersonating the owner
start_cheat_caller_address(contract_address, OWNER);
// Mint only 1,000 tokens to the owner
erc20_token.mint(OWNER, mint_amount);
let spender: ContractAddress = 'SPENDER'.try_into().unwrap();
// Owner approves SPENDER to spend 2,000 tokens (more than balance)
erc20_token.approve(spender, approval_amount);
// Stop impersonating owner
stop_cheat_caller_address(contract_address);
// Spender has sufficient allowance but owner doesn't have enough balance
// This should panic with 'amount exceeds balance'
cheat_caller_address(contract_address, spender, CheatSpan::TargetCalls(1));
erc20_token.transfer_from(OWNER, TOKEN_RECIPIENT, transfer_amount);
}
Para ejecutar todas las pruebas, usa el comando scarb test en tu terminal. Esto ejecutará todas las funciones de prueba y mostrará los resultados. Deberías ver una salida indicando que cada prueba pasó correctamente:

Ejercicio: Probando la función mint
Escribe una prueba para la función mint para practicar lo que has aprendido. La prueba debería verificar que:
- Solo el propietario puede acuñar tokens
- El saldo del destinatario aumenta en la cantidad acuñada
- El suministro total aumenta en la cantidad acuñada
Una vez hecho, ejecuta scarb test test_mint para verificar que funciona.
Conclusión
Este tutorial cubrió la construcción y prueba de un contrato de token ERC-20 en Starknet. A partir de aquí, el contrato se puede extender con características como pausa (pausing), controles de acceso, y demás.
Alternativamente, se pueden utilizar los componentes preconstruidos de OpenZeppelin para Cairo en lugar de construir todo desde cero. Consulta el capítulo “Componente 2” (Component 2) para aprender cómo integrar los componentes ERC20, Ownable y Pausable de OpenZeppelin en un contrato.
Este artículo es parte de una serie de tutoriales sobre Programación en Cairo en Starknet