Relecture du contrat de marché, avant gravure

Application 770819337 sur le testnet Algorand, et le source contrat/marche/contrat.py. 1er septembre 2026.

Cette relecture n'est pas aveugle, et il faut le savoir pour la lire

La demande décrivait un travail en aveugle : ne rien savoir des relectures précédentes, cette ignorance étant la valeur du résultat. Cette condition n'est pas remplie ici. La session qui écrit ce rapport a déjà mené l'audit adverse de ce même contrat : elle a construit un banc, un registre de gardes, elle y a reproduit des défauts, et elle a retiré son propre verdict favorable après avoir été contredite par une relecture indépendante.

On ne désapprend pas où l'on a déjà regardé. Ce rapport est donc étiqueté relecture informée : plus rapide sur le terrain déjà cartographié, aveugle exactement là où elle a déjà conclu. Le signalement a été fait avant de commencer, et une relecture réellement vierge a été confiée à une autre session.

Aucun des fichiers interdits n'a été ouvert : ni le suivi des tâches, ni son journal, ni la répartition du travail, ni la mise en contexte du projet. Le dossier d'une autre relecture, présent sur le disque, n'a pas été lu non plus.

Ce rapport a été rattrapé par une correction, à 13:00:50 UTC

Le fichier a été modifié pendant que je le lisais, passant de 2 924 à 3 038 lignes. Je m'en suis aperçue parce que les numéros de ligne d'une recherche ne correspondaient plus à ceux d'une recherche antérieure.

Ma faute de méthode, et elle est nette : j'aurais dû figer une copie du source avec son empreinte avant de commencer, comme ce projet le fait déjà pour son catalogue. Mesurer une cible mouvante donne un résultat qu'on ne peut ni reproduire ni contester. C'est fait maintenant, trop tard pour la première moitié du travail.

Version figée pour la suite : 49f6750d…c2266346, 3 038 lignes.

Ce qui suit est donc daté. Les trois premières surfaces sont corrigées depuis, par une garde nommée _le_paiement_aboutira appelée en cinq endroits. Je l'ai relue, et cette relecture est en bas de page : le moment d'une correction faite vite est le plus dangereux de tous. Le quatrième cas tient toujours à cette heure.

Le défaut et sa mesure restent publiés tels quels plutôt que réécrits : un rapport qu'on corrige après coup pour qu'il paraisse juste ne se vérifie plus.

Avant tout le reste : le source ne produit pas le programme déployé

La demande exigeait de vérifier cette correspondance en premier, et de le dire avant tout si elle échouait. Elle échoue.

Programme d'approbationOctetssha256
gravé en 7708193375 220 e8f753b9…58915fd3
produit par le source d'aujourd'hui5 243 d9d56b60…8350ec5f

Premier écart à l'octet 432. Désassemblés, les deux programmes font 3 309 et 3 321 lignes, avec 133 lignes ajoutées et 121 retirées : ce n'est pas un détail d'encodage, c'est un changement de logique.

Ce n'est pas un artefact d'outil, et c'est vérifié par deux chemins

L'écart pourrait venir de la chaîne de compilation plutôt que du source. Deux chemins indépendants donnent le même résultat : l'assemblage du TEAL par le nœud, et le champ byteCode.approval de l'ARC-56 produit par le compilateur lui-même. Les deux annoncent 5 243 octets et la même empreinte. L'écart vient donc du source.

La chronologie l'explique

application 770816691 créée    2026-09-01 10:44:34 UTC
application 770819337 créée    2026-09-01 11:44:13 UTC
contrat.py modifié             2026-09-01 12:02:27 UTC   18 minutes plus tard
artefacts régénérés            2026-09-01 12:02:32 UTC
deploiement.json réécrit       2026-09-01 12:03:15 UTC

Le source a été modifié après le déploiement. L'historique de l'application ne porte qu'une seule création et aucune mise à jour : le programme gravé est donc bien une version antérieure.

Conséquence pour toute relecture : il y a deux objets distincts, et il faut choisir lequel fait foi. Relire le source pendant que l'instance testnet sert de référence revient à relire un programme et à en éprouver un autre.

Le défaut : une hypothèse fausse, sur laquelle repose toute la logique de remboursement

Le contrat affirme à trois endroits, en toutes lettres, qu'un remboursement en ALGO ne peut pas échouer :

« L'ALGO N'EST JAMAIS CONCERNÉ : un compte reçoit toujours de l'ALGO. »
« EN ALGO, LA QUESTION NE SE POSE PAS. Un compte reçoit toujours de l'ALGO, _rendre_l_argent émet un paiement qui aboutit toujours. »
« exigée seulement en jeton : en ALGO, un remboursement ne peut pas échouer, donc il n'y a rien à provisionner. »

