Indexer un nouvel événement
Le backend ne lit pas l'historique du contrat à chaque requête : l'indexeur (backend/src/blockchain/lottery.indexer.ts) recopie les événements du contrat dans PostgreSQL, et l'API sert ensuite la base. Un événement que l'indexeur ne traite pas n'existe pas pour l'API, les écrans ni les statistiques.
L'indexeur traite aujourd'hui six événements de BlockLucky : RoundOpened, TicketsPurchased, DrawRequested, WinnerPicked, PrizeClaimed et RoundCancelled. Cinq autres sont émis par le contrat mais ignorés : RefundClaimed, FeesWithdrawn, TreasuryUpdated, RoundConfigUpdated et VrfConfigUpdated. Aucun événement de BlockLuckyPOAP n'est indexé.
Ce guide indexe RefundClaimed dans la table Refund créée par le guide Ajouter une migration. Après l'annulation d'un tour, la base saura alors qui a été remboursé, de combien et par quelle transaction.
Fichiers à toucher
| Fichier | Modification |
|---|---|
backend/prisma/schema.prisma et une migration |
Table de destination, voir Ajouter une migration |
backend/src/blockchain/lottery.indexer.ts |
Gestionnaire de l'événement et entrée dans processLogs |
backend/__tests__/chain/onchain-lottery.chain.ts |
Test sur une vraie chaîne |
| Indexeur d'événements | Tableau des événements traités |
Si l'événement est nouveau dans le contrat, suivre d'abord Modifier le contrat : l'ABI de backend/src/blockchain/abi/BlockLucky.json doit le contenir, sinon parseLog l'ignore.
Fonctionnement de l'indexeur
flowchart TD
T["Minuteur INDEXER_INTERVAL_MS, 3 s"] --> S["syncOnce"]
S --> C["Curseur ChainCursor id BlockLucky"]
C --> G["getLogs du contrat, 2 000 blocs au plus"]
G --> P["processLogs : tri par bloc puis par index"]
R["Reçu d'une transaction envoyée par le backend"] --> P
P --> D{"Nom de l'événement"}
D --> H["Gestionnaire onXxx : écriture idempotente"]
D --> I["Ignoré"]
H --> U["Curseur avancé au dernier bloc lu"]| Mécanisme | Détail |
|---|---|
| Deux entrées | syncOnce, appelé toutes les INDEXER_INTERVAL_MS par lottery.keeper.ts, et processReceipt, appelé par la passerelle juste après chaque transaction du backend pour que l'API réponde avec des données à jour |
| Curseur | Ligne BlockLucky de la table ChainCursor : dernier bloc traité. Au premier passage, départ à CHAIN_START_BLOCK |
| Confirmations | Les CHAIN_CONFIRMATIONS derniers blocs ne sont pas lus, pour ne pas indexer un bloc réorganisé |
| Taille des lots | 2 000 blocs au plus par passage (MAX_BLOCK_RANGE) |
| Filtre | Seuls les journaux émis par LOTTERY_CONTRACT_ADDRESS sont traités |
| Ordre | Tri par numéro de bloc puis par index dans le bloc |
| Erreur | Un gestionnaire qui lève une exception interrompt le passage : le curseur n'avance pas, le lot entier est relu au passage suivant |
Important
Un même événement est donc presque toujours traité deux fois : par processReceipt juste après la transaction, puis par syncOnce quand le curseur atteint son bloc. Il peut l'être davantage après une erreur ou une réindexation. Chaque gestionnaire doit être idempotent : traiter deux fois le même événement doit laisser la base dans le même état qu'une seule fois.
1. Écrire le gestionnaire
Dans la classe LotteryIndexer, à côté de onPrizeClaimed :
private async onRefundClaimed(event: LogDescription, log: Log) {
const contractRoundId = Number(event.args.roundId);
const round = await this.ensureRound(contractRoundId, log.blockNumber);
try {
await prisma.refund.create({
data: {
lotteryId: round.id,
walletAddress: (event.args.player as string).toLowerCase(),
amountWei: (event.args.amount as bigint).toString(),
transactionHash: log.transactionHash,
logIndex: log.index,
refundedAt: await this.blockTime(log.blockNumber),
},
});
businessEventsTotal.inc({ event: 'refund_indexed' });
} catch (error) {
if (!isUniqueViolation(error)) throw error;
}
}| Choix | Raison |
|---|---|
Arguments lus par leur nom (event.args.player) |
Noms de l'ABI : RefundClaimed(uint256 indexed roundId, address indexed player, uint256 amount) |
| Adresse en minuscules | Toutes les adresses de la base sont en minuscules ; les comparaisons se font sans conversion |
| Montant en chaîne | Un uint256 dépasse la précision d'un nombre JavaScript et d'un BIGINT |
ensureRound |
Crée le tour en base à partir de getRound s'il n'a jamais été vu, par exemple après une réindexation partielle |
blockTime |
Date du bloc, mise en cache : la date d'un événement est celle de la chaîne, pas celle de l'indexation |
Unicité (transactionHash, logIndex) et isUniqueViolation |
Un second passage échoue sur la contrainte unique et est ignoré : c'est ce qui rend le gestionnaire idempotent |
Métrique business_events_total |
Compte les événements métier indexés, visible sur /metrics |
Un gestionnaire qui met à jour une ligne existante plutôt que d'en créer une est idempotent par nature, à condition d'écrire des valeurs absolues tirées de l'événement (status: 'COMPLETED', prizeWei) et jamais d'incréments. L'incrément de prizePool de onTicketsPurchased n'est sûr que parce qu'il est fait dans la même transaction que la création des tickets, elle-même protégée par l'unicité (lotteryId, ticketIndex).
2. Brancher l'événement
Dans processLogs, ajouter le cas au switch :
case 'RefundClaimed':
await this.onRefundClaimed(event, log);
break;3. Effets secondaires
Un gestionnaire peut déclencher une action, à condition qu'elle supporte elle aussi la répétition :
| Effet | Outil | Protection contre la répétition |
|---|---|---|
| Créditer des EtherPoints | ledgerService.creditPoints |
Ne créditer que si la ligne vient d'être créée, comme onTicketsPurchased qui renvoie tôt quand les tickets existent déjà |
| Prévenir les clients | emitSocketEvent, voir Ajouter un événement temps réel |
Les clients doivent tolérer un doublon |
| Lancer une transaction | enqueueChainAction de chain-actions.ts |
Identifiant de job <type>-<tour>-<portefeuille> : BullMQ refuse un second job de même identifiant, et les erreurs « déjà fait » du contrat terminent le job |
| Envoyer un email | enqueueEmail |
Aucune : n'envoyer qu'à la création de la ligne |
4. Tester sur une vraie chaîne
Les tests unitaires du backend simulent Prisma mais pas la chaîne : un gestionnaire d'événement se teste avec npm run test:chain, sur le nœud Hardhat local. Ajouter un cas dans onchain-lottery.chain.ts, qui réutilise ses fonctions login et waitFor :
it('rembourse les participants d un tour annulé et indexe chaque remboursement', async () => {
const dave = await login('dave');
const buy = await request(app)
.post('/api/v1/lottery/buy')
.set('Authorization', `Bearer ${dave.token}`)
.send({ quantity: 1 });
expect(buy.status).toBe(201);
const admin = await prisma.user.create({
data: { walletAddress: `0xrefund${RUN}`, email: `refund.${RUN}@etherbay.fr`, role: 'ADMIN' },
});
const token = sessionService.sign({ userId: admin.id, walletAddress: admin.walletAddress, role: 'ADMIN' });
const cancel = await request(app).post('/api/v1/lottery/refund').set('Authorization', `Bearer ${token}`).send({});
expect(cancel.status).toBe(200);
const refund = await waitFor(async () => {
await lotteryIndexer.syncOnce();
return prisma.refund.findFirst({ where: { walletAddress: dave.user.walletAddress } });
});
expect(BigInt(refund.amountWei)).toBeGreaterThan(0n);
});Le scénario passe par tout le circuit : l'annulation émet RoundCancelled, l'indexeur met en file un CLAIM_REFUND par payeur, le worker démarré dans beforeAll signe claimRefund avec le portefeuille intégré de Dave, le contrat émet RefundClaimed, et l'indexeur crée la ligne.
docker compose up -d --wait
cd backend
npx prisma migrate deploy
npm run test:chain5. Réindexer l'historique
Les événements déjà dépassés par le curseur ne repassent pas d'eux-mêmes dans le nouveau gestionnaire. Pour les traiter, ramener le curseur avant le premier bloc concerné, ici le bloc de déploiement :
UPDATE "ChainCursor" SET "lastBlock" = 41234566 WHERE "id" = 'BlockLucky';Le nombre est le deploymentBlock de deployments/<réseau>.json, moins un. Supprimer la ligne fait repartir l'indexeur de CHAIN_START_BLOCK. Tous les gestionnaires sont rejoués : c'est sans danger parce qu'ils sont idempotents, et c'est la raison pour laquelle ils doivent le rester. La procédure complète est dans Reconstruire depuis la chaîne.
Indexer un événement du contrat POAP
L'indexeur ne lit que l'adresse LOTTERY_CONTRACT_ADDRESS, avec l'interface de BlockLucky. Indexer BadgeMinted de BlockLuckyPOAP demande :
- une interface construite sur
abi/BlockLuckyPOAP.json; - un second appel
getLogsfiltré surPOAP_CONTRACT_ADDRESS; - un second curseur, par exemple une ligne
BlockLuckyPOAPdansChainCursor, pour que les deux contrats avancent indépendamment ; - l'adaptation du filtre d'adresse de
processLogs, qui écarte aujourd'hui tout journal ne venant pas du contrat de loterie.
Aujourd'hui, les badges sont lus directement sur la chaîne à la demande (badgeOf de lottery.gateway.ts).
Liste de contrôle
- Événement présent dans l'ABI embarquée.
- Table ou colonnes de destination créées par une migration.
- Gestionnaire idempotent : contrainte unique ou mise à jour en valeurs absolues.
- Cas ajouté dans
processLogs. - Effets secondaires déclenchés une seule fois.
- Test
test:chainvert. - Réindexation prévue si l'historique doit être repris.
- Page Indexeur d'événements mise à jour.