Logo
Nuevos usuarios de RPC: 35% de descuento el primer mesVer oferta
OnFinality Learn
Guías de red y protocolo13 min de lectura

Objeto de transacción vs recibo en Ethereum: resolver un hash

Aprende en qué se diferencia el objeto de transacción firmado del recibo de ejecución y cómo clasificar cualquier hash como no encontrado, pendiente, minado, revertido o reemplazado usando JSON-RPC sin procesar.

TL;DR

Un objeto de transacción es la entrada firmada a la EVM: remitente, destinatario, valor, nonce, parámetros de gas, calldata y firma. Un recibo es el registro de salida producido al ejecutar esa transacción, y es el recibo lo que el receiptsRoot del bloque compromete. La asimetría que importa en la práctica es que eth_getTransactionByHash puede devolver una transacción antes de que sea minada, con blockNumber, blockHash y transactionIndex establecidos en null, mientras que eth_getTransactionReceipt sigue devolviendo null hasta que la transacción se incluye en un bloque. Ese par de resultados basta para clasificar un hash como pendiente, minado, revertido o reemplazado, pero un null de ambas llamadas es ambiguo y debe desambiguarse con la altura de la cadena, el nonce pendiente del remitente o un segundo endpoint. Dado que las reorganizaciones pueden hacer desaparecer un recibo, la reconciliación debe basarse tanto en blockHash como en transactionHash.

El objeto de transacción y el recibo como dos registros diferentes

Un objeto de transacción es lo que una clave firmó y alguien difundió. Cada campo existe porque el protocolo lo necesita para verificar la firma o ejecutar el payload: from (recuperado de la firma, no transmitido), to (null para creación de contrato), value, nonce, gas, los campos de comisión (gasPrice en transacciones legacy, maxFeePerGas y maxPriorityFeePerGas en transacciones tipo 2), el calldata en input con data mantenido como alias legacy, type, y los tres componentes de firma v, r y s. La especificación de las API de ejecución de Ethereum para eth_getTransactionByHash define este objeto de retorno, y la documentación JSON-RPC de ethereum.org describe la misma forma en su referencia de métodos.

La propiedad crítica del objeto de transacción es que describe la intención, no el resultado. Dice qué debería ocurrir y quién lo autorizó. No dice si la ejecución tuvo éxito, cuánto gas se consumió realmente, qué eventos se emitieron ni qué dirección creó un despliegue. Todos esos son resultados de ejecución y viven en un objeto diferente que solo se produce cuando un bloque incluye la transacción. Una aplicación que trata un objeto de transacción devuelto como prueba de éxito está leyendo la mitad equivocada del protocolo.

Ese otro objeto es el recibo. Contiene status (0x1 éxito o 0x0 revertido), cumulativeGasUsed (gas usado por todas las transacciones hasta esta inclusive en el bloque), gasUsed (gas usado solo por esta transacción), effectiveGasPrice, logs y logsBloom, contractAddress (no nulo solo para despliegues exitosos), type, y los localizadores transactionHash, blockHash, blockNumber y transactionIndex. La entrada de las API de ejecución para eth_getTransactionReceipt enumera los campos y sus codificaciones.

La cabecera del bloque compromete los recibos de todas sus transacciones mediante receiptsRoot. Esa es la razón estructural por la que el recibo domina el diseño de reconciliación: el hash del bloque ancla el contenido exacto del recibo, por lo que un recibo obtenido junto con un hash de bloque puede verificarse después, mientras que un objeto de transacción desnudo no. También explica por qué una transacción revertida sigue teniendo un recibo y sigue consumiendo gas. La ejecución ocurrió, sus efectos se revirtieron y el protocolo aun así registró el intento a un precio.

Mapeo campo por campo entre transacción y recibo

Los dos objetos se solapan solo en unos pocos campos. Todo lo demás pertenece exactamente a uno de ellos, lo que es un atajo útil cuando decides qué llamada hacer para una pregunta dada.

Compartidos entre ambos: hash/transactionHash (el mismo valor de 32 bytes), blockNumber, blockHash, transactionIndex y type. Presentes solo en la transacción: from, to, value, nonce, gas (el límite), gasPrice, maxFeePerGas, maxPriorityFeePerGas, input, data, accessList, v, r, s y chainId donde el cliente lo incluye. Presentes solo en el recibo: status, gasUsed, cumulativeGasUsed, effectiveGasPrice, logs, logsBloom, contractAddress, y blobGasUsed/blobGasPrice en transacciones tipo 3 que transportan blobs.

