Tous les articles

Série Ledger, partie II : soldes et immuabilité

·
15 sept. 2026
·
17 min de lecture
·
••• vues
·
••• j'aime

Traduit de l'article original en anglais. Lire en anglais

Série Ledger, partie II : soldes et immuabilité

La partie 1 de cette « série ledger » se terminait dans un monde plus simple, où chaque mouvement était réglé dès qu'il était enregistré. Le problème, c'est que l'argent réel ne se comporte pas comme ça. Prenons Tobi, l'un de vos utilisateurs, il a 100 USDC dans votre app, 50 USDC de plus attendent sur un dépôt qui n'a pas encore été confirmé, et un retrait de 30 USDC est en route vers un wallet externe. Que doit afficher son solde à ce moment-là, et combien peut-il réellement dépenser ?

Selon ce que l'app compte, elle peut afficher 100, 120 ou 150, et la réponse honnête à la question de savoir combien il peut dépenser, c'est 70. Cet écart entre ce qui est enregistré, ce qui est attendu et ce qui est dépensable à un instant donné, c'est le sujet de cette partie II.

Pourquoi un seul solde ne suffit plus

Rien ne change dans le modèle en partie double. Chaque mouvement s'équilibre toujours, les écritures sont toujours écrites une seule fois et jamais touchées. Ce qui change, c'est que l'argent passe maintenant du temps dans différents états. Un paiement par carte met des jours à être réglé, un dépôt crypto reste non confirmé jusqu'à ce que le nombre de confirmations de bloc atteigne un seuil défini, un versement est en vol chez un fournisseur qui pourrait échouer, personne ne le sait.

Le solde unique de la partie 1 ne peut décrire que l'argent qui est arrivé. Il n'a aucun moyen de décrire l'argent qui n'est pas encore réglé ou qui est en transit/en vol, donc les systèmes qui gardent un seul nombre finissent soit par cacher de l'argent qui appartient à l'utilisateur, soit par céder de l'argent qui n'est pas encore en custody. Le système doit aussi enregistrer l'argent qui est en attente.

Les trois soldes

Un ledger qui modélise l'argent en transit répond avec trois nombres, et ce sont toujours de simples sommes, sur les écritures et sur les dépôts qui attendent encore d'être comptabilisés.

  1. Comptabilisé : c'est la somme de tous les mouvements réglés sur l'ensemble des comptes de Tobi, son wallet plus tous les comptes qui détiennent de l'argent réservé pour ses retraits, les faits définitifs. Le solde comptabilisé de Tobi est de 100 USDC.

  2. En attente : c'est celui qui sème le plus souvent la confusion, parce que « solde en attente » est utilisé pour dire deux choses différentes. La première, c'est l'activité en attente seule, +50 USDC entrants et -30 USDC sortants, 80 USDC en vol, soit un net de +20. La seconde, c'est le comptabilisé plus cette activité, 100 + 50 - 30 = 120 USDC, le montant auquel le solde va très probablement se stabiliser. Ce sont deux questions différentes, ne les mélangez jamais dans un seul endpoint.

  3. Disponible : c'est ce que Tobi peut réellement dépenser tout de suite. La valeur comptabilisée moins les montants sortants, 100 - 30 = 70 USDC. L'argent entrant qui n'est pas réglé n'est pas disponible, les 50 USDC encore non confirmés on chain n'existent pas pour la dépense dans ce wallet tant qu'ils ne sont pas réglés.