C'est faux. Un paiement vers un compte qui n'existe pas dans le registre échoue si le montant est inférieur au minimum de compte, soit 100 000 microALGO. Et un compte peut cesser d'exister : la fermeture avec close-remainder-to est un geste ordinaire d'Algorand qui le retire du registre.

Mesuré, pas déduit

Le fait a été joué sur le testnet avec un contrat sonde écrit pour cela, qui n'émet qu'un paiement interne. Quatre cas, choisis pour séparer la cause de la coïncidence :

Paiement interne émis par une applicationRésultat
22 100 microALGO vers un compte inexistantéchoue, below min
99 999 microALGO vers un compte inexistantéchoue, below min
100 000 microALGO vers un compte inexistantpasse
22 100 microALGO vers un compte qui existe [témoin]passe

Le témoin compte autant que les autres : sans lui, un refus obtenu ne prouverait pas le refus attendu. Au premier montage, les quatre cas échouaient, témoin compris, pour une signature ABI erronée. C'est le témoin qui l'a révélé, et non le raisonnement.

Le dernier maillon a été joué aussi : un compte financé puis fermé revient exactement à l'état d'un compte jamais financé, et refuse alors 22 100 microALGO tout en acceptant 100 000.

Enfin, l'échec d'une transaction interne emporte l'appel entier. Il n'existe aucun moyen, en AVM, de rattraper l'échec d'une sortie : la seule protection est de ne pas l'émettre.

Les trois surfaces, de la plus grave à la moins

1. Une enchère en jeton se bloque définitivement grave

Quand un enchérisseur est dépassé, le contrat lui rend sa provision de boîte par un paiement de 22 100 microALGO, montant fixe qui est toujours sous le seuil.

if rembourse and enchere.depot_mise.native > UInt64(0):
    itxn.Payment(receiver=precedent, amount=enchere.depot_mise.native, ...)

Séquence qui bloque l'enchère :

La créance préexistante est nécessaire : c'est elle qui met le drapeau rembourse à vrai alors que la mise n'a pas pu être rendue. Le mécanisme qui décide de ce drapeau est par ailleurs correct, il distingue bien « remboursé » de « aucune boîte nouvelle » ; c'est le paiement qu'il autorise qui échoue.

Conséquence : plus personne ne peut surenchérir. L'attaquant reste meneur d'une enchère qu'il ne peut plus perdre, et les pièces du vendeur sont immobilisées. C'est exactement le défaut que la mécanique des créances a été écrite pour supprimer, réintroduit par le chemin de l'ALGO.

2. Le chemin de secours d'une annonce se bloque aussi grave

reprendre_une_annonce_expiree est ouverte à n'importe qui, et c'est voulu : elle existe pour qu'un vendeur ne soit pas prisonnier si le service disparaît. Elle rend le dépôt au vendeur inscrit dans l'annonce, qui n'est pas l'appelant.

Le dépôt minimal d'une annonce d'une pièce vaut 43 700 microALGO, et son remboursement 41 700 une fois les frais déduits : sous le seuil. Si le vendeur a fermé son compte, ce chemin échoue, et avec lui l'annulation. Les pièces restent au coffre sans aucune sortie.

La réserve d'acceptation de 100 000 microALGO ferait repasser le total au-dessus du seuil, mais elle n'est rendue qu'à qui l'a versée et seulement si la position se referme : un second vendeur du même actif n'en a aucune.

3. Un achat en ALGO à petit prix moindre

Le plancher de prix en ALGO vaut 10 000 microALGO, soit un dixième du minimum de compte. Une annonce entre 10 000 et 99 999 dont le vendeur a fermé son compte devient inachetable, le paiement du prix échouant. La gravité est moindre parce que le vendeur ne nuit qu'à lui-même, mais la pièce est perdue de la même façon.

Un quatrième cas, du même motif : une garde appliquée à un bout et pas à l'autre

Cherché en réponse à la consigne de trouver les jumeaux avant de corriger. Celui-ci ne porte pas sur l'ALGO mais sur les pièces, et sa forme est la même : une vérification qui existe, qui est juste, et qui n'est appelée qu'à un seul de ses deux bouts.

Le contrat possède une garde nommée _peut_recevoir, qui vérifie qu'un compte a bien accepté toutes les pièces qu'on va lui livrer. Sa documentation annonce elle-même qu'elle « sert aux deux bouts ». Elle n'est appelée qu'une fois, à la clôture d'une enchère, pour constater qu'un gagnant s'est dés-opté et basculer en repli plutôt que de laisser les biens prisonniers. Ce chemin-là est sain, et remarquablement traité.

Le renvoi des pièces d'une annonce ne la consulte jamais. Il tolère qu'une pièce ait quitté le coffre, ce qui est un cas différent, mais rien ne vérifie que le destinataire peut la recevoir.