El solapamiento es exactamente el conjunto de localizadores. Eso es deliberado: los campos localizadores son lo que te permite unir una transacción con su recibo y con el bloque, y ambos objetos informan null para ellos hasta la inclusión. En una transacción pendiente, blockNumber, blockHash y transactionIndex son null en el objeto de transacción y no hay recibo en absoluto. Cualquier código que asuma que transactionIndex es un entero tiene una desreferencia nula latente en filas pendientes.

La asimetría entre pendiente y minado en la práctica

La diferencia operativa más útil es cómo se comportan las dos llamadas antes de la inclusión. eth_getTransactionByHash devuelve la transacción completa en cuanto un nodo la conoce, ya provenga del mempool o de un bloque minado. eth_getTransactionReceipt devuelve null hasta que la transacción es minada. Así, el par (transacción presente, recibo null) significa pendiente, y el recibo en sí es la única prueba positiva de inclusión.

Esto no es una peculiaridad del proveedor. Se deriva de la semántica de JSON-RPC 2.0, donde un resultado null es una respuesta de éxito válida, no un error. Un nodo que no conoce un hash responde {"result": null} sin objeto de error, lo que significa que el mismo null puede significar pendiente, desconocido, podado o servido por un nodo que está atrasado. El clasificador de abajo trata null como un hecho sobre la vista del nodo, nunca como un veredicto sobre la cadena.

Un caso límite merece atención: to es null para creación de contrato, mientras que contractAddress en el recibo solo se completa si el despliegue tuvo éxito. Una reversión durante el constructor produce un recibo con status: 0x0 y contractAddress: null, por lo que ningún campo por sí solo te dice si existe una dirección de despliegue.

Un clasificador ejecutable para seis estados de hash

El script de abajo resuelve un hash contra un endpoint e imprime un único estado clasificado. Evita deliberadamente los arrays por lotes de JSON-RPC, ya que algunos proveedores los rechazan o limitan su tasa, y emite las dos consultas de forma concurrente para que la comprobación de pendiente cueste un viaje de ida y vuelta en lugar de dos. Ejecútalo contra tu propio endpoint con un hash real.

La comparación de nonces es lo que separa pending de replaced_or_dropped. Si el nonce pendiente del remitente ha avanzado más allá del nonce de esta transacción, otra transacción ocupó esa ranura y esta nunca será minada. La comprobación no es perfecta: un nodo con un mempool poco profundo puede informar un nonce pendiente que aún no ha avanzado, y tras una transacción descartada el nonce pendiente se estabiliza en lugar de mantenerse adelantado. Trata el resultado como una señal fuerte y confírmalo con un segundo endpoint antes de actuar en consecuencia.

  • pending — transacción conocida, recibo null, el nonce pendiente del remitente aún no ha superado el nonce de la transacción.
  • replaced_or_dropped — transacción conocida, recibo null, el nonce pendiente del remitente ya superó el nonce de la transacción.
  • mined_success / mined_reverted — recibo presente con estado 0x1 / 0x0.
  • not_found — ambas llamadas devuelven null.
  • inconsistent_view — las dos llamadas no concuerdan, por ejemplo un recibo sin objeto de transacción.
// classify-tx.mjs — Node.js 18+
// Usage: node classify-tx.mjs <RPC_URL> <TX_HASH>
// Classifies a transaction hash into: not_found | pending | replaced_or_dropped |
// mined_success | mined_reverted | inconsistent_view
const [rpcUrl, txHash] = process.argv.slice(2);
if (!rpcUrl || !txHash) {
  console.error('Usage: node classify-tx.mjs <RPC_URL> <TX_HASH>');
  process.exit(1);
}

// Single JSON-RPC call. Batch arrays are not supported by every provider,
// so the classifier stays on plain single requests and runs them in parallel.
async function rpc(method, params) {
  const res = await fetch(rpcUrl, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params })
  });
  const json = await res.json();
  if (json.error) throw new Error(`${method}: ${json.error.code} ${json.error.message}`);
  return json.result;
}

const toInt = (hex) => (hex === null || hex === undefined ? null : Number(BigInt(hex)));

const [tx, receipt] = await Promise.all([
  rpc('eth_getTransactionByHash', [txHash]),
  rpc('eth_getTransactionReceipt', [txHash])
]);

if (!tx && !receipt) {
  console.log(JSON.stringify({ state: 'not_found', hash: txHash }));
  process.exit(0);
}