Le disponible est un seul nombre ici parce que ce wallet applique une seule politique. Beaucoup de systèmes le séparent, ce que Tobi peut dépenser et ce qu'il peut retirer finissent par être deux nombres différents. Un dépôt par carte peut être dépensable dans l'app tout de suite et rester bloqué pour les paiements pendant des jours, parce que sinon quelqu'un dépose avec une carte volée, retire le tout en crypto au même moment, et c'est vous qui vous retrouvez avec le chargeback sur les bras. Certaines entreprises font l'inverse et accordent un crédit temporaire, de l'argent rendu disponible avant d'être réglé selon certains paramètres, c'est comme ça que fonctionnent les dépôts instantanés, et aux États-Unis, la Regulation CC fixe même les délais que les banques doivent respecter pour rendre les fonds déposés disponibles. Les soldes marchands ajoutent ensuite des réserves de trésorerie et des blocages pour risque par-dessus tout ça.

Le crédit temporaire a un prix, cela dit. Vous créditez Tobi dès que le dépôt apparaît, DR deposits_in_transit 50 / CR tobi:wallet 50, il le dépense, le dépôt échoue pour une raison quelconque, et maintenant son wallet est négatif. Le ledger doit donc autoriser, par conception, les soldes négatifs sur certains comptes précis, et les suivre comme une créance, de l'argent que Tobi vous doit désormais. Et c'est de là que viennent vos pertes liées à la fraude et votre risque de crédit, donc vous décidez à l'avance quels types de comptes ont le droit de passer en négatif. Certains systèmes de ledger, comme celui que nous avons construit par exemple, ont un flag exactement pour ça, debits_must_not_exceed_credits, activez-le sur un compte et tout virement qui pousserait ses débits au-delà de ses crédits est rejeté.

Suivre l'argent

Maintenant, prenons les mouvements de Tobi un par un. Comme dans la partie 1, chacun est toujours enregistré comme une transaction équilibrée.

Son dépôt de 50 USDC est détecté avec une confirmation. Les coins sont on chain mais pas définitifs, comme vous le savez, le réseau peut encore jeter le bloc qui les contient et le remplacer par un autre, ce qu'on appelle une « reorg », c'est de l'argent que vous voyez mais que vous ne détenez pas encore. Rien n'est comptabilisé dans le ledger à ce stade. Au lieu d'une écriture comptable, le dépôt a son propre historique en ajout seul, chaque fois que quelque chose change à son sujet, une nouvelle confirmation, un nouveau statut, c'est une nouvelle ligne, et le solde en attente de Tobi lit les 50 à partir de là. Aucune écriture ne touche la custody ni son wallet. C'est la custody qui est rapprochée du solde finalisé de la chaîne, et c'est exactement ce que vérifient les auditeurs et les systèmes de preuve de réserves, en standard bien sûr.

Soldes à ce stade : comptabilisé 100, activité en attente +50, disponible 100.

Le dépôt n'est comptabilisé qu'une fois que la chaîne le déclare définitif, et une fois que l'enregistrement de ce dépôt précis chez votre fournisseur de custody concorde. Vérifiez dépôt par dépôt. Si vous comparez seulement avec le solde total du fournisseur, un autre dépôt qui arrive au même moment peut donner l'impression que le total est juste alors que ces 50 manquent toujours. Ensuite, c'est une seule transaction :

DR custody       50
CR tobi:wallet   50

Maintenant, dans le même commit, l'historique du dépôt reçoit sa ligne créditée et les 50 passent d'en attente à disponible. Soldes à ce stade : comptabilisé 150, activité en attente 0, disponible 150. Rien n'a été modifié, le ledger vient simplement d'entendre parler de ce dépôt, alors qu'il était déjà définitif.

Ce que « définitif » veut dire dépend de la chaîne. Douze confirmations, c'est un truc de proof of work. Sur Ethereum en proof of stake, le vrai signal, c'est le checkpoint finalisé, deux à trois epochs, ~15 minutes un jour normal. Solana a ses propres niveaux de commitment, et depuis que sa mise à jour Alpenglow est passée en production en octobre 2026, confirmed et finalized veulent dire la même chose. Sur un rollup comme Base ou OP, la confirmation de votre transaction par le sequencer n'est que la première étape, la finalité L1 arrive ~20 minutes plus tard. La finalité finit par être un seuil que vous fixez par chaîne, et chaque dépôt devrait enregistrer quel seuil l'a réglé, pour que le jour où une chaîne se comporte mal, vous sachiez exactement quels dépôts ont été crédités sous quelle règle. Et si le fournisseur déclare un dépôt terminé alors qu'il est encore sous votre seuil, vous le mettez de côté pour examen et ne créditez rien. Si vous utilisez un système comme Fireblocks, vous configurez simplement ça dans votre dashboard.

