Traducido del artículo original en inglés. Leer en inglés

La Parte 1 de esta "serie ledger" terminó en un mundo más simple, donde cada movimiento se liquidaba en cuanto se registraba. La cosa ahora es que el dinero real no se comporta así. Tobi, por ejemplo, es uno de tus usuarios, tiene 100 USDC en tu app, otros 50 USDC están en un depósito que no se ha confirmado, y un retiro de 30 USDC va camino a una wallet externa. ¿Qué debería mostrar su saldo en ese momento, y cuánto puede gastar realmente?
Según lo que cuente la app, puede mostrar 100, 120 o 150, y la respuesta honesta a cuánto puede gastar es 70. Ese intervalo entre lo que está registrado, lo que se espera y lo que se puede gastar en ese momento es de lo que trata esta parte II.
Aquí no cambia nada del modelo de partida doble. Cada movimiento sigue cuadrando, las entradas se siguen escribiendo una vez y nunca se tocan. Lo que cambia es que ahora el dinero pasa tiempo en distintos estados. Un pago con tarjeta tarda días en liquidarse, un depósito cripto queda sin confirmar hasta que las confirmaciones de bloque llegan a cierto número definido, un pago saliente está en tránsito con un proveedor que podría fallar, nadie lo sabe.
El saldo único de la Parte 1 solo puede describir el dinero que ya llegó. No tiene forma de describir dinero que aún no está liquidado o que está en tránsito/en vuelo, así que los sistemas que guardan un solo número terminan o escondiendo dinero que es del usuario o entregando dinero que todavía no está en custodia. El sistema también tiene que registrar el dinero en estado pendiente.
Un ledger que modela el dinero en tránsito responde con tres números, y todos siguen siendo simples sumas, sobre las entradas y sobre los depósitos que aún esperan contabilizarse.
Contabilizado: Es la suma de todos los movimientos liquidados en todas las cuentas de Tobi, su wallet más cualquier cuenta que guarde dinero reservado para sus retiros, los hechos finales. El saldo contabilizado de Tobi es 100 USDC.
Pendiente: Este es el que más confunde casi siempre, porque "saldo pendiente" se usa para decir dos cosas distintas. Una es solo la actividad pendiente, +50 USDC entrando y -30 USDC saliendo, 80 USDC en tránsito, un neto de +20. La otra es lo contabilizado más esa actividad, 100 + 50 - 30 = 120 USDC, en lo que probablemente quedará el saldo. Son dos preguntas distintas, nunca las mezcles en un mismo endpoint.
Disponible: Es lo que Tobi puede gastar realmente ahora mismo. El valor contabilizado menos los valores salientes, 100 - 30 = 70 USDC. El dinero entrante que no se ha liquidado no está disponible, los 50 USDC que siguen sin confirmar on chain no existen para gastar en esta wallet hasta que se liquiden.
Aquí lo disponible es un solo número porque esta wallet aplica una sola política. Muchos sistemas lo separan, lo que Tobi puede gastar y lo que puede retirar terminan siendo dos números distintos. Un depósito con tarjeta puede gastarse dentro de la app de inmediato y quedar bloqueado para pagos durante días, porque si no alguien deposita con una tarjeta robada, lo saca como cripto en ese mismo momento, y el que se queda con el contracargo eres tú. Algunas empresas van en la dirección contraria y conceden crédito temporal, dinero que se pone a disposición antes de liquidarse según ciertos parámetros, así funcionan los depósitos instantáneos, y en EE. UU., la Regulation CC incluso fija los plazos que los bancos tienen que seguir para que los fondos depositados estén disponibles. Además, los saldos de comerciantes llevan colchones de efectivo y retenciones por riesgo encima de todo eso.
Pero el crédito temporal tiene un precio. Le acreditas a Tobi en el momento en que aparece el depósito, DR deposits_in_transit 50 / CR tobi:wallet 50, él lo gasta, el depósito falla por cualquier motivo raro, y ahora su wallet está en negativo. Así que el ledger tiene que permitir saldos negativos en cuentas específicas por diseño, y registrarlo como una cuenta por cobrar, dinero que ahora Tobi te debe. Y de ahí salen tus pérdidas por fraude y tu riesgo de crédito, así que decides de antemano a qué tipos de cuenta se les permite quedar en negativo. Algunos sistemas de ledger, como el que construimos nosotros por ejemplo, tienen un flag exactamente para esto, debits_must_not_exceed_credits, lo activas en una cuenta y cualquier transferencia que empuje sus débitos por encima de sus créditos se rechaza.
Ahora, toma los movimientos de Tobi uno por uno. Igual que en la Parte 1, cada uno se sigue registrando como una transacción que cuadra.
Su depósito de 50 USDC se detecta con una confirmación. Las monedas están on chain pero no son finales, como sabes, la red todavía puede descartar el bloque que las contiene y meter uno distinto en su lugar, lo que se llama un "reorg", este es dinero que puedes ver pero que todavía no tienes. En este punto no se contabiliza nada en el ledger. En lugar de una contabilización, el depósito tiene su propio historial de solo adición, cada vez que algo cambia en él, una nueva confirmación, un nuevo estado, eso es una fila nueva, y el saldo pendiente de Tobi lee los 50 de ahí. Ninguna entrada toca la custodia ni su wallet. La custodia es lo que se concilia contra el saldo finalizado de la cadena, que es exactamente lo que revisan los auditores y los sistemas de prueba de reservas, como es estándar, claro.
Saldos ahora: contabilizado 100, actividad pendiente +50, disponible 100.
El depósito solo se contabiliza cuando la cadena lo da por final, y cuando el propio registro de tu proveedor de custodia sobre ese depósito exacto coincide. Compruébalo depósito por depósito. Si solo comparas contra el saldo total del proveedor, otro depósito que llegue al mismo tiempo puede hacer que el total parezca correcto mientras estos 50 siguen faltando. Entonces es una transacción:
DR custody 50
CR tobi:wallet 50Ahora, en el mismo commit el historial del depósito recibe su fila de acreditado y los 50 pasan de pendiente a disponible. Saldos ahora: contabilizado 150, actividad pendiente 0, disponible 150. No se editó nada, el ledger simplemente se acaba de enterar de este depósito, cuando ya era final.
Qué significa "final" depende de la cadena. Doce confirmaciones es cosa de la prueba de trabajo. En el Ethereum de prueba de participación la señal real es el checkpoint finalizado, de dos a tres épocas, ~15 minutos en un día normal. Solana tiene sus propios niveles de commitment, y desde que su actualización Alpenglow entró en producción en octubre de 2026, confirmed y finalized significan lo mismo. En un rollup como Base u OP, que el secuenciador confirme tu transacción es solo el primer paso, la finalidad en L1 llega ~20 minutos después. La finalidad termina siendo un umbral que fijas por cadena, y cada depósito debería registrar qué umbral lo liquidó, para que el día que una cadena se porte mal sepas exactamente qué depósitos se acreditaron bajo qué regla. Y si el proveedor da un depósito por completado mientras sigue por debajo de tu umbral, lo retienes para revisión y no acreditas nada. Si usas un sistema como Fireblocks, simplemente lo configuras en tu dashboard.
Ahora, un reorg antes de la finalidad es el caso sencillo, y el depósito no ha fallado. La transacción normalmente vuelve a la mempool y cae otra vez en un bloque posterior, así que el depósito simplemente vuelve a estar sin confirmar y su conteo de confirmaciones empieza de cero. Si lo marcas como fallido en ese punto, pasa una de dos cosas cuando vuelva a caer, tu sistema lo ignora y Tobi nunca recibe el crédito mientras la custodia se queda con monedas que no le debes a nadie, o aparece como si fuera un depósito totalmente nuevo y a Tobi se le acredita dos veces. Un depósito solo falla cuando se puede demostrar que ya no existe, en Ethereum por ejemplo cuando otra transacción del mismo remitente usó ese nonce y esa es final, e incluso entonces no hay nada que revertir porque el ledger nunca lo vio.
Ese segundo caso es la razón por la que la clave de un depósito importa tanto. Es posible que una transacción lleve varias transferencias de USDC, así que la clave es la cadena, el hash de la transacción y la posición de la transferencia dentro de esa transacción. En Ethereum eso es su lugar en el recibo de la transacción. En este caso no recurras a logIndex, cuenta a lo largo de todo el bloque, así que cambia en el momento en que una transacción de un bloque descartado cae en otro distinto. Y la restricción única que asegura que cada depósito se acredite una sola vez va en la transacción del ledger que lo contabiliza. Si la pones en el historial, rechaza la segunda fila de confirmación, ya que cada fila ahí lleva la misma clave.
Volviendo a Tobi, sus 50 entraron. Ahora pide un retiro de 30 USDC. Antes de llamar a cualquier proveedor, el dinero se reserva:
DR tobi:wallet 30
CR tobi:withdrawals 30Saldos ahora: contabilizado 150, actividad pendiente -30, disponible 120. Lo contabilizado sigue siendo 150 porque se lee a través de todas las cuentas de Tobi, la wallet bajó a 120 y la cuenta de retiros recogió los 30. La reserva es una partida doble real, por eso la disponibilidad baja antes de que el retiro se contabilice, los 30 ya están comprometidos aunque todavía no hayan salido del edificio.
Quizá hayas notado que el depósito se quedó fuera del ledger hasta que fue final, mientras que el retiro entró en el ledger antes de que saliera nada. Todo se reduce a lo que cada uno le hace a lo disponible. La retención le quita a lo que Tobi puede gastar, así que tiene que confirmarse junto con la comprobación del saldo, o dos peticiones pueden gastar los mismos 30. El depósito pendiente todavía no añade nada que pueda gastar, así que puede esperar fuera hasta que sea final. El crédito provisional es la única excepción, por eso ahí el depósito se contabiliza de inmediato.
El proveedor entrega, y el pago saliente se completa:
DR tobi:withdrawals 30
CR custody 30Saldos ahora: contabilizado 120, actividad pendiente 0, disponible 120. La finalización solo libera la reserva, la custodia paga el riel y la wallet queda intacta. Si al contabilizar se volviera a descontar de lo disponible, Tobi vería cómo 150 se convierte en 90 mientras paga 30.
Ahora, mantuve ese retiro limpio a propósito, reservó 30 y liquidó 30. Los reales casi nunca lo hacen. El gas no se conoce hasta que la transacción se mina, los proveedores cobran sus propias comisiones, y el gas de una transferencia de USDC se paga en ETH, un activo distinto. Así que la cantidad que retienes y la que finalmente se liquida casi nunca coinciden, reservas un límite superior, los 30 más lo máximo que podría ser la comisión, digamos 2:
DR tobi:wallet 32
CR tobi:withdrawals 32Luego liquidas las cantidades reales y liberas el resto, en una sola transacción:
DR tobi:withdrawals 32
CR custody 30.00
CR revenue:fees 0.40
CR tobi:wallet 1.60Lo disponible bajó 32 mientras estaba en tránsito y termina en 119.60, y la cuenta de retiros sigue en cero neto. El gas que realmente cobró la red es un gasto tuyo, pagado con tu propio ETH en un movimiento aparte, DR expense:gas / CR treasury:eth, y cuadra por sí solo porque la comprobación de la Parte 1 se hace por activo. Los 0.40 ahora son ingresos tuyos, así que también se sacan de la custodia de clientes, DR treasury:usdc 0.40 / CR custody 0.40. La custodia la mantienes solo para dinero de clientes. Si pagas el gas desde ahí, cada retiro se come un poco del dinero de todos los demás, y el primer lugar donde se va a notar es tu cron de reservas, lo que tienes por activo frente a lo que les debes a los usuarios, saliendo corto.
Los rieles de tarjeta lo complican todavía más:
Esto puede ir de tres maneras, y solo dos de ellas son fallos.
Un error simple: el retiro pendiente se rechaza antes de contabilizarse, quizá porque la dirección no pasa el screening. La reserva se libera:
DR tobi:withdrawals 30
CR tobi:wallet 30Lo disponible vuelve a 150, lo contabilizado nunca se movió porque el dinero se quedó.
El caro: el pago saliente se contabilizó, la cadena lo confirmó, y días después el exchange al que fue lo devuelve. Esa devolución es un movimiento financiero nuevo en sí mismo, y on chain es solo otra transferencia entrante, así que sigue el mismo camino que cualquier depósito, su propio historial, tu umbral de finalidad, emparejada con el pago saliente exacto que está devolviendo, y solo entonces se contabiliza. Contabiliza también lo que realmente llegó, el exchange que lo devuelve puede quedarse con su propia comisión, así que salieron 30 y quizá te vuelvan 29.50:
DR custody 29.50
CR tobi:wallet 29.50El pago saliente original se queda en el historial, y la devolución también.
Y luego está la tercera manera, la que a la mayoría le cuesta pérdidas: el proveedor dio timeout y nadie sabe si el pago saliente llegó. No puedes tratar un estado desconocido como un fallo. Trátalo como fallido, libera los 30, que igual cae on chain, y así de fácil pagaste dos veces, ¡felicidades!
El estándar real es que un resultado desconocido se queda pendiente hasta que el proveedor responda.
Aunque algunos proveedores nunca responden, depende de cómo esté construido su sistema. Así que lo desconocido tampoco puede quedarse en pendiente para siempre, baja por una escalera, y cada escalón o lo resuelve con una transacción del ledger o se lo pasa al siguiente:
Y para siquiera saber que algo está atascado, una cuenta de compensación no puede quedarse en un solo número. Digamos que Tobi envía dos retiros de 30, uno se liquida y el otro nunca. tobi:withdrawals muestra 30, y como ambos eran de 30, nada en ese número te dice cuál está atascado. Así que cada entrada que liquida o libera algo apunta a la entrada exacta que cierra, eso es la conciliación de partidas abiertas, sacada directamente de las cuentas por cobrar, y salta una alerta con cualquier cosa que siga abierta pasado el SLA de tu riel.
Mirando atrás todo lo que hizo el dinero de Tobi arriba, nunca se actualizó ninguna entrada existente, ni tampoco una sola fila del historial del depósito. El estado avanzó porque cayeron transacciones nuevas encima de las viejas, y esa es la segunda mitad de este artículo.
Este tipo de sistemas están diseñados para recibir correcciones, una entrada con un cero de más, un movimiento registrado en la cuenta equivocada. La tentación a las 2:45 AM es un UPDATE. Los datos se arreglan con una txn de corrección, nunca con una actualización de fila. La corrección tiene que reflejar la original con los lados invertidos y apuntar a ella, con un motivo y un origen para que el registro se explique solo cuando alguien lo mire:
function reverse(original: Txn, reason: string, source: string): Txn {
return {
id: randomUUID(),
referenceId: original.id,
effectiveAt: original.effectiveAt,
reason,
source,
entries: original.entries.map((e) => ({
accountId: e.accountId,
side: e.side === "DR" ? "CR" : "DR",
amountMinor: e.amountMinor,
assetId: e.assetId,
})),
};
}Cada campo se copia por nombre a propósito, si haces spread de la entrada original con ...e, la corrección termina también con el id y los timestamps de la original. Las dos txn juntas suman cero neto, y el saldo final nunca esconde cómo ocurrió. Soporte puede reproducir el historial de Tobi, primero las filas propias del depósito y luego el ledger, y ver depósito, confirmación, reserva, liberación, en orden. Tu db respalda esto igual que lo hacía la regla de la Parte 1, ¡un UPDATE o un DELETE contra una tabla de entradas se rechaza! Sin pensarlo dos veces.
Una cosa que reverse() tal como está escrito no evita es revertir la misma transacción dos veces, ejecútalo dos veces y Tobi recupera su dinero dos veces. Las reversiones de una transacción nunca deben sumar más que la original. Un simple índice único puede frenar una segunda reversión completa, pero no puede sumar nada, así que una reversión completa más una parcial se cuelan igual. La comprobación real lee lo que ya se ha revertido y escribe la nueva reversión como una sola txn atómica, y ese es un problema de la Parte III, del que vamos a hablar.
La Parte 1 decía que el saldo guardado es una caché que tiene que coincidir con el log. Con tres saldos hay más en juego, porque la caché es lo que aprueba el gasto. Un saldo que se usa para aprobar un retiro tiene que reflejar las reservas y contabilizaciones confirmadas, y tiene que moverse en el mismo commit que escribe las entradas, si no una lectura entre medias ve dinero que ya está prometido.
La prueba de la Parte 1 sigue valiendo, borra cada número guardado y reproduce el ledger más el historial de depósitos, y obtienes de vuelta los mismos tres saldos, y la parte pendiente siempre se puede comprobar contra un escaneo nuevo de la cadena. Con los checkpoints de arriba, la reproducción empieza en el último checkpoint verificado en lugar de la primera entrada escrita, y eso es lo que mantiene esta prueba viable a escala. Y cuando un número guardado y uno reproducido no coinciden, un job de conciliación compara los dos y alerta sobre la desviación. Luego averiguas cuál de los dos miente. Si las entradas están bien y la caché está mal, reconstruyes la caché a partir de las entradas, ningún movimiento financiero nuevo pertenece a ese arreglo. Si las entradas en sí están mal, contabilizas una txn de corrección como la de arriba. En cualquier caso, investigas antes de volver a confiar en el número. Esa alerta no es negociable.
NOTA AL MARGEN: un saldo guardado es una suma, y las sumas necesitan espacio. USDC tiene 6 decimales en Ethereum y 7 en Stellar, ETH tiene 18. Un entero con signo de 64 bits llega como máximo a unos 9.22 × 10¹⁸, en wei eso es solo ~9.22 ETH, un depósito decente. El bigint de Postgres es exactamente ese entero de 64 bits, la misma palabra que el bigint de TypeScript de la Parte 1, con un límite muy distinto. Usa cantidades de 128 bits, que es justo lo que hacemos nosotros, lo que en Postgres significa numeric con escala 0, mantén bigint en lugar de number en TypeScript, y guarda la escala de cada activo junto con el activo, 30000000 no significa nada hasta que sabes que es USDC con 6 decimales.
Ahora, sobre cómo el commit en sí se mantiene seguro bajo mucha carga, los bloqueos, etc., eso es la Parte III.
Hay dos relojes en cada movimiento, cuándo pasó a ser efectivo y cuándo lo registró tu sistema. Una liquidación de tarjeta puede ser efectiva ayer y llegar hoy, el webhook de un proveedor puede llegar con horas de retraso. Si hoy reconstruyes el saldo de ayer, contiene movimientos de los que el sistema no sabía nada ayer. Ese número es lo que sabes ahora sobre ayer, no lo que sabías entonces.
Las correcciones y las devoluciones también tienen que elegir cada una su fecha efectiva. Una corrección de un error de registro lleva la fecha de creación de la original, porque la original nunca fue cierta, por eso reverse() de arriba copia original.effectiveAt. Una devolución lleva la de hoy, porque el pago saliente sí ocurrió y el dinero de verdad volvió después. Si te equivocas con estas dos, cada saldo a fecha de esos días sale muy mal, y te vuelve a costar.
Luego están los períodos cerrados. Una vez cerrado un período, con los extractos enviados, los informes presentados y los intereses devengados, una entrada tardía con fecha efectiva dentro de él ya no puede cambiar esos números. La contabilizas en el período abierto como un ajuste fuera de período con una fecha efectiva que apunta hacia atrás, los bancos normales llaman a esto fecha valor, desde cuándo cuenta, y fecha contable, cuándo se contabilizó. Y una entrada con fecha valor retroactiva significa que los intereses o las comisiones se calcularon sobre el saldo equivocado, así que el recálculo también entra como una nueva transacción de ajuste.
El modelo aquí es correcto, tenemos tres saldos, historial de solo adición, una caché que coincide. Ahora llegan dos retiros de 80 USDC cada uno con un milisegundo de diferencia. Ambos leen disponible 120, ambos pasan la comprobación, ambos reservan. 160 fuera de 120, y el historial simplemente registrará cómo lo perdiste.
Nada de lo anterior lo impide, y nada de lo anterior estaba mal. Los saldos y el historial describen el dinero, no protegen la escritura, dos peticiones todavía pueden leer el mismo disponible y escribir las dos dentro del mismo. Hacer esa escritura atómica, que la comprobación y la reserva se confirmen juntas o no se confirmen, es de lo que trata la Parte III, Transacciones y atomicidad.
Recibe los últimos artículos en tu bandeja de entrada.