Fintech e bancos

Smart contracts no Ethereum: EVM, Solidity, padrões e segurança

Avançado11 min de leituraAtualizado

Esta página foi traduzida do inglês com a ajuda de IA e conferida com o original. Ler a versão em inglês

Um smart contract é um pequeno programa rodando em um computador público, que qualquer pessoa pode chamar, que guarda valor real e que raramente pode ser corrigido. Este guia cobre o modelo de execução, um contrato lido linha a linha, os padrões de tokens, o ferramental, a integração com o backend e as classes de vulnerabilidades responsáveis pela maior parte das perdas.

O que você vai aprender

  • Explicar a diferença entre contas externas e contas de contrato, além de transações, gas e custo de armazenamento
  • Ler um contrato Solidity e raciocinar sobre ele, incluindo eventos, modifiers e visibilidade
  • Implementar e consumir corretamente as interfaces ERC-20 e ERC-721
  • Chamar contratos a partir de um serviço backend lidando com reorganizações e nonces
  • Reconhecer e mitigar vulnerabilidades de reentrância, controle de acesso, oráculos e ordenação

Escrever um smart contract é diferente de quase tudo no desenvolvimento de software. O programa roda em uma máquina que você não administra, o bytecode é público, qualquer pessoa no mundo pode chamar qualquer função que você expôs, o estado que ele guarda muitas vezes vale dinheiro e, depois de implantado, em geral não dá para corrigi-lo. O resultado é uma disciplina mais próxima de escrever firmware de aviônica do que um serviço web: código pequeno, explícito, testado à exaustão e paranoico com cada chamada externa.

Este guia parte da mecânica do ledger apresentada em fundamentos de blockchain e avança para a camada programável: como o Ethereum executa código, no que o Solidity realmente compila, quais padrões você precisa implementar para ser interoperável, como um backend comum conversa com uma chain e quais classes de vulnerabilidades respondem pela maior parte do dinheiro perdido até hoje.

Contas, transações e gas

O Ethereum tem dois tipos de conta que compartilham o mesmo espaço de endereços. Uma conta externa (externally owned account) é controlada por uma chave privada, tem saldo e nonce, e é a única coisa capaz de iniciar uma transação. Uma conta de contrato guarda código e armazenamento, e só age quando é chamada. O endereço de um contrato implantado é derivado de forma determinística a partir de quem o implantou e do nonce, e é por isso que os endereços de deploy podem ser previstos.

Uma transação carrega remetente, destinatário, valor, calldata, nonce, limite de gas e parâmetros de taxa. O nonce é um contador estritamente crescente por remetente que impede replay e força a ordem: uma transação com nonce 8 não pode ser executada antes da de nonce 7, e um nonce travado bloqueia tudo o que vem depois — algo que todo backend que envia transações vai acabar descobrindo na hora mais inconveniente.

A execução acontece na Ethereum Virtual Machine, uma máquina de pilha com palavras de 256 bits. Cada opcode tem um custo em gas, e o remetente paga pelo que é executado. O gas tem duas funções: precifica os recursos escassos da rede e limita a execução, de modo que um loop infinito simplesmente fica sem gas em vez de travar a chain. As taxas se dividem em uma base fee definida pelo protocolo, que é queimada, e uma gorjeta de prioridade para o proponente do bloco, com o remetente declarando o máximo que aceita pagar.

Approximate relative costs (EVM, order-of-magnitude guidance)

  arithmetic / comparison        ~3 gas        cheap, ignore it
  memory expansion               grows quadratically past a point
  keccak256 hashing              ~30 + 6 per word
  reading a cold storage slot    ~2,100 gas
  writing a new non-zero slot    ~20,000 gas   <- dominates everything
  updating an existing slot      ~5,000 gas
  emitting an event (per topic)  ~375 gas      <- cheap history
  external call                  ~2,600 gas + whatever it does

Rule of thumb: storage is permanent obligation for every node,
so it is priced accordingly. Compute is nearly free by comparison.