Maintenant, une reorg avant la finalité, c'est le cas simple, et le dépôt n'a pas échoué. La transaction retourne généralement dans le mempool et atterrit à nouveau dans un bloc ultérieur, donc le dépôt repasse simplement à non confirmé et son compteur de confirmations repart de zéro. Marquez-le comme échoué à ce moment-là et l'une de deux choses se produit quand il atterrit à nouveau, soit votre système l'ignore et Tobi n'est jamais crédité pendant que la custody reste assise sur des coins que vous ne devez à personne, soit il réapparaît comme un tout nouveau dépôt et Tobi est crédité deux fois. Un dépôt n'échoue qu'une fois qu'il a disparu de façon prouvable, sur Ethereum par exemple quand une autre transaction du même expéditeur a utilisé ce nonce et que celle-ci est définitive, et même dans ce cas il n'y a rien à annuler parce que le ledger ne l'a jamais vu.

C'est à cause de ce second cas que la clé d'un dépôt compte autant. Une seule transaction peut contenir plusieurs transferts USDC, donc la clé, c'est la chaîne, le hash de la transaction et la position du transfert dans cette transaction. Sur Ethereum, c'est sa place dans le reçu de la transaction. Ne vous servez pas de logIndex dans ce cas, il compte sur l'ensemble du bloc, donc il change dès qu'une transaction d'un bloc jeté atterrit dans un autre. Et la contrainte d'unicité qui garantit que chaque dépôt n'est crédité qu'une fois se place sur la transaction du ledger qui le comptabilise. Placez-la sur l'historique et elle rejettera la deuxième ligne de confirmation, puisque toutes les lignes y portent la même clé.

Revenons à Tobi, ses 50 sont passés. Maintenant il demande un retrait de 30 USDC. Avant d'appeler le moindre fournisseur, l'argent est réservé :

DR tobi:wallet        30
CR tobi:withdrawals   30

Soldes à ce stade : comptabilisé 150, activité en attente -30, disponible 120. Le comptabilisé est toujours à 150 parce qu'il se lit sur l'ensemble des comptes de Tobi, le wallet est descendu à 120 et le compte de retraits a récupéré les 30. La réservation est une vraie écriture en partie double, c'est pour ça que le disponible baisse avant que le retrait soit comptabilisé, les 30 sont déjà promis même s'ils n'ont pas encore quitté le bâtiment.

Vous avez peut-être remarqué que le dépôt est resté hors du ledger jusqu'à être définitif, alors que le retrait est entré dans le ledger avant que quoi que ce soit ne parte. Tout dépend de ce que chacun fait au disponible. Le blocage retire quelque chose de ce que Tobi peut dépenser, donc il doit être commité en même temps que la vérification du solde, sinon deux requêtes peuvent dépenser les mêmes 30. Le dépôt en attente n'ajoute encore rien qu'il puisse dépenser, donc il peut attendre dehors jusqu'à être définitif. Le crédit provisoire est la seule exception, c'est pour ça que, dans ce cas, le dépôt est comptabilisé tout de suite.

Le fournisseur livre, et le versement se termine :

DR tobi:withdrawals   30
CR custody            30

Soldes à ce stade : comptabilisé 120, activité en attente 0, disponible 120. La finalisation ne fait que solder la réservation, la custody paie le rail et le wallet reste intact. Si la comptabilisation déduisait à nouveau du disponible, Tobi verrait 150 devenir 90 en ne payant que 30.

