Ethereum: de historia a labs

Artículo 47 de 48

98%

completado al llegar aquí

Sistemas distribuidosNota de investigación13 min de lectura

Arquitectura production-ready para dApps

Arquitectura para depósitos y retiros sobre un custodio: confirmación por finalized, reorgs, idempotencia por nonce, conciliación y respuesta a incidentes.

Ruta Ethereum: 0/48

Un cliente pide retirar 2 ETH. Tu servicio llama a la API del proveedor de custodia para que firme y difunda la transacción. La llamada se corta por timeout. ¿Reintentas? Si el primer pedido sí llegó, acabas de pagar dos veces. Si no reintentas y no llegó, el cliente espera para siempre.

Ese es el tipo de problema que separa una demo de un sistema en producción. Este capítulo arma la arquitectura de un servicio de depósitos y retiros sobre Ethereum cuando las llaves las guarda un custodio: un proveedor que conserva las claves privadas, firma transacciones según políticas y las envía a la red. El custodio resuelve la firma. La política de confirmación, la contabilidad, la idempotencia y la conciliación siguen siendo tuyas.

Flujo visual

Las piezas y quién responde por cada una

Separar lectura, escritura, contabilidad y firma permite que cada pieza falle sin arrastrar a las demás.
  1. Lectura de la cadenaDos fuentes RPC independientes y un indexador con cursor y etiquetas.
  2. Libro contable internoSaldos pendientes y disponibles, con restricciones únicas en la base de datos.
  3. Motor de políticasDecide en qué etiqueta se acredita y qué retiros se aprueban.
  4. CustodioFirma y difunde; tu sistema guarda la intención antes de llamarlo.
  5. Conciliación y alertasCompara libro, custodio y cadena en finalized; abre incidentes con procedimiento.

Brújula de lectura

Actor, evidencia y estado

Antes de avanzar, ubica quién actúa, qué prueba queda y qué parte del sistema cambia.

Actor

Tu servicio y el custodio

El custodio firma; tu servicio decide cuándo pedir una firma y cuándo dar algo por hecho.

Evidencia

Recibo en finalized

Un recibo con status 1 en un bloque finalizado es la prueba que cierra un depósito o un retiro.

Estado

Libro contable

Saldos pendientes y disponibles, cada movimiento atado a un bloque o a una intención única.

Preguntas guía

Usa estas preguntas para revisar un diseño real de tu equipo, no solo este capítulo:

  • ¿En qué etiqueta de bloque pasa un depósito de pendiente a disponible, y quién aprobó esa decisión?
  • Si el bloque de un depósito sale de la cadena canónica, ¿qué fila de tu base de datos cambia?
  • ¿Qué identificador permite reintentar un retiro sin riesgo de pagarlo dos veces?
  • ¿Cómo detectas depósitos de ETH nativo, que hoy no emiten logs en mainnet?
  • ¿Con qué frecuencia comparas tu libro con el custodio y con la cadena, y qué haces si no cuadran?

Conexiones dentro de la serie

Prerequisitos internos recomendados:

Estado de un depósito: de latest a finalized

Un depósito no es un evento, es una máquina de estados. Proponemos cuatro estados. Detectado: viste la transferencia en un bloque latest. Pendiente: lo registras en el libro como saldo no disponible y se lo muestras al cliente. Disponible: el bloque alcanzó finalized y el saldo se puede retirar. Descartado: el bloque salió de la cadena canónica antes de finalizar.

La etiqueta finalized es la que la especificación describe como criptoeconómicamente segura: no se revierte salvo intervención manual coordinada por la comunidad. latest puede salir de la cadena incluso en condiciones normales. Revertir un bloque finalizado exigiría que un atacante perdiera al menos un tercio del ETH en stake.

De ahí sale la regla central para este tipo de servicio: un saldo solo es retirable si el depósito que lo origina está en un bloque finalizado. Mostrarlo antes como pendiente es buena experiencia de usuario. Permitir retirarlo antes convierte cada reorg en una pérdida.

La transición a Descartado no es teórica. Si el indexador retrocede por una reorg, todos los depósitos atados a los blockHash revertidos vuelven atrás. Si la misma transacción reaparece en la rama nueva, entra como un depósito nuevo, con su propio blockHash y logIndex, y recorre la máquina desde el principio.

Figura 1

Máquinas de estado de un depósito y de un retiro

Fuentes:

[1][4]
Máquinas de estado de un depósito y de un retiro
Cómo leer esta figura