Disso decorrem três consequências imediatas. Mantenha o estado persistente no mínimo e agrupe valores pequenos relacionados em um único slot. Use eventos em vez de arrays em armazenamento para registrar histórico de que só leitores fora da chain precisam. E trate qualquer loop sem limite sobre um array que cresce como uma futura negação de serviço, porque uma função que acaba ultrapassando o limite de gas do bloco fica permanentemente impossível de chamar.

Se uma transação reverte, todas as alterações de estado são desfeitas, mas o gas consumido continua sendo pago. A atomicidade vem de graça; a taxa, não.

Um contrato, linha a linha

Solidity é uma linguagem de tipagem estática orientada a contratos. Os trechos abaixo usam destaque de sintaxe de JavaScript, já que o Solidity compartilha seu formato de chaves.

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

/// A minimal escrow: a payer locks funds for a payee, released by an arbiter.
contract Escrow {
    address public immutable payer;
    address public immutable payee;
    address public immutable arbiter;
    uint256 public immutable deadline;
    bool    private released;

    event Funded(address indexed from, uint256 amount);
    event Released(address indexed to, uint256 amount);
    event Refunded(address indexed to, uint256 amount);

    error NotAuthorised();
    error AlreadySettled();
    error TooEarly(uint256 deadlineAt);

    modifier onlyArbiter() {
        if (msg.sender != arbiter) revert NotAuthorised();
        _;
    }

    constructor(address _payee, address _arbiter, uint256 lockSeconds) payable {
        payer    = msg.sender;
        payee    = _payee;
        arbiter  = _arbiter;
        deadline = block.timestamp + lockSeconds;
        emit Funded(msg.sender, msg.value);
    }

    function release() external onlyArbiter {
        if (released) revert AlreadySettled();
        released = true;                       // effects before interaction
        uint256 amount = address(this).balance;
        emit Released(payee, amount);
        (bool ok, ) = payee.call{value: amount}("");
        require(ok, "transfer failed");
    }

    function refund() external {
        if (msg.sender != payer) revert NotAuthorised();
        if (released) revert AlreadySettled();
        if (block.timestamp < deadline) revert TooEarly(deadline);
        released = true;
        uint256 amount = address(this).balance;
        emit Refunded(payer, amount);
        (bool ok, ) = payer.call{value: amount}("");
        require(ok, "transfer failed");
    }
}

Lendo na ordem: o pragma fixa uma faixa de versões do compilador, e em produção é boa prática fixar uma versão exata em vez de usar o caret. Variáveis immutable são definidas na construção e ficam no código, não no armazenamento, então lê-las é barato. public em uma variável de estado gera um getter; fora isso, public não implica segurança nenhuma, já que todo o estado da chain pode ser lido independentemente das palavras-chave de visibilidade — não existem segredos em um contrato.

As declarações event definem as entradas de log que os indexadores consomem; parâmetros indexed viram topics pesquisáveis, e você tem direito a três. Tipos error personalizados são mais baratos que mensagens de revert e muito mais fáceis de tratar programaticamente. O modifier isola uma verificação de autorização, com _; marcando onde o corpo da função é executado.

msg.sender é quem fez a chamada imediata e msg.value é o ether enviado junto. A linha mais importante do arquivo é released = true;, colocada antes da chamada externa — a ordem checks-effects-interactions discutida mais adiante. Repare também que payee.call{value: amount}("") é a forma atual de enviar ether, porque os helpers antigos repassavam uma cota fixa de gas que quebra quando o destinatário é um contrato, e que o resultado booleano precisa ser verificado: uma chamada de baixo nível que falha não reverte automaticamente.

Padrões de tokens

A interoperabilidade no Ethereum vem de um pequeno conjunto de interfaces de aplicação publicadas como ERCs. Duas delas dominam.

ERC-20 descreve tokens fungíveis: saldos, transferências e uma allowance para gasto delegado.

interface IERC20 {
    function totalSupply() external view returns (uint256);
    function balanceOf(address account) external view returns (uint256);
    function transfer(address to, uint256 value) external returns (bool);
    function allowance(address owner, address spender) external view returns (uint256);
    function approve(address spender, uint256 value) external returns (bool);
    function transferFrom(address from, address to, uint256 value) external returns (bool);

    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint256 value);
}

