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-Identifier declara la licencia; el compilador advertirá si falta.
  • pragma solidity ^0.8.24 fija 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.0 en 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 clave public genera automáticamente un getter owner() 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 a HashMap<Address, BigInteger> pero no es iterable; solo puedes consultar por clave.
  • constructor() se ejecuta exactamente una vez al desplegar; msg.sender es 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 a memory pero de solo lectura, se usa para parámetros de funciones external — ahorra más gas que memory.

Nota: Olvidar declarar memory/calldata para parámetros de tipo array, struct, string causará 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 que public cuando hay parámetros de array/string grandes.
  • payable: permite que la función reciba ETH con la transacción; msg.value es la cantidad de wei enviados.
  • view: asegura que no modifica el estado; no cuesta gas al llamar off-chain (mediante eth_call).
  • require(condition, "message"): verifica la condición, revierte toda la transacción con el mensaje si es falsa — similar a lanzar IllegalArgumentException pero 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 de transfer()/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 onlyOwner después de la declaración withdraw hace que el código se lea como “solo el propietario de la billetera puede retirar dinero” — mucho más claro que repetir require en 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ámetros indexed para 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/call al enviar ETH: transfer limita a 2300 gas y fácilmente falla con contratos receptores que tienen lógica compleja (proxy, multisig). Usa call con require para verificar el resultado como en el ejemplo en la sección 4.
  • Olvidar require para 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 external y public en el contexto equivocado: usar public para 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 a memory en lugar de leerlos directamente de calldata.
  • No declarar view/pure cuando 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, usa external para 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 del require.
  • changeEtherBalance verifica 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.

solidityblockchainsmart-contractethereumweb3