Tirage et versement de la cagnotte
Le tirage se fait en deux transactions séparées par l'oracle Chainlink VRF. Il est précédé d'une salle d'attente : quand les achats se ferment, le keeper attend DRAW_REVEAL_DELAY_SECONDS (600 s par défaut) avant de demander le tirage, pour que les participants suivent la roue en direct. La première transaction, envoyée par le keeper du backend ou par un administrateur, demande un nombre aléatoire et fige le tour. La seconde, envoyée par le coordinateur VRF, apporte ce nombre : le contrat désigne alors lui-même le gagnant dans fulfillRandomWords, calcule la cagnotte et ouvre le tour suivant. Le backend découvre le résultat en lisant les événements, l'annonce en direct, puis réclame la cagnotte pour le compte du gagnant.
Diagramme de séquence
sequenceDiagram autonumber participant K as Keeper participant R as Redis participant BL as Contrat BlockLucky participant VRF as Coordinateur VRF participant I as Indexeur du worker participant DB as PostgreSQL participant API as API Socket.IO participant Q as File chain-actions participant W as Worker chain-actions K->>R: setIfAbsent chain:keeper-lock K->>BL: paused, isDrawReady BL-->>K: true K->>R: setIfAbsent chain:draw-ready-at:tour K->>K: attente tant que maintenant < instant prêt + DRAW_REVEAL_DELAY_SECONDS K->>BL: requestDraw par le compte opérateur BL->>VRF: requestRandomWords, numWords 1 BL-->>K: reçu avec DrawRequested K->>I: processReceipt I->>DB: statut LOCKED, chainlinkRequestId, drawRequestedAt K->>R: del chain:keeper-lock VRF->>BL: rawFulfillRandomWords avec la preuve vérifiée BL->>BL: fulfillRandomWords, gagnant = ticketOwners de randomWords modulo ticketCount BL->>BL: WinnerPicked puis _openRound et RoundOpened I->>BL: getLogs depuis ChainCursor I->>DB: statut COMPLETED, winnerWallet, prizeWei, drawnAt I->>R: publish draw:winner sur socket-events R-->>API: message relayé, io.emit draw:winner I->>Q: CLAIM_PRIZE jobId CLAIM_PRIZE-tour-gagnant I->>DB: nouveau tour OPEN Q-->>W: job CLAIM_PRIZE W->>BL: claimPrize(tour) signé par le portefeuille du gagnant BL-->>W: PrizeClaimed et envoi des ethers I->>DB: prizeClaimedAt
Étape 1 : demande du tirage
Par le keeper
startChainServices (backend/src/blockchain/lottery.keeper.ts) lance runKeeperOnce toutes les KEEPER_INTERVAL_MS (10 s par défaut), dans le worker ou dans l'API si RUN_WORKERS=true. À chaque passage :
- Rien en mode
simulated. - Verrou
chain:keeper-lockposé parredis.setIfAbsent, pour une durée égale à l'intervalle avec un minimum de 5 s. Si une autre instance le détient, le passage s'arrête. - Si le contrat est en pause (
paused()), rien. - Si
isDrawReady(): le keeper lit le tour courant, enregistre l'instant où il est devenu prêt danschain:draw-ready-at:<tour>s'il ne l'est pas déjà (le plus petit de maintenant et deendTime), puis compare l'heure à cet instant plusDRAW_REVEAL_DELAY_SECONDS. Avant cette heure, la passe renvoiewaiting-roomsans rien envoyer. Après, appel derequestDraw()avec le compte opérateurchainClient.operator, attente du reçu, puislotteryIndexer.processReceipt(receipt). - Sinon, si
isExpiredWithoutDraw(): annulation du tour. Voir Annulation et remboursement. - Le verrou est supprimé dans le bloc
finally. Les erreursDrawNotReadyetRoundNotCancellable(un autre acteur a été plus rapide) ne sont pas journalisées ; les autres le sont en avertissement.
isDrawReady (smart-contract/contracts/BlockLucky.sol) est vraie quand le tour est Open, compte au moins minTicketsToDraw tickets, et est complet ou a atteint endTime. Le keeper demande donc le tirage DRAW_REVEAL_DELAY_SECONDS après le dernier ticket vendu ou la fin du temps, à KEEPER_INTERVAL_MS près. GET /lottery/status lit le même instant pour publier phase: 'WAITING_ROOM' et revealAt, ce qui aligne le compte à rebours des clients sur le keeper (voir Loterie).
Par un administrateur
POST /api/v1/lottery/draw dans backend/src/api/lottery/lottery.routes.ts : authenticate, requireRoles('ADMIN'), validate(triggerDrawSchema). lotteryController.triggerDraw appelle lotteryService.executeChainlinkDraw, qui délègue à drawOnchain (backend/src/api/lottery/lottery.services.ts) :
lotteryGateway.requestDraw()(backend/src/blockchain/lottery.gateway.ts) envoierequestDrawavec le compte opérateur et indexe le reçu.- Le tour courant est relu et recopié en base.
- La réponse 200 contient
vrfProof.status: 'PENDING', l'identifiant de requête VRF et le hash de la transaction. Le gagnant n'est pas encore connu.
Le champ roundId du corps est ignoré : on ne peut tirer que le tour courant. Cette route n'attend pas le délai de révélation : elle tire dès que isDrawReady est vrai, même si la salle d'attente des clients affiche encore un compte à rebours. requestDraw n'exige aucun rôle dans le contrat : n'importe quel compte peut la déclencher dès que les conditions sont remplies, ce que fait aussi Chainlink Automation par performUpkeep s'il est configuré. Le délai de révélation n'existe que dans le backend : le contrat ne le connaît pas. Voir Chainlink Automation.
Dans le contrat
requestDraw (whenNotPaused, nonReentrant) vérifie isDrawReady, puis _requestRandomness passe le tour à Drawing, note drawRequestedAt, appelle s_vrfCoordinator.requestRandomWords avec la configuration vrfConfig (key hash, abonnement, confirmations, limite de gaz du rappel, paiement natif ou LINK), mémorise roundByRequestId[requestId] et émet DrawRequested(roundId, requestId, ticketCount).
onDrawRequested (backend/src/blockchain/lottery.indexer.ts) passe le tour à LOCKED en base avec chainlinkRequestId et drawRequestedAt. À partir de ce moment, les achats sont refusés par le contrat (RoundNotOpen) et par la passerelle (409).
Étape 2 : réponse de l'oracle
Le coordinateur VRF vérifie la preuve cryptographique, puis appelle rawFulfillRandomWords sur le contrat, qui n'accepte que l'adresse du coordinateur et transmet à fulfillRandomWords :
- Retrouve le tour par
roundByRequestId. Si la requête est inconnue, si le tour n'est plusDrawingou si l'identifiant ne correspond pas à la dernière requête, la réponse est ignorée sans erreur. winningTicket = randomWords[0] % ticketCount,winner = ticketOwners[roundId][winningTicket].fee = totalCollected * feeBps / 10000,prize = totalCollected - fee,accruedFees += fee.- Statut
Completed, émission deWinnerPicked(roundId, winner, winningTicket, prize, fee, requestId). _openRound()ouvre le tour suivant avec la configuration courante et émetRoundOpened.
En local, le coordinateur est VRFCoordinatorV2_5Mock. Le script smart-contract/scripts/local-vrf-responder.ts, lancé dans le conteneur blockchain, interroge les événements RandomWordsRequested toutes les 2 s (VRF_POLL_INTERVAL_MS) et appelle fulfillRandomWords sur le coordinateur simulé. Sur Sepolia, c'est le réseau d'oracles Chainlink qui répond. Voir Tirage Chainlink VRF.
Étape 3 : indexation et annonce
La transaction de l'oracle n'est pas envoyée par le backend : seul l'indexeur périodique la voit. lotteryIndexer.syncOnce tourne toutes les INDEXER_INTERVAL_MS (3 s par défaut). Il lit le curseur ChainCursor, demande les journaux du contrat de lastBlock + 1 à la tête de chaîne moins CHAIN_CONFIRMATIONS (plages de 2 000 blocs au plus), les traite dans l'ordre (bloc, puis index du journal) et avance le curseur.
onWinnerPicked :
- Met à jour
LotteryRound:COMPLETED,winnerWallet,winningTicket,prizeWei,chainlinkRequestId,drawnAt(date du bloc). emitSocketEvent('draw:winner', ...)aveccontractRoundId,winnerWallet,prizePool(en ETH, cagnotte nette) etvrfProofHash, qui contient le hash de la transaction de l'oracle. Dans le worker, l'événement est publié sur le canal Redis<REDIS_KEY_PREFIX>:<NODE_ENV>:socket-events. L'API, abonnée parstartSocketEventBridge(backend/src/socket/event-bridge.ts), le réémet à tous les clients connectés. Les clients l'écoutent dansuseSocket(frontend-web/src/hooks/useSocket.ts).enqueueChainAction({ type: 'CLAIM_PRIZE', contractRoundId, walletAddress: winner }).
onRoundOpened crée ensuite le tour suivant en base.
Étape 4 : versement de la cagnotte
executeChainAction (backend/src/blockchain/chain-actions.ts), exécuté par le worker chain-actions (concurrence 1) :
- Retrouve le compte du gagnant par son adresse. Sans compte ou sans clé chiffrée, le job se termine sans rien faire.
- Signe
claimPrize(contractRoundId)avec le portefeuille du gagnant (walletService.signerFor). Les frais de réseau sont payés par le gagnant, sur son solde d'ethers fictifs. - Le contrat vérifie l'état
Completed, que l'appelant est le gagnant et que la cagnotte n'a pas été versée, passeprizeClaimedà vrai, émetPrizeClaimed, puis envoieprizeau gagnant. - L'indexeur traite
PrizeClaimedet renseigneprizeClaimedAt.
Le gagnant peut aussi réclamer lui-même : POST /api/v1/lottery/claim-prize avec contractRoundId (authenticate, idempotency, validate(claimPrizeSchema)) appelle lotteryService.claimPrize, puis lotteryGateway.claimPrize. Le frontend web le propose dans frontend-web/src/components/lottery/WinnerClaimCard.tsx.
Erreurs possibles
| Point | Code ou effet | Cause |
|---|---|---|
POST /lottery/draw |
403 | Rôle ADMIN absent, ou 2FA non activée avec ADMIN_MFA_REQUIRED=true |
POST /lottery/draw |
409 | DrawNotReady : pas assez de tickets, durée non écoulée, ou tirage déjà demandé |
POST /lottery/draw |
503 | Contrat en pause, ou réseau injoignable |
POST /lottery/draw |
502 | Échec non reconnu, par exemple abonnement VRF non financé ou consommateur non enregistré |
POST /lottery/claim-prize |
409 | Mode simulated, RoundNotCompleted, PrizeAlreadyClaimed |
POST /lottery/claim-prize |
403 | NotWinner |
POST /lottery/claim-prize |
402 | Solde insuffisant pour payer les frais |
Job CLAIM_PRIZE |
Terminé sans erreur | PrizeAlreadyClaimed (déjà réclamée, par exemple à la main) |
Job CLAIM_PRIZE |
Échec définitif | NotWinner, RoundNotCompleted (UnrecoverableError) |
Job CLAIM_PRIZE |
Jusqu'à 5 tentatives | Erreur réseau, solde insuffisant pour les frais, autre erreur |
| Oracle | Tour bloqué en Drawing |
Aucune réponse VRF : seul retryDraw (rôle opérateur, après drawTimeout) relance une requête |
Attention
Le backend n'appelle jamais retryDraw. Si l'oracle ne répond pas, un opérateur doit l'appeler à la main après drawTimeout. Voir Runbooks blockchain.
À savoir
Si le gagnant a supprimé son compte (voir Suppression RGPD), son adresse n'est plus en base et sa clé est effacée : le job CLAIM_PRIZE se termine sans rien faire et la cagnotte reste dans le contrat, réclamable seulement par l'adresse gagnante.
Idempotence
- Demande de tirage. Le contrat refuse une seconde demande pour le même tour (
DrawNotReady, car le tour n'est plusOpen). Le verrou du keeper évite que plusieurs instances envoient la transaction en même temps.POST /lottery/drawn'a pas de middlewareidempotency: un double appel renvoie 409 au second. - Réponse VRF.
fulfillRandomWordsignore une réponse inconnue, en retard ou en double, sans annuler la transaction de l'oracle. - Indexation.
onWinnerPickedetonDrawRequestedsont des mises à jour qui donnent le même résultat si elles sont rejouées. En revanche, sisyncOnceéchoue après avoir traité une partie des journaux, le curseur n'avance pas et la plage est relue :draw:winnerpeut être émis une seconde fois, et la mise en file deCLAIM_PRIZEest absorbée par l'identifiant de job. - Versement. L'identifiant
CLAIM_PRIZE-<tour>-<adresse>empêche deux jobs identiques, etprizeClaimeddans le contrat empêche deux versements.
En mode simulé
executeChainlinkDraw passe le tour à LOCKED, choisit un gagnant parmi les tickets par crypto.randomInt dans blockchainService.requestChainlinkVrfDraw (backend/src/services/blockchain.service.ts), passe le tour à COMPLETED et diffuse draw:winner par broadcastWinnerEvent. L'identifiant de requête et la « preuve » sont des chaînes aléatoires, la réponse indique verifiedFair: false et simulated: true, et aucune cagnotte n'est versée.