Keeper du tirage
Un tour de loterie ne se termine pas tout seul : le contrat BlockLucky attend qu'un compte appelle requestDraw() quand le tour est prêt, ou closeExpiredRound() quand il a expiré sans assez de participants. Sur un réseau public, Chainlink Automation peut jouer ce rôle grâce à checkUpkeep et performUpkeep (voir Chainlink Automation). Le backend embarque son propre automate, le keeper, pour que le cycle tourne aussi sur le nœud local et sans abonnement Automation.
Le code est dans backend/src/blockchain/lottery.keeper.ts. Le même fichier démarre l'indexeur périodique. Le délai de la salle d'attente est calculé par backend/src/blockchain/draw-reveal.ts.
runKeeperOnce
runKeeperOnce() exécute une passe et renvoie l'action réalisée :
| Valeur renvoyée | Signification |
|---|---|
draw-requested |
Le keeper a appelé requestDraw() |
round-closed |
Le keeper a appelé closeExpiredRound() |
waiting-room |
Le tour est prêt au tirage, mais le délai de révélation n'est pas écoulé |
idle |
Rien à faire, verrou déjà pris, contrat en pause, mode simulé ou erreur |
Horloge de la blockchain locale
Sur un nœud Hardhat, l'heure du contrat (block.timestamp) n'avance qu'à chaque bloc miné, donc à chaque transaction. Sans activité, isDrawReady() et isExpiredWithoutDraw() restent fausses après la fin d'un tour, et le tour ne serait jamais tiré ni annulé. Au début de chaque passe, syncLocalChainClock() mine donc un bloc vide (evm_mine) quand le dernier bloc a plus de 5 secondes de retard sur l'horloge du serveur. Elle n'agit qu'avec CHAIN_ID=31337, hors production et hors relais RPC de secours ; une erreur est journalisée sans bloquer la passe.
Déroulé d'une passe
flowchart TD
A["runKeeperOnce()"] --> B{"mode onchain ?"}
B -->|"non"| I["idle"]
B -->|"oui"| C{"verrou chain:keeper-lock obtenu ?"}
C -->|"non"| I
C -->|"oui"| S["syncLocalChainClock()"]
S --> D{"paused() ?"}
D -->|"oui"| R["idle, verrou libéré"]
D -->|"non"| E{"isDrawReady() ?"}
E -->|"oui"| W{"instant prêt + DRAW_REVEAL_DELAY_SECONDS atteint ?"}
W -->|"non"| WR["waiting-room, verrou libéré"]
W -->|"oui"| F["requestDraw() puis processReceipt"]
F --> G["draw-requested, verrou libéré"]
E -->|"non"| H{"isExpiredWithoutDraw() ?"}
H -->|"oui"| J["closeExpiredRound() puis processReceipt"]
J --> K["round-closed, verrou libéré"]
H -->|"non"| RLes deux conditions sont des fonctions view du contrat, ce qui évite de dupliquer la règle dans le backend :
| Fonction | Vraie quand |
|---|---|
isDrawReady() |
Tour Open, au moins minTicketsToDraw tickets, et soit tous les tickets vendus, soit endTime atteint |
isExpiredWithoutDraw() |
Tour Open, endTime atteint et moins de minTicketsToDraw tickets |
Avec la configuration du déploiement local (smart-contract/deployments/localhost.json), un tour dure 1 800 s, se tire à partir de 2 tickets et s'arrête à 10 000 tickets.
Salle d'attente et délai de révélation
Quand isDrawReady() devient vrai, le keeper ne tire pas tout de suite. Il lit le tour courant, puis appelle markDrawReady : la clé Redis chain:draw-ready-at:<tour> reçoit, si elle n'existe pas encore, l'instant où le tour est devenu prêt, c'est-à-dire le plus petit de l'heure courante et de endTime. La clé vit 7 jours. Tant que l'heure courante est inférieure à cet instant plus DRAW_REVEAL_DELAY_SECONDS (600 s par défaut, de 0 à 86 400), la passe renvoie waiting-room.
| Situation | Instant enregistré | Tirage demandé |
|---|---|---|
Fin du temps constatée après endTime |
endTime |
endTime + délai |
Dernier ticket vendu avant endTime |
heure de la première passe ou lecture qui le constate | cet instant + délai |
DRAW_REVEAL_DELAY_SECONDS=0 |
idem | à la passe suivante |
GET /lottery/status appelle le même markDrawReady : le premier des deux, keeper ou lecture du statut, fixe l'instant, et revealAt publié aux clients correspond à l'heure où le keeper tirera, à KEEPER_INTERVAL_MS près. Si Redis est vidé pendant l'attente, l'instant est recalculé ; avec un endTime dépassé, il revient à endTime, sinon l'attente repart de zéro.
Le délai ne bloque que le keeper. POST /lottery/draw, Chainlink Automation ou tout autre compte peuvent appeler requestDraw dès que le contrat l'accepte. Un tour expiré sans assez de tickets est clos sans attente.
Les deux transactions sont signées par le compte opérateur. Leur reçu est passé à lotteryIndexer.processReceipt : le statut LOCKED (événement DrawRequested) ou CANCELLED (événement RoundCancelled) est en base dès la fin de la passe, et les jobs de remboursement sont mis en file. Le tour suivant est ouvert par le contrat dans la même transaction pour une annulation (RoundOpened du tour suivant dans le même reçu) et dans la transaction de réponse de l'oracle pour un tirage.
Après requestDraw
Le keeper ne désigne pas le gagnant. requestDraw envoie une requête à Chainlink VRF ; l'oracle répond dans une transaction ultérieure qui appelle fulfillRandomWords et émet WinnerPicked. C'est l'indexeur qui projette ce résultat, émet draw:winner et met en file le versement de la cagnotte. Sur le nœud local, la réponse est fournie par le script smart-contract/scripts/local-vrf-responder.ts, lancé par l'image Docker de la blockchain.
Le contrat prévoit retryDraw() pour relancer une requête VRF restée sans réponse au-delà de drawTimeout. Le keeper ne l'appelle pas : un tour bloqué en Drawing demande une intervention, décrite dans les runbooks blockchain.
Verrou
Plusieurs process peuvent exécuter le keeper (l'API avec RUN_WORKERS=true et le worker, ou plusieurs workers). Pour qu'un seul envoie la transaction, chaque passe pose d'abord le verrou Redis chain:keeper-lock avec setIfAbsent :
| Paramètre | Valeur |
|---|---|
| Clé | chain:keeper-lock, préfixée comme toutes les clés (blocklucky:<NODE_ENV>:chain:keeper-lock) |
| Durée de vie | max(5, ceil(KEEPER_INTERVAL_MS / 1000)) secondes, soit 10 s par défaut |
| Libération | Dans le bloc finally, après la passe, qu'elle réussisse ou non |
La durée de vie sert de filet si le process meurt pendant la passe. Une passe qui attend un minage plus long que la durée de vie (cas possible sur Sepolia) peut laisser un second process prendre le verrou ; il trouve alors en général isDrawReady() faux, car le tour est déjà en Drawing, ou reçoit DrawNotReady, ignoré.
Sans Redis, le verrou est tenu en mémoire par process ; il ne protège plus contre deux process.
Erreurs
Toute erreur est absorbée et la passe renvoie idle. L'erreur est décodée avec decodeContractError. DrawNotReady et RoundNotCancellable sont attendues lors d'une course entre deux keepers ou avec Chainlink Automation, et ne sont pas journalisées. Les autres sont journalisées en avertissement (« Automate du tirage : … »). Le keeper réessaie à la passe suivante.
startChainServices
startChainServices() démarre les deux boucles du mode onchain et renvoie un objet { stop }, ou null en mode simulé.
| Boucle | Intervalle | Variable | Minimum |
|---|---|---|---|
lotteryIndexer.syncOnce() |
3 000 ms | INDEXER_INTERVAL_MS |
500 ms |
runKeeperOnce() |
10 000 ms | KEEPER_INTERVAL_MS |
1 000 ms |
Une première synchronisation de l'indexeur est lancée immédiatement, sans attendre le premier intervalle. Les boucles utilisent setInterval sans attendre la fin de la passe précédente ; l'indexeur se protège par son drapeau syncing et le keeper par son verrou.
stop() arrête les deux minuteurs. Il est enregistré comme étape chain de l'arrêt propre, dans backend/src/server.ts et backend/src/worker.ts. Une passe en cours n'est pas interrompue.
Lieux d'exécution :
backend/src/worker.tsappelle toujoursstartChainServices();backend/src/server.tsl'appelle seulement siRUN_WORKERSvauttrue(valeur par défaut).
En Docker Compose, l'API a RUN_WORKERS: "false" : seul le worker fait tourner le keeper.
Tirage manuel
Un administrateur peut forcer le tirage par POST /api/v1/lottery/draw, qui appelle requestDraw de la passerelle. La transaction est la même que celle du keeper ; elle échoue avec DrawNotReady (409) si isDrawReady() est faux. POST /api/v1/lottery/refund appelle cancelRound, réservé au rôle opérateur, qui annule le tour courant sans condition de durée.
Tester le keeper
Le test d'intégration avance l'horloge du nœud Hardhat au-delà de endTime, puis appelle directement runKeeperOnce() et vérifie qu'il renvoie draw-requested :
await chainClient.provider.send('evm_increaseTime', [Number(round.endTime) - Math.floor(Date.now() / 1000) + 60]);
await chainClient.provider.send('evm_mine', []);
expect(await runKeeperOnce()).toBe('draw-requested');