Carril superior, depósito: detectado en latest, pendiente con saldo no disponible, disponible cuando el bloque es finalized. Desde detectado y pendiente sale una flecha roja a descartado, rotulada reorg antes de finalized. Carril inferior, retiro: solicitado con clave de idempotencia y saldo reservado, enviado al custodio, difundido con remitente y nonce, incluido con recibo, y completado si el status es 1 y el bloque es finalized. Desde incluido sale una flecha a fallido si el status es 0, con el gas pagado; desde difundido sale una flecha de reemplazo con el mismo nonce y comisión mayor que vuelve a difundido; una reorg devuelve incluido a difundido.

Visual original de TrautsLab. Arriba, un depósito pasa de detectado a pendiente y solo es disponible en finalized; una reorg lo descarta. Abajo, un retiro se registra con una clave de idempotencia antes de llamar al custodio, se sigue por remitente y nonce, y se cierra con un recibo de status 1 en finalized.

Traza de ejecución de un depósito: qué mirar en la cadena

Para tokens ERC-20, el depósito se detecta por el evento Transfer(address indexed _from, address indexed _to, uint256 _value), que el estándar exige emitir en cada transferencia, incluso de valor cero. Cualquier contrato puede emitir un evento con esa firma. Por eso el filtro debe incluir siempre la dirección del contrato del token que aceptas, no solo el topic del evento.

Un contrato de token puede tener lógica propia, por ejemplo una comisión al transferir, que haga que el evento no refleje lo que realmente recibió la dirección. Para los tokens que aceptes, conviene verificar al menos en la conciliación que el saldo real de la dirección de depósito, leído con balanceOf en finalized, coincide con la suma de lo acreditado.

El ETH nativo es otro caso. En el EVM, los logs solo se crean con los opcodes LOG0 a LOG4; una transferencia de ETH no ejecuta ninguno. Una transferencia directa desde una cuenta externa se ve en la lista de transacciones del bloque. Una enviada por un contrato, como una smart wallet, no queda registrada como log en ninguna parte; para verla hace falta rastrear la ejecución o comparar saldos.

Eso puede cambiar. EIP-7708 propone que toda transferencia de ETH con valor distinto de cero hacia otra cuenta emita un log idéntico a un LOG3, con el mismo topic de Transfer y la dirección emisora 0xfffffffffffffffffffffffffffffffffffffffe. Está programada para la actualización Glamsterdam, que ya se activó en Sepolia y Hoodi; al momento de escribir, la fecha de mainnet aún no figura. Cuando llegue, un filtro por topic sin dirección empezará a recibir esos logs: otra razón para filtrar siempre por contrato.

Invariante de un retiro: una intención, un nonce

La pregunta inicial, reintentar o no, se resuelve con una clave de idempotencia: un identificador único por intención de retiro que tu servicio genera y guarda antes de llamar al custodio. Si la llamada falla o se corta, consultas el estado de esa intención en lugar de crear otra. Si tu custodio acepta un identificador externo por solicitud, pásale esa misma clave; si no, guarda el identificador que te devuelva junto a la tuya.

En la cadena, la unidad de idempotencia es el nonce: un contador que indica el número de transacción de la cuenta emisora. Como cada nonce de una cuenta se usa una sola vez, dos transacciones con el mismo remitente y nonce no pueden quedar ambas en la cadena. Si un retiro queda pendiente por comisión baja, la respuesta correcta es reemplazarlo: misma cuenta, mismo nonce, comisión mayor. Enviar uno nuevo con otro nonce puede terminar en dos pagos.

Por eso el retiro se sigue por (remitente, nonce) y no solo por hash: un reemplazo cambia el hash. El retiro termina en uno de dos estados finales. Completado: recibo con status 1 en un bloque finalizado. Fallido: recibo con status 0; la transacción se incluyó, pagó gas y no movió los fondos, así que la reserva vuelve al saldo del cliente y el caso pasa a revisión. Una reorg antes de finalized devuelve el retiro a "difundido".

El esquema siguiente es ilustrativo (PostgreSQL). Su objetivo es que la base de datos, no la memoria del proceso, garantice que nada se aplica dos veces.

sql
-- Ilustrativo: restricciones que vuelven idempotentes depósitos y retiros.
CREATE TABLE deposits (
  chain_id     bigint      NOT NULL,
  block_hash   bytea       NOT NULL,
  log_index    integer     NOT NULL,
  token        bytea       NOT NULL,          -- contrato del token aceptado
  to_address   bytea       NOT NULL,
  amount       numeric(78) NOT NULL,
  status       text        NOT NULL CHECK (status IN ('pending', 'available', 'discarded')),
  PRIMARY KEY (chain_id, block_hash, log_index)
);
 
