Fundamentos de Solidity: contract, state e function

1. Visão geral

Solidity é a linguagem principal para escrever smart contracts na Ethereum e em cadeias compatíveis com EVM. Este artigo te ajuda a dominar os três blocos de construção mais fundamentais: contract, state variable e function — através de um contrato Wallet simples. Após ler, você será capaz de escrever contracts que armazenam estado, controlar acesso com modifier e escrever seu primeiro teste com Hardhat.

2. O que é um contract

Em Solidity, contract é similar a uma class em Java — ele define state (dados) e behavior (funções) que serão deployados na blockchain em um endereço único.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract Wallet {
    // state variables e funções vão ficar aqui
}
  • A linha // SPDX-License-Identifier declara a licença; o compilador avisará se faltar.
  • pragma solidity ^0.8.24 fixa a versão do compilador a partir de 0.8.24 (mas menor que 0.9.0).
  • Cada contract quando deployado terá seu próprio endereço (address), como um “object” que vive para sempre na chain.

Nota: Nunca use pragma muito genérico como >=0.8.0 <0.9.0 em produção — novos compiladores podem mudar comportamentos padrão (ex: verificação de overflow).

3. State variable — dados armazenados on-chain

State variable é uma variável gravada diretamente no storage da blockchain, persiste entre chamadas de função e consome gas para ser escrita.

contract Wallet {
    address public owner;
    uint256 public balance;
    mapping(address => uint256) public allowance;

    constructor() {
        owner = msg.sender;
    }
}
  • address public owner: armazena o endereço do proprietário do contrato. A palavra-chave public gera automaticamente um getter owner() sem custo.
  • uint256 public balance: saldo, tipo inteiro sem sinal de 256 bits — o tipo de número mais comum em Solidity.
  • mapping(address => uint256): similar a HashMap<Address, BigInteger> mas sem iteração de chaves, apenas busca por chave.
  • constructor() executa exatamente uma vez no deploy; msg.sender é o endereço que chama a transação de deploy.

3.1. Diferenças entre storage, memory e calldata

function updateAllowance(address spender, uint256 amount) external {
    // amount está em calldata (apenas leitura, mais barato em gas)
    allowance[spender] = amount; // escreve em storage
}
  • storage: dados permanentes na chain (state variable).
  • memory: dados temporários durante execução da função, apagados após sua conclusão.
  • calldata: similar a memory mas apenas leitura, usado para parâmetros de funções external — economiza mais gas que memory.

Nota: Esquecer de declarar memory/calldata para parâmetros do tipo array, struct, string causará erro do compilador — diferente de Java, onde você não precisa se preocupar com a memória.

4. Functions e visibility

Funções em Solidity obrigam a declarar a visibilidade explicitamente, não há padrão “package-private” como em 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: pode ser chamada apenas de fora do contract (ex: carteira MetaMask, outro contract) — mais barato em gas que public quando parâmetros são arrays/strings grandes.
  • payable: permite que a função receba ETH junto com a transação; msg.value é a quantidade de wei enviada.
  • view: promete não alterar o estado, não consome gas quando chamada off-chain (via eth_call).
  • require(condition, "message"): verifica a condição, desfaz toda a transação com mensagem se falhar — similar a lançar IllegalArgumentException, mas reverte tanto o estado quanto o gas gasto (exceto o gas da verificação em si).
  • owner.call{value: amount}(""): a forma recomendada de enviar ETH em vez de usar transfer()/send(), pois não tem limite fixo de 2300 gas.

Os quatro níveis de visibility:

Visibility Chamada de dentro do contract Chamada de contract herdeiro Chamada de fora
private ✅ ❌ ❌
internal ✅ ✅ ❌
public ✅ ✅ ✅
external ❌ (deve usar this.foo()) ❌ ✅

5. Modifier — reutilizar verificações

Repetir require(msg.sender == owner, ...) em várias funções é um sinal de que deve ser extraído para um modifier.

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 um bloco de verificação; _; é o ponto onde o corpo da função original é inserido.
  • Usar onlyOwner após a declaração de withdraw faz o código ler como “apenas o proprietário da carteira pode sacar” — mais claro que repetir require em cada função.
  • Modifiers podem receber parâmetros, ex: modifier minAmount(uint256 amount) { require(amount > 0, "..."); _; }.

6. Event — registros para off-chain

Contracts não conseguem retornar dados históricos diretamente para o frontend; event é usado para registrar logs que dApps podem ouvir.

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 a estrutura do log, com máximo de 3 parâmetros com indexed para facilitar filtragem.
  • emit Deposited(...) escreve o log no recibo da transação, com custo de gas muito menor que armazenar em storage.
  • Frontend usa bibliotecas como ethers.js para ouvir contract.on("Deposited", ...) e atualizar a UI em tempo real.

7. Erros comuns para iniciantes em Solidity

  • Confundir transfer/send/call ao enviar ETH: transfer limita a 2300 gas, falha facilmente com contratos receptores que têm lógica complexa (proxy, multisig). Use call junto com require para verificar o resultado como no exemplo da seção 4.
  • Não usar require para validar entrada: falta de validação deixa o contract vulnerável a exploração (ex: sacar valores negativos, overflow — embora Solidity 0.8+ reverta automaticamente em overflow, mas a lógica de negócio ainda precisa de validação própria).
  • Usar external vs public no contexto errado: usar public para funções chamadas apenas de fora consome mais gas quando parâmetros são arrays/strings, porque Solidity precisa copiar dados para memory em vez de ler diretamente de calldata.
  • Não declarar view/pure quando possível: faz ferramentas como block explorer, carteiras não reconhecerem funções de leitura, usuários pensam erroneamente que precisam pagar gas.

Quando NÃO deixar função como public: se a função nunca é chamada de outro contract ou por herança, use external para economizar gas e limitar a superfície de ataque (attack surface).

8. Escrever testes com Hardhat

Adicione a dependência ao seu projeto Hardhat (Node.js):

npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox

Teste o comportamento de withdraw que permite apenas o owner:

const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("Wallet", function () {
  it("apenas o owner pode sacar dinheiro", 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 e carrega o artefato do contract.
  • wallet.connect(stranger) simula uma transação enviada de um endereço diferente do owner.
  • revertedWith("...") afirma que a transação foi revertida com a mensagem correta do require.
  • changeEtherBalance verifica se o saldo de ETH do owner aumentou exatamente 1 ETH após o saque — forma comum de testar em vez de apenas verificar o valor retornado.

Execute os testes:

npx hardhat test
Wallet
  ✔ apenas o owner pode sacar dinheiro (120ms)

1 passing (120ms)

9. Conclusão

Você acaba de passar por quatro conceitos fundamentais do Solidity: contract como unidade de deployment, state variable para armazenar dados on-chain com três regiões de memória (storage/memory/calldata), funções com visibility e modifier para controlar acesso, e events para comunicação com o off-chain. Esses conceitos formam a base para tópicos mais avançados como herança, interfaces ou padrões ERC-20/ERC-721 — todos construídos sobre esses mesmos quatro blocos.

solidityblockchainsmart-contractethereumweb3