Para gestionar correctamente los nonces de EVM bajo concurrencia, trata el nonce como estado de la aplicación: inicialízalo una vez con eth_getTransactionCount(addr, 'pending') y luego incrementa un contador local por cada transmisión. Evita leer 'pending' para cada transacción, ya que puede estar desactualizado o ser propenso a condiciones de carrera, lo que provoca errores de 'nonce demasiado bajo' o transacciones atascadas. Vuelve a sincronizar solo después de reconexiones o reorganizaciones, y maneja errores como 'replacement transaction underpriced' con un aumento en el precio del gas.
La respuesta directa: el nonce es estado de la aplicación, no una llamada RPC por solicitud
La forma correcta de usar eth_getTransactionCount bajo concurrencia es no llamarlo para cada transacción que envíes. En su lugar, trata el nonce como estado de la aplicación: inicialízalo una vez con eth_getTransactionCount(ADDRESS, 'pending') y luego mantén un contador monotónico local que incrementes por cada transmisión. Solo vuelve a sincronizar desde el nodo después de una reconexión, una reorganización o si pierdes el rastro de las transacciones en vuelo. Esto evita la clásica condición de carrera donde múltiples remitentes concurrentes leen el mismo nonce 'pending' y luego colisionan, produciendo errores de 'nonce demasiado bajo' o transacciones atascadas.
Este artículo explica el mecanismo detrás del nonce, por qué la etiqueta 'pending' es poderosa y peligrosa a la vez, y te proporciona un proyecto Node.js reproducible para ver el fallo y la solución. También incluye una tabla de decisiones para los mensajes de error JSON-RPC comunes y sus remedios.
Cómo funciona el nonce en EVM: un escalar por cuenta
En el protocolo Ethereum, cada transacción de una cuenta de propiedad externa (EOA) lleva un campo nonce: un escalar secuencial por cuenta que comienza en 0 para la primera transacción. El yellow paper de Ethereum define el estado de la cuenta como incluyendo un nonce, y la especificación de execution-apis para eth_getTransactionCount establece que devuelve el número de transacciones enviadas desde una dirección, que es exactamente el siguiente nonce que debes usar.
El nodo mantiene dos vistas de este recuento: el estado confirmado (lo que está en los bloques canónicos) y el estado pendiente (confirmado más lo que está en el mempool del nodo). El método eth_getTransactionCount acepta un parámetro de bloque: 'latest' (el valor predeterminado) devuelve el recuento confirmado, 'pending' incluye las transacciones en el mempool, y un número de bloque hexadecimal devuelve el recuento en un bloque histórico específico.
La idea crítica es que 'pending' no es una verdad global: es la vista del mempool del nodo específico que estás consultando. Diferentes nodos (y diferentes proveedores) pueden tener mempools diferentes, especialmente durante retrasos de propagación o después de una reorganización. Si dependes de 'pending' para cada envío, podrías obtener un valor obsoleto que ya ha sido utilizado por otro remitente, o podrías perder una transacción que otro nodo ya ha visto.
Por qué fallan los envíos concurrentes: la condición de carrera
Considera un script simple que lanza N remitentes paralelos, cada uno llamando a eth_getTransactionCount(ADDRESS, 'pending') y luego transmitiendo una transacción con ese nonce. Debido a que las llamadas ocurren concurrentemente, es probable que reciban el mismo valor de nonce. La primera transacción se mina, pero las demás se rechazan con 'nonce demasiado bajo' porque ese nonce ya está en uso.
Incluso si serializas las lecturas, la vista pendiente del nodo podría no incluir aún tu transacción recién transmitida debido al retraso de propagación. Por eso, leer 'pending' para cada envío es fundamentalmente propenso a condiciones de carrera.
El patrón seguro es tener un único escritor (o un bloqueo distribuido) que posea el contador de nonces. Inicialízalo una vez, incrementa localmente y solo vuelve a sincronizar cuando sospeches que el contador local está desincronizado (por ejemplo, después de una reorganización o una transacción descartada).
Modos de fallo y cómo recuperarse
Cuando envías una transacción con un nonce incorrecto, el nodo devuelve un error JSON-RPC. Aquí están los comunes y cómo manejarlos:
'nonce too low' – El nonce que usaste ya está en la cadena canónica o en el mempool. Esto generalmente significa que contaste dos veces. Recuperación: vuelve a sincronizar tu contador local desde eth_getTransactionCount(ADDRESS, 'pending') y reenvía con el nonce correcto.
'nonce too high' – Omitiste un nonce, creando un hueco. El nodo pondrá tu transacción en cola, pero no se minará hasta que se llenen todos los nonces inferiores. Recuperación: envía primero la transacción con el nonce inferior faltante, o cancela el hueco enviando una transacción con el nonce faltante (por ejemplo, una transferencia de valor 0 a ti mismo).
'replacement transaction underpriced' – Intentaste reemplazar una transacción pendiente con el mismo nonce pero con un precio de gas que no es lo suficientemente alto. La regla de reemplazo de Ethereum requiere un aumento de precio de al menos ~10% (el porcentaje exacto no está en la especificación del protocolo, pero es aplicado por nodos como Geth). Recuperación: reenvía con un precio de gas más alto (por ejemplo, 10-20% más).
Reorganizaciones y transacciones descartadas – Después de una reorganización, la vista de 'pending' del nodo puede retroceder, y las transacciones que estaban en el mempool pueden ser descartadas. Si tu contador local está por delante del nodo, obtendrás 'nonce too high'. Recuperación: vuelve a sincronizar desde 'pending' y prepárate para reenviar cualquier transacción que se haya perdido.
Proyecto Node.js reproducible: ve el error y la solución
El siguiente script de Node.js utiliza ethers v6 (o JSON-RPC puro mediante fetch) para demostrar los conceptos. Requiere un endpoint proporcionado por el usuario (por ejemplo, un endpoint de Ethereum de OnFinality) y una clave privada para una EOA con algunos fondos.
El script tiene tres modos: check imprime el nonce 'latest' y 'pending' para una dirección; bug ejecuta un envío concurrente que demuestra la condición de carrera; safe ejecuta el patrón seguro con un contador local y lógica de reintento.
Guarda el script como nonce-demo.js y ejecútalo con node nonce-demo.js <endpoint> <privateKey> <mode>.
const { ethers } = require('ethers');
async function main() {
const endpoint = process.argv[2];
const privateKey = process.argv[3];
const mode = process.argv[4] || 'check';
const provider = new ethers.JsonRpcProvider(endpoint);
const wallet = new ethers.Wallet(privateKey, provider);
const address = wallet.address;
if (mode === 'check') {
const latest = await provider.getTransactionCount(address, 'latest');
const pending = await provider.getTransactionCount(address, 'pending');
console.log(`Latest nonce: ${latest}`);
console.log(`Pending nonce: ${pending}`);
return;
}
if (mode === 'bug') {
// Concurrent sends: each reads pending nonce and sends
const senders = [];
for (let i = 0; i < 5; i++) {
senders.push((async () => {
const nonce = await provider.getTransactionCount(address, 'pending');
try {
const tx = await wallet.sendTransaction({
to: address, // send to self
value: 0,
nonce,
gasLimit: 21000,
maxFeePerGas: ethers.parseUnits('1', 'gwei'),
maxPriorityFeePerGas: ethers.parseUnits('1', 'gwei')
});
console.log(`Sent with nonce ${nonce}: ${tx.hash}`);
} catch (e) {
console.log(`Failed with nonce ${nonce}: ${e.shortMessage || e.message}`);
}
})());
}
await Promise.all(senders);
return;
}
if (mode === 'safe') {
// Seed once
let nextNonce = await provider.getTransactionCount(address, 'pending');
console.log(`Starting nonce: ${nextNonce}`);
// Serialize sends with a simple queue
const sendQueue = [];
for (let i = 0; i < 5; i++) {
sendQueue.push((async () => {
const nonce = nextNonce++;
let attempt = 0;
while (attempt < 3) {
try {
const tx = await wallet.sendTransaction({
to: address,
value: 0,
nonce,
gasLimit: 21000,
maxFeePerGas: ethers.parseUnits('1', 'gwei'),
maxPriorityFeePerGas: ethers.parseUnits('1', 'gwei')
});
console.log(`Sent with nonce ${nonce}: ${tx.hash}`);
return;
} catch (e) {
const msg = e.shortMessage || e.message;
if (msg.includes('replacement transaction underpriced')) {
// Bump gas price by 20%
attempt++;
const newGas = ethers.parseUnits((1 + attempt * 0.2).toFixed(2), 'gwei');
console.log(`Underpriced, retrying with gas ${newGas}`);
// Re-send with higher gas (simplified: need to recreate tx)
// In practice, you'd use a higher fee and same nonce
} else if (msg.includes('nonce too low')) {
// Re-sync
nextNonce = await provider.getTransactionCount(address, 'pending');
console.log(`Nonce too low, re-synced to ${nextNonce}`);
return;
} else {
console.log(`Failed: ${msg}`);
return;
}
}
}
})();
}
await Promise.all(sendQueue);
}
}
main().catch(console.error);Salida esperada y tabla de resultados
Cuando ejecutes el modo check, verás dos números. La diferencia entre ellos indica cuántas transacciones están actualmente en el mempool para esa dirección. Por ejemplo:
Esto significa que hay dos transacciones pendientes (nonces 5 y 6).
El modo bug probablemente producirá varios errores de 'nonce too low' porque todos los remitentes leen el mismo nonce pendiente. El modo safe debería enviar todas las transacciones con éxito con nonces secuenciales.
Completa la tabla a continuación con tus propios resultados para verificar el comportamiento:
- Modo | Salida observada | Explicación
check| Latest: X, Pending: Y | Y - X = número de transacciones en vuelobug| Múltiples 'nonce too low' | Condición de carrera: todos leen el mismo noncesafe| Todas enviadas con nonces secuenciales | El contador local previene colisiones
Latest nonce: 5
Pending nonce: 7Tabla de decisiones para mensajes de error
La siguiente tabla asigna mensajes de error JSON-RPC comunes a su causa y la solución recomendada. Úsala como referencia rápida al depurar problemas de nonce.
- Error | Causa | Solución
nonce too low| El nonce ya está en uso (minado o en el mempool) | Vuelve a sincronizar desde 'pending' y reenvía con el nonce correctononce too high| Hueco en la secuencia de nonces | Envía primero el nonce inferior faltante, o cancela el huecoreplacement transaction underpriced| Reemplazo de una transacción pendiente con un aumento de precio de gas insuficiente | Reenvía con al menos ~10% más de precio de gastransaction underpriced| Precio de gas por debajo del mínimo del nodo | Aumenta el precio de gas para cumplir con el mínimo del nodoalready known| La transacción ya está en el mempool | Espera la confirmación o reemplázala con un gas más altononce too lowdespués de una reorganización | La vista del nodo retrocedió | Vuelve a sincronizar el nonce desde 'pending' y reenvía las transacciones perdidas
Limitaciones y compensaciones
La visibilidad del mempool varía entre proveedores y clientes de nodos. Una transacción puede ser visible en un nodo pero no en otro, por lo que 'pending' no es una verdad global. Siempre usa un endpoint confiable y considera usar un proveedor de RPC dedicado como la API de Ethereum de OnFinality para un comportamiento consistente.
Las políticas de reemplazo están diseñadas para aumentos de precio, no para reenvíos idénticos. Si envías el mismo nonce con el mismo precio de gas, el nodo lo rechazará como 'replacement transaction underpriced'. Siempre aumenta el precio de gas al menos un 10% al reemplazar.
Nunca asumas que 'pending' es instantáneamente consistente después de una reorganización. El nodo puede tardar en reconstruir su mempool. Si estás en un entorno de alto riesgo, considera esperar unos segundos después de una reorganización antes de volver a sincronizar.
Para aplicaciones de alto rendimiento, considera usar un gestor de nonces local o una biblioteca como el NonceManager de ethers (aunque tiene sus propias limitaciones). La clave es centralizar la asignación de nonces.
Próximos pasos y lecturas adicionales
Ahora que comprendes la gestión de nonces, puedes aplicar estos patrones a tus propias aplicaciones. Para más soluciones de problemas de RPC, explora el centro de aprendizaje de OnFinality para guías sobre tiempos de espera y reintentos en RPC de Ethereum, mejores prácticas de agrupación JSON-RPC y monitoreo de endpoints RPC.
Si estás construyendo en Ethereum, también consulta Cómo elegir un endpoint RPC de Ethereum y Descodificar razones de reversión en Ethereum. Para producción, considera usar un servicio de API dedicado con precios de RPC que se ajusten a tus necesidades.
Recuerda: el nonce es un escalar simple, pero gestionarlo correctamente bajo concurrencia es un problema clásico de sistemas distribuidos. Trátalo como estado, no como una consulta por solicitud, y evitarás la mayoría de los escollos.