Transacciones en Ethereum: nonce, gas y firma
1. Resumen general
Cada cambio de estado en Ethereum comienza con una transacción. La mayoría de errores al enviar transacciones como “nonce too low”, “replacement transaction underpriced” o “insufficient funds” están relacionados con tres campos: nonce, gas y firma.
En este artículo, verás cómo funciona cada campo. Luego obtendrás el nonce tú mismo, calcularás las tarifas según EIP-1559, firmarás la transacción offline y la enviarás a la red usando web3j en una aplicación Spring Boot 3.x.
2. Estructura de una transacción EIP-1559
Desde la actualización de Londres, el tipo de transacción más común es tipo 2 (EIP-1559). Incluye los siguientes campos:
| Campo | Significado |
|---|---|
chainId |
ID de la red (1 = Mainnet, 11155111 = Sepolia), previene repetición entre cadenas |
nonce |
Número secuencial de transacciones enviadas por la cuenta del remitente |
maxPriorityFeePerGas |
“Propina” máxima pagada a los validadores, calculada por unidad de gas |
maxFeePerGas |
Tarifa total máxima que aceptas pagar por unidad de gas |
gasLimit |
Gas máximo que la transacción puede usar |
to, value, data |
Dirección del destinatario, cantidad de wei transferida y datos de llamada del contrato |
accessList |
Lista de direcciones/almacenamiento que se espera acceder (opcional) |
yParity, r, s |
Firma ECDSA |
La transacción no tiene un campo from. La dirección del remitente se recupera de la firma. Este es el punto clave que verás nuevamente en la sección 6.
3. Cómo preparar el proyecto
Añade web3j a pom.xml:
<dependency>
<groupId>org.web3j</groupId>
<artifactId>core</artifactId>
<version>4.12.2</version>
</dependency>
Declara la URL del nodo RPC (Sepolia, Anvil o Hardhat local funcionan todos):
eth.rpc-url=http://localhost:8545
eth.chain-id=31337
Crea un bean de Web3j:
@Configuration
public class Web3Config {
@Bean(destroyMethod = "shutdown")
Web3j web3j(@Value("${eth.rpc-url}") String rpcUrl) {
return Web3j.build(new HttpService(rpcUrl));
}
}
destroyMethod = "shutdown" ayuda a cerrar el pool de threads y el cliente HTTP de web3j cuando la aplicación se detiene.
4. Nonce
4.1. ¿Qué es el nonce?
Nonce es un contador de transacciones enviadas por una cuenta (EOA). Comienza desde 0 e incrementa exactamente 1 después de que cada transacción se incluye en un bloque. El nodo solo acepta transacciones con un nonce exactamente igual al siguiente valor:
- Nonce más bajo: rechazado con error
nonce too low. - Nonce más alto: espera en el mempool hasta que se cierre el hueco.
- Nonce igual a una transacción pendiente: se considera un reemplazo de la transacción anterior, si la tarifa es suficientemente mayor (generalmente ≥ 10%).
Gracias al nonce, la misma transacción firmada no puede ser reproducida una segunda vez.
4.2. Obtener el nonce del nodo
public BigInteger pendingNonce(Web3j web3j, String address) throws IOException {
return web3j.ethGetTransactionCount(address, DefaultBlockParameterName.PENDING)
.send()
.getTransactionCount();
}
Aquí usamos PENDING en lugar de LATEST. LATEST solo cuenta transacciones que han sido incluidas en un bloque. Si acabas de enviar una transacción y aún no ha sido minada, LATEST devolverá el nonce anterior. La siguiente transacción entonces reemplazará la anterior en lugar de seguirla.
4.3. Gestionar el nonce al enviar concurrentemente
Si múltiples threads llaman a pendingNonce casi al mismo tiempo, pueden recibir el mismo valor. Un enfoque simple es preguntar al nodo solo una vez, y luego incrementarlo en memoria:
@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 asegura que el nodo sea consultado solo una vez por cada dirección. getAndIncrement es una operación atómica, por lo que dos threads nunca reciben el mismo nonce.
Nota: Si el envío de una transacción falla en el paso de RPC (por ejemplo, el nodo la rechaza), el nonce asignado creará un “hueco”. Cada transacción después de él quedará atrapada en el mempool. Cuando encuentres un error, llama a
reset()para resincronizar con el nodo. Este enfoque tampoco es seguro cuando múltiples instancias de aplicación comparten una billetera. En ese caso necesitas locks distribuidos o un servicio de firma centralizado.
5. Gas
5.1. gasLimit y gasUsed
Cada opcode en la EVM cuesta una cantidad fija de gas. Una transferencia pura de ETH cuesta exactamente 21.000 gas. Llamar a un contrato cuesta más, dependiendo de la lógica dentro.
gasLimites el techo que estableces. Si la EVM se queda sin gas antes de completar, la transacción revierte pero aún pierdes la tarifa por todo el gas usado.gasUsedes la cantidad real de gas. El sobrante no se cobra.
Para llamadas a contratos, estima previamente con 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));
}
Agregamos un margen de seguridad adicional del 20%. El estado de la cadena puede cambiar entre la estimación y cuando se extrae la transacción.
5.2. Estructura de tarifas según EIP-1559
El precio de cada unidad de gas consta de dos partes:
- baseFee: auto-ajustado por el protocolo según la plenitud del bloque, y se quema.
- priority fee (tip): pagado a los validadores para ser priorizado.
El precio real que pagas por gas es:
effectiveGasPrice = min(maxFeePerGas, baseFee + maxPriorityFeePerGas)
fee = gasUsed * effectiveGasPrice
Una fórmula común es establecer maxFeePerGas = 2 * baseFee + tip. Esta fórmula mantiene la transacción válida incluso si baseFee aumenta en varios bloques, sin que pagues más de lo necesario.
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));
}
Supongamos que baseFee = 20 gwei y tip = 1,5 gwei. Con una transferencia pura de ETH, obtendrías:
maxFeePerGas = 41.5 gwei
effectiveGasPrice = min(41.5, 20 + 1.5) = 21.5 gwei
fee = 21000 * 21.5 gwei = 0.0004515 ETH
Tu billetera debe tener al menos value + gasLimit * maxFeePerGas. Si no, el nodo devuelve error insufficient funds for gas * price + value, incluso si la tarifa real es mucho menor.
6. Firma
6.1. ¿Qué se firma y con qué?
Ethereum usa ECDSA en la curva secp256k1. Para transacción tipo 2, los datos siendo firmados son:
keccak256( 0x02 || rlp([chainId, nonce, maxPriorityFeePerGas, maxFeePerGas,
gasLimit, to, value, data, accessList]) )
El resultado consta de r, s e yParity. De la firma y los datos, cualquiera puede recuperar la clave pública, y por lo tanto la dirección from. Dado que chainId es parte de los datos firmados, una transacción firmada para Sepolia no puede ser reproducida en Mainnet (EIP-155).
6.2. Firmar offline con 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);
}
}
Explicación paso a paso:
createEtherTransactioncon parámetrochainIdde tipolongcrea una transacción EIP-1559.TransactionEncoder.signMessagecodifica RLP, hace hash keccak256, firma con la clave privada encredentials, y luego codifica todo incluyendo la firma.Numeric.toHexStringdevuelve una cadena hex lista para enviar víaeth_sendRawTransaction.
La cadena resultante siempre comienza con el byte de tipo de transacción:
0x02f873827a6907845968...
Esta función no necesita conexión de red. Puedes firmar en una máquina aislada, y luego llevar la cadena hex a otra máquina para enviarla.
6.3. Probar la firma
La prueba abajo firma una transacción, la decodifica de nuevo y verifica que la dirección recuperada coincida con la del firmante:
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));
}
}
La primera prueba confirma lo dicho en la sección 2: from se infiere de la firma, no se almacena en la transacción. La segunda prueba muestra que chainId es parte de los datos firmados. Hash.sha3 en la cadena hex firmada es exactamente el hash de transacción que los exploradores de bloques muestran.
7. Enviar transacción y esperar el recibo
Combina las partes en un servicio:
@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 pregunta al nodo cada 2 segundos, máximo 30 veces. Cuando recibe un recibo, aún debes verificar receipt.isStatusOK(). Un valor de false significa que la transacción fue incluida en un bloque pero revirtió, y la tarifa fue deducida de todas formas.
8. Errores comunes
| Mensaje del nodo | Causa | Cómo manejarlo |
|---|---|---|
nonce too low |
Nonce ya usado | Resincroniza nonce con PENDING |
replacement transaction underpriced |
Coincide con nonce de transacción pendiente pero tarifa no es lo suficientemente alta | Aumenta tanto maxFeePerGas como maxPriorityFeePerGas al menos 10% |
intrinsic gas too low |
gasLimit es más bajo que el mínimo (21.000 + costo de data) |
Usa eth_estimateGas |
insufficient funds for gas * price + value |
Saldo < value + gasLimit * maxFeePerGas |
Recarga o reduce maxFeePerGas |
invalid chain id |
Firmado con chainId diferente de la red del nodo |
Lee chainId de la configuración o eth_chainId |
Receipt status = 0x0 |
Revirtió o sin gas durante la ejecución | Verifica la lógica del contrato, aumenta gasLimit |
Nota: No guardes claves privadas en
application.propertieso variables de entorno en servidores de producción. Auto-firmar conCredentialses apropiado para dev, test y herramientas internas. Para billeteras que guardan dinero real, firma a través de KMS/HSM o un firmante separado. La aplicación solo debe enviar datos para firmar, nunca tocar la clave.
Nota: No debes gestionar el nonce en memoria si ejecutas múltiples instancias compartiendo una billetera. Consolida todos los comandos de firma a un único servicio, o dale a cada instancia su propia billetera.
9. Conclusión
En este artículo, has visto tres componentes que determinan si una transacción de Ethereum será aceptada o no:
- Nonce asegura el orden y previene repetición. Obtélolo por
PENDINGy asigna atómicamente al enviar concurrentemente. - Gas consta de
gasLimit(cuánto) ymaxFeePerGas/maxPriorityFeePerGas(precio cuánto). Solo pagasgasUsed * min(maxFee, baseFee + tip). - Firma ECDSA secp256k1 en datos que contienen
chainId. Tanto prueba propiedad como es la única fuente para inferir la direcciónfrom.
Con web3j, puedes firmar transacciones completamente offline, probar con JUnit sin necesitar un nodo, y luego enviar a la red usando solo eth_sendRawTransaction.