O padrão de allowance é o motivo pelo qual toda aplicação descentralizada pede que você "aprove" antes de poder movimentar seus tokens: o contrato chama transferFrom usando a allowance que você concedeu. Duas armadilhas antigas moram aqui. Aprovações ilimitadas significam que um bug futuro no contrato aprovado pode esvaziar o saldo, então prefira valores exatos ou aprovações assinadas no estilo permit. E decimals é metadado, não regra do protocolo — existem tokens com 6, 8 ou 18 casas decimais, e qualquer código que fixe 18 vai acabar errando a escala de um valor por um fator de um trilhão.

ERC-721 descreve tokens não fungíveis, em que cada identificador é único e tem um dono individual, com ownerOf, safeTransferFrom e um tokenURI apontando para os metadados. As variantes safe chamam um hook de recebimento quando o destinatário é um contrato, para que tokens não sejam enviados a um contrato incapaz de lidar com eles — um hook que é, ele próprio, uma chamada externa e, portanto, uma superfície de reentrância. O ERC-1155 combina os dois modelos para contratos multitoken.

Use implementações de referência auditadas para essas interfaces em vez de escrever as suas. Um contrato de token é um problema resolvido, e a versão resolvida foi revisada por mais gente do que sua equipe teria como pagar.

Ferramental e fluxo de trabalho

O ciclo de desenvolvimento moderno é mais ou menos este.

  1. Escolha um framework

    O Foundry roda testes escritos no próprio Solidity, o que mantém teste e contrato no mesmo modelo mental e torna fuzzing e testes de invariantes recursos de primeira classe. O Hardhat é baseado em JavaScript ou TypeScript e se integra naturalmente a uma base de código Node.js. Muitas equipes usam os dois.
  2. Escreva os testes antes do contrato

    Testes baseados em propriedades e testes de invariantes combinam muito bem com esse domínio: afirme que o supply total é sempre igual à soma dos saldos, que nenhum caminho permite a quem chama sacar mais do que depositou, e deixe o fuzzer procurar o contraexemplo. A disciplina descrita em desenvolvimento orientado a testes se aplica diretamente.
  3. Rode análise estática e cobertura na CI

    Linters, um analisador no estilo do Slither e snapshots de gas devem rodar em todo pull request, exatamente como em qualquer outro pipeline de integração contínua. Quebre o build por achados novos, não pelo passivo histórico.
  4. Faça deploy em uma testnet

    As redes de teste reproduzem a mecânica da mainnet com ether sem valor obtido em um faucet. Faça deploy com o script real, não com uma versão simplificada, para que o próprio deploy seja testado.
  5. Verifique o código-fonte

    Publicar o código-fonte e as configurações do compilador em um explorador de blocos permite que qualquer pessoa reproduza o bytecode. Um contrato não verificado que guarda fundos de usuários deve ser considerado não confiável por padrão.
  6. Audite antes de o valor chegar

    A revisão externa deve ser proporcional ao valor em risco. Publique o código, depois um bug bounty, depois o dinheiro — nessa ordem.

Conversando com uma chain a partir de um backend

A maioria dos produtos reais são serviços comuns que por acaso leem e gravam em uma chain via JSON-RPC, usando uma biblioteca como ethers.js, viem ou web3j.

import { JsonRpcProvider, Contract, Wallet } from "ethers";

const provider = new JsonRpcProvider(process.env.RPC_URL);
const signer   = new Wallet(process.env.SIGNER_KEY, provider);   // use a KMS/HSM signer in production
const token    = new Contract(TOKEN_ADDRESS, IERC20_ABI, signer);

// Read: free, served by the node, no transaction.
const balance = await token.balanceOf(userAddress);

// Write: costs gas, returns once mined, may still be reorganised.
const tx = await token.transfer(recipient, 1_000_000n, { gasLimit: 120_000n });
const receipt = await tx.wait(5);            // wait for 5 confirmations
if (receipt.status !== 1) throw new Error("transaction reverted");