itxn.AssetTransfer(xfer_asset=piece, asset_receiver=vers,
                   asset_amount=1, asset_close_to=vers, ...)   # « vers » n'est jamais interrogé

reprendre_une_annonce_expiree est ouverte à n'importe qui, précisément pour qu'un vendeur ne soit jamais prisonnier si le service disparaît. Elle envoie les pièces au vendeur inscrit dans l'annonce, qui n'est pas l'appelant. Si ce vendeur s'est dés-opté de sa propre pièce après l'avoir déposée, le chemin de secours échoue.

Le vendeur garde une sortie tant qu'il peut se ré-opter et annuler lui-même. Il la perd s'il a fermé son compte, et alors trois choses deviennent définitives : la pièce reste au coffre, la position reste ouverte, et surtout

le marché ne peut plus jamais être fermé. La garde de fermer_le_marche exige que le solde minimum du coffre soit revenu à celui d'un compte nu. Un actif encore accepté l'en empêche, pour toujours.

C'est la question de Karl qui revient par une autre porte, celle qui avait justifié d'écrire cette sortie : est-ce qu'il est prisonnier pour toujours ? Ici, oui, et il suffit d'un seul vendeur, négligent ou non, pour condamner la fermeture du marché entier.

Établi par lecture : la garde existe, son unique appel est localisé, et la conséquence se déduit de la condition de fermeture. Je ne l'ai pas rejoué de bout en bout, et c'est à faire avant de corriger.

Ce qui rend ce défaut structurel plutôt qu'accidentel

Le contrat porte une remarque lucide sur la raison pour laquelle un défaut voisin avait échappé à tout le monde : la règle « il faut avoir accepté un actif pour le recevoir » était présente à l'esprit pour les pièces et absente pour le jeton de paiement, parce qu'on pense que l'argent arrive toujours.

La même cécité s'est déplacée d'un cran. Elle a été corrigée pour les jetons, et elle demeure entière pour l'ALGO, sous la forme d'une hypothèse écrite trois fois et jamais éprouvée. Toute la mécanique de tolérance existe déjà, elle ne couvre simplement pas cette monnaie.

Le quatrième cas dit la même chose sous une autre forme : la garde était écrite, sa documentation annonçait qu'elle servait aux deux bouts, et elle n'en couvrait qu'un. Une explication juste, écrite dans le code, n'a protégé ni le cran suivant ni le second bout. C'est une chose à savoir sur ce que vaut un commentaire, y compris un bon.

Le budget de calcul : la borne de trois tient, mais sous une condition que rien n'écrit

Mesuré par simulation sur l'application 770822974, celle qui porte le correctif, dont j'ai vérifié que le source la produit exactement. La simulation ne coûte ni frais ni actif immobilisé : on peut donc pousser jusqu'à ce que ça morde, ce qu'un essai réel interdirait.

GesteMonnaieLotBudgetMarge sur 700
mise en venteALGO363862
mise en ventejeton3657 43
mise aux enchèresjeton2582118

Chaque pièce coûte 116 unités, et la monnaie en jeton en ajoute 19. La borne de trois tient, et la borne de deux propre aux enchères est délibérée et documentée. Rien à redire sur ces chiffres : ils confirment ceux du chantier.

Ce que personne n'avait fait varier : la taille du groupe

Toutes les mesures de budget de ce projet ont été prises sur un groupe minimal : l'appel, son dépôt, et les transferts de pièces. Or le contrat parcourt le groupe entier, au moins deux fois, pour compter les appels au marché et pour trouver ce qui lui est adressé. Chaque transaction ajoutée coûte donc du budget, et personne ne l'avait mesuré.

Mise en vente de 3 pièces en jetonTransactionsBudgetVerdict
groupe minimal5657passe
une transaction de plus6685passe
deux transactions de plus7700 refusé

Une transaction ajoutée au groupe coûte 28 unités. À trois pièces en jeton, la marge de 43 n'en supporte qu'une seule. La borne de trois pièces n'est donc pas une borne du contrat, c'est une borne du contrat dans un groupe quasi minimal, et cette condition n'est écrite nulle part.

Ce n'est pas une faille de sécurité : on ne compose pas le groupe d'autrui, et un vendeur ne peut nuire qu'à sa propre transaction. C'est un risque de service, et il porte loin sur un contrat immuable : le jour où le site ajouterait au groupe une acceptation d'actif, un paiement de service ou une signature de confort, la vente de trois pièces en jeton cesserait de passer.

Le refus vient alors du protocole et non du contrat, donc aucune garde ne peut l'intercepter ni le traduire. L'utilisateur reçoit : dynamic cost budget exceeded, executing concat. Rien dans ce message ne dit qu'il faut retirer une pièce du lot.

La sortie de secours existe et fonctionne

