Annulation et remboursement
Un tour est annulé dans deux cas : sa durée est écoulée sans avoir atteint minTicketsToDraw tickets, ou la Mairie l'annule. Dans les deux cas, le contrat marque le tour Cancelled et chaque mise devient remboursable par retrait (claimRefund). La Mairie ne peut conserver aucun fonds d'un tour annulé. Le backend réclame ensuite automatiquement chaque remboursement pour le compte des payeurs.
Diagramme de séquence
sequenceDiagram
autonumber
participant A as Mairie ou keeper
participant API as API lottery
participant G as LotteryGateway
participant BL as Contrat BlockLucky
participant I as LotteryIndexer
participant DB as PostgreSQL
participant Q as File chain-actions
participant W as Worker chain-actions
alt annulation par la Mairie
A->>API: POST /lottery/refund
API->>G: cancelRound
G->>BL: cancelRound par l'opérateur
else tour expiré, par le keeper
A->>BL: isExpiredWithoutDraw puis closeExpiredRound
end
BL->>BL: statut Cancelled, RoundCancelled, _openRound
BL-->>G: reçu
G->>I: processReceipt
I->>DB: statut CANCELLED
I->>DB: payerWallet distincts des tickets du tour
loop pour chaque payeur
I->>Q: CLAIM_REFUND jobId CLAIM_REFUND-tour-payeur
end
I->>DB: nouveau tour OPEN
API-->>A: 200 refundedAmountEth, transactionHash
Q-->>W: job CLAIM_REFUND
W->>DB: compte du payeur et clé chiffrée
W->>BL: claimRefund(tour) signé par le payeur
BL-->>W: RefundClaimed et envoi des ethersDéclenchement
Tour expiré, par le keeper
runKeeperOnce (backend/src/blockchain/lottery.keeper.ts) teste d'abord isDrawReady, puis isExpiredWithoutDraw. Cette seconde fonction du contrat est vraie quand le tour est Open, que endTime est atteint et que ticketCount < minTicketsToDraw. Le keeper appelle alors closeExpiredRound() avec le compte opérateur, attend le reçu et l'indexe. closeExpiredRound n'exige aucun rôle : n'importe quel compte, ou Chainlink Automation, peut clôturer un tour expiré.
Le keeper ne fait rien quand le contrat est en pause : un tour qui expire pendant une pause n'est clôturé qu'à la reprise, ou par un appel direct à closeExpiredRound, qui n'est pas bloqué par la pause.
Par la Mairie
POST /api/v1/lottery/refund dans backend/src/api/lottery/lottery.routes.ts : authenticate, requireRoles('ADMIN'), sans validation de corps ni idempotence. lotteryController.cancelAndRefund appelle lotteryService.CancelAndAutoRefundRound, qui délègue à cancelOnchain (backend/src/api/lottery/lottery.services.ts) :
- Lecture du tour courant (
readCurrentRound) pour connaître le montant collecté. lotteryGateway.cancelRound()(backend/src/blockchain/lottery.gateway.ts) envoiecancelRound()avec le compte opérateur, attend le reçu et l'indexe.- Relecture du tour en base, déjà passé à
CANCELLEDpar l'indexation du reçu. - Réponse 200 avec
autoRefundExecuted: true,refundedAmountEth(total collecté), le hash et le lien vers l'explorateur.
Le roundId éventuellement envoyé est ignoré : seul le tour courant peut être annulé.
À savoir
autoRefundExecuted: true signifie que les remboursements sont mis en file, pas qu'ils ont été versés. Les ethers arrivent dans les portefeuilles quand le worker a traité chaque job CLAIM_REFUND.
Dans le contrat
cancelRound (smart-contract/contracts/BlockLucky.sol) est réservée à OPERATOR_ROLE. Elle ne vérifie ni l'état du tour ni la pause : elle annule le tour courant, qu'il soit Open ou Drawing. _cancelRound supprime l'association avec une éventuelle requête VRF en cours (une réponse tardive sera ignorée), passe le tour à Cancelled, émet RoundCancelled(roundId, ticketCount, totalCollected) et ouvre le tour suivant.
Remboursements
onRoundCancelled (backend/src/blockchain/lottery.indexer.ts) passe le tour à CANCELLED, sélectionne les valeurs distinctes de payerWallet parmi les tickets du tour, et met en file un job CLAIM_REFUND par payeur.
executeChainAction (backend/src/blockchain/chain-actions.ts) retrouve le compte du payeur par son adresse et signe claimRefund(contractRoundId) avec son portefeuille. Le contrat vérifie que le tour est Cancelled et que la contribution de l'appelant est non nulle, la remet à zéro, émet RefundClaimed et envoie le montant.
Le remboursement est calculé sur contributions[roundId][msg.sender], c'est-à-dire sur le payeur. Pour un ticket offert, c'est l'expéditeur qui est remboursé, pas le destinataire. Les frais de la transaction de remboursement sont payés par le payeur sur son solde d'ethers fictifs.
L'événement RefundClaimed n'est pas indexé : la base ne garde pas trace des remboursements effectués. Pour savoir si un payeur a été remboursé, lire contributionOf(roundId, payeur) dans le contrat (0 après remboursement).
Erreurs possibles
| Point | Code ou effet | Cause |
|---|---|---|
POST /lottery/refund |
403 | Rôle ADMIN absent ou 2FA non activée ; AccessControlUnauthorizedAccount si le compte opérateur n'a pas OPERATOR_ROLE |
POST /lottery/refund |
503 | Réseau injoignable |
POST /lottery/refund |
502 | « L'annulation du tour a échoué. » pour toute erreur non reconnue |
| Keeper | Ignoré silencieusement | RoundNotCancellable : le tour n'est plus expiré sans tirage |
Job CLAIM_REFUND |
Terminé sans erreur | NothingToRefund : déjà remboursé |
Job CLAIM_REFUND |
Terminé sans rien faire | Payeur sans compte ou sans clé chiffrée (compte supprimé, adresse externe) |
Job CLAIM_REFUND |
Échec définitif | RoundNotCancelled (UnrecoverableError) |
Job CLAIM_REFUND |
Jusqu'à 5 tentatives | Réseau, solde insuffisant pour les frais |
Idempotence
- Un second
POST /lottery/refundannule le tour suivant, qui vient d'être ouvert : l'appel n'est pas idempotent. Il n'est pas protégé parIdempotency-Key. La console Mairie doit éviter le double envoi. closeExpiredRoundne réussit qu'une fois par tour : ensuiteisExpiredWithoutDrawest fausse pour le nouveau tour.- L'identifiant
CLAIM_REFUND-<tour>-<adresse>déduplique les jobs, et la contribution remise à zéro empêche un second remboursement (NothingToRefund, traité comme une réussite). - Les payeurs dont les tickets n'ont pas été indexés (indexeur en retard ou en panne) ne reçoivent pas de job. Ils peuvent toujours appeler
claimRefundeux-mêmes : le contrat garde leur contribution sans limite de durée.
En mode simulé
CancelAndAutoRefundRound passe seulement le tour à CANCELLED en base et répond autoRefundExecuted: true avec le message « Tous les participants ont été remboursés automatiquement via le Smart Contract ». Aucun remboursement n'a lieu : ce message est trompeur en mode simulé.