Skip to content
StableAudienceDevSécuritéAudit banqueComplianceOwner@architecture-teamDernière revue2026-05-22

Transactions — Protection contre le double-spend

Quatre couches de défense (transaction SQL SERIALIZABLE, locks ordonnés, idempotence par intent, garanties). Pour la vue d'ensemble, voir Système de transactions — overview.

6. Protection contre le double-spend

6.1 Le problème

Sans protection, le scénario suivant est possible :

  • Un compte A a un solde de 1 000 XAF.
  • Deux requêtes de retrait de 800 XAF arrivent au même instant (par exemple : l'utilisateur double-clique sur son bouton, ou le réseau rejoue une requête après un timeout).
  • Requête 1 lit le solde (1 000), valide le retrait (1 000 ≥ 800), enregistre le débit.
  • Requête 2, arrivée en parallèle, lit elle aussi le solde avant la mise à jour de la requête 1 (1 000), valide également, enregistre un second débit.
  • Résultat : deux retraits de 800 XAF passent. L'argent réel (1 600 XAF) a quitté le système. Le solde affiché devient 200 (au lieu de -600).

C'est précisément ce type d'écart qui a été observé en production sur l'environnement de review, avec des différences allant jusqu'à 317 626 XAF.

6.2 Les quatre couches de défense

Le système empile quatre mécanismes. Chacun est indépendant des autres et constitue un filet de sécurité. Il faut que les quatre soient défaillants simultanément pour qu'un double-spend soit possible.

Couche 1 — Verrou pessimiste au niveau ligne

Lors de la confirmation d'une transaction, le service ledger-wallets pose un verrou exclusif sur les lignes de la base concernées, via la clause SQL SELECT ... FOR UPDATE. PostgreSQL garantit qu'une autre transaction qui tente le même SELECT FOR UPDATE attend que la première commit ou rollback avant de pouvoir lire la ligne.

Concrètement :

sql
BEGIN;
  SELECT * FROM accounts WHERE id = '<source>' FOR UPDATE;
  -- À cet instant, cette ligne est verrouillée.
  -- Une transaction concurrente qui fait le même SELECT attend.
  ...
COMMIT;
  -- Le verrou est relâché, les transactions en attente peuvent procéder.

La vérification de solde s'effectue après le verrou. La transaction concurrente, lorsqu'elle est enfin exécutée, lit le solde post-commit de la première, et constate que le montant est insuffisant — elle est rejetée proprement avec un code HTTP 422.

Couche 2 — Ordre canonique des verrouillages

Dans un transfert, deux comptes sont impliqués (source et destination). Deux transactions concurrentes verrouillant les comptes dans des ordres différents provoqueraient un interblocage (deadlock) : A attend B, B attend A.

Le système impose que les comptes soient toujours verrouillés dans le même ordre canonique : tri ascendant par identifiant (UUID). Deux transactions sur les mêmes comptes demandent donc les verrous dans le même ordre, et l'une attend simplement que l'autre termine. Aucun interblocage n'est possible par construction.

Couche 3 — Isolation SERIALIZABLE

Le verrou au niveau ligne ne protège pas contre tous les types de conflits (lectures de plage, conditions dépendant de l'état global). Le niveau d'isolation PostgreSQL SERIALIZABLE force le système à garantir que le résultat net de la transaction est équivalent à une exécution séquentielle de toutes les transactions concurrentes.

Si PostgreSQL détecte qu'une telle équivalence est impossible, il rejette une des transactions avec l'erreur serialization_failure (SQLSTATE 40001). Le service ledger-wallets intercepte cette erreur et rejoue automatiquement la transaction jusqu'à trois fois, avec un délai exponentiel (100 ms, 400 ms, 1 600 ms). Au-delà, l'opération est remontée au client comme un conflit temporaire (HTTP 409).

Un délai d'attente maximal sur les verrous est également posé (SET LOCAL lock_timeout = '3s') pour éviter qu'une transaction ne bloque le service indéfiniment. Au-delà de 3 secondes d'attente, l'erreur lock_not_available (SQLSTATE 55P03) est levée et traitée de la même façon qu'un conflit de sérialisation.

Couche 4 — Contraintes d'intégrité de base de données

Ultime filet de sécurité, indépendant du code applicatif : les contraintes PostgreSQL rejettent toute tentative de violation, même en cas de bug logiciel :

  • CHK_accounts_positive_balance : un compte non-trésorerie ne peut pas avoir un solde négatif.
  • CHK_accounts_balance_consistency : le solde total d'un compte est toujours la somme de son solde disponible et de son solde gelé.
  • CHK_accounts_positive_available_balance et CHK_accounts_positive_frozen_balance : sous-soldes positifs.
  • Trigger trg_ledgers_append_only : toute tentative d'UPDATE ou DELETE sur la table ledgers lève une exception.

6.3 Garanties fournies

Avec ces quatre couches :

  • Un double-spend par concurrence est rejeté au niveau applicatif (couches 1 + 2 + 3) avec un code d'erreur explicite.
  • Un double-spend par bug ou par manipulation directe est rejeté au niveau base de données (couche 4), avec une exception tracée.
  • Un rejeu de requête par retry réseau est absorbé par l'idempotence (retour de la réponse mémorisée, aucun second débit).
  • Une tentative de modification rétroactive du grand livre est impossible sans intervention DBA sur le trigger, qui laisse une trace système.

Le scénario où deux retraits simultanés de 800 XAF sur un compte à 1 000 XAF se termine aujourd'hui par :

  • Un retrait de 800 qui réussit (HTTP 200).
  • Un retrait qui échoue avec un message clair : solde disponible 200, requis 800 (HTTP 422).
  • Un solde final de 200 XAF, exactement cohérent avec les écritures du grand livre.

Nex — Plateforme fintech CEMAC