Transações no Ethereum: nonce, gas e assinatura
1. Visão geral
Toda mudança de estado no Ethereum começa com uma transação. A maioria dos erros ao enviar transações como “nonce too low”, “replacement transaction underpriced” ou “insufficient funds” estão relacionados a três campos: nonce, gas e assinatura.
Neste artigo, você verá como cada campo funciona. Depois, você obterá nonce, calculará as taxas de acordo com EIP-1559, assinará transações offline e as enviará para a rede usando web3j em uma aplicação Spring Boot 3.x.
2. Estrutura de uma transação EIP-1559
A partir da atualização de Londres, o tipo de transação mais comum é o tipo 2 (EIP-1559). Ele contém os seguintes campos:
| Campo | Significado |
|---|---|
chainId |
ID da rede (1 = Mainnet, 11155111 = Sepolia), impede repetição entre cadeias |
nonce |
Número sequencial da transação da conta que envia |
maxPriorityFeePerGas |
Máximo de “gorjeta” a pagar ao validador, por unidade de gas |
maxFeePerGas |
Taxa máxima total que você aceita pagar por unidade de gas |
gasLimit |
Máximo de gas que a transação pode gastar |
to, value, data |
Endereço destinatário, quantidade de wei para transferir e dados de chamada de contrato |
accessList |
Lista de endereços/armazenamento que se espera acessar (opcional) |
yParity, r, s |
Assinatura ECDSA |
A transação não tem campo from. O endereço do remetente é recuperado a partir da assinatura. Este é um ponto importante que você verá novamente na seção 6.
3. Preparação do projeto
Adicione web3j ao pom.xml:
<dependency>
<groupId>org.web3j</groupId>
<artifactId>core</artifactId>
<version>4.12.2</version>
</dependency>
Configure a URL do nó RPC (Sepolia, Anvil ou Hardhat local funcionam):
eth.rpc-url=http://localhost:8545
eth.chain-id=31337
Crie um bean Web3j:
@Configuration
public class Web3Config {
@Bean(destroyMethod = "shutdown")
Web3j web3j(@Value("${eth.rpc-url}") String rpcUrl) {
return Web3j.build(new HttpService(rpcUrl));
}
}
destroyMethod = "shutdown" ajuda a fechar o thread pool e o cliente HTTP do web3j quando a aplicação é encerrada.
4. Nonce
4.1. O que é Nonce
Nonce é um contador do número de transações que uma conta (EOA) enviou. Começa em 0 e aumenta exatamente 1 após cada transação incluída em um bloco. O nó apenas aceita transações com nonce exatamente igual ao próximo valor:
- Nonce menor: rejeitado com erro
nonce too low. - Nonce maior: fica aguardando no mempool até que a lacuna seja preenchida.
- Nonce igual a uma transação pendente: é considerado uma substituição da transação antiga, se a taxa for suficientemente mais alta (geralmente ≥ 10%).
Graças ao nonce, a mesma transação assinada não pode ser reenviada duas vezes.
4.2. Obter nonce do nó
public BigInteger pendingNonce(Web3j web3j, String address) throws IOException {
return web3j.ethGetTransactionCount(address, DefaultBlockParameterName.PENDING)
.send()
.getTransactionCount();
}
Aqui usamos PENDING em vez de LATEST. LATEST apenas conta transações já incluídas em um bloco. Se você acabou de enviar uma transação e ela ainda não foi minerada, LATEST retornará o nonce anterior. A próxima transação então substituirá a anterior em vez de continuar com ela.
4.3. Gerenciando nonce ao enviar simultaneamente
Se vários threads chamarem pendingNonce quase ao mesmo tempo, eles podem receber o mesmo valor. A solução simples é consultar o nó apenas uma vez e depois incrementar em memória:
@Component
public class NonceManager {
private final Web3j web3j;
private final ConcurrentHashMap<String, AtomicLong> counters = new ConcurrentHashMap<>();
public NonceManager(Web3j web3j) {
this.web3j = web3j;
}
public BigInteger next(String address) {
AtomicLong counter = counters.computeIfAbsent(
address.toLowerCase(), a -> new AtomicLong(fetchPending(a)));
return BigInteger.valueOf(counter.getAndIncrement());
}
public void reset(String address) {
counters.remove(address.toLowerCase());
}
private long fetchPending(String address) {
try {
return web3j.ethGetTransactionCount(address, DefaultBlockParameterName.PENDING)
.send().getTransactionCount().longValueExact();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}
computeIfAbsent garante que o nó é consultado apenas uma vez por endereço. getAndIncrement é uma operação atômica, portanto dois threads nunca receberão o mesmo nonce.
Observação: Se uma transação falhar na etapa de RPC (por exemplo, rejeitada pelo nó), o nonce alocado criará um “buraco”. Todas as transações subsequentes ficarão presas no mempool. Ao encontrar um erro, chame
reset()para sincronizar novamente com o nó. Este método também não é seguro quando múltiplas instâncias de aplicação compartilham a mesma carteira. Nesse caso, você precisará de um lock distribuído ou um serviço de assinatura centralizado.
5. Gas
5.1. gasLimit e gasUsed
Cada opcode na EVM consome uma quantidade fixa de gas. Uma transferência pura de ETH custa exatamente 21.000 gas. Chamar um contrato custa mais, dependendo da lógica interna.
gasLimité o limite que você define. Se a EVM terminar o gas antes de concluir a execução, a transação reverte, mas você ainda perde a taxa de todo o gas usado.gasUsedé a quantidade real de gas usada. O restante não é cobrado.
Para chamadas de contrato, faça uma estimativa usando eth_estimateGas:
public BigInteger estimateGas(Web3j web3j, String from, String to, String data) throws IOException {
var call = org.web3j.protocol.core.methods.request.Transaction
.createEthCallTransaction(from, to, data);
BigInteger estimate = web3j.ethEstimateGas(call).send().getAmountUsed();
return estimate.multiply(BigInteger.valueOf(120)).divide(BigInteger.valueOf(100));
}
Adicionamos uma margem de segurança de 20%. O estado da cadeia pode mudar entre a estimativa e quando a transação é minerada.
5.2. Taxas de acordo com EIP-1559
O preço por unidade de gas tem duas partes:
- baseFee: ajustado automaticamente pelo protocolo de acordo com a plenitude do bloco e é queimado.
- priority fee (tip): pago ao validador para ser priorizado.
O preço real que você paga por gas é:
effectiveGasPrice = min(maxFeePerGas, baseFee + maxPriorityFeePerGas)
fee = gasUsed * effectiveGasPrice
Uma fórmula comum é definir maxFeePerGas = 2 * baseFee + tip. Esta fórmula permite que a transação permaneça válida mesmo se o baseFee aumentar por vários blocos consecutivos, sem que você pague mais do que o necessário.
public record FeeQuote(BigInteger maxPriorityFeePerGas, BigInteger maxFeePerGas) {}
public FeeQuote quoteFee(Web3j web3j) throws IOException {
BigInteger baseFee = web3j.ethGetBlockByNumber(DefaultBlockParameterName.LATEST, false)
.send().getBlock().getBaseFeePerGas();
BigInteger tip = web3j.ethMaxPriorityFeePerGas().send().getMaxPriorityFeePerGas();
return new FeeQuote(tip, baseFee.multiply(BigInteger.TWO).add(tip));
}
Suponha que baseFee = 20 gwei e tip = 1,5 gwei. Para uma transferência de ETH, você terá:
maxFeePerGas = 41.5 gwei
effectiveGasPrice = min(41.5, 20 + 1.5) = 21.5 gwei
fee = 21000 * 21.5 gwei = 0.0004515 ETH
Sua carteira deve ter pelo menos value + gasLimit * maxFeePerGas. Caso contrário, o nó retorna um erro insufficient funds for gas * price + value, mesmo que a taxa real seja muito menor.
6. Assinatura
6.1. Assinar o quê e com quê
Ethereum usa ECDSA na curva secp256k1. Para transações do tipo 2, os dados assinados são:
keccak256( 0x02 || rlp([chainId, nonce, maxPriorityFeePerGas, maxFeePerGas,
gasLimit, to, value, data, accessList]) )
O resultado consiste em r, s e yParity. A partir da assinatura e dos dados, qualquer pessoa pode recuperar a chave pública e, consequentemente, deduzir o endereço from. Como chainId está incluído nos dados assinados, uma transação assinada para Sepolia não pode ser reenviada na Mainnet (EIP-155).
6.2. Assinar offline com web3j
@Component
public class TransactionSigner {
private static final BigInteger TRANSFER_GAS = BigInteger.valueOf(21_000);
public String signTransfer(Credentials credentials, long chainId, BigInteger nonce,
String to, BigInteger valueWei, FeeQuote fee) {
RawTransaction tx = RawTransaction.createEtherTransaction(
chainId, nonce, TRANSFER_GAS, to, valueWei,
fee.maxPriorityFeePerGas(), fee.maxFeePerGas());
byte[] signed = TransactionEncoder.signMessage(tx, credentials);
return Numeric.toHexString(signed);
}
}
Explicação passo a passo:
createEtherTransactioncom o parâmetrochainIddo tipolongcria uma transação EIP-1559.TransactionEncoder.signMessagefaz codificação RLP, hashing keccak256, assinatura com a chave privada emcredentialse depois re-codifica a assinatura.Numeric.toHexStringretorna uma string hex pronta para ser enviada viaeth_sendRawTransaction.
A string resultante sempre começa com o byte do tipo de transação:
0x02f873827a6907845968...
Esta função não precisa de conexão com a rede. Você pode assinar em uma máquina isolada e depois levar a string hex para outra máquina para enviar.
6.3. Testando a assinatura
O teste abaixo assina uma transação, decodifica e verifica que o endereço recuperado corresponde ao signatário:
class TransactionSignerTest {
private static final Credentials ALICE = Credentials.create(
"0x4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318");
private static final String BOB = "0x3535353535353535353535353535353535353535";
private static final FeeQuote FEE = new FeeQuote(
Convert.toWei("1.5", Convert.Unit.GWEI).toBigInteger(),
Convert.toWei("41.5", Convert.Unit.GWEI).toBigInteger());
private final TransactionSigner signer = new TransactionSigner();
@Test
void signedTransferRecoversSenderAndKeepsFields() throws SignatureException {
String hex = signer.signTransfer(ALICE, 11155111L, BigInteger.valueOf(7), BOB,
Convert.toWei("0.01", Convert.Unit.ETHER).toBigInteger(), FEE);
assertThat(hex).startsWith("0x02");
var decoded = (SignedRawTransaction) TransactionDecoder.decode(hex);
assertThat(decoded.getFrom()).isEqualToIgnoringCase(ALICE.getAddress());
assertThat(decoded.getNonce()).isEqualTo(BigInteger.valueOf(7));
assertThat(((Transaction1559) decoded.getTransaction()).getChainId())
.isEqualTo(11155111L);
}
@Test
void differentChainIdProducesDifferentTransactionHash() {
BigInteger value = BigInteger.ONE;
String sepolia = signer.signTransfer(ALICE, 11155111L, BigInteger.ZERO, BOB, value, FEE);
String mainnet = signer.signTransfer(ALICE, 1L, BigInteger.ZERO, BOB, value, FEE);
assertThat(Hash.sha3(sepolia)).isNotEqualTo(Hash.sha3(mainnet));
}
}
O primeiro teste confirma o que foi dito na seção 2: from é deduzido da assinatura, não armazenado na transação. O segundo teste mostra que chainId faz parte dos dados assinados. Hash.sha3 na string hex assinada é exatamente o hash da transação que o block explorer exibe.
7. Enviar transação e aguardar o recibo
Combine as partes em um serviço:
@Service
public class PaymentService {
private final Web3j web3j;
private final NonceManager nonceManager;
private final TransactionSigner signer;
private final long chainId;
public PaymentService(Web3j web3j, NonceManager nonceManager, TransactionSigner signer,
@Value("${eth.chain-id}") long chainId) {
this.web3j = web3j;
this.nonceManager = nonceManager;
this.signer = signer;
this.chainId = chainId;
}
public TransactionReceipt pay(Credentials payer, String to, BigInteger valueWei) throws Exception {
BigInteger nonce = nonceManager.next(payer.getAddress());
FeeQuote fee = quoteFee();
String signedHex = signer.signTransfer(payer, chainId, nonce, to, valueWei, fee);
EthSendTransaction response = web3j.ethSendRawTransaction(signedHex).send();
if (response.hasError()) {
nonceManager.reset(payer.getAddress());
throw new IllegalStateException(response.getError().getMessage());
}
return new PollingTransactionReceiptProcessor(web3j, 2_000, 30)
.waitForTransactionReceipt(response.getTransactionHash());
}
private FeeQuote quoteFee() throws IOException {
BigInteger baseFee = web3j.ethGetBlockByNumber(DefaultBlockParameterName.LATEST, false)
.send().getBlock().getBaseFeePerGas();
BigInteger tip = web3j.ethMaxPriorityFeePerGas().send().getMaxPriorityFeePerGas();
return new FeeQuote(tip, baseFee.multiply(BigInteger.TWO).add(tip));
}
}
PollingTransactionReceiptProcessor consulta o nó a cada 2 segundos, no máximo 30 vezes. Ao receber o recibo, você deve verificar também receipt.isStatusOK(). Um valor false significa que a transação foi incluída no bloco mas reverteu, e a taxa ainda foi deduzida.
8. Erros comuns
| Mensagem do nó | Causa | Solução |
|---|---|---|
nonce too low |
Nonce já foi usado | Sincronize o nonce com PENDING |
replacement transaction underpriced |
Mesmo nonce de uma transação pendente, mas taxa insuficiente | Aumente maxFeePerGas e maxPriorityFeePerGas em pelo menos 10% |
intrinsic gas too low |
gasLimit menor que o mínimo (21.000 + custo de data) |
Use eth_estimateGas |
insufficient funds for gas * price + value |
Saldo < value + gasLimit * maxFeePerGas |
Deposite mais ou reduza maxFeePerGas |
invalid chain id |
Assinatura com chainId diferente da rede do nó |
Leia chainId da configuração ou eth_chainId |
Recibo status = 0x0 |
Revert ou falta de gas durante a execução | Verifique a lógica do contrato, aumente gasLimit |
Observação: Não deixe chaves privadas em
application.propertiesou em variáveis de ambiente em servidores de produção. Assinar comCredentialsé apropriado para ambientes de desenvolvimento, teste e ferramentas internas. Para carteiras com dinheiro real, assine via KMS/HSM ou um serviço de assinatura separado. A aplicação deve apenas enviar os dados que precisam ser assinados, nunca tocando nas chaves.
Observação: Não gerencie nonce na memória se você executar múltiplas instâncias compartilhando a mesma carteira. Centralize todos os comandos de assinatura em um único serviço ou atribua uma carteira separada para cada instância.
9. Conclusão
Neste artigo, você viu os três componentes que determinam se uma transação Ethereum será aceita ou não:
- Nonce garante ordem e impede repetição. Obtenha usando
PENDINGe aloque atomicamente ao enviar simultaneamente. - Gas consiste em
gasLimit(quanto) emaxFeePerGas/maxPriorityFeePerGas(o quê). Você paga apenasgasUsed * min(maxFee, baseFee + tip). - Assinatura ECDSA secp256k1 nos dados contendo
chainId. Ela prova propriedade e é a única fonte para deduzir o endereçofrom.
Com web3j, você pode assinar transações completamente offline, testá-las com JUnit sem precisar de um nó e depois enviá-las para a rede usando apenas eth_sendRawTransaction.