Portefeuille et loterie
Cette page décrit comment le navigateur affiche le portefeuille intégré du citoyen, achète et offre des tickets, suit le tirage et retire une cagnotte. Le principe à retenir : le web n'a jamais de clé privée. Le citoyen n'installe ni MetaMask ni autre extension. Le backend crée un portefeuille Ethereum par compte, chiffre sa clé, le crédite d'une dotation de bienvenue en ethers fictifs et signe les transactions à sa place. Le navigateur ne fait qu'appeler l'API et afficher les reçus.
Le fonctionnement côté serveur est dans Portefeuilles intégrés et Passerelle du contrat et erreurs. Le parcours métier complet est dans Achat et cadeau de ticket et Tirage et versement de la cagnotte.
Mode onchain et mode démonstration
Le backend fonctionne en BLOCKCHAIN_MODE=onchain (vrai contrat sur une blockchain locale ou Sepolia) ou simulated (données en base, sans chaîne). Le frontend ne lit pas cette variable : il déduit le mode des réponses.
| Indice | Source | Utilisé par |
|---|---|---|
guarantees.simulated |
GET /lottery/status |
DemoModeBadge, textes du tirage, WinnerClaimCard |
guarantees.vrfCertified |
GET /lottery/status |
page /tirage (preuve VRF affichée ou non) |
mode: 'onchain' | 'simulated' |
GET /lottery/wallet |
badge de WalletCard |
explorerUrl, etherscanUrl |
portefeuille, tickets, retrait | liens vers l'explorateur, absents en mode simulé |
En mode simulé, l'interface reste utilisable mais affiche « Mode démonstration » et précise que le tirage n'est pas certifié par Chainlink VRF. Le détail des deux modes est dans Mode onchain ou simulé.
Composants
WalletCard
frontend-web/src/components/lottery/WalletCard.tsx appelle useGetWalletQuery() (GET /lottery/wallet) et affiche :
| Élément | Donnée |
|---|---|
| Titre « Portefeuille BlockLucky intégré » | fixe |
| Badge « Mode démonstration » | si mode === 'simulated' |
| Adresse raccourcie (6 caractères de chaque côté), adresse complète en infobulle | walletAddress |
| Solde en ethers fictifs, 4 décimales au plus, formaté selon la langue | balanceEth, ou « Solde indisponible » si null |
| Lien « Voir le portefeuille sur l'explorateur de blocs » | explorerUrl si présent |
Un squelette s'affiche pendant le chargement et un message « Portefeuille momentanément indisponible » en cas d'erreur. Le composant est utilisé dans /billetterie et /profil. Le tag Wallet est invalidé après chaque achat, cadeau ou retrait, et à la réception de draw:winner : le solde se met à jour seul.
DemoModeBadge et NetworkPill
DemoModeBadge est un Badge de ton warning qui ne s'affiche que si sa prop simulated est vraie. Il apparaît dans l'en-tête de /accueil, /billetterie et /tirage.
NetworkPill résume status.network : « Participations suspendues par la Mairie » si isPaused (ou circuitBreakerTripped), « Relais secondaire actif » si isFallback (ou fallbackActive), sinon « Réseau Ethereum synchronisé » avec la latence si elle est connue.
WinnerClaimCard
frontend-web/src/components/lottery/WinnerClaimCard.tsx n'affiche rien, sauf si le portefeuille du compte connecté est le gagnant. Il compare, en minuscules :
- l'adresse de
GET /lottery/wallet(ou, à défaut,user.walletAddress) ; - le gagnant de
lastWinnerAnnouncement(événement temps réel) ou, à défaut,status.round.winnerWallet.
stateDiagram-v2 [*] --> Masque Masque --> Gagnant: adresse du compte egale au gagnant Gagnant --> Demo: guarantees.simulated Gagnant --> Attente: mode onchain Attente --> EnCours: Retirer mes gains EnCours --> Verse: claimed EnCours --> DejaVerse: PrizeAlreadyClaimed EnCours --> Erreur: error Erreur --> EnCours: nouvel essai
| État | Affichage |
|---|---|
| Démonstration | Rappel que la cagnotte n'est pas versée sur une blockchain réelle ; pas de bouton |
| Attente | Explication : la plateforme verse automatiquement la cagnotte ; bouton « Retirer mes gains » en secours |
| Versé | Nouveau solde (walletBalanceEth) et lien Etherscan (etherscanUrl) |
| Déjà versé | « Cagnotte déjà versée sur votre portefeuille » |
| Erreur | Alert de ton warning (contrat en pause) ou error |
Le bouton appelle claimPrize(contractRoundId), donc POST /lottery/claim-prize. En mode simulé, le backend refuserait l'appel avec une erreur 409 ; le composant ne propose donc pas le bouton. La carte est placée en haut de /accueil et de /tirage, dans une zone aria-live="polite".
Le hook useBlockLuckyWallet
frontend-web/src/hooks/useBlockLuckyWallet.ts regroupe les trois opérations qui déclenchent une transaction signée par le portefeuille intégré.
| Fonction | Appel | Succès | Retour |
|---|---|---|---|
purchaseTicket(quantity, retryAttempt) |
POST /lottery/buy |
Met à jour les points (updateUserPoints), ouvre la feuille « Points crédités » |
BuyTicketResponse ou null |
sendGiftTicket(recipientEmail, personalMessage) |
POST /lottery/gift |
Met à jour les points de l'expéditeur, feuille « Ticket offert à... » | GiftTicketResponse ou null |
claimPrize(contractRoundId) |
POST /lottery/claim-prize |
aucune modale | ClaimPrizeOutcome : claimed, already-paid ou error |
Il expose aussi isBuying, isGifting et isClaiming. purchaseTicket envoie ticket_submitted avant l'appel et ticket_confirmed après le succès (voir Analytique et consentement).
Les deux premières fonctions ne lèvent jamais d'exception : elles traduisent l'erreur en feuille modale et renvoient null. claimPrize renvoie un résultat que le composant affiche lui-même.
Messages d'erreur
notifyChainError applique les règles dans cet ordre. La première qui correspond l'emporte.
| Ordre | Condition | Réaction | Cache invalidé |
|---|---|---|---|
| 1 | status === 429 (limitation de débit) |
Feuille « File d'attente active », position fixe 245 | aucun |
| 2 | errors.circuitBreaker vrai (contrat en pause, EnforcedPause, réponse 503) |
Feuille « Participations suspendues » avec le message du backend ou un texte de repli | Lottery |
| 3 | status === 402 ou errors.contractError === 'InsufficientFunds' |
Feuille « Solde insuffisant » : la dotation ne couvre pas le ticket et les frais réseau, rien n'a été débité | Wallet |
| 4 | status === 409 (tour fermé, expiré, complet, tirage pas prêt...) |
Feuille « Action impossible pour ce tour » avec le message du backend | Lottery |
| 5 | achat seulement, status === 503 ou errors.canRetry |
Feuille « Réseau encombré » avec bouton « Réessayer l'achat » | aucun |
| 6 | autre cas | Feuille générique avec le message du backend ou le texte de repli | aucun |
Les codes viennent de la table CONTRACT_ERRORS de backend/src/blockchain/lottery.gateway.ts : 503 pour EnforcedPause, 409 pour RoundNotOpen, RoundExpired, RoundSoldOut, PrizeAlreadyClaimed..., 402 pour un solde insuffisant, 503 avec canRetry pour un nœud injoignable, 502 avec canRetry pour une erreur non reconnue. En mode simulé, LotteryService.buyTickets renvoie aussi 503 avec canRetry quand le relais échoue. Le plafond personnel de jeu responsable, qui pouvait produire un 400, a été retiré : la seule limite est un ticket par participant et par tour (409).
Le bouton de la feuille « Réseau encombré » rappelle purchaseTicket(quantity, retryAttempt + 1) depuis GlobalOverlays. Le compteur retryAttempt est envoyé au backend, qui le renvoie incrémenté.
Pour claimPrize, l'ordre est : PrizeAlreadyClaimed donne already-paid (et invalide Wallet), disjoncteur donne une erreur warning, solde insuffisant pour les frais du retrait donne une erreur error, tout autre cas donne le message du backend.
Billetterie
frontend-web/src/app/(citoyen)/billetterie/page.tsx propose deux onglets (SegmentedTabs) : « Acheter pour moi » et « Offrir un ticket ». ?mode=cadeau ouvre directement le second ; c'est le lien du bouton « Offrir un ticket » de /accueil.
Achat pour soi
La page achète toujours un seul ticket (purchaseTicket(1, 0)) : il n'y a pas de choix de quantité.
- Affichage du total : prix d'un ticket
round.ticketPriceEth(repli 0,01), « + frais réseau, prélevés sur votre portefeuille BlockLucky », du bonus de 50 points plus le certificat POAP, et de la règle « Un ticket par habitant et par tirage. ». - Case obligatoire : « Je comprends que j'engage des ethers fictifs, sans valeur monétaire, dans un tirage pédagogique ».
- Bouton « Confirmer mon ticket », désactivé tant que la case n'est pas cochée, si le tour n'est pas
OPEN, si le contrat est en pause ou si l'âge n'est pas vérifié.
Un second achat sur le même tour reçoit 409 Vous avez déjà votre ticket pour ce grand tirage., affiché dans la feuille « Action impossible pour ce tour ».
Si user.ageVerificationStatus existe et ne vaut pas VERIFIED, une alerte s'affiche au-dessus du formulaire et les boutons d'achat et de cadeau sont désactivés :
| Statut | Message | Lien vers /profil/identite |
|---|---|---|
PENDING |
« Votre pièce d'identité est en cours de vérification par la Mairie. Vous pourrez jouer dès sa validation. » | « Voir le suivi » |
NONE, REJECTED |
« Vérifiez votre âge pour participer au tirage. » | « Envoyer ma pièce » |
Le statut vient du hook useAgeVerification (voir Vérification d'identité) : le statut lu par GET /user/identity, ou à défaut celui du profil stocké. Sans aucun des deux, rien n'est bloqué côté client : le backend reste seul juge.
Le reçu, à droite, passe par trois états :
| État | Affichage |
|---|---|
| aucun | « Votre reçu apparaîtra ici après confirmation » |
pending |
« Demande envoyée, en attente du réseau » et « Ticket en cours de validation » |
confirmed |
Pour chaque ticket : hash raccourci, numéro de bloc, date, lien Etherscan ; points gagnés et nouveau solde ; mention « Certificat POAP en cours d'émission » si poapBadge.status === 'MINT_PENDING' |
Si l'achat échoue, le reçu revient à l'état vide et la feuille d'erreur s'affiche. Si le backend répond « Vérifiez votre âge pour jouer. » alors que le statut connu de la page ne l'avait pas prévu, c'est la feuille générique qui s'affiche. Le numéro de ticket sur 5 chiffres (ticketNumber) renvoyé par l'API n'est pas affiché par le reçu.
À savoir
Le texte du reçu en attente affirme qu'un nouvel appui ne créera pas de doublon. Dans les faits, la protection vient du bouton en état loading (désactivé, aria-busy) et du garde if (isBuying) return. Le client n'envoie pas d'en-tête Idempotency-Key : deux onglets peuvent donc envoyer deux achats.
Cadeau
Le formulaire demande l'email du destinataire (isValidEmail) et un message personnel de 250 caractères au plus, prérempli. sendGiftTicket appelle POST /lottery/gift. Un destinataire qui a déjà son ticket pour le tour est refusé en 409. En cas de succès, la page affiche « Ticket envoyé à ... Vous avez reçu +n pts » ; le bonus annoncé est de 75 points (GIFT_BONUS_POINTS). Le bouton est désactivé si le tour est fermé ou en pause, ou si l'âge n'est pas vérifié.
Achat rapide
/accueil propose un bouton « Acheter 1 ticket » qui appelle purchaseTicket(1, 0) puis relance getLotteryStatus. Il n'y a pas de case de compréhension sur ce raccourci : la mention « 1 ticket = 0,01 ETH fictif + frais réseau... aucune somme réelle engagée » figure sous le bouton.
Ce bouton (variante secondary, en encre), le lien « Salle de tirage » et la mention sont placés à l'intérieur de la carte jaune du compte à rebours, comme sur l'écran d'accueil du mobile. CountdownTimer accepte pour cela une prop facultative children, rendue sous l'échéance. L'accueil lui passe aussi sound : un bouton SoundToggle s'affiche à côté du numéro du tirage et chrono.mp3 est joué une fois par échéance quand il reste 8 secondes ou moins (CHRONO_SECONDS). La carte affiche une pilule encre « Chainlink VRF » ou « Mode démonstration », le numéro du tirage, la cagnotte en grand et l'échéance dans un encart sun-soft. Quand les participations sont fermées ou en pause, un message role="status" s'ajoute au-dessus du bouton, qui est désactivé.
Vérification d'identité
frontend-web/src/app/(citoyen)/profil/identite/page.tsx répond à /profil/identite. On y arrive par le bouton « Vérification d'identité » de /profil (libellé « Vérification d'identité : âge vérifié » une fois le statut VERIFIED) ou par le lien de l'alerte de la billetterie.
Le statut vient du hook useAgeVerification, partagé avec /billetterie et /profil. Il appelle useGetIdentityStatusQuery (GET /user/identity, tag Identity), sauté sans session, relancé à chaque montage et quand la fenêtre reprend le focus. Si le statut reçu diffère de user.ageVerificationStatus, il met à jour le profil dans le slice auth (updateUserProfile) et dans le stockage (bl.auth.user). Il renvoie identity, status (statut reçu, ou à défaut celui du profil), isLoading, isError et refetch. La page affiche NONE si aucun statut n'est connu. Le type IdentityStatus de user.types.ts porte status, submittedAt, reviewedAt et note.
| Statut | Badge | Affichage |
|---|---|---|
NONE |
« À envoyer » | formulaire d'envoi |
PENDING |
« En cours de vérification » | date d'envoi ; pas de formulaire |
VERIFIED |
« Âge vérifié » | message de confirmation et bouton « Acheter mon ticket » vers /billetterie |
REJECTED |
« Refusée » | motif saisi par la Mairie (note) s'il existe, puis formulaire pour envoyer une nouvelle pièce |
Le formulaire comporte :
- deux zones de choix de fichier, recto (« Face avec votre photo ») et verso ; chacune accepte une image JPEG ou PNG de 4 Mo au plus, affiche un aperçu et le nom du fichier, ou une erreur de format ou de taille ;
- une case de consentement (
CheckboxCard) : « J'accepte que la Mairie d'EtherBay consulte ma pièce pour vérifier mon âge. Les images sont chiffrées puis effacées dès sa décision. » ; - le bouton « Envoyer à la Mairie », désactivé tant que les deux faces et le consentement manquent.
Les images sont lues en data URL par FileReader et envoyées par useSubmitIdentityMutation (POST /user/identity avec { front, back }), qui invalide les tags Identity et User. En cas de succès, la page passe ageVerificationStatus à PENDING dans le slice auth et dans le profil stocké (bl.auth.user), vide le formulaire et affiche « Pièce envoyée. La Mairie la vérifie, vous serez notifié de sa décision. ». En cas d'échec, elle affiche le message du backend.
À savoir
Grâce à useAgeVerification, une décision de la Mairie est prise en compte au prochain chargement de /billetterie, /profil ou /profil/identite, ou quand l'onglet reprend le focus, sans nouvelle connexion.
Le traitement côté serveur est décrit dans Utilisateur.
Tirage en direct
frontend-web/src/app/(citoyen)/tirage/page.tsx rassemble :
| Bloc | Source |
|---|---|
Salle d'attente : nombre de personnes en ligne, texte des 10 dernières minutes (round.inWaitingRoom) |
draw:waiting-room via useSocket(round.id), statut rafraîchi toutes les 15 s |
WinnerClaimCard |
voir plus haut |
RouletteAnimation : gagnant, identifiant de requête VRF, état « tirage demandé » si le tour est LOCKED sans gagnant |
lastWinnerAnnouncement ou round.winnerWallet, round.chainlinkRequestId |
| Bloc de certification : preuve affichée seulement si le tirage est certifié, lien vers le contrat sur l'explorateur | guarantees, blockchain.lotteryExplorerUrl |
ChatBox : discussion et réactions rapides (Bravo, EtherBay, VRF, 10k) |
chat:message, chat:send |
RouletteAnimation dessine la roue de l'écran de tirage du mobile : l'icône wheel (dix secteurs alternant jaune pâle, lavande, blanc et pêche, numérotés de 1 à 10) en taille fixe de 260 px, un pointeur encre en haut (jaune en thème sombre) et un moyeu encre marqué « VRF » (ou « Démo » si le tirage n'est pas certifié). Pendant la rotation, la roue tourne avec animate-bl-spin et la pastille du haut passe à « Tirage... ». Le gagnant s'affiche ensuite dans une pilule jaune. Les props (spinning, winnerWallet, vrfRequestId, certified, drawRequested) sont inchangées. Le dessin est en aria-hidden : seul le texte sous la roue, dans une zone aria-live="polite", est restitué. Tant que spinning est vrai, le composant joue roue-tirage.mp3 en boucle et l'arrête à la fin de la rotation. La page /tirage place un SoundToggle dans son en-tête, à côté du badge de mode de démonstration.
Le bouton « Simuler la roue de tirage » ne fait qu'animer la roue pendant 3,5 secondes (setRouletteSpinning). Il n'appelle pas l'API et ne tire aucun gagnant.
À l'arrivée de draw:winner, la roue tourne pendant 5 secondes ; le gagnant et WinnerClaimCard restent masqués pendant la rotation, puis s'affichent quand elle s'arrête. Un gagnant connu seulement par le rafraîchissement du statut, sans l'événement, s'affiche sans rotation.
Le tirage réel est lancé par le keeper du backend ou par la Mairie depuis /mairie/securite (POST /lottery/draw, rôle ADMIN). Le résultat arrive par l'événement draw:winner, détaillé dans Temps réel.
Historique et POAP
/historique appelle GET /lottery/my-tickets (25 tickets par défaut) et affiche chaque ticket avec TicketCard et son lien vers l'explorateur, ainsi que la galerie des badges POAP renvoyés dans poapBadges. Les badges sont décrits dans Badge POAP.