Solidity básico: contrato, estado y función
1. Descripción general
Solidity es el lenguaje principal para escribir smart contracts en Ethereum y cadenas compatibles con EVM. Este artículo te ayuda a entender los tres bloques de construcción más básicos: contract, state variable y function — a través de un contrato Wallet simple. Después de leer, podrás escribir contratos que guarden estado, controlar el acceso mediante modificadores y escribir tus primeros tests con Hardhat.
2. ¿Qué es un contrato?
En Solidity, un contract es similar a una class en Java — define el estado (datos) y comportamiento (funciones) que se desplegarán en blockchain como una dirección independiente.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract Wallet {
// variables de estado y funciones van aquí
}
- La línea
// SPDX-License-Identifierdeclara la licencia; el compilador advertirá si falta. pragma solidity ^0.8.24fija la versión del compilador a partir de 0.8.24 o superior (pero menor que 0.9.0).- Cada contrato al desplegarse tendrá una dirección (address) propia, como un “objeto” que vive eternamente en la cadena.
Nota: Nunca uses un pragma tan flexible como
>=0.8.0 <0.9.0en producción — versiones nuevas del compilador pueden cambiar el comportamiento predeterminado (por ejemplo, la verificación de desbordamiento).
3. State variable — datos almacenados on-chain
Las state variables son variables que se escriben directamente en el almacenamiento de la blockchain, persisten entre llamadas a funciones y consumen gas al escribir.
contract Wallet {
address public owner;
uint256 public balance;
mapping(address => uint256) public allowance;
constructor() {
owner = msg.sender;
}
}
address public owner: almacena la dirección del propietario del contrato. La palabra clavepublicgenera automáticamente un getterowner()sin costo.uint256 public balance: saldo, tipo de entero sin signo de 256 bits — el tipo numérico más común en Solidity.mapping(address => uint256): similar aHashMap<Address, BigInteger>pero no es iterable; solo puedes consultar por clave.constructor()se ejecuta exactamente una vez al desplegar;msg.senderes la dirección que llamó a la transacción de despliegue.
3.1. Diferenciar storage, memory y calldata
function updateAllowance(address spender, uint256 amount) external {
// amount está en calldata (solo lectura, gas económico)
allowance[spender] = amount; // escribe en storage
}
storage: datos permanentes en la cadena (state variables).memory: datos temporales durante la ejecución de la función, se pierden cuando termina la función.calldata: similar amemorypero de solo lectura, se usa para parámetros de funcionesexternal— ahorra más gas quememory.
Nota: Olvidar declarar
memory/calldatapara parámetros de tipoarray,struct,stringcausará un error del compilador — a diferencia de Java, donde no necesitas preocuparte por la región de memoria.
4. Funciones y visibilidad
Las funciones en Solidity deben declarar la visibilidad explícitamente, no hay un valor predeterminado “package-private” como en Java.
contract Wallet {
address public owner;
uint256 public balance;
constructor() {
owner = msg.sender;
}
function deposit() external payable {
balance += msg.value;
}
function withdraw(uint256 amount) external {
require(msg.sender == owner, "Wallet: not owner");
require(amount <= balance, "Wallet: insufficient balance");
balance -= amount;
(bool success, ) = owner.call{value: amount}("");
require(success, "Wallet: transfer failed");
}
function getBalance() external view returns (uint256) {
return balance;
}
}
external: solo se puede llamar desde fuera del contrato (por ejemplo, billetera MetaMask, otro contrato) — consume menos gas quepubliccuando hay parámetros de array/string grandes.payable: permite que la función reciba ETH con la transacción;msg.valuees la cantidad de wei enviados.view: asegura que no modifica el estado; no cuesta gas al llamar off-chain (medianteeth_call).require(condition, "message"): verifica la condición, revierte toda la transacción con el mensaje si es falsa — similar a lanzarIllegalArgumentExceptionpero revierte tanto el estado como el gas consumido (excepto el gas usado para la verificación).owner.call{value: amount}(""): forma recomendada de enviar ETH en lugar detransfer()/send()` porque no tiene un límite estricto de 2300 gas.
Cuatro niveles de visibilidad:
| Visibilidad | Llamada desde dentro del contrato | Llamada desde contrato heredado | Llamada desde fuera |
|---|---|---|---|
private |
✅ | ❌ | ❌ |
internal |
✅ | ✅ | ❌ |
public |
✅ | ✅ | ✅ |
external |
❌ (debes usar this.foo()) |
❌ | ✅ |
5. Modificadores — reutilizar condiciones de verificación
Repetir require(msg.sender == owner, ...) en muchas funciones es señal de que deberías extraerlo en un modificador.
contract Wallet {
address public owner;
uint256 public balance;
modifier onlyOwner() {
require(msg.sender == owner, "Wallet: not owner");
_;
}
constructor() {
owner = msg.sender;
}
function withdraw(uint256 amount) external onlyOwner {
require(amount <= balance, "Wallet: insufficient balance");
balance -= amount;
payable(owner).transfer(amount);
}
}
modifier onlyOwner()define un bloque de verificación;_;es el lugar donde se insertará el cuerpo de la función original.- Añadir
onlyOwnerdespués de la declaraciónwithdrawhace que el código se lea como “solo el propietario de la billetera puede retirar dinero” — mucho más claro que repetirrequireen cada función. - Los modificadores pueden recibir parámetros, por ejemplo
modifier minAmount(uint256 amount) { require(amount > 0, "..."); _; }.
6. Eventos — registrar logs para off-chain
Un contrato no puede devolver datos históricos directamente al frontend; los event se usan para registrar logs que una dApp puede escuchar.
contract Wallet {
event Deposited(address indexed from, uint256 amount);
event Withdrawn(address indexed to, uint256 amount);
uint256 public balance;
function deposit() external payable {
balance += msg.value;
emit Deposited(msg.sender, msg.value);
}
}
event Deposited(...)declara la estructura del log, con un máximo de 3 parámetrosindexedpara facilitar el filtrado.emit Deposited(...)escribe el log en el recibo de transacción, con un costo de gas mucho menor que guardar en storage.- El frontend usa bibliotecas como ethers.js para escuchar
contract.on("Deposited", ...)y actualizar la UI en tiempo real.
7. Errores comunes al aprender Solidity
- Confundir
transfer/send/callal enviar ETH:transferlimita a 2300 gas y fácilmente falla con contratos receptores que tienen lógica compleja (proxy, multisig). Usacallconrequirepara verificar el resultado como en el ejemplo en la sección 4. - Olvidar
requirepara validar entrada: la falta de validación expone el contrato a explotación (por ejemplo, retiros negativos, desbordamientos — aunque Solidity 0.8+ revierte automáticamente en desbordamientos, la lógica de negocio aún necesita validación propia). - Confundir
externalypublicen el contexto equivocado: usarpublicpara una función que solo se llama desde fuera consume más gas cuando hay parámetros de array/string, porque Solidity debe copiar datos amemoryen lugar de leerlos directamente decalldata. - No declarar
view/purecuando es posible: hace que herramientas como block explorers y billeteras no reconozcan funciones de solo lectura, y los usuarios creen erróneamente que deben pagar gas.
Cuándo NO dejar una función como
public: si una función nunca se llama desde otro contrato o desde herencia, usaexternalpara ahorrar gas y limitar la superficie de ataque (attack surface).
8. Escribir tests con Hardhat
Añade dependencias a tu proyecto Hardhat (Node.js):
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
Test del comportamiento de withdraw que solo permite al propietario:
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("Wallet", function () {
it("solo el propietario puede retirar fondos", async function () {
const [owner, stranger] = await ethers.getSigners();
const Wallet = await ethers.getContractFactory("Wallet");
const wallet = await Wallet.deploy();
await wallet.connect(owner).deposit({ value: ethers.parseEther("1") });
await expect(
wallet.connect(stranger).withdraw(ethers.parseEther("1"))
).to.be.revertedWith("Wallet: not owner");
await expect(
wallet.connect(owner).withdraw(ethers.parseEther("1"))
).to.changeEtherBalance(owner, ethers.parseEther("1"));
});
});
ethers.getContractFactory("Wallet")compila y carga el artefacto del contrato.wallet.connect(stranger)simula una transacción enviada desde una dirección diferente a la del propietario.revertedWith("...")afirma que la transacción se revierte con el mensaje correcto delrequire.changeEtherBalanceverifica que el saldo de ETH del propietario aumenta exactamente 1 ETH después del retiro — una forma común de probar en lugar de solo revisar el valor devuelto.
Ejecuta los tests:
npx hardhat test
Wallet
✔ solo el propietario puede retirar fondos (120ms)
1 pasando (120ms)
9. Conclusión
Acabas de pasar por cuatro conceptos fundamentales de Solidity: los contract como unidad de despliegue, las state variables que almacenan datos on-chain en tres regiones (storage/memory/calldata), funciones con visibilidad y modificadores que controlan el acceso, y eventos para comunicarse con off-chain. Estos conceptos son la base para que avances hacia temas más avanzados como herencia, interfaces, o estándares ERC-20/ERC-721 — todos construidos sobre estos mismos cuatro bloques.