if (tx && receipt) {
  const state = receipt.status === '0x1' ? 'mined_success'
    : receipt.status === '0x0' ? 'mined_reverted'
    : 'mined_unknown_status';
  console.log(JSON.stringify({
    state, hash: txHash, blockNumber: toInt(receipt.blockNumber),
    blockHash: receipt.blockHash, transactionIndex: toInt(receipt.transactionIndex),
    gasUsed: toInt(receipt.gasUsed), effectiveGasPrice: toInt(receipt.effectiveGasPrice),
    logs: (receipt.logs || []).length, contractAddress: receipt.contractAddress
  }));
  process.exit(0);
}

if (tx && !receipt) {
  // Transaction is known but not yet mined: blockNumber/blockHash/transactionIndex are null.
  if (tx.blockNumber !== null) {
    console.log(JSON.stringify({ state: 'inconsistent_view', hash: txHash,
      detail: 'transaction has a blockNumber but no receipt yet' }));
    process.exit(0);
  }
  // Compare the account's pending nonce with the transaction nonce. If the account
  // has moved past this nonce, the slot was filled by another transaction.
  const pendingNonce = toInt(await rpc('eth_getTransactionCount', [tx.from, 'pending']));
  const txNonce = toInt(tx.nonce);
  const state = pendingNonce !== null && pendingNonce > txNonce ? 'replaced_or_dropped' : 'pending';
  console.log(JSON.stringify({ state, hash: txHash, from: tx.from, nonce: txNonce, pendingNonce }));
  process.exit(0);
}

console.log(JSON.stringify({ state: 'inconsistent_view', hash: txHash,
  detail: 'receipt present without transaction object' }));

Ejecuciones medidas contra un endpoint público de Ethereum

El clasificador se probó el 2026-10-18 contra un endpoint público de Ethereum mainnet. Un hash sintácticamente válido que nunca se difundió devolvió not_found con ambos resultados null. Una transacción real tomada del bloque 26.218.014 devolvió mined_success con blockNumber 26218014, transactionIndex 3, gasUsed 21000, effectiveGasPrice 2036108547 y cero logs, coincidiendo con una transferencia simple de ETH. Ambas ejecuciones terminaron en menos de un segundo con una conexión ya establecida.

El mismo script contra un endpoint público de Base falló en lugar de informar mal: la llamada al recibo devolvió el error JSON-RPC -32602 con el mensaje de que las solicitudes de archivo requieren un token personal, mientras que la llamada a la transacción tuvo éxito. Esa es exactamente la ambigüedad para la que existe la rama de error. Un cliente ingenuo que trata un null o un error como pendiente habría informado un estado que nunca se observó; el script mismo lanza una excepción y sale con código distinto de cero, como muestra literalmente la transcripción de abajo.

La ejecución sin procesar de abajo no está editada salvo por el argumento del hash. Trátala como evidencia de las formas de las llamadas, no como una afirmación de rendimiento sobre ningún proveedor.

$ node classify-tx.mjs https://ethereum-rpc.publicnode.com 0x5c504ed432cb51138bcf09aa5e8a410dd4a1e204ef84bfed1be16dfba1b22060
{"state":"not_found","hash":"0x5c504ed432cb51138bcf09aa5e8a410dd4a1e204ef84bfed1be16dfba1b22060"}

$ node classify-tx.mjs https://ethereum-rpc.publicnode.com 0x9d07584175e77cc5c7ba7249a6b5322ff171b71208ec4966975f5d7c5483a97c
{"state":"mined_success","hash":"0x9d07584175e77cc5c7ba7249a6b5322ff171b71208ec4966975f5d7c5483a97c","blockNumber":26218014,"blockHash":"0x52e4f099f235ce78bc66bf51a5a98d8c7a7ec53bbe274bbf41385799948aba22","transactionIndex":3,"gasUsed":21000,"effectiveGasPrice":2036108547,"logs":0,"contractAddress":null}

$ node classify-tx.mjs https://base-rpc.publicnode.com <base-tx-hash>
Error: eth_getTransactionReceipt: -32602 Archive requests require a personal token.
    at rpc (classify-tx.mjs:20:25) ... exit code 1

Clasificar resultados null, errores y carreras de reorganización

Las tres transcripciones difieren en tipo, y esa diferencia es la lección de diseño. Un resultado null es un éxito válido de JSON-RPC que dice algo sobre la vista del nodo. Un objeto de error JSON-RPC dice que la solicitud fue rechazada, normalmente por una restricción de capacidad o acceso. Un fallo de transporte no dice nada sobre la cadena. Mapéalos a tres resultados diferentes en tu aplicación y nunca dejes que un error o un timeout caiga en el cubo de pendiente.

Los arrays por lotes merecen el mismo cuidado. Si envías varias consultas en un array, un proveedor puede devolver menos resultados de los que enviaste o devolver un objeto de error en la ranura de una solicitud. Indexa los resultados por id en lugar de por posición antes de unirlos a los hashes, y trata un id faltante como un error solo para ese hash.