Bon, j'ai volontairement gardé ce retrait propre, il a réservé 30 et réglé 30. Les vrais le font rarement. Le gas n'est pas connu avant que la transaction soit minée, les fournisseurs prennent leurs propres frais, et le gas d'un transfert USDC se paie en ETH, un autre actif. Donc le montant que vous bloquez et le montant finalement réglé ne correspondent presque jamais, vous réservez une borne supérieure, les 30 plus le maximum que les frais pourraient atteindre, disons 2 :

DR tobi:wallet        32
CR tobi:withdrawals   32

Ensuite, vous réglez les montants réels et libérez le reste, en une seule transaction :

DR tobi:withdrawals      32
CR custody            30.00
CR revenue:fees        0.40
CR tobi:wallet         1.60

Le disponible a baissé de 32 pendant que le retrait était en vol et atterrit à 119.60, et le compte de retraits revient toujours à zéro en net. Le gas réellement facturé par le réseau est votre charge, payée avec votre propre ETH dans un mouvement séparé, DR expense:gas / CR treasury:eth, et il s'équilibre tout seul parce que la vérification de la partie 1 s'exécute par actif. Les 0.40 sont maintenant votre produit, donc ils sont aussi sortis de la custody clients, DR treasury:usdc 0.40 / CR custody 0.40. La custody, vous la gardez uniquement pour l'argent des clients. Si vous payez le gas avec, chaque retrait grignote un peu de l'argent de tous les autres, et le premier endroit où ça se verra, c'est votre cron de réserves, ce que vous détenez par actif comparé à ce que vous devez aux utilisateurs, qui reviendra en déficit.

Les rails de carte rendent ça encore plus difficile :

Ce qui se passe quand le retrait échoue

Ça peut se passer de trois façons, et seulement deux d'entre elles sont des échecs.

Une simple erreur : le retrait en attente est rejeté avant d'être comptabilisé, peut-être parce que l'adresse échoue au screening. La réservation est libérée :

DR tobi:withdrawals   30
CR tobi:wallet        30

Le disponible revient à 150, le comptabilisé n'a jamais bougé parce que l'argent est resté.

Le cas coûteux : le versement a été comptabilisé, la chaîne l'a confirmé, et quelques jours plus tard l'exchange qui l'a reçu le renvoie. Ce retour est un nouveau mouvement financier à part entière, et on chain ce n'est qu'un transfert entrant de plus, donc il suit le même chemin que n'importe quel dépôt, son propre historique, votre seuil de finalité, rapproché du versement exact qu'il retourne, et seulement ensuite il est comptabilisé. Comptabilisez aussi ce qui est réellement arrivé, l'exchange qui le renvoie peut garder ses propres frais, donc 30 sont partis et peut-être que 29.50 vous reviennent :

DR custody       29.50
CR tobi:wallet   29.50

Le versement d'origine reste dans l'historique, et le retour aussi.

Et puis il y a la troisième façon, celle qui fait perdre de l'argent à la plupart des gens : le fournisseur a fait un timeout et personne ne sait si le versement est arrivé. Vous ne pouvez pas traiter un statut inconnu comme un échec. Traitez-le comme un échec, libérez les 30, le versement atterrit quand même on chain, et voilà, vous avez payé deux fois, félicitations !

La vraie norme, c'est qu'un résultat inconnu reste en attente jusqu'à ce que le fournisseur réponde.

Certains fournisseurs ne répondent jamais, cela dit, selon la façon dont leur système est construit. Donc l'inconnu ne peut pas non plus rester en attente pour toujours, il descend une échelle, et chaque échelon soit le résout avec une transaction du ledger, soit le passe au suivant :

  1. Demandez directement le statut au fournisseur « un requery », n'attendez pas simplement le webhook.
  2. Rapprochez-le des enregistrements du fournisseur et de la chaîne elle-même, à vous de choisir.
  3. Passez-le en perte, avec une validation dans votre backoffice.

