Tutorial: testando contratos Solidity com evm-redex
English version: Tutorial: testing Solidity contracts with evm-redex.
Este tutorial percorre, do zero, escrever um pequeno contrato em Solidity, compilá-lo e testá-lo com evm-redex/pbt — a DSL de testes baseados em propriedades. Todos os trechos abaixo são o código real e executável em "tutorial/" — "counter.rkt", "token.rkt", "ballot.rkt", "auction.rkt", "purchase.rkt" e "explore.rkt"; rode todos com raco test tutorial.
Você vai precisar do solc (o compilador Solidity) para transformar um arquivo .sol no artefato JSON que a biblioteca lê; os artefatos versionados em "tutorial/" foram gerados com solc 0.8.33, então dá para acompanhar sem recompilar.
1 A ideia em um parágrafo
A biblioteca executa o bytecode do contrato sobre uma especificação executável da EVM. Então o ciclo é sempre o mesmo: compile o .sol para bytecode com o solc, carregue o artefato com read-artifact, faça o deploy num mundo novo e, então, ou chame o contrato com call (para ler estado ou enviar uma mensagem), ou enuncie uma propriedade com define-evm-property e deixe o motor tentar refutá-la sobre muitas entradas geradas.
2 Um primeiro contrato: Counter
Eis o "olá mundo" dos contratos com estado — um contador, em "tutorial/contracts/Counter.sol":
"Counter.sol"
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Counter {
uint256 public count; // public -> getter `count()` de graça
function increment() public { count += 1; }
function add(uint256 n) public { count += n; }
function decrement() public {
require(count > 0, "underflow"); // uma guarda para testar reversão
count -= 1;
}
}
2.1 Compile
Peça ao solc o bytecode e a ABI, num único arquivo JSON:
solc --combined-json bin,bin-runtime,abi contracts/Counter.sol > Counter.json
2.2 Carregue e faça o deploy
read-artifact extrai o bytecode de criação e de runtime (e a ABI) do JSON; deploy roda o construtor e devolve o endereço implantado e o mundo resultante.
(require evm-redex/pbt) (define ART (read-artifact "Counter.json" #:contract "Counter")) (define DEPLOYER 2703024129) (define DEP (deploy (artifact-creation ART) #:from DEPLOYER)) (define COUNTER (deploy-result-address DEP)) (define BASE (deploy-result-world DEP)) ; o mundo logo após o deploy
2.3 Leia o estado
count é uma variável pública, então o Solidity gera um getter count(). Um call executa uma mensagem; call-result-return são os bytes de retorno, que abi-decode converte de volta num número:
(define (count-of world) (car (abi-decode (list "uint256") (call-result-return (call world COUNTER #:data (encode-call "count()")))))) (count-of BASE) ; => 0
2.4 Envie transações
encode-call monta a calldata de uma função; call devolve um call-result, e call-result-world é o mundo após a chamada. Encadeie esse mundo por algumas chamadas e leia o contador de volta:
(define CALLER 2964324353) (define (send world sig . args) (call-result-world (call world COUNTER #:from CALLER #:data (apply encode-call sig args)))) (let* ([w (send BASE "increment()")] [w (send w "add(uint256)" 5)]) (count-of w)) ; => 6
2.5 Enuncie uma propriedade
Chamadas concretas servem para uma conferência rápida, mas o objetivo da biblioteca é testar uma propriedade sobre muitas entradas geradas. define-evm-property no modo transação (a cláusula #:call) roda uma transação real; #:given lista as entradas geradas, e #:post recebe o mundo antes (w0) e depois (w1) e o resultado da transação:
(define (tx sig . args) (make-tx #:sender CALLER #:to COUNTER #:gas-limit 200000 #:gas-price 0 #:data (apply encode-call sig args))) (define-evm-property add-raises-count-by-n #:given ([n (gen-word-in 0 (expt 2 200))]) #:world BASE #:call (tx "add(uint256)" n) #:post (lambda (w0 w1 r) (= (count-of w1) (+ (count-of w0) n)))) (check-evm-property add-raises-count-by-n #:trials 50)
Ao rodar, imprime:
✓ property add-raises-count-by-n passed 50 tests. |
Uma propriedade também pode exigir que uma chamada reverta. #:revert-when diz "sob esta condição, a transação deve reverter"; a partir de um contador zero, decrement() sempre reverte:
(define-evm-property decrement-reverts-at-zero #:given () ; sem entradas geradas — uma asserção simples #:world BASE ; aqui count é 0 #:call (tx "decrement()") #:revert-when #t) (check-evm-property decrement-reverts-at-zero #:trials 1)
3 Um contrato mais rico: Token
Um token mínimo acrescenta saldos, uma transferência guardada e um evento — "tutorial/contracts/Token.sol":
"Token.sol"
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Token {
mapping(address => uint256) public balanceOf; // -> `balanceOf(address)`
uint256 public totalSupply;
event Transfer(address indexed from, address indexed to, uint256 value);
function mint(address to, uint256 amount) public {
totalSupply += amount;
balanceOf[to] += amount;
emit Transfer(address(0), to, amount);
}
function transfer(address to, uint256 amount) public returns (bool) {
require(balanceOf[msg.sender] >= amount, "insufficient balance");
balanceOf[msg.sender] -= amount;
balanceOf[to] += amount;
emit Transfer(msg.sender, to, amount);
return true;
}
}
Faça o deploy e, desta vez, construa um mundo em que ALICE já tem 1000 tokens (aqui o mint é irrestrito, então qualquer conta pode chamá-lo):
(define ART (read-artifact "Token.json" #:contract "Token")) (define ALICE 659918) (define BOB 723712) (define DEP (deploy (artifact-creation ART) #:from 3736076289)) (define TOKEN (deploy-result-address DEP)) (define (u256 r) (car (abi-decode (list "uint256") (call-result-return r)))) (define (bal w a) (u256 (call w TOKEN #:data (encode-call "balanceOf(address)" a)))) (define (total w) (u256 (call w TOKEN #:data (encode-call "totalSupply()")))) (define (send w from sig . args) (call-result-world (call w TOKEN #:from from #:data (apply encode-call sig args)))) (define MINTED (send (deploy-result-world DEP) ALICE "mint(address,uint256)" ALICE 1000)) (bal MINTED ALICE) ; => 1000
3.1 A propriedade que importa: conservação
O invariante que um token nunca pode quebrar é que uma transferência move valor sem criar nem destruir nenhum. Esta única propriedade cobre tanto o caminho de sucesso quanto o de reversão, sobre valores que cercam o saldo de ALICE:
(define (tx from sig . args) (make-tx #:sender from #:to TOKEN #:gas-limit 200000 #:gas-price 0 #:data (apply encode-call sig args))) (define-evm-property transfer-conserves-supply #:given ([amount (gen-word-in 0 2000)]) ; cerca o saldo de 1000 #:world MINTED #:call (tx ALICE "transfer(address,uint256)" BOB amount) #:post (lambda (w0 w1 r) (and (= (total w1) (total w0)) ; o supply nunca muda (if (eq? (txn-run-outcome r) 'revert) (= (bal w1 ALICE) (bal w0 ALICE)) ; revertido (and (= (bal w1 ALICE) (- (bal w0 ALICE) amount)) (= (bal w1 BOB) (+ (bal w0 BOB) amount))))))) (check-evm-property transfer-conserves-supply #:trials 100)
E uma propriedade focada em reversão — transferir mais do que se tem deve falhar:
(define-evm-property transfer-reverts-when-insufficient #:given ([amount (gen-word-in 1001 100000)]) ; sempre mais do que ALICE tem #:world MINTED #:call (tx ALICE "transfer(address,uint256)" BOB amount) #:revert-when #t) (check-evm-property transfer-reverts-when-insufficient #:trials 50)
Quando uma propriedade falha, o motor encolhe as entradas aleatórias até um contraexemplo mínimo e o imprime — esse caso encolhido é o grande ganho dos testes baseados em propriedades. Experimente enfraquecer o require de transfer no contrato e rodar de novo: a propriedade de conservação é refutada com uma transferência minúscula.
4 Mais exemplos do Solidity by Example
Os dois contratos acima já cobrem o ciclo inteiro; o resto desta seção o aplica aos contratos clássicos da documentação do Solidity, Solidity by Example. Cada um é um módulo executável completo em "tutorial/" — a fonte Solidity, o deploy e a propriedade que fixa sua regra central.
4.1 Votação (Voting)
"tutorial/ballot.rkt" testa Ballot, o contrato de votação com delegação. Duas coisas são novas aqui: o construtor recebe argumentos (um array de nomes de proposta em bytes32), que você codifica em ABI e anexa ao bytecode de criação; e um getter pode devolver uma struct, que você decodifica como uma tupla.
(define ART (read-artifact "Ballot.json" #:contract "Ballot")) (define CHAIR 3298922497) ; nomes de proposta são bytes32 — preencha cada um até 32 bytes (define NAMES (list (name->b32 "alpha") (name->b32 "beta") (name->b32 "gamma"))) ; args do construtor são codificados em ABI e anexados ao creation code (define DEP (deploy (append (artifact-creation ART) (abi-encode (list "bytes32[]") (list NAMES))) #:from CHAIR)) (define BALLOT (deploy-result-address DEP))
O chairperson (quem faz o deploy) concede o direito de voto, o eleitor vota e a contagem se atualiza — e a regra que vale checar é que só o chairperson pode conceder direito de voto, então uma chamada de qualquer outro deve reverter:
(define OUTSIDER 50157932545) (define-evm-property only-chair-grants-rights #:given ([who gen-address]) #:world BASE #:call (make-tx #:sender OUTSIDER #:nonce 0 #:to BALLOT #:gas-limit 300000 #:gas-price 0 #:data (encode-call "giveRightToVote(address)" who)) #:revert-when #t) ; quem não é chairperson sempre reverte (check-evm-property only-chair-grants-rights #:trials 30)
4.2 Um leilão aberto
"tutorial/auction.rkt" testa SimpleAuction. Suas chamadas carregam ether: o #:value de make-tx financia o lance. Contas que dão lances precisam de saldo, então enchemos algumas à mão — o mesmo que deploy faz por quem faz o deploy:
(require (only-in evm-redex world-ref world-set acct-balance acct-with-balance)) (define (fund w a wei) (world-set w a (acct-with-balance (world-ref w a) (+ (acct-balance (world-ref w a)) wei))))
A regra central do leilão é que um lance é aceito exatamente quando supera o maior atual, e então se torna o novo maior — uma propriedade cobre tanto o caminho de aceitação quanto o de rejeição:
(define-evm-property bid-raises-the-highest #:given ([v (gen-word-in 0 500)]) #:world W1 ; aqui o maior lance é 100 #:call (make-tx #:sender BOB #:nonce (nonce-of W1 BOB) #:to AUCTION #:value v #:gas-limit 300000 #:gas-price 0 #:data (encode-call "bid()")) #:post (lambda (w0 w1 r) (if (> v (highest-bid w0)) (and (eq? (txn-run-outcome r) 'success) (= (highest-bid w1) v)) (eq? (txn-run-outcome r) 'revert)))) (check-evm-property bid-raises-the-highest #:trials 60)
4.3 Compra remota segura
"tutorial/purchase.rkt" testa Purchase, um escrow de quatro estados (Created → Locked → Release → Inactive). O construtor é payable — o vendedor tranca o dobro do valor do item — então você faz o deploy com #:value:
(define DEP (deploy (artifact-creation ART) #:from SELLER #:value 200)) ; value = 100
O caminho feliz é o comprador chamar confirmPurchase (igualando o depósito) e depois confirmReceived. A regra que vale fixar é que, a partir do estado Locked, só o comprador pode confirmar o recebimento:
(define-evm-property only-buyer-confirms-receipt #:given () #:world LOCKED-W #:call (make-tx #:sender OUTSIDER #:nonce 0 #:to PURCHASE #:gas-limit 300000 #:gas-price 0 #:data (encode-call "confirmReceived()")) #:revert-when #t) (check-evm-property only-buyer-confirms-receipt #:trials 1)
O quarto contrato do Solidity by Example, o Canal de Micropagamento, não está incluído: ele verifica uma assinatura ECDSA off-chain com ecrecover, e produzir essa assinatura acontece fora da EVM que esta biblioteca modela — então testá-lo exigiria um assinador off-chain, além do escopo deste tutorial.
5 Explorando à mão com #lang evm-redex/sim
Propriedades servem para verificar; quando você só quer cutucar um contrato, o simulador de transações se lê como um roteiro. "tutorial/explore.rkt" é uma sessão inteira — rode com racket tutorial/explore.rkt:
"explore.rkt"
#lang evm-redex/sim
.account ALICE balance=1eth
.account BOB
.deploy TOKEN from=ALICE code=@Token.json:Token
tx from=ALICE to=TOKEN sig="mint(address,uint256)" args=(ALICE, 1000)
tx from=ALICE to=TOKEN sig="transfer(address,uint256)" args=(BOB, 100)
tx from=BOB to=TOKEN sig="transfer(address,uint256)" args=(ALICE, 5000) ; reverte
call from=ALICE to=TOKEN sig="balanceOf(address)" args=(BOB) returns=uint256
Ele imprime um recibo por transação — com o evento Transfer e a razão da reversão já decodificados — e um diff final do estado:
tx ALICE -> TOKEN |
status: success |
log: Transfer(from=0x0, to=0x4e8a…8f7, value=0x3e8) |
tx ALICE -> TOKEN |
status: success |
log: Transfer(from=0x4e8a…8f7, to=0x28e4…4c38, value=0x64) |
tx BOB -> TOKEN |
status: revert (Error("insufficient balance")) |
call TOKEN.balanceOf(address) = (100) |
|
--- final state --- |
state root: 0x4b40…1730 |
… |
6 Por onde seguir
A referência completa de evm-redex/pbt — cada cláusula de define-evm-property, o vocabulário de observação e os geradores — está em Library reference: the property-based testing DSL.
A linguagem de cenários #lang evm-redex/sim está documentada em #lang evm-redex/sim — simulating transactions.
Exemplos trabalhados maiores — ERC-20/721 reais, contratos da OpenZeppelin e um benchmark que reproduz bugs reais de auditoria — vivem sob "tests/" e são descritos em The evm-redex test suite: tutorial, tests, and examples.