La cuarta entrada a la clasificación es el tiempo. Un hash puede pasar por pending, luego mined_success, luego una reorganización y luego pending de nuevo. El clasificador responde qué es verdad ahora, así que persiste el estado con la altura de bloque en la que se observó y vuelve a ejecutar la clasificación durante tu barrido de confirmación en lugar de asumir un progreso monótono.

Seleccionar el endpoint es una decisión separada de clasificar el resultado: un nodo que responde de forma consistente, expone los espacios de nombres que necesitas y no limita la tasa de tu barrido vale más que uno con latencia ocasionalmente menor. La guía de nodos RPC de Ethereum en RPC Assistant cubre esa elección.

Desambiguar un hash no encontrado

Cuando ambas llamadas devuelven null, la respuesta honesta es que este nodo no puede ver la transacción. Resuélvelo en cuatro pasos antes de concluir nada. Primero, revisa el hash en sí: 32 bytes, prefijo 0x, 66 caracteres, y sin espacios en blanco ni un medio truncado por una copia de la interfaz. Segundo, compara el eth_blockNumber del nodo con una segunda fuente; un nodo que está horas atrasado no tendrá una transacción reciente aunque la red sí. Tercero, pregunta a un segundo endpoint independiente. Un proveedor que poda o no indexa el rango que necesitas puede devolver null para una transacción que otros nodos tienen.

Cuarto, para una transacción que crees que acabas de enviar, mira el eth_getTransactionCount del remitente con la etiqueta pending y la etiqueta latest. Si pending es igual al nonce que asignaste, la transacción no ha sido aceptada en ningún pool que este nodo pueda ver. Si pending es mayor que tu nonce, la ranura ya fue consumida por otra cosa. Si latest es igual a tu nonce y pending no se ha movido, la cuenta no ha avanzado en absoluto.

Solo después de esas comprobaciones tiene sentido una conclusión, e incluso entonces la afirmación correcta está acotada: no encontrada en este endpoint a esta altura, no ausente de la red. Si necesitas la vista del mempool, txpool_content en un nodo con el espacio de nombres habilitado responde directamente, como se cubre en leer el mempool de Ethereum con el espacio de nombres txpool.

Logs del recibo, logsBloom y por qué no deberías filtrar manualmente

El array logs de un recibo contiene los registros de eventos emitidos durante la ejecución, cada uno con address, topics y data. logsBloom es un resumen probabilístico de 256 bytes de las direcciones y temas de ese recibo, y existe para que un nodo pueda omitir rápidamente los recibos que no pueden coincidir con un filtro. La cabecera del bloque tiene su propio logsBloom, y el bloom de los recibos del bloque es contra lo que se prueba una solicitud eth_getLogs antes de abrir cualquier recibo.

La regla práctica es nunca implementar pruebas de bloom en el código de la aplicación. Los blooms tienen falsos positivos por diseño, por lo que una coincidencia positiva no prueba que un evento esté presente, y aún tendrías que escanear los recibos. Si necesitas eventos, pídelos con eth_getLogs, cuya semántica de filtrado se cubre en filtrado de eventos y temas con eth_getLogs. Usa logs.length y logsBloom solo como metadatos baratos en un recibo que ya obtuviste, por ejemplo para omitir la obtención de cuerpos de eventos de transacciones que no emitieron nada.

Una trampa relacionada es asumir que existen logs para transacciones revertidas. No existen: el recibo de una transacción revertida tiene status: 0x0 y un array logs vacío, porque los cambios de estado y los eventos se revirtieron juntos. Un indexador que basa su tabla de eventos solo en el hash de transacción y nunca comprueba el estado perderá silenciosamente la distinción entre no eventos y eventos revertidos.

Comportamiento ante reorganizaciones y reconciliación basada en blockHash

Un recibo es tan duradero como el bloque que lo produjo. Si ese bloque queda huérfano, la transacción puede volver al mempool y re-incluirse en un bloque diferente, o puede desaparecer por completo. En esa ventana eth_getTransactionReceipt puede pasar de un recibo poblado de vuelta a null, y luego a un recibo con un blockHash y blockNumber diferentes. Cualquier reconciliación que almacene solo transactionHash y status tratará estos como la misma fila y nunca lo notará.

Almacena blockHash junto con el recibo y vuelve a verificarlo durante los barridos de confirmación, especialmente para bloques dentro de tu profundidad de confirmación. Trata un blockHash cambiado para una transacción conocida como una señal de reorganización en lugar de una actualización, y revierte el estado derivado antes de aplicar el nuevo recibo. La profundidad a la que dejas de comprobar es una decisión de producto, y detección de reorganizaciones de bloques en Ethereum con profundidad RPC cubre cómo medirla.