Et pour savoir déjà que quelque chose est bloqué, un compte de compensation ne peut pas rester un seul nombre. Disons que Tobi envoie deux retraits de 30, l'un est réglé et l'autre ne l'est jamais. tobi:withdrawals affiche 30, et comme les deux étaient de 30, rien dans ce nombre ne vous dit lequel est bloqué. Donc chaque écriture qui règle ou libère quelque chose pointe vers l'écriture exacte qu'elle clôture, c'est le lettrage des postes ouverts, emprunté directement à la gestion des comptes clients, et une alerte se déclenche sur tout ce qui reste ouvert au-delà du SLA de votre rail.

L'historique reste

Si l'on revient sur tout ce qu'a fait l'argent de Tobi plus haut, aucune écriture existante n'a jamais été mise à jour, et pas une seule ligne de l'historique du dépôt non plus. L'état a évolué parce que de nouvelles transactions sont venues s'ajouter par-dessus les anciennes, et c'est la seconde moitié de cet article.

Ce type de système est conçu pour recevoir des corrections, une écriture avec un zéro en trop, un mouvement passé sur le mauvais compte. La tentation à 2 h 45 du matin, c'est un UPDATE. Vous corrigez les données avec une txn de correction, jamais avec la mise à jour d'une ligne. La correction doit être le miroir de l'original avec les côtés inversés et pointer vers lui, en portant une raison et une source pour que l'enregistrement s'explique tout seul quand on le consulte :

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,
    })),
  };
}

Chaque champ est copié par son nom, volontairement, étalez l'écriture d'origine avec ...e et la correction se retrouve aussi avec l'id et les timestamps de l'original. Ensemble, les deux txn s'annulent à zéro, et le solde final ne cache jamais comment on en est arrivé là. Le support peut rejouer l'historique de Tobi, d'abord les lignes du dépôt puis le ledger, et voir dépôt, confirmation, réservation, libération, dans l'ordre. Votre base de données le garantit de la même façon que la règle de la partie 1, un UPDATE ou un DELETE sur une table d'écritures est rejeté ! Pas d'hésitation là-dessus.

Une chose que reverse(), tel qu'il est écrit, n'empêche pas, c'est d'annuler deux fois la même transaction, lancez-le deux fois et Tobi récupère son argent deux fois. Le cumul des annulations d'une transaction ne doit jamais dépasser l'original. Un simple index unique peut bloquer une deuxième annulation complète, mais il ne sait rien additionner, donc une annulation complète plus une annulation partielle passent quand même. La vraie vérification lit ce qui a déjà été annulé et écrit la nouvelle annulation dans une seule txn atomique, et c'est un problème pour la partie III, dont nous parlerons.

Le solde stocké doit concorder

La partie 1 disait que le solde stocké est un cache qui doit concorder avec le journal. Avec trois soldes, les enjeux augmentent, parce que c'est le cache qui autorise les dépenses. Un solde utilisé pour autoriser un retrait doit refléter les réservations et les écritures commitées, et il doit bouger dans le même commit que celui qui écrit les écritures, sinon une lecture entre les deux voit de l'argent qui est déjà promis.

Le test de la partie 1 tient toujours, supprimez tous les nombres stockés et rejouez le ledger plus l'historique des dépôts, vous retrouvez les trois mêmes soldes, et la partie en attente peut toujours être vérifiée avec un nouveau scan de la chaîne. Avec les checkpoints vus plus haut, le rejeu démarre au dernier checkpoint vérifié au lieu de la toute première écriture jamais écrite, et c'est ce qui garde ce test praticable à grande échelle. Et quand un nombre stocké et un nombre rejoué ne concordent pas, un job de rapprochement compare les deux et lève une alerte en cas d'écart. Ensuite, vous trouvez lequel des deux ment. Si les écritures sont justes et que le cache est faux, vous reconstruisez le cache à partir des écritures, aucun nouveau mouvement financier n'a sa place dans ce correctif. Si ce sont les écritures elles-mêmes qui sont fausses, vous comptabilisez une txn de correction comme celle ci-dessus. Dans les deux cas, vous enquêtez avant de refaire confiance au nombre. Cette alerte n'est pas négociable.

