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:
- Tipos menores que 32 bytes (ex.:
uint16
,address
,bool
) podem ser “compactados” no mesmo slot, se couberem juntos. - Variáveis grandes (ex.:
uint256
) consomem um slot inteiro. - 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)
. - 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:
- 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. - Não confie na sorte: Se for usar upgrade proxy, defina slots de implementação com hashing (
keccak256
) pra evitar colisões. - Entenda arrays/mappings: Mapeamentos e arrays dinâmicos podem gerar gargalos de gas se não forem usados corretamente.
- 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!