La dapp
L'app du dépôt, cairn-app/ : un front Next.js (App Router), servi sur le port 3100 en
local. Elle tourne sur des données de démonstration, marquées « Illustrative », tant
qu'aucun déploiement n'est configuré, puis sur les contrats du protocole.
Depuis le 2026-10-02, cairn-app/ remplace la dapp React + Vite provisoire de
projet/dapp/, retirée du dépôt ce jour-là. Cette page la décrit telle qu'elle est.
Les écrans
| Route | Écran |
|---|---|
/ |
Accueil : le mécanisme, les frais, les baskets, le token du protocole |
/markets |
Découverte : pulse trésorerie, filtres, tri et recherche, table des marchés ; une trésorerie illisible s'affiche « Figures unavailable » |
/market/[slug] |
Détail d'un marché : chart, ce que la trésorerie détient pour les holders, trades récents, panneau de trade avec la répartition de la taxe avant signature, progression dans la bande 1, position du wallet |
/launch |
Lancement : formulaire, basket, aperçu, vérifications avant l'irréversible. Depuis la huitième boucle d'audit, il ne propose que les baskets lus sur la chaîne, et revérifie sur la chaîne celui qui est choisi avant l'envoi |
/claim |
Les réclamations du wallet sur Ethereum : ses actions, fenêtre par fenêtre, et ses frais de créateur |
/drops |
Les airdrops, fenêtre par fenêtre et marché par marché |
/docs |
Le mécanisme, les frais, les baskets, les airdrops, les limites |
L'écran de réclamation
Écrit le 2026-10-05. Il liste les fenêtres que le wallet peut réclamer : le marché, la fin
de la fenêtre, les actions, leurs montants et leur valeur en dollars. Le seul dû qu'il
affiche est celui que donne le contrat de l'airdrop, claimable, relu toutes les 60
secondes tant que l'écran est ouvert. Une action dont le contrat est à court après un
transfert d'urgence porte la mention « awaiting settlement » et reste hors de la
réclamation ; elle devient réclamable une fois les comptes soldés.
En dessous, un total et « Claim all », qui envoie les réclamations par lots de cinq cycles
(claimMany ; dix jusqu'à ce que la mise à niveau Glamsterdam d'Ethereum atteigne Sepolia, le
2026-10-06, plus bas), et claim en nommant les autres actions pour une fenêtre dont une action
attend son règlement. Chaque appel est simulé d'abord : un lot qui échouerait est découpé en
ses fenêtres, une fenêtre en ses autres actions, si bien qu'une fenêtre ou une action qui ne
peut pas être payée tout de suite est sautée et nommée, sans faire échouer le reste. Le
résultat dit ce qui a été payé, les actions différées, toujours dues, et les fenêtres
sautées, avec leur raison. Une transaction dont le reçu ne se lit pas garde son hash, et
rien n'est renvoyé : l'écran la suit jusqu'à ce qu'elle-même, ou une transaction à sa place, soit minée.
Depuis la dixième boucle d'audit, une accélération dans le wallet compte pour la réclamation
elle-même, « View tx » pointant sur elle, et une annulation ou un autre remplacement la termine
comme non faite, « View tx » sur ce qui a été miné (plus bas).
Depuis le 2026-10-06 :
- Seul le refus du wallet arrête la suite, et depuis la dixième boucle d'audit une annulation ou un remplacement dans le wallet aussi. Une transaction qui échoue est comptée (« did not go through »), ce qu'elle couvrait reste listé, et les autres partent
- De la place pour une action encore en route.
claimManypaie chaque action qu'une fenêtre liste au moment où il passe, et une fenêtre encore ouverte aux livraisons peut recevoir une action de plus entre l'estimation et l'inclusion : l'app ajoute 400 000 gas par action du basket que la fenêtre ne liste pas encore (100 000 avant la mise à niveau Glamsterdam). Le gas inutilisé n'est pas payé - Du gas taillé pour Glamsterdam. Sepolia a activé la mise à niveau Glamsterdam d'Ethereum le 2026-10-06, qui fait coûter à un slot de stockage neuf environ cinq fois son gas d'avant ; le mainnet n'avait pas encore de date. Chaque action qu'une réclamation paie peut écrire trois slots neufs : l'app laisse donc 400 000 gas par action encore en route et réclame cinq fenêtres par transaction, là où dix fenêtres de cinq actions pourraient prendre jusqu'à environ 14 millions de gas
- Le gas propre à chaque écriture. Sous la mise à niveau, un slot de stockage écrit depuis zéro coûte environ 110 000 gas, et un trade peut rencontrer des écritures que son estimation n'a jamais vues : les cumuls de frais du hook vidés par une réclamation juste avant (deux de ces réclamations sont ouvertes à tous), la marque de l'heure suivante, le propre relevé du trader bougé dans le même bloc. Depuis la neuvième boucle d'audit, l'app donne à chaque écriture son estimation plus 50 000 gas, et à un trade, estimé au dernier bloc, aussi le gas de chacune de ces écritures qui peut encore arriver, lue à ce même bloc (112 000 par cumul, 150 000 pour la marque, 135 000 pour le relevé ; toutes quand une lecture échoue). Une écriture dont l'estimation échoue part avec une limite fixe tirée de son cas le plus lourd, jamais l'estimation du wallet, qui n'a pas de marge ; un lancement n'est alors pas envoyé, et la page dit de réessayer. Jusque-là, chaque écriture recevait 150 000 gas au-dessus de son estimation (35 000 avant la mise à niveau). Un wallet met de côté la limite fois ses frais avant de signer ; seul le gas utilisé est payé
- Une action non payée à la dernière réclamation. Une action qu'une réclamation a différée, parce que son token a refusé le transfert vers ce wallet (un gel par l'émetteur, par exemple), est retenue par le navigateur, affichée « not paid at your last claim » et laissée hors de « Claim all ». Chaque réclamation la retente seule d'abord et la reprend dès qu'elle passerait ; quand il n'y a rien d'autre à réclamer, le bouton devient « Try the assets not paid again »
- Non vérifiée, jamais refusée. Une simulation ne compte que si le nœud dit pourquoi l'appel échouerait. Une fenêtre dont la vérification a rencontré une erreur du RPC reste listée, pas encore vérifiée, et quand aucune simulation n'a répondu, rien n'est envoyé
- Illisible n'est pas vide. Un marché dont les chiffres n'ont pas pu être lus est nommé, jamais affiché comme ne devant rien : « Nothing to claim » ne s'affiche que si chaque marché a été lu, et la partie du créateur dit que ses marchés n'ont pas pu être lus au lieu de « You haven't launched a market »
Les frais du créateur se réclament sur le même écran, sur Ethereum.
Ce que l'app affiche
- Jamais un faux zéro. Une valeur inconnue s'affiche « — » ou comme une attente, jamais comme zéro. Depuis le 2026-10-06, un solde du wallet qui n'a pas pu être lu non plus : le panneau de position dit qu'il n'a pas pu être lu, et le panneau de trade montre le dernier solde lu, marqué « last read », sans refuser une vente au-dessus ; la simulation de la transaction refuse un vrai dépassement
- Une trésorerie illisible. Depuis le 2026-10-05, une trésorerie dont le vault ne répond plus à ses propres lectures, après un upgrade raté par exemple, s'affiche « Figures unavailable » dans la liste des marchés, sur la carte du marché, dans le bandeau du token du protocole et sur la page du marché. Le total des marchés la laisse de côté et dit combien de trésoreries il ne compte pas. Depuis le 2026-10-06, un vault qui ne répond pas et n'a jamais été lu prend le rail que suppose la configuration du pont du protocole, et le panneau de la trésorerie le dit : l'endroit où sont ses actions est déduit, et ses avoirs peuvent être incomplets
- L'ETH dû au vault. Depuis le 2026-10-06, l'ETH que le hook et le lock de liquidité gardent pour un vault, après un paiement que le vault a refusé, fait partie de sa trésorerie, de sa valeur et des totaux : une ligne « ETH owed », avec la mention qu'il n'est pas encore dans le vault et y entre dès que le vault le prend. Il se lit sur le hook et le lock, si bien qu'il reste à jour même quand le vault ne répond pas
- Le marché du token du protocole. Depuis le 2026-10-06, quand la Lens ne peut pas lire sa trésorerie, il reste affiché tel que lu la dernière fois, au lieu de passer pour pas encore lancé, et le dit : son prix et sa market cap portent la mention « (last read) » partout où ils s'affichent, sans variation sur 24 h, et son graphique indique « Last read » au lieu de « Now ». Le Worker n'en relève aucun prix tant que la Lens ne répond pas : la panne n'ajoute aucun point au graphique. Sa taxe du moment est inconnue : pas d'étiquette anti-snipe, et le panneau de trade affiche la taxe normale en vigueur, sans ligne de surplus. Les chiffres de son token se lisent toujours sur le token : « Burned by buyback », sur l'accueil, reste à jour, alors que sa market cap reste la dernière lue. Depuis la septième boucle d'audit, la panne fait un trou dans son graphique, le dernier prix lu daté de son âge, et les chiffres du token, le marché lu à l'instant ou gardé, prennent la dernière valeur lue quand ils ne répondent pas, jamais zéro ni un nom vide ; un contrat du protocole qui échoue après un upgrade raté ne les efface plus
- Un pool récupéré. Un pool dont le mode fin a retiré la liquidité n'affiche ni prix ni trading : n'importe qui peut déplacer gratuitement le prix d'un pool vide. Depuis le 2026-10-06, il n'a plus non plus de variation sur 24 h ni de graphique, l'écran dit pourquoi, et le Worker n'en relève plus le prix
- La part d'un trade pour les actions. Depuis le 2026-10-06, c'est ce que les lignes de taxe de ce trade ont envoyé à la trésorerie, au barème qu'il a payé, jamais aux réglages du jour ; « — » quand elle n'est pas connue
- La market cap compte l'offre en circulation, la supply moins ce que détient l'adresse de burn, dans l'en-tête comme sur le graphique (2026-10-06) ; depuis la septième boucle d'audit, elle affiche « — », jamais 0 $, quand l'offre n'a pas pu être lue
- Le graphique place chaque point à son heure. Depuis la septième boucle d'audit, chaque échantillon de prix se place à son heure sur la plage : 1H, 24H et 7D finissent maintenant, et « All » part du premier échantillon, jamais de l'âge du marché. Un point survolé dit son âge (« 3h 20m ago »), la ligne se coupe là où des échantillons manquent (une panne, une nuit où personne n'avait la page ouverte), et au repos le graphique montre le prix du marché, « Now », ou « Last read » pour un marché gardé tel que lu. Jusque-là, les points s'étalaient régulièrement et se dataient par leur rang : le dernier prix lu avant une panne passait pour actuel
- Le volume sur 24 h affiche « — » tant qu'il ne peut pas être compté. Depuis la septième boucle d'audit, quand les lectures des journaux de trades du Worker échouent de suite, sa fenêtre des trades n'avance plus ; le volume, par marché et dans les totaux, affiche alors « — » au lieu d'un chiffre qui fondrait comme si les trades avaient cessé, et revient avec la première lecture qui la rattrape
- Un réglage par défaut. Un réglage que l'app n'a pas pu lire sur la chaîne est affiché à sa valeur par défaut, et marqué comme tel
- Un wallet de la whitelist paie la taxe normale. Depuis la huitième boucle d'audit, pendant la fenêtre anti-snipe d'un marché, un wallet connecté inscrit sur la whitelist de ce marché paie la taxe normale par le routeur StockFun, par lequel passe le panneau de trade : le panneau ne lui montre plus de surplus anti-snipe, et une note dit pourquoi. La cotation était déjà juste ; la ligne de surplus la contredisait. Tant que la whitelist ou le wallet sont inconnus, le panneau montre la taxe hors exemptions
- Pourquoi une action n'a pas de prix. Depuis la huitième boucle d'audit : sur Robinhood Chain, l'oracle retient le prix d'une action pendant une opération sur titres, et celui de toutes les actions tant que le séquenceur est arrêté ou tout juste revenu, un contrôle éteint tant que Chainlink ne publie pas de feed de disponibilité pour cette chaîne (voir Le rail Robinhood). Là où la valeur d'une action manque pour cette raison, l'app le dit : « No price for NVDA right now: corporate action in progress », ou le séquenceur de Robinhood Chain arrêté, en reprise ou d'état inconnu, sous le tableau du basket du panneau de l'airdrop et sous la carte de l'airdrop de l'accueil, et à côté de l'action sur la page de réclamation. La carte de l'accueil montrait une telle action à 0,00 $ ; elle montre désormais « — »
- Un pot qui est une estimation le dit. Depuis la neuvième boucle d'audit. Quand une part d'une trésorerie ne se lit pas ou ne se chiffre pas (une action dont l'oracle retient le prix, un feed ETH/USD périmé, un vault miroir gardé tel que lu), son chiffre compte ce qui a pu être chiffré et porte désormais un « + » partout où il s'affiche : lignes et cartes de la liste des marchés, carte du token du protocole, pouls de la trésorerie, landing, panneau du drop et part d'un holder. La liste des marchés dit qu'un tel pot trie sur ce qui a pu être chiffré, et le pouls dit combien de trésoreries il a comptées ainsi. Jusque-là, seul le panneau du drop disait « an estimate »
- Une approbation illisible n'est pas nulle. Depuis la neuvième boucle d'audit. Une vente par le routeur de StockFun demande d'abord l'allocation du routeur. Une lecture qui échouait comptait pour aucune : un holder qui avait approuvé se voyait redemander une approbation, avec deux signatures, et un second échec envoyait une approbation pour rien. Le panneau de trade dit désormais que l'approbation n'a pas pu être lue, ne cote rien, et n'envoie ni approbation ni vente tant qu'elle ne se lit pas. Depuis la dixième boucle d'audit, la vente qui suit une approbation lit l'allocation, se cote et se vérifie à un bloc au moins égal à celui de l'approbation (plus bas) : un nœud en retard d'un bloc ne peut plus y répondre zéro
- Le volume sur 24 h compte chaque bloc une fois. Depuis la huitième boucle d'audit, une lecture du Worker servie par un nœud en retard de quelques blocs sur le précédent ne fait plus compter deux fois les mêmes blocs à la fenêtre des trades. Pour cette seule lecture, les trades les plus récents peuvent manquer au flux ; la lecture suivante les rend
Depuis la dixième boucle d'audit, le 2026-10-06 :
- Une transaction annulée dans le wallet n'est jamais montrée comme faite. Un wallet peut annuler une transaction en attente (un transfert de rien vers soi-même au même nonce) ou l'accélérer (le même appel à un prix plus haut). L'app lit désormais ce qui a été miné à sa place : une accélération compte pour l'action elle-même, « View tx » pointant sur elle ; une annulation ou tout autre remplacement termine le flux comme non fait, « Cancelled in your wallet. » ou « Replaced by another transaction in your wallet. », « View tx » sur ce qui a été miné, et arrête une suite de réclamations comme le refus du wallet. Jusque-là, un trade annulé affichait « Done », une approbation annulée comptait comme donnée, une réclamation du créateur annulée affichait « Claimed » et cachait l'ETH pour la session, et un lancement annulé affichait « $TICKER is live. » avec l'adresse de token inventée de la démo ; une transaction accélérée après les trois minutes d'attente habituelles n'était jamais confirmée, et le panneau restait bloqué jusqu'au rechargement de la page
- Suivie après l'attente. Une fois l'attente habituelle abandonnée, au bout de trois minutes, l'app suit la transaction par son nonce : quand le compte des transactions du wallet a dépassé ce nonce sans reçu pour la transaction, elle cherche ce qui a été miné à ce nonce, jamais dans un bloc d'avant l'envoi, et applique la même règle. Une annulation ou un remplacement ne se lit jamais que sur une transaction trouvée dans un bloc, jamais sur un reçu absent
- Un lancement n'est en ligne que si la chaîne le dit. Le lancement lit la création du marché dans l'événement de la factory elle-même ; sans lui, il revient au formulaire avec le lien de sa transaction, et l'adresse de la démo n'apparaît jamais hors de la démo
- Le bloc de votre dernière transaction, pendant une minute. Pendant 60 secondes après le minage de l'une de ses propres transactions, l'app vérifie, estime et cote ce qu'elle envoie ensuite sur cette chaîne à un bloc au moins égal à celui-là, et y lit l'allocation d'une vente. Un endpoint à répartition de charge peut répondre depuis un nœud en retard d'un bloc : la vente juste après son approbation y était refusée (« Approve it first »), et un second essai pouvait envoyer une seconde approbation. Un nœud qui n'a pas atteint ce bloc compte désormais pour un retard, et il est interrogé de nouveau ; si aucun ne l'atteint en quelques secondes, rien n'est envoyé. Pour une vente juste après son approbation, la page dit alors « Your approval went through, but no quote could be read from the pool, so the sale was not sent… » (sa cotation est la première lecture faite à ce bloc) ; une écriture dont la vérification ne peut pas atteindre ce bloc (une approbation, une réclamation, un lancement) dit « This could not be checked against the block of your last transaction just now, so nothing was sent. Try again in a moment. »
- Ce qui reste. Un faux « Replaced » reste possible dans un cas étroit, quand tout cela se rencontre : aucun nœud n'a montré la vente, si bien que l'app a deviné son nonce d'après le compte du wallet ; un nœud en retard sur les autres, mais pas sur le bloc lu avant l'envoi, a répondu ce compte ; une autre transaction du même wallet, que ce nœud n'avait pas vue, a pris le nonce deviné ; et la vente, envoyée par un relais privé, n'était toujours pas minée après les trois minutes et une grâce d'environ 36 secondes. Une annulation n'est jamais montrée comme faite, et rien ne reste bloqué
Le lancement
Le formulaire de lancement ne propose que les baskets lus sur le registre de la chaîne, sous la numérotation du registre : avant la première lecture, il n'en propose aucun (« Reading the baskets from the chain… »), et avant le déploiement des contrats, il dit que le lancement ouvrira dès qu'ils seront en ligne. Juste avant l'envoi, il relit sur la factory les frais de création et le basket choisi, son nom, ses actions et leurs poids, et refuse d'envoyer, sans rien envoyer, si l'un ou l'autre diffère de ce qu'il affiche ; le message nomme le basket que la chaîne tient sous ce numéro. Jusqu'à la huitième boucle d'audit, le formulaire proposait, avant sa première lecture, les baskets de la configuration sous leurs numéros de configuration, et un déploiement dont le registre numérote ses baskets autrement aurait pu lancer un marché sur un autre basket que celui montré. Le formulaire ne paie que les frais de création : il n'a pas d'achat du créateur, et ne nomme aucune whitelist anti-snipe, ce qu'il dit (la factory accepte l'un et l'autre d'un appel direct : voir Lancer un marché). La confirmation nomme le basket vérifié à l'envoi.
La direction artistique
Light mode chaud : un canevas blanc, une lumière satinée pêche dans les seuls bandeaux d'accroche, et une app sobre, qui tient du relevé de compte.
| Rôle | Valeur |
|---|---|
| Fond | Blanc pur |
| Panneaux, champs | Stone |
| Bandeaux d'accroche | Pêche satinée |
| Encre, boutons pleins | Near-black, pilules |
| Accent : airdrops, chiffres clés | Terracotta, Clay au survol |
| Variations de prix | Vert et rouge sobres, toujours avec leur signe |
Typographie : Poppins pour les titres et toute l'interface, chiffres tabulaires ; Instrument Serif pour les seules grandes valeurs financières ; une police monospace pour les adresses et les hashes. Cartes à rayon de 14px, bordures d'un pixel plutôt que des ombres.
Interdits : le crypto sombre générique — fond noir, lueurs néon, dégradés violets, glassmorphism —, l'esthétique de terminal de trading, les mascottes, les fusées et la « moon », les écrans où le prix écrase tout, les emojis, et tout logo d'entreprise : un actif est nommé par son ticker, en texte.
Ce qui vient d'où
Les chiffres de l'app viennent de sa configuration, src/config/cairn.json, de variables
d'environnement, ou de la chaîne ; aucun n'est écrit dans un composant. Les lignes de la
taxe y portent les valeurs par défaut des contrats, comparées aux constantes que génère le
package partagé de projet/, un écart étant signalé ; un marché vivant affiche les lignes
des réglages en vigueur. Depuis le 2026-10-05, ces chiffres sont des réglages de l'owner :
une valeur en vigueur se lit sur le contrat qui la porte.
L'état du protocole est lu sur la chaîne, sans indexer : par un Worker, cairn-worker/, qui
lit les contrats une fois pour tous et pousse les changements aux pages ouvertes. Si le
Worker ne répond plus, la page lit la chaîne elle-même, par un RPC public. Les données du
wallet, ses soldes et ce qu'il peut réclamer, sont lues par le navigateur. Depuis la septième
boucle d'audit, le Worker ne lit que des endpoints qui disent servir sa chaîne : un endpoint
d'une autre chaîne compte comme en panne, si bien qu'un secours sur le mauvais réseau ne peut
plus faire disparaître tous les marchés. Depuis la huitième, il publie son huitième schéma de
données, qui ajoute la raison pour laquelle le prix d'une action manque ; une app qui lit un
schéma plus ancien fonctionne toujours, sans cette raison. Depuis la neuvième, le watcher du
Worker garde ce qu'il a appris de ses RPC (quel endpoint est en panne et depuis quand, combien
de temps attendre, quelle chaîne sert chacun) d'une mise en sommeil de son objet Cloudflare à
l'autre entre deux lectures, sans jamais stocker l'adresse d'un endpoint : une panne du RPC
principal coûte une sonde par pause qui double, au lieu de trois requêtes à chaque lecture, et
une lecture saine ne demande rien deux fois. Il relève aussi le numéro de bloc propre de
Robinhood Chain, là où il relevait celui de la chaîne sur laquelle Robinhood Chain se règle.
L'audit de copy
pnpm audit:copy, dans projet/, scanne le code du backend, du keeper, du package partagé
et des contrats ; l'app n'est pas dans son périmètre. Il échoue sur le moindre mot interdit
ou la moindre prohibition visuelle vérifiable statiquement. Il se lance à la demande ; il ne
fait pas partie de pnpm build.
Ce n'est pas un linter de style : le vocabulaire interdit est une contrainte juridique.