CREATE TABLE withdrawals (
  idempotency_key uuid        PRIMARY KEY,     -- se escribe antes de llamar al custodio
  from_address    bytea,
  nonce           numeric(20),
  tx_hash         bytea,                       -- cambia si hay reemplazo
  status          text        NOT NULL CHECK (status IN
                    ('requested', 'submitted', 'broadcast', 'included', 'completed', 'failed')),
  UNIQUE (from_address, nonce)
);

Riesgo operativo: permisos, pausa y un incidente que cruza capas

Si tu arquitectura incluye contratos propios, como un contrato de depósitos o un vault, sus permisos de administración son parte de la superficie de producción. Una clave de administrador en una sola laptop contradice todo lo anterior. El capítulo de access control cubre multisig y timelocks; aquí importa un mecanismo más: la pausa de emergencia.

OpenZeppelin ofrece el módulo Pausable, que permite implementar una parada de emergencia activable por una cuenta autorizada. Solo protege las funciones donde aplicas los modificadores whenNotPaused o whenPaused; heredar el módulo no pausa nada por sí solo. Decide de antemano qué funciones se pausan, quién puede pausar y quién puede reanudar.

Ahora, un incidente típico. El contrato emitió el evento correcto, el recibo tiene status 1, pero la interfaz muestra el saldo anterior. El problema puede estar en la caché, en el indexador, en la política de confirmación, en el proveedor de RPC o en una consulta con otra clave. Reintentar la transacción sería la peor respuesta: duplicaría una intención que la cadena ya ejecutó. El procedimiento empieza al revés: tomar el hash, leer el recibo en dos fuentes, ubicar el bloque respecto de finalized, y recién entonces mirar el índice.

Revisión de arquitectura

Antes de habilitar retiros en producción

0 de 9 revisados

Cada punto debe tener un responsable y una prueba que lo demuestre.

Quién decide qué es verdad

Quédate con una frase: el custodio firma, pero tu sistema decide qué es verdad. Un depósito es disponible cuando su bloque es finalizado; un retiro es único por su clave de idempotencia y por su nonce; y el libro contable se demuestra correcto conciliándolo con la cadena. La ruta práctica sigue desde aquí con llaves, firmas y custodia.

Referencias

Bibliografía académica

Formato IEEENumerado para ingeniería, sistemas y protocolos.
  1. [1]

    Ethereum Execution APIs specification, schema BlockTag.

    specID: ETH-EXECUTION-APISAbrir fuente (abre en otra pestaña)

    Definiciones de latest, safe y finalized; recibos con status.

  2. [2]

    ethereum.org, "Proof-of-stake (PoS)."

    official docsID: ETH-POS-DOCSAbrir fuente (abre en otra pestaña)

    Finalidad y costo de revertir un bloque finalizado.

  3. [3]

    F. Vogelsteller and V. Buterin, "EIP-20: Token Standard," Ethereum Improvement Proposals, 2015.

    Evento Transfer obligatorio en cada transferencia.

  4. [4]

    ethereum.org, "Transactions."

    official docsID: ETH-TRANSACTIONS-DOCSAbrir fuente (abre en otra pestaña)

    Nonce como contador secuencial de transacciones de la cuenta.

  5. [5]

    ethereum.org, "Opcodes for the EVM."

    official docsID: ETH-EVM-OPCODESAbrir fuente (abre en otra pestaña)

    LOG0 a LOG4 como únicos opcodes que crean logs.

  6. [6]

    V. Buterin et al., "EIP-7708: ETH transfers emit a log," Ethereum Improvement Proposals, 2024.

    Log de Transfer emitido por SYSTEM_ADDRESS para transferencias de ETH.

  7. [7]

    T. Beiko et al., "EIP-7773: Hardfork Meta - Glamsterdam," Ethereum Improvement Proposals, 2024.

    EIPs programadas y fechas de activación de Glamsterdam.

  8. [8]

    OpenZeppelin, "Utilities: Pausable," OpenZeppelin Contracts 5.x documentation.

    official docsID: OZ-PAUSABLEAbrir fuente (abre en otra pestaña)

    Parada de emergencia y modificadores whenNotPaused y whenPaused.

Fuentes primarias, papers seminales y especificaciones técnicas citadas en esta investigación.

Rutas vecinas

Si este tema te abrió hambre, sigue por aquí.

Series y labs que comparten área o dependen de esta ruta. Pocas opciones, para no convertir la curiosidad en menú infinito.

Sigue leyendo

Ideas conectadas con este artículo

Una ruta corta para continuar sin abrir veinte pestañas y perder el hilo.

Los comentarios se cargan al final de la lectura para no meter peso antes de tiempo.