Ten en cuenta que el propio objeto de transacción lleva los mismos campos localizadores, así que la misma regla se aplica si estás reconciliando desde objetos de transacción: el blockHash en la transacción es una promesa de que debería existir un recibo para ese bloque. Si no existe, estás viendo una lectura a mitad de reorganización o un nodo inconsistente.

Consultas por hash frente a recuperación masiva a nivel de bloque

La consulta por hash es la herramienta adecuada cuando ya tienes un hash y quieres una respuesta: un usuario lo envió, un webhook se disparó o una billetera está sondeando. Cuesta una solicitud por hash y escala linealmente, lo que se convierte en un problema de límite de tasa cuando estás reconciliando miles de hashes tras una interrupción.

Cuando estás procesando un bloque, invierte la dirección. Obtén el bloque una vez, luego obtén todos sus recibos en una sola llamada eth_getBlockReceipts y únelos a la lista de transacciones por transactionIndex o hash. Eso reemplaza N llamadas por hash con una o dos llamadas por bloque y garantiza una instantánea consistente del mismo bloque, algo que las llamadas por hash no pueden hacer si una reorganización ocurre entre ellas. La mecánica, incluido el respaldo para nodos que no implementan el método, está en eth_getBlockReceipts para recibos masivos.

Un enfoque híbrido funciona bien en la práctica: recuperación masiva para indexación hacia adelante, consulta por hash para comprobaciones iniciadas por el usuario, y el clasificador de arriba como el único lugar que convierte el par de resultados sin procesar en un estado sobre el que tu aplicación pueda actuar.

Tabla de resultados para completar contra tu propio endpoint

El comportamiento del proveedor varía en los detalles que más importan para la clasificación, así que mide en lugar de asumir. Ejecuta las cuatro pruebas de abajo contra cada endpoint del que dependas, contra el mismo conjunto de fixtures, y registra las respuestas. Las celdas vacías son no medidas, no falsas.

  • Transacción pendiente: ¿eth_getTransactionByHash devuelve el objeto con blockNumber: null, y eth_getTransactionReceipt devuelve null? Registra el estado HTTP y el result sin procesar.
  • Hash desconocido: ¿la respuesta es {"result": null} sin error, o un objeto de error JSON-RPC? Registra el código.
  • Transacción antigua más allá de la retención del nodo: ¿resultado null o un error que menciona poda o archivo? Registra ambos.
  • Ventana de reorganización: tras una reorganización, ¿un recibo de un bloque huérfano devuelve null, el recibo antiguo o un error? Registra el comportamiento observado y la profundidad de bloque en la que cambió.

Limitaciones, solución de problemas y próximos pasos

El clasificador es un punto de partida, no un servicio de transacciones completo. No maneja reorganizaciones por sí mismo, confía en un único proveedor, y su heurística de nonces puede informar erróneamente replaced_or_dropped cuando la vista del mempool del proveedor está desactualizada o cuando el remitente usa varios nodos. Tampoco te dice por qué se revirtió una transacción: el recibo te da status: 0x0 y el gas consumido, y diagnosticar la causa requiere un trace, que es un espacio de nombres diferente con su propio perfil de coste.

Fallos comunes y su significado: un -32601 Method not found en cualquiera de las llamadas significa que el endpoint no expone ese espacio de nombres, no que la transacción falte. Un -32602 en un recibo con frecuencia significa que la solicitud necesita acceso de archivo, como mostró la ejecución contra Base de arriba. Un timeout que termina en una excepción debe clasificarse como error, nunca mapearse a pending. Y un recibo que aparece con un blockHash inesperado es un evento de reorganización, no una actualización de datos.

Como siguiente paso, conecta el clasificador al endpoint que realmente usas, ejecuta la tabla de resultados y solo entonces decide si necesitas recuperación masiva a nivel de bloque. eth_getTransactionReceipt devuelve null cubre el bucle de sondeo que debería situarse encima, y la página de la red Ethereum junto con precios de RPC cubren el lado de la infraestructura si prefieres ejecutar contra un endpoint gestionado en lugar de uno público. Hay más guías como esta en el centro de aprendizaje de OnFinality, y el servicio de API cubre el acceso gestionado para equipos que necesitan un comportamiento consistente entre cadenas.

Nunca te preocupes por la infraestructura nuevamente

OnFinality elimina la carga pesada de DevOps para que puedas construir de forma más inteligente y rápida.

Comenzar