Operar isso em produção exige respeitar algumas realidades. O gerenciamento de nonces precisa ser centralizado: dois workers assinando com a mesma chave vão colidir, então serialize os envios por chave e implemente substituição por taxa (replace-by-fee) para transações travadas. Nunca mantenha uma chave privada na memória da aplicação nem em uma variável de ambiente se puder evitar — use um módulo de hardware ou um serviço gerenciado de assinatura, com limites de gasto e chaves separadas por ambiente. Estime o gas, mas imponha um teto, porque uma estimativa feita sobre um estado desatualizado pode errar feio. Trate o endpoint RPC como pouco confiável: use pelo menos dois provedores e compare as alturas de bloco. E indexe eventos com um cursor persistido, aguardando uma margem de confirmações e usando como chave dos handlers o hash da transação mais o índice do log, para que replays sejam inofensivos.

Atualização de contratos

O bytecode implantado não pode mudar, então sistemas atualizáveis colocam um proxy na frente dele. O proxy guarda o armazenamento e o endereço de um contrato de implementação, e encaminha cada chamada com delegatecall, executando o código da implementação sobre o armazenamento do proxy. Trocar o endereço da implementação muda o comportamento mantendo o estado e o endereço.

A disciplina exigida não perdoa. O layout de armazenamento só pode crescer por acréscimo no final entre versões, porque a implementação lê os slots por posição; inserir uma variável no meio corrompe silenciosamente tudo o que vem depois. Construtores não rodam para a implementação, então a inicialização vai para uma função initialize protegida contra uma segunda chamada. Os seletores de função não podem colidir entre proxy e implementação. E a chave de atualização vira o segredo mais valioso do sistema, e é por isso que projetos sérios a colocam atrás de uma carteira multisig e de um timelock, para que os usuários vejam a mudança chegando e possam sair antes.

A atualizabilidade também enfraquece a promessa central: um contrato que pode ser substituído só é tão confiável quanto seus administradores. Por isso alguns protocolos implantam contratos imutáveis e lidam com mudanças por migração — contratos novos, e os usuários migram. É mais honesto e dá bem mais trabalho.

As classes de vulnerabilidades que importam

A maior parte das perdas se concentra em um punhado de padrões.

Reentrância. Uma chamada externa passa o controle a outro contrato, que chama o seu de volta antes de a primeira invocação terminar de atualizar o estado — assim, uma função de saque que envia ether e depois zera o saldo pode ser drenada em loop. A correção é estrutural: checks, effects, interactions — validar as entradas, atualizar o seu estado e só então chamar o mundo externo. Um reentrancy guard acrescenta defesa em profundidade; e lembre-se de que os hooks de recebimento do ERC-721 e os callbacks de transferência de tokens são chamadas externas, mesmo quando não parecem.

Controle de acesso. Autorização ausente ou errada em funções privilegiadas, inicializadores que continuam podendo ser chamados, tx.origin usado para autenticação (ele identifica o remetente original da transação, então qualquer contrato que o usuário chame pode se passar por ele). Use msg.sender com uma verificação de papel explícita e liste todos os pontos de entrada privilegiados na revisão.

Aritmética e conversões. Desde o Solidity 0.8, overflow reverte por padrão, mas blocos unchecked explícitos e downcasts como de uint256 para uint128 continuam truncando silenciosamente. A divisão inteira sempre trunca, então a ordem importa: multiplique antes de dividir e decida conscientemente de que lado do erro de arredondamento o protocolo fica.

Manipulação de oráculos. Um contrato que lê o preço de um pool on-chain pode ser atacado movimentando esse pool dentro de uma única transação, muitas vezes financiada por um flash loan. Use médias ponderadas pelo tempo ou uma rede de oráculos descentralizada com vários reporters independentes, e nunca confie no preço spot de um pool com pouca liquidez.

