Guia Low-Level de Storage e Upgrade Proxy em Solidity

Se você está cansado de escrever contratos sem entender o que rola por baixo do capô e quer dar aquele passo rumo ao low-level, aqui é o lugar. Vamos falar sobre layout de storage na EVM, manipulação com sload() e sstore() e, de quebra, abordar o famigerado upgrade proxy — com todos os riscos de storage collision que podem surgir se você não tomar cuidado. Se quer deixar de ser um “dev normalzinho” e virar praticamente um ninja do assembly, vem comigo!

1. O que É Storage e Por Que Ele é Tão Importante

A EVM oferece um mapeamento persistente com 2 ^ 256 - 1 slots de 32 bytes pra cada contrato. Cada “slot” pode armazenar dados que vão desde tipos primitivos (como uint256) até arrays dinâmicos, strings e mappings (com algumas regras especiais).

Quando você faz:

uint256 x;
x = 123;

No fundo, a EVM está gravando o valor 123 em um slot de storage específico que corresponde à variável x. Isso é caro em gas, pois mexe diretamente com o estado global. Se for algo que você só precisa durante a execução da função, prefira “memory” — mas falaremos disso a seguir.

2. Storage vs. Memory: Quando Usar Cada Um

Quem já leu sobre otimização de gas provavelmente ouviu que ler/escrever em storage sai caro, enquanto memory é bem mais em conta. A lógica é:

  • Storage: Persistência total, custo alto (SLOAD e SSTORE).
  • Memory: Dados temporários que vivem só na execução da função, custo menor (MLOAD e MSTORE), mas ainda cresce se a memória precisar se expandir.

Um ponto interessante: na EVM, quando você lê um slot de storage pela primeira vez, ele é “frio” (cold) e cobra ~2100 gas. Da segunda vez em diante, vira “quente” (warm) e sai ~100 gas. Mesmo assim, memory continua barato se for acessar várias vezes.

Exemplo de Otimização

Se você acessa s.b múltiplas vezes, compensa trazer o valor pra memory (ex.: uint256 temp = s.b;) e só usar temp dali em diante. Assim, faz apenas um SLOAD em vez de vários.

3. Layout de Variáveis em Slots: Como a EVM “Empacota” Dados

A EVM aloca variáveis de estado em slots de 32 bytes. Ela segue regras do tipo:

  1. Tipos menores que 32 bytes (ex.: uint16, address, bool) podem ser “compactados” no mesmo slot, se couberem juntos.
  2. Variáveis grandes (ex.: uint256) consomem um slot inteiro.
  3. Arrays dinâmicos e mappings não ficam “em linha”; eles ocupam um slot base e seus elementos são indexados por hashing com keccak256(slot + key).
  4. Structs costumam ocupar slots na sequência, podendo ter packing se forem campos menores que 32 bytes (até encher o slot).

Exemplo Básico

contract SimpleStorageLayout {
  // slot 0 (empacota 16 x uint16 = 32 bytes)
  uint16 a; uint16 b; uint16 c; /* ... até p */
  
  // slot 1
  bytes12 calice;
  address alice;
  
  // slot 2
  address bob;
  
  // slot 3
  uint256 balance;
}

Aqui, o compilador conseguiu enfiar 16 variáveis uint16 em um único slot de 32 bytes (16*2 bytes = 32). Já bytes12 (12 bytes) + address (20 bytes) cabem juntos no segundo slot, sobrando 0 bytes. bob pega o slot 2. balance pega o slot 3.

Inheritance e C3 Linearization

Se tiver herança, o Solidity segue a ordem do contrato base para o derivado, compartilhando slots se couber, respeitando as mesmas regras de packing.

4. Arrays e Mappings: Regras Especiais

4.1 Arrays Dinâmicos

Se você tiver algo como uint256[] blocks;, o slot em si armazena o tamanho do array (header slot). Os dados reais ficam a partir de keccak256(slotBase) em diante:

blocks.length => armazenado no slot X
blocks[0]     => slot keccak256(X) + 0
blocks[1]     => slot keccak256(X) + 1
...

4.2 Mappings

Com mapping(address => uint256) users;, cada endereço vira uma key usada no hash:

slotElemento = keccak256(abi.encodePacked(chave, slotBaseDoMapping));

