Portefeuille et loterie
Sur mobile comme sur le web, le citoyen n'a pas de portefeuille à installer ni à connecter. Il n'y a ni MetaMask, ni WalletConnect, ni clé privée sur le téléphone. Le backend crée à l'inscription un portefeuille Ethereum intégré, chiffre sa clé, le crédite d'une dotation de bienvenue de 0,5 ETH fictif (WELCOME_GRANT_WEI) et signe chaque transaction pour le compte du citoyen. L'application se contente d'appeler l'API, d'afficher le solde, le ticket et les reçus, et d'ouvrir l'explorateur de blocs.
Le fonctionnement serveur est décrit dans Portefeuilles intégrés et Passerelle du contrat et erreurs. La version web de cette page est Portefeuille et loterie du web.
Règles du grand tirage
| Règle | Source | Effet dans l'application |
|---|---|---|
| Un seul ticket par participant et par grand tirage | MAX_TICKETS_PER_PLAYER (1 par défaut) au déploiement du contrat, contrôle du backend, quantité limitée à 1 par buyTicketSchema |
Aucun choix de quantité ; une fois le ticket acheté, le bouton affiche « Ticket déjà validé » |
| Un cadeau compte comme le ticket du destinataire | Backend : 409 « Cette personne a déjà son ticket pour ce grand tirage. » | Fenêtre d'information dédiée |
| Numéro de ticket sur 5 chiffres | ticketNumber des réponses (index du ticket plus un, complété par des zéros) |
Affiché sur la carte du ticket, la roue et les résultats |
| Phases du tour | round.phase de GET /lottery/status : OPEN, WAITING_ROOM, DRAWING, COMPLETED, CANCELLED |
Bouton d'achat, accès à la salle, écran du tirage |
| Délai de révélation | round.revealAt, calculé avec DRAW_REVEAL_DELAY_SECONDS (600 par défaut) |
Compte à rebours de la salle d'attente |
| Âge vérifié | AGE_VERIFICATION_REQUIRED côté backend |
Fenêtre qui mène à VerifyAge (voir Vérification d'âge requise) |
lotteryFormat.ts et drawFormat.ts regroupent les calculs partagés : resolvePhase déduit la phase de status et inWaitingRoom si le backend ne la fournit pas, resolveParticipants lit participants et maxParticipants du tour, purchaseDeadline renvoie waitingRoomOpensAt ou drawTargetTime, et findCurrentRoundTicket retrouve le ticket du tour courant dans GET /lottery/my-tickets.
Mode onchain et mode démonstration
| Indice | Source | Utilisé par |
|---|---|---|
guarantees.simulated |
GET /lottery/status |
LiveDrawScreen (masquage du retrait des gains), HowItWorksScreen (certification VRF) |
balanceEth |
GET /lottery/wallet |
Accueil, billetterie, cadeau, profil |
etherscanUrl, explorerUrl, blockchain.lotteryExplorerUrl |
tickets, portefeuille, badges, statut | TransactionHistoryScreen et HowItWorksScreen, qui n'affichent le lien que si l'URL existe |
En mode simulated, les URL d'explorateur sont null et aucun lien n'apparaît. Depuis la refonte, aucun écran n'affiche plus d'étiquette « Mode démonstration » : seule IntegratedWalletCard, qui n'est plus utilisée, savait le faire. Le mode simulé ne renvoie pas non plus de solde en ETH : walletService.balanceOf renvoie null, donc balanceEth vaut null, la tuile « ETH fictifs » du profil disparaît, et la billetterie considère le solde comme suffisant (sufficient vaut vrai quand le solde est inconnu). Voir Mode onchain ou simulé.
Composants web3
Les trois composants de mobile-app/src/components/web3 n'ont plus d'appelant depuis la refonte de l'interface. Ils sont conservés dans le dépôt.
| Composant | Rôle d'origine |
|---|---|
IntegratedWalletCard.tsx |
Carte « Portefeuille BlockLucky intégré » : adresse raccourcie, solde, étiquette « Mode démonstration », lien vers l'explorateur |
IntegratedWalletButton |
Pastille compacte de l'ancien en-tête de l'accueil (voir ci-dessous) |
ExplorerLink.tsx |
Bouton texte qui appelle Linking.openURL(url), utilisé seulement par IntegratedWalletCard |
IntegratedWalletButton
IntegratedWalletButton.tsx reçoit walletAddress, balanceEth, providerLabel et onPress. Il remplaçait WalletConnectButton, supprimé, dont le libellé « Connecter » évoquait un portefeuille externe. L'accueil actuel affiche à sa place le logo et le bouton des notifications ; le solde est visible sur le profil, la billetterie et l'historique.
Les écrans ouvrent désormais l'explorateur directement avec Linking.openURL, dans le navigateur du téléphone.
Le hook useCitizenWallet
mobile-app/src/hooks/useCitizenWallet.ts est l'équivalent mobile de useBlockLuckyWallet. Les fonctions d'achat et de cadeau renvoient un résultat typé au lieu d'ouvrir elles-mêmes des fenêtres : c'est l'écran qui choisit l'affichage.
| Membre | Rôle |
|---|---|
purchaseTickets(retryAttempt) |
POST /lottery/buy avec quantity: 1 ; met à jour les points ; renvoie SUCCESS, ALREADY_OWNED, AGE_REQUIRED ou FAILED |
sendGiftTicket(recipientEmail, personalMessage) |
POST /lottery/gift ; met à jour les points de l'expéditeur ; mêmes résultats |
retryPurchaseFromQueue(retryAttempt) |
Relance l'achat depuis la fenêtre globale « Réseau encombré » et ouvre la fenêtre suivante selon le résultat |
claimPrize(contractRoundId) |
POST /lottery/claim-prize ; renvoie { status: 'CLAIMED' | 'ALREADY_CLAIMED' | 'FAILED', data?, message? } |
generateDeterministicWallet(seed) |
Ancienne adresse factice calculée depuis l'email, plus appelée |
isBuying, isGifting, isClaiming |
États de chargement des mutations |
Le hook n'envoie aucun événement analytique, contrairement au web, et n'envoie pas d'en-tête Idempotency-Key.
Messages d'erreur
classifyTicketError(err, alreadyOwnedMessage) lit err.status et les détails dans err.data.errors (ou err.data.details), dans cet ordre :
| Condition | Résultat | Message |
|---|---|---|
403 et code === 'AGE_VERIFICATION_REQUIRED' |
AGE_REQUIRED |
Message du backend, « Vérifiez votre âge pour jouer. » |
409 avec contractError === 'PlayerLimitReached' ou sans contractError |
ALREADY_OWNED |
Message du backend, sinon « Vous avez déjà votre ticket pour ce grand tirage. » ou son équivalent pour un cadeau |
429 |
FAILED, queued |
« Forte affluence » |
reconnu par describeBlockchainError |
FAILED sans nouvel essai |
Titre et message de la table suivante |
503, canRetry, absence de statut ou FETCH_ERROR |
FAILED, canRetry |
« L'achat n'a pas abouti. » et message du backend |
describeBlockchainError(err) traduit trois cas :
| Condition | Titre | Message |
|---|---|---|
status === 402 ou contractError === 'InsufficientFunds' |
Solde insuffisant | Le portefeuille ne contient pas assez d'ethers fictifs pour le ticket et les frais de réseau |
status === 503 et circuitBreaker |
Loterie en pause | Message du backend ou texte de repli sur la pause de sécurité |
status === 409 |
Opération impossible | Message du backend |
Les échecs non relançables sont aussi enregistrés par sentryClient.captureException. claimPrize distingue d'abord 409 avec PrizeAlreadyClaimed (déjà versé) et 403 (« Ce portefeuille n'est pas le gagnant de ce tour »), puis describeBlockchainError, puis un message générique.
Points à connaître :
- un 409 sans
contractError, par exemple « Ce tour est terminé, le tirage va être lancé. » en mode simulé, est classéALREADY_OWNED: le message du backend s'affiche, mais dans la fenêtre prévue pour un ticket déjà acheté ; - les titres et messages du hook sont en français seulement, sans
t().
Vérification d'âge requise
Quand le backend répond 403 avec errors.code === 'AGE_VERIFICATION_REQUIRED', la billetterie, le cadeau, le détail d'une récompense et le ticket à gratter ouvrent une fenêtre qui propose d'aller à VerifyAge. Pour la boutique, la détection est faite par isAgeVerificationError de apiErrors.ts. L'envoi de la pièce d'identité est décrit dans Authentification et stockage sécurisé.
Billetterie
BuyTicketScreen.tsx achète le ticket unique du grand tirage :
| Élément | Donnée |
|---|---|
| Carte du grand tirage | Cagnotte (prizePool), numéro du tour (#001), compte à rebours jusqu'à waitingRoomOpensAt, participants |
| Récapitulatif | Prix (round.ticketPriceEth), solde actuel et solde après achat (GET /lottery/wallet) |
| Pied fixe | « Total · 1 ticket », bouton, rappel « En confirmant, vous acceptez le règlement du tirage. » |
Le bouton change de libellé et se désactive dans trois cas : ticket déjà présent dans GET /lottery/my-tickets (« Ticket déjà validé »), phase différente de OPEN (« Achats clos pour ce tirage ») ou solde connu inférieur au prix (« Solde insuffisant »). Après un succès, une fenêtre confirme le ticket et ses points, puis mène à l'accueil ou à « Mes tickets ». Un échec relançable incrémente retryAttempt (5 au plus) pour le nouvel essai.
Cadeau
GiftTicketScreen.tsx offre un ticket à un proche désigné par son email, avec un message facultatif. Les règles de désactivation sont les mêmes (phase, solde). Le backend refuse de s'offrir un ticket à soi-même et un destinataire qui a déjà son ticket. Le paramètre de route ticketId, passé depuis « Mes tickets », n'est pas lu par l'écran.
Accueil et Mes tickets
L'accueil (HomeScreen.tsx) rafraîchit le statut toutes les 30 secondes. Selon la phase, la carte du grand tirage propose « Mon ticket · 0,01 ETH » (achat), « Voir mon ticket » si le citoyen a déjà son ticket, « Entrer dans la salle d'attente » en WAITING_ROOM ou « Suivre le tirage en direct » en DRAWING ; les deux derniers ouvrent WaitingRoom. Le compte à rebours vise revealAt en salle d'attente et waitingRoomOpensAt sinon. MilestonesPanel affiche les paliers renvoyés par le backend (5 000, 7 500, 9 000 et 10 000 participants) et DeadlineCard l'échéance.
« Mes tickets » (MyTicketsScreen.tsx) affiche le ticket du tour courant (CurrentTicketCard, avec le délai avant révélation), puis, avec des filtres, les résultats des grands tirages passés (isWinner, winningTicketNumber, prizeEth) et les codes obtenus aux tickets à gratter.
Salle d'attente
WaitingRoomScreen.tsx interroge le statut toutes les 5 secondes. Dans les 8 dernières secondes du compte à rebours, il joue une fois chrono.mp3, sauf si les sons sont coupés dans Affichage.
| Situation | Affichage |
|---|---|
Phase WAITING_ROOM et ticket du citoyen trouvé |
Salle active : compte à rebours minutes et secondes jusqu'à revealAt, ticket du citoyen et adresse raccourcie, présence (onlineWaitingUsers), discussion RoomChat |
| Autre phase ou pas de ticket | WaitingRoomClosed, avec l'accès à l'achat |
Phase DRAWING ou COMPLETED, compte à rebours arrivé à zéro, gagnant annoncé (draw:winner) pour le tour suivi, ou tour courant plus récent que celui du ticket |
replace('LiveDraw', { contractRoundId }), avec le numéro du tour où le citoyen a son ticket |
Sans revealAt, le compte à rebours part de waitingRoomOpensAt plus WAITING_ROOM_SECONDS (600, valeur codée dans drawFormat.ts). Les messages d'ambiance changent selon le temps écoulé. La connexion Socket.IO n'est ouverte que lorsque la salle est active (voir Temps réel et notifications).
Tirage en direct
LiveDrawScreen.tsx interroge le statut toutes les 4 secondes et rejoint la salle Socket.IO pendant les phases DRAWING, COMPLETED et CANCELLED. Hors tirage, il affiche le dernier tour terminé du citoyen.
Ouvert avec contractRoundId depuis la salle d'attente, l'écran suit ce tour même si le contrat en a déjà ouvert un autre : la roue tourne tant que le résultat n'est pas connu (liste des tickets relue toutes les 4 secondes), puis s'arrête sur le numéro gagnant dès que le ticket du citoyen le donne. Sur la blockchain locale, le choix du gagnant et l'ouverture du tour suivant sont quasi immédiats : sans ce suivi, le statut montrait déjà le nouveau tour et la roue n'apparaissait que dans l'historique. La salle d'attente transmet aussi le nombre de participants, le plafond et la cagnotte de ce tour (participants, maxParticipants, prizePool) : l'écran les utilise tant que le statut affiche un autre tour, pour que la roue garde tous ses secteurs jusqu'à l'arrêt au lieu de se vider avec le nouveau tour, encore sans participant.
| Élément | Comportement |
|---|---|
DrawWheel |
Roue de 10 secteurs animée avec Animated : rotation en boucle pendant le tirage, puis arrêt sur le secteur du numéro gagnant ; le son roue-tirage.mp3 accompagne la rotation si les sons sont activés (voir Thème et composants) |
| Étiquettes de la roue | Numéros à 5 chiffres répartis sur le nombre de participants ; le numéro gagnant et celui du citoyen y figurent toujours |
| Résultat | Numéro gagnant, gagné ou perdu (isWinner du ticket, ou adresse du gagnant égale à celle du portefeuille), annoncé par accessibilityLiveRegion |
| Retrait des gains | Bouton affiché au gagnant si guarantees.simulated est faux ; message selon CLAIMED, ALREADY_CLAIMED ou FAILED |
| Tour annulé | Message dédié, sans roue arrêtée |
L'annonce draw:winner reçue par le socket est prise en compte si elle concerne le même contractRoundId. La roue ne tire rien : le gagnant vient toujours du contrat ou du backend.
Historique et POAP
TransactionHistoryScreen.tsx appelle GET /lottery/wallet et GET /lottery/my-tickets. Il affiche le portefeuille avec un lien vers l'explorateur, les tickets avec leur numéro et le lien vers la transaction, et les badges POAP sous forme de lignes (titre, édition, date de frappe, lien vers l'explorateur). Le visuel SVG des badges n'est plus chargé. Les badges sont décrits dans Badge POAP.
Tickets à gratter
Les tickets à gratter s'achètent en EtherPoints, sans ETH. ScratchCardScreen.tsx a deux usages :
| Ouverture | Comportement |
|---|---|
ScratchCard avec offer (violet ou lucky), ou sans paramètre |
Choix de l'offre, confirmation, POST /marketplace/scratch, puis zone à gratter sur la récompense tirée au hasard par le backend |
ScratchCard avec claim |
Révélation d'un code déjà obtenu par POST /marketplace/claim depuis RewardDetail |
| Offre | Coût affiché | Variable du backend |
|---|---|---|
| Violet | 50 EtherPoints | SCRATCH_COST_VIOLET |
| Lucky | 100 EtherPoints | SCRATCH_COST_LUCKY |
Les coûts affichés sont codés dans scratchOffers.ts : si les variables du backend changent, l'application affiche un prix faux, mais c'est le backend qui débite. Après l'achat, le nouveau solde (newBalance) est écrit dans le profil par setPointsBalance. Les erreurs sont traduites par statut : 402 ou 400 « EtherPoints insuffisants », 404 ou 409 « Aucune récompense disponible », 403 de vérification d'âge vers VerifyAge. Une fois le code révélé, l'écran mène à « Mes codes » par replace('MyCodes').