Ordenação de transações e front-running. Transações pendentes são públicas antes de serem incluídas, e os proponentes as ordenam para lucrar. Uma operação sem limite de slippage pode sofrer um ataque sanduíche; um compromisso revelado cedo demais pode ser copiado. Entre as mitigações estão esquemas commit-reveal, parâmetros explícitos de slippage e de prazo, e canais privados de envio.

Negação de serviço por dependência externa. Empurrar fundos para uma lista de destinatários falha por inteiro se um único destinatário reverter. Prefira um padrão de pull payments, em que cada parte saca o próprio saldo.

Tratamento de assinaturas. Replay entre chains ou contratos quando o domain separator é omitido, assinaturas maleáveis e nonces ausentes. Siga o padrão de dados estruturados tipados e inclua um chain id, um endereço de contrato e um nonce em tudo o que você assina.

O raciocínio geral de segurança de arquitetura de segurança de aplicações continua valendo — menor privilégio, defesa em profundidade, fronteiras de confiança explícitas —, com a diferença de que aqui o atacante tem financiamento, é automatizado e lê seu código-fonte antes mesmo de você terminar o deploy.

Principais pontos

  • Só contas controladas por chave iniciam transações; contratos reagem, e toda operação é medida em gas, com o armazenamento custando muito mais que o processamento.
  • Leia Solidity com a fronteira de confiança em mente: todo o estado é público, chamadas externas entregam o controle e chamadas de baixo nível não revertem quando falham.
  • Implemente ERC-20 e ERC-721 a partir de referências auditadas, e nunca presuma 18 casas decimais nem conceda allowances ilimitadas por padrão.
  • Teste com fuzzing e invariantes, rode análise estática na CI, verifique o código-fonte publicado e faça auditoria antes de o contrato guardar valor real.
  • Backends precisam de gerenciamento centralizado de nonces, assinatura apoiada em hardware, provedores RPC redundantes e indexação idempotente de eventos com margem de confirmações.
  • A atualização via proxy exige layouts de armazenamento que só crescem por acréscimo e transfere a confiança para quem detém a chave de atualização — timelock e multisig são o mínimo.
  • Reentrância, controle de acesso falho, conversões silenciosas, oráculos manipuláveis e ordenação de transações respondem pela maioria dos exploits; checks-effects-interactions e pull payments evitam boa parte deles.

Fontes e leituras complementares

Perguntas frequentes

Dá para alterar um contrato já implantado?
Não diretamente: o bytecode implantado é imutável. Equipes que precisam poder alterá-lo implantam um proxy que guarda o estado e delega a execução a um endereço de implementação que pode ser trocado. Isso traz a possibilidade de atualizar ao custo de um mecanismo de governança, de disciplina com o layout de armazenamento e de uma premissa de confiança a mais, já que quem controla a atualização controla, na prática, os fundos.
Por que o armazenamento é tão caro em comparação com o processamento?
Cada slot de armazenamento precisa ser mantido por todos os nós para sempre, então seu custo reflete uma obrigação permanente de toda a rede, e não um cálculo pontual. Gravar um novo slot diferente de zero custa muito mais que aritmética ou memória, liberar slots gera reembolso, e as otimizações clássicas são agrupar variáveis, usar eventos para o histórico e manter dados volumosos fora da chain.
Ainda preciso de SafeMath no Solidity moderno?
Não. Desde a versão 0.8.0, a aritmética reverte por padrão em caso de overflow e underflow, o que torna a biblioteca redundante. Quando você já provou um limite e quer economizar gas, um bloco unchecked explícito documenta a decisão. Conversões entre larguras de inteiro continuam truncando silenciosamente, então os downcasts seguem merecendo atenção.
Como um serviço backend pode acompanhar eventos on-chain de forma confiável?
Faça polling ou assine os logs, mas persista um cursor com o último bloco processado e espere uma margem de segurança de confirmações antes de agir, porque blocos recentes podem ser reorganizados. Torne os handlers idempotentes usando o hash da transação e o índice do log como chave, e reconcilie periodicamente relendo uma janela de blocos em vez de confiar apenas em um stream em tempo real.