Le contrat écrit que l'on élargit le budget en ajoutant au groupe un appel à une autre application. C'était une hypothèse à vérifier depuis ce côté-ci, puisque cet appel est lui-même une transaction de plus. Mesuré : elle tient largement. Le même lot passe à 693 unités sur un plafond porté à 1 400, soit 707 de marge.

Le solde est donc très positif : l'appel étranger coûte 28 unités de parcours et en apporte 700. La sortie est réelle, mais elle suppose que le client sache composer un tel groupe, ce qui n'est ni automatique ni visible depuis l'interface.

Ce que je n'ai pas pu mesurer, et pourquoi

clore_l_enchere est le chemin le plus chargé du contrat : il vérifie la réserve, l'acceptation du gagnant, la présence des pièces, calcule la commission, effectue deux remboursements et rend les pièces en refermant chaque position. Il n'est pas mesurable aujourd'hui : il demande une enchère arrivée à son terme, et la durée minimale est d'une heure. C'est la mesure que je recommande en premier après celle-ci.

Ce que j'ai examiné et jugé sain

Ce que je n'ai pas vérifié, et pourquoi

Relecture de la correction, arrivée pendant ce travail

La garde ajoutée tient en deux lignes, et elle est juste :

if montant >= Global.min_balance:
    return True
return vers.balance > UInt64(0)

Au-dessus du minimum, le paiement crée le compte et aboutit toujours, donc la question ne se pose pas et l'on s'épargne une lecture. En dessous, un solde nul signifie que le compte n'existe pas, puisqu'un compte qui existe porte au moins son minimum. Le cas limite du montant exactement égal au minimum tombe du bon côté, ce que ma mesure confirme.

Elle est appelée aux cinq endroits qui en ont besoin, dont les trois que j'avais signalés. Le commentaire nomme aussi la contrainte à connaître avant de la déplacer : lire un solde exige que le compte soit référencé dans l'appel, ce qu'envoyer de l'ALGO n'exige pas. Un sixième appel posé sans cette précaution échouerait, et pire que le défaut corrigé.

Ce que la correction change, et qui mérite d'être su

Quand le paiement ne peut pas aboutir, il n'est pas émis et le montant reste au coffre sans qu'aucune trace ne soit inscrite. En jeton, le même geste de l'utilisateur donne une créance qu'il récupère quand il veut. En ALGO, c'est une perte sèche.

C'est précisément l'une des deux voies que le contrat avait explicitement écartées pour les jetons, en écrivant qu'abandonner l'argent au coffre confisquerait la mise de quelqu'un qui a peut-être seulement nettoyé son portefeuille. Elle est adoptée ici pour l'ALGO.

L'arbitrage se défend, et l'exposition est bornée. La garde ne retient le paiement que sous le minimum de compte : au plus 99 999 microALGO par événement, soit moins d'un dixième d'ALGO. Inscrire une créance coûterait une boîte, donc une provision, pour un montant plus petit que la provision elle-même. Karl a tranché, la décision est écrite, et c'est le bon endroit pour l'écrire.

Une remarque pour l'avenir plutôt que pour aujourd'hui : reclamer construit un transfert d'actif sans vérifier que la monnaie n'est pas l'ALGO. C'est inoffensif tant qu'aucune créance en ALGO ne peut naître, ce qui est le cas. Le jour où l'on voudrait en inscrire une, elle serait irrécupérable, et rien dans le code ne le signalerait.

Ce que je recommande

  1. Trancher d'abord quel programme fait foi, et redéployer si c'est le source. Toute relecture menée avant cet arbitrage porte sur un objet incertain.
  2. Fait depuis : le paiement en ALGO a désormais un filet, aux cinq endroits qui en avaient besoin. Reste à l'éprouver en le faisant refuser, avec un compte réellement fermé.
  3. Le quatrième cas n'est pas corrigé à l'heure où j'écris. La garde _peut_recevoir n'a toujours qu'un seul appel, alors que sa documentation en annonce deux.
  4. Une vérification mécanique à faire une fois pour toutes : compter les appels de chaque sous-routine de vérification et les confronter à ce que son commentaire promet. Les deux défauts de ce rapport ont cette forme, et elle se cherche sans lire le code.
  5. Éprouver la correction en la faisant refuser, avec un compte réellement fermé, et non seulement en constatant qu'un cas nominal passe.

Un contrat immuable ne se corrige jamais. Un défaut qui immobilise le bien d'un tiers et qu'un inconnu peut déclencher pour le prix d'une fermeture de compte mérite d'être fermé avant la gravure, pas après.

Relecture informée, non aveugle, déclarée comme telle avant d'être entreprise. Empreintes relevées sur la chaîne et recoupées par une seconde session indépendante. Faits établis par des essais joués sur le testnet Algorand, jamais sur le réseau principal. Le contrat sonde utilisé pour la mesure est l'application 770821519, déployée pour ce seul usage.