En buda.com programamos con plata. Y si hay algo que aprendimos rápido es que acá los detalles no son detalles: un decimal mal puesto, un bug de idempotencia o una race condition pueden hacer que el saldo se duplique, desaparezca, que un pago se ejecute dos veces, o que los balances no cuadren.
Y como buen exchange, en Buda todo pasa en paralelo: abonos, retiros y órdenes… todo al mismo tiempo. Por eso, cualquier operación afecta directamente lo que el usuario puede hacer después.
Imaginemos el caso de Alice:
Alice tiene $1.000 pesos en Buda. Quiere retirar esos $1.000 para comprarse un helado, así que ejecuta el retiro, pero justo en ese mismo instante se arrepiente y decide convertirlos a BTC.
Como Alice tiene un superpoder y hace todo muy rápido, ambas operaciones ocurren exactamente al mismo tiempo. Suena exagerado, pero en la práctica pasa mucho. Tenemos miles de usuarios haciendo miles de operaciones por segundo.
Entonces, ¿qué pasa si Alice retira su dinero y hace una orden de compra en el mismo instante? ¿Le duplicamos la plata? Si ambas operaciones alcanzan a hacerse antes de que se registre el cambio de balance, Alice podría terminar con $1.000 en su cuenta bancaria y otros $1.000 convertidos a BTC.

Esto es el famoso problema del double spend* (o doble gasto). Un final feliz para Alice, pero uno triste para nosotros.
¿Cómo evitamos esto?
La verdad es que este problema no es nada nuevo. Desde hace siglos que las personas hemos tenido que buscar formas de asegurarnos de que el dinero no se repita dos veces.
En la Edad Media, por ejemplo, se usaban los tally sticks: palos de madera donde se marcaban deudas y luego se partían en dos. Cada mitad quedaba con una persona distinta y, como el corte era único, ambas podían verificar que estaban contando la misma historia. Esto era una solución simple al problema del doble gasto de la época. Hoy el concepto es el mismo, solo que la fuente de la verdad vive en una base de datos y no en un palo de madera.
****Para resolver este problema en Buda usamos un ledger, que es básicamente un registro de transacciones: cada movimiento queda anotado y el balance final se explica como la suma de esos movimientos. Así evitamos que un mismo saldo se use más de una vez y nos aseguramos de que todos estemos mirando lo mismo.
En Buda manejamos el ledger así:
- Subcuentas por propósito
Dividimos el balance en varias subcuentas, por ejemplo:
| main | Balance Disponible |
| --- | --- |
| orders | Balance congelado en órdenes pendientes |
| withdrawals | Balance congelado en retiros pendientes |
La gracia es separar el saldo disponible del congelado, para que el mismo peso no se pueda gastar dos veces. Así, cada vez que un usuario ingresa una orden, transferimos plata de `main` a `orders` y si hace un retiro de `main` a `withdrawals`.
- Si llamamos al
update_ledgerdesde dos lados al mismo tiempo, solo una operación puede modificar las cuentas a la vez y la segunda se queda esperando. - Cuando la segunda operación continúa, ve el estado actualizado: recién ahí el
available_balance < amountsí tiene sentido, porque se evalúa con el balance real del momento (después del lock).
Entonces, ¿dónde ocurre la magia?Cada operación corre dentro de una transacción y toma un lock a nivel de base de datos. Ese lock bloquea las cuentas exactas que vamos a tocar (por ejemplo las cuentas main y withdrawals de Alice), así dos operaciones que compiten por el mismo saldo pasan en serie, pero el resto del sistema sigue corriendo.
Siguiendo con la analogía: un tally stick se podía marcar por una persona a la vez. Mientras alguien lo estaba marcando, nadie más podía cambiarlo. En Buda pasa parecido: cuando una operación quiere mover plata, toma el lock y actualiza el saldo. Si otra operación llega al mismo tiempo, espera a que la primera termine, y cuando le toca, ve el saldo ya actualizado.Ahora, el flujo sería algo así:
def update_ledger
LedgerAccount.transaction do
lock_ledger_accounts
perform_transaction
end
end
def perform_transaction
case transaction_type
when 'withdrawal'
perform_withdrawal
when 'order'
perform_order
end
end
def lock_ledger_accounts
# Si en otra parte se está operando sobre las mismas filas, esperamos.
LedgerAccount.where(user_id: user_id, currency: currency).lock('FOR UPDATE')
end
Esto garantiza que:
El problema del if A primera vista, algo así podría parecer suficiente:
def perform_withdrawal
return if available_balance < amount
transfer(from: :main, to: :withdrawals, amount: amount)
end
def perform_order
return if available_balance < amount
transfer(from: :main, to: :orders, amount: amount)
end
Pero como se habrán dado cuenta, ese if no soluciona el problema, ambas funciones pueden correr al mismo tiempo. Si las dos leen el mismo balance antes de que se registre la primera transferencia, el if se cumple en ambos casos y ocurren las dos transferencias.
En resumen, evitamos el doble gasto con tres reglas simples: cuentas separadas por propósito, bloqueo en la base de datos sobre las cuentas involucradas, y validación del balance en el momento correcto.
Es una solución simple a un problema complejo. Programar con plata suena como hacer un par de sumas y restas, pero en la práctica está lleno de casos borde que aparecen cuando todo pasa al mismo tiempo. Se trata de entender que una operación puede afectar a otra, que el orden importa y que un pequeño detalle puede terminar duplicando balances.
Ahora cada vez que Alice quiera hacer dos operaciones al instante, verá que una falla porque no tiene balance, pero en cualquier caso es un final feliz para ella: o hay helado, o hay BTC y un final feliz para nosotros, no duplicamos plata :).
Si quieres resolver problemas como este, estamos buscando desarrolladores :)