À NOTER : un solde stocké est une somme, et les sommes ont besoin de place. L'USDC a 6 décimales sur Ethereum et 7 sur Stellar, l'ETH en a 18. Un entier signé de 64 bits plafonne autour de 9.22 × 10¹⁸, en wei ça ne fait que ~9.22 ETH, un seul dépôt correct. Le bigint de Postgres est exactement cet entier de 64 bits, même mot que le bigint TypeScript de la partie 1, limite très différente. Utilisez des montants sur 128 bits, c'est exactement ce que nous faisons, ce qui dans Postgres veut dire numeric avec une échelle de 0, gardez bigint plutôt que number en TypeScript, et stockez l'échelle de chaque actif avec l'actif, 30000000 ne veut rien dire tant que vous ne savez pas que c'est de l'USDC à 6 décimales.

Quant à savoir comment le commit lui-même reste sûr sous forte charge, le verrouillage, etc, c'est pour la partie III.

Quel était le solde à ce moment-là ?

Il y a deux horloges sur chaque mouvement, le moment où il est devenu effectif et le moment où votre système l'a enregistré. Un règlement par carte peut être effectif hier et arriver aujourd'hui, le webhook d'un fournisseur peut arriver avec des heures de retard. Reconstruisez aujourd'hui le solde d'hier et il contient des mouvements dont le système n'avait pas entendu parler hier. Ce nombre, c'est ce que vous savez maintenant d'hier, pas ce que vous saviez à l'époque.

Les corrections et les retours doivent aussi chacun choisir une date d'effet. La correction d'une erreur de saisie porte la date de création de l'original, parce que l'original n'a jamais été vrai, c'est pour ça que reverse() plus haut copie original.effectiveAt. Un retour porte la date du jour, parce que le versement a réellement eu lieu et que l'argent est vraiment revenu plus tard. Inversez ces deux-là une seule fois et chaque solde à date pour ces jours-là sera complètement faux, ce qui vous coûtera encore.

Ensuite, il y a les périodes clôturées. Une fois qu'une période est clôturée, relevés envoyés, rapports déposés, intérêts courus, une écriture tardive effective à l'intérieur de cette période ne peut plus changer ces nombres. Vous la comptabilisez dans la période ouverte comme un ajustement hors période avec une date d'effet qui pointe en arrière, les banques classiques appellent ça la date de valeur, à partir de quand elle compte, et la date de comptabilisation, quand elle a été passée. Et une écriture à date de valeur antérieure veut dire que des intérêts ou des frais ont été calculés sur le mauvais solde, donc le recalcul entre lui aussi comme une nouvelle transaction d'ajustement.

Ce que cela ne garantit toujours pas

Le modèle ici est juste, nous avons trois soldes, un historique en ajout seul, un cache qui concorde. Maintenant, deux retraits de 80 USDC chacun arrivent à une milliseconde d'intervalle. Les deux lisent un disponible de 120, les deux passent la vérification, les deux réservent. 160 sortis de 120, et l'historique enregistrera simplement comment vous les avez perdus.

Rien de ce qui précède n'empêche ça, et rien de ce qui précède n'était faux. Les soldes et l'historique décrivent l'argent, ils ne protègent pas l'écriture, deux requêtes peuvent toujours lire le même disponible et écrire toutes les deux dedans. Rendre cette écriture atomique, la vérification et la réservation commitées ensemble ou pas du tout, c'est le sujet de la partie III, Transactions et atomicité.

•••
Partager cet article :
Série
Série Ledger

Restez informé

Recevez les derniers articles dans votre boîte mail.