O valor (ex.: uint256) é escrito nesse slot.

5. Manipulação Low-Level: sload() e sstore() + Bitmask

5.1 Lendo Direto do Slot (sload)

Em assembly (Yul), você pode fazer:

assembly {
    let val := sload(0x00) // Lê o slot 0
}

Se esse slot empacotou 3 variáveis, você vai pegar todas em formato bytes32. Pra extrair uma parte (por ex., uint24), precisa usar shift e masking:

// Exemplo: extrair b (3 bytes) de um struct que ocupa slot 0x01
let fullSlot := sload(0x01)
let afterShift := shr(0x10, fullSlot)   // descarta 16 bits (talvez do 'a')
let bValue := and(0xffffff, afterShift) // isola 24 bits

5.2 Escrevendo Direto no Slot (sstore)

Pra escrever:

assembly {
    sstore(0x00, 0x41414141)
}

Isso sobrescreve tudo no slot 0, sem checagem de tipo ou proteções. Se nesse slot tinha 2 variáveis, você está pisoteando ambas.

6. Upgrade Proxy e Storage Collisions

Essa é a parte perigosa. Em padrões de upgrade (ex.: Proxy + delegatecall), o contrato proxy mantém o storage, enquanto a lógica (logic contract) fica em outro endereço. Quando o usuário chama uma função, a fallback do proxy redireciona via delegatecall pra lógica. Isso executa a lógica usando o storage do proxy.

Problema: se a lógica e o proxy têm layouts diferentes, pode rolar “collision”. Por exemplo:

contract ImplFlagManagerV1 {
  uint256 public flag;

  function setFlag(uint256 _flag) external {
    flag = _flag;
  }
}

contract DelegateCallFlagManager {
  uint256 public flag;

  function delegateCallSet(ImplFlagManagerV1 _impl, uint256 _flag) external {
      bytes memory payload = abi.encodeWithSignature("setFlag(uint256)", _flag);
      (bool success, ) = address(_impl).delegatecall(payload);
      require(success, "Fail");
  }
}

Se no proxy (DelegateCallFlagManager) o uint256 flag estiver no slot 0, mas no ImplFlagManagerV1 também estiver no slot 0, bacana. Só que se, por acaso, no proxy a primeira variável fosse address impl em vez de uint256 flag, e o ImplFlagManagerV1 assume que slot 0 é flag, você escreveria um número no lugar onde se espera um address. Tchanam: collision e comportamento bizarro.

6.1 Como Mitigar

Um método comum é usar o Unstructured Storage, reservando um slot gigante (por ex. bytes32 constant EIP1967_IMPL_SLOT = keccak256("eip1967.proxy.implementation") - 1;) para guardar o endereço de implementação. Assim, você garante que não vai conflitar com as variáveis do logic contract. Há também padrões como Transparent Proxy, UUPS e Diamond Pattern, cada um com técnicas para minimizar collisions e controlar upgrades.

7. Conclusão

Manusear o storage em baixo nível é o passo definitivo pra sair do grupo de “dev normalzinho” e entrar no clube de quem realmente entende a EVM. Saber como sload/sstore funcionam, o que é bitmask, e por que arrays dinâmicos usam hashing pra indexar elementos é essencial pra criar contratos seguros e otimizados — especialmente em cenários de upgrade proxy, onde collisions podem detonar todo o projeto.

Dicas finais:

  1. Leia o layout: Ferramentas como forge inspect <Contrato> storage-layout (Foundry) ou o compilador do Solidity podem mostrar como as variáveis ficaram nos slots.
  2. Não confie na sorte: Se for usar upgrade proxy, defina slots de implementação com hashing (keccak256) pra evitar colisões.
  3. Entenda arrays/mappings: Mapeamentos e arrays dinâmicos podem gerar gargalos de gas se não forem usados corretamente.
  4. Testes e Auditorias: Sempre rode testes e, se possível, peça auditorias — principalmente no caso de proxies e upgrades.

Pronto, jovem! Agora você sabe tudo sobre storage layout, manipulação low-level e as armadilhas de upgrade. Qualquer dúvida ou sugestão, manda ver. Seu próximo desafio é aplicar esse conhecimento pra não cair em ciladas de collisions ou perder gas à toa. Happy hacking!

Subscribe to HellHex

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe