Audit adverse indépendant · session hotel-saint-michel-bf · 1er septembre 2026

Le contrat de règlement ASA Compass, vu par quelqu'un qui veut voler

Deux défauts critiques restaient après trois relectures. Tous deux disent la même chose : le contrat a été blindé du côté des pièces, jamais du côté de l'argent ; et une garde a été écrite contre un cas nommé, jamais contre sa classe.

Cible et outillage.
Source auditéecontrat/marche/contrat.py
Empreinte source (sha256)b06215f4f36d3f77…b920e970
Bytecode d'approbation4 864 octets · a009dd5163…7f9b734f
Reproduction de l'outillagema compilation locale rend ce bytecode à l'octet près, si bien que je mesure exactement ce qui sera déployé.
Instance d'essai (testnet)application 770802760
Méthodeattaque réelle et simulation sur testnet ; chaque refus prouvé par son message et son compteur de programme lus dans l'ARC-56.

La charge de la preuve est inversée : rien n'est déclaré sain sans l'avoir éprouvé sur le chemin le plus coûteux. Ce qui n'a pas été éprouvé est nommé « non vérifié », pas « probablement bon ».

Ce que j'ai trouvé, en un coup d'œil

#DéfautGravitéStatut
C1Enchère en jeton : le remboursement de la mise verrouille le bien du vendeurCritiqueNouveau · prouvé
C2NFT gelable : la garde des actifs dangereux ignore l'adresse de gelCritiqueProuvé (signalé non vérifié ailleurs)
NCollision de réserve d'acceptation (défaut nommé et assumé)AssuméConfirmé · arbitrage jugé défendable
M1refermer_une_position est du code mort (fermeture « vers soi-même » refusée)MoyenNouveau · prouvé
F1clore_l_enchere sur-facture le vendeur de 1 à 2 millièmes d'ALGOFaibleCorrectif annoncé, encore mesurable
F2Le trop-versé ALGO d'une offre en jeton n'est pas renduFaibleNouveau · prouvé
F3Le plancher d'enchère jeton peut faire déborder une multiplicationFaibleNouveau · prouvé

Critique C1. L'enchère en jeton : rembourser la mise verrouille le bien du vendeur

Les corrections précédentes ont blindé le retour des pièces à la clôture : si le gagnant ne peut plus les recevoir, un « repli » rend les pièces au vendeur plutôt que de les laisser prisonnières. Mais le retour de l'argent n'a jamais reçu la même protection.

Quand une enchère est libellée en jeton, trois endroits remboursent une mise par _rendre_l_argent, qui émet un transfert d'actif vers le bénéficiaire sans vérifier qu'il accepte encore ce jeton :

Or un transfert d'actif Algorand vers un compte qui s'est dés-opté échoue (« must optin ») et fait échouer tout l'appel qui le contient. Un enchérisseur qui mise en jeton puis se dés-opte de ce jeton transforme donc chacun de ces remboursements en mur :

D1. L'enchère gelée à son prix (surenchères impossibles)

Le meneur mise, puis se dés-opte du jeton. Toute surenchère doit d'abord lui rendre sa mise : le transfert échoue, la surenchère est refusée. Plus personne ne peut le dépasser. Il remporte l'enchère au prix de départ en évinçant toute concurrence.

enchère jeton, mise 1300, dés-optation du jeton, puis surenchère 1400 :
inner tx 0 failed: receiver error: must optin, asset 770802619
missing from [attaquant]   (pc=4816, _rendre_l_argent dans encherir)

D2. Le bien du vendeur prisonnier pour toujours

Le meneur mise (réserve atteinte, ou même non atteinte), ne s'opte pas aux pièces, se dés-opte du jeton. À l'échéance :

Aucune sortie : les pièces du vendeur restent au coffre définitivement. Coût pour l'attaquant : sa seule mise, elle aussi verrouillée. Sabotage à sacrifice, mais qui détruit un bien de valeur pour le prix d'un dépôt.

enchère 0, échue, meneur dés-opté du jeton et non opté aux pièces :
clore_l_enchere -> inner tx 0 failed: must optin, asset 770802619
missing from [attaquant]   (pc=4816, repli). Clôture réelle, pas simulée.

Ce verrou est propre au jeton. Un compte reçoit toujours de l'ALGO, un paiement ne peut pas échouer « must optin » ; les enchères en ALGO ne sont pas vulnérables.

Correctif possible (décision de conception à Karl) : avant de rembourser une mise en jeton, vérifier que le bénéficiaire est encore opté ; sinon retenir la mise en dépôt réclamable, et surtout ne jamais faire dépendre la clôture d'un transfert qu'un bénéficiaire peut faire échouer à volonté. La clôture doit toujours pouvoir rendre les pièces au vendeur, quoi qu'il advienne de l'argent.

Critique C2. Le NFT gelable : une garde écrite contre un cas, pas contre sa classe

La garde de dépôt _piece_reprenable rejette un actif dont l'adresse de reprise (clawback) est active, mais ne regarde pas l'adresse de gel. Un NFT à clawback vide mais gelable se dépose normalement ; son gestionnaire de gel gèle alors l'avoir du coffre, et la pièce ne sort plus par aucun chemin, tous passant par _rendre_les_pieces.

actif clawback=vide, freeze=un compte ; listé (la garde ne bloque pas) ;
gel de l'avoir du coffre ; annuler ->
inner tx 0 failed: asset 770804102 frozen in [coffre]   (pc=4723)

La classe est fermée à deux membres, et deux seulement. Je l'ai démontrée adresse par adresse :

AdresseAjoute un membre ?Pourquoi
Clawbackouiun tiers prend la pièce du coffre
Gelouiun tiers verrouille l'avoir du coffre
Managernonpeut reconfigurer, mais une adresse mise à zéro ne se rétablit jamais
Destructionnonexige toute la réserve, que le coffre bloque en détenant une unité
Reservenoncosmétique
default_frozennonsoit le gel est actif (rejeté), soit l'actif est intransférable et le dépôt échoue seul

La garde complète est donc « clawback à zéro ET gel à zéro au dépôt », et rien de plus, la permanence du zéro rendant la vérification au dépôt suffisante pour toujours.

Honnêteté sur l'antériorité. La relecture statique indépendante avait signalé le gel dans sa section « Non vérifié », en raisonnant qu'il devait se comporter comme le clawback. Elle ne l'avait ni éprouvé ni érigé en défaut. Ma contribution est la preuve sur la chaîne, la fermeture formelle de la classe (clawback et gel, sans troisième porte), et la mesure qui rend la décision facile : sur les 2 222 collections publiées, filtrer le gel n'exclut que 3 collections de plus (24 au total, 1,08 %). L'inquiétude qu'il touche une large part du catalogue ne s'est pas vérifiée.

La décision d'exclure les actifs gelables appartient à Karl, comme celle d'exclure les reprenables. Mais la garde doit exister et être décidée sciemment, pas rester absente par oubli.

Assumé Le défaut nommé : la collision de réserve d'acceptation

Le contrat déclare lui-même, aux lignes de _rendre_le_depot, une perte non corrigée. Sur un actif tiré à plusieurs exemplaires, le premier vendeur paie la réserve d'acceptation de 0,1 ALGO ; s'il referme sa position avant le dernier détenteur, sa sortie ne referme pas l'acceptation et sa réserve ne lui est pas rendue. Le dernier à sortir referme, mais n'a rien payé, donc n'est pas remboursé non plus. La réserve reste au coffre.

Mon jugement : l'arbitrage est défendable. La perte est bornée à 0,1 ALGO, ne survient que si deux vendeurs listent le même actif fongible en même temps et que le payeur ne referme pas en dernier, cas rare sur un corpus de pièces uniques ; le remède (une boîte par actif pour retenir qui a payé) coûterait plus que le mal.

Une réserve, que Karl doit entendre. Cette perte tombe sur un utilisateur (le premier vendeur), là où le contrat, partout ailleurs, fait pencher le doute du côté de la plateforme (la troncature de la commission favorise le vendeur). Pour rester fidèle à ce principe, la plateforme pourrait absorber ces 0,1 ALGO plutôt que de les laisser à la charge du premier vendeur. Vu la rareté, ce n'est pas un blocage ; c'est une cohérence de principe à trancher, et à documenter côté site pour les rares collections multi-éditions.

Défauts mineurs, tous prouvés

Moyen M1. refermer_une_position ne peut jamais s'exécuter

Cette méthode neuve, censée récupérer la réserve d'un actif orphelin pour rendre le marché fermable, ferme la position du coffre vers son propre compte (asset_close_to vaut l'application elle-même). Le protocole refuse toute fermeture d'un actif vers soi-même (« cannot close asset ID … to itself », mesuré). La méthode est donc du code mort. Dans la version corrigée, un orphelin ne semble pas pouvoir se former par les méthodes du contrat, donc l'impact est faible ; mais sur ce projet « ça ne peut pas arriver » s'est trompé plusieurs fois, et un filet de sécurité qui ne fonctionne pas est un défaut en soi. Correctif trivial : fermer vers l'exploitant (zéro unité ne paie personne, et la réserve libérée reste au coffre de toute façon).

Faible F1. clore_l_enchere sur-facture le vendeur, malgré une correction annoncée

Une correction récente a appris à _rendre_le_depot à ne facturer que les sorties réellement payantes, jamais celles à frais nuls ; acheter a été aligné. Mais clore_l_enchere ne l'a pas été : son chemin conclu compte le paiement du vendeur et la commission (émis à frais nuls) comme s'ils étaient facturés, et son repli compte de même la pénalité. Le vendeur est donc sous-remboursé de 1 000 à 2 000 µALGO par clôture.

clôture conclue réelle, 2 pièces, avec commission :
remboursement ALGO du vendeur = 268 500 µALGO
attendu si correct = 270 500 ; écart mesuré = 2 000 (sur-compte confirmé)

Ce défaut avait été signalé par la relecture statique il y a plusieurs heures, et sa correction annoncée. Je le mesure encore sur la version définitive : la correction a atteint _rendre_le_depot et acheter, jamais clore_l_enchere. Une annonce de correction n'est pas une preuve de correction.

Faible F2. Le trop-versé ALGO d'une offre en jeton reste au coffre

acheter et les offres en ALGO rendent désormais le trop-payé. Mais pour une offre en jeton, l'ALGO de la boîte est retenu à une constante : tout ALGO versé au-delà n'est ni capturé ni rendu.

offre jeton, boîte ALGO versée 86 900 (dont 50 000 en trop) :
dépôt enregistré = 36 900  ->  50 000 µALGO perdus au coffre

Perte silencieuse sur le sur-versement, seulement si le site sur-provisionne. C'est exactement la classe de perte que la correction visait ailleurs, et l'oubli est ici, sur le seul chemin de l'offre en jeton.

Faible F3. Débordement du plancher d'enchère en jeton

Le plancher d'une enchère en jeton se calcule planchers[jeton] × plancher_enchere ∕ PRIX_PLANCHER. Les deux facteurs sont bornés à 1012 chacun par leurs gardes ; leur produit peut atteindre 1024, au-delà de 264. Avec les deux au plafond, mettre_aux_encheres est refusée « * overflowed » (pc=2865). Réservé à l'exploitant, récupérable, et sans valeur réaliste, mais c'est un débordement latent. Correctif : diviser avant de multiplier, ou borner le produit.

Vérifié sain Ce que j'ai éprouvé et jugé bon

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

Cette section n'est pas une formalité : c'est elle qui dit ce que vaut le reste. L'absence d'un tel aveu est ce qui a rendu un rapport précédent trompeur.

Mes propres fausses pistes

Un audit qui tait ses erreurs n'est pas croyable sur ses trouvailles.

Ce que les deux relectures précédentes ont manqué, et pourquoi leur méthode ne pouvait pas le voir

C'est la partie qui apprend quelque chose plutôt que d'ajouter une liste.

L'audit dynamique : éprouver les gardes qui existent

Il a joué toutes les méthodes contre un vrai nœud et prouvé que 56 gardes sur 57 refusent bien ce qu'elles doivent refuser, en confirmant le compteur de programme de chacune. C'est un travail solide, et sa discipline (un refus ne compte que si la bonne garde a joué) est juste. Mais éprouver que chaque garde existante refuse ne dit rien des gardes qui manquent : une garde absente ne refuse rien, donc aucun test de refus ne peut la révéler. Mes deux critiques sont des gardes absentes, l'une côté argent, l'autre contre le gel, et cette méthode ne pouvait structurellement pas les voir. À cela s'ajoute que l'audit dynamique a dialogué avec la session qui écrivait le contrat, ce qu'il reconnaît lui-même : une vérification qui co-conçoit avec ce qu'elle vérifie hérite de ses angles morts.

La relecture statique en aveugle : lire à la recherche des gardes absentes

Elle a trouvé dix-huit défauts, tous des gardes absentes, chacun reproduit sur la chaîne, dont le double-comptage de groupe et les bornes mesurées en ALGO seulement. C'est elle qui a rendu ce contrat bien plus sûr. Elle a même signalé le gel dans sa section « Non vérifié ». Pourtant elle a manqué le verrou côté argent (C1), et n'a pas éprouvé le gel (C2). Pourquoi ?

Une asymétrie d'attention, la même des deux côtés. Le protocole impose qu'un compte accepte un actif avant de le recevoir. Cette règle a été présente à tous les esprits pour les pièces, un NFT « ayant visiblement besoin d'un opt-in », et absente pour l'argent, parce qu'on imagine que l'argent « arrive toujours ». Or un jeton de paiement est un actif comme un autre, soumis à la même règle. Les deux relectures ont donc protégé le retour des pièces (l'acheteur doit être opté, le repli sauve les biens) et laissé le retour de l'argent nu : le remboursement d'une mise en jeton peut échouer exactement comme la livraison d'une pièce. C1 vit dans cet angle mort.

Une occurrence corrigée là où il fallait une cause. La garde des actifs dangereux a été écrite contre le clawback, le cas que Karl avait nommé, et non contre la classe des actifs qu'un tiers contrôle après le dépôt. Le gel en est le jumeau exact. La relecture statique l'a pressenti, puisqu'il est dans son « Non vérifié », mais l'a laissé comme un fil à tirer plutôt que comme un défaut. La leçon vaut au-delà de ce contrat : la section « non vérifié » d'une relecture est l'endroit où la suivante doit commencer à creuser. Ce fil-là, tiré, était un critique.

Là où je ne suis pas d'accord

Conclusion

Le contrat a été considérablement renforcé, et les corrections mesurées tiennent. Mais il reste au moins deux chemins par lesquels le bien d'un vendeur devient irrécupérable : une enchère en jeton dont l'enchérisseur se dés-opte, et un NFT gelable qu'un tiers gèle. Aucun des deux n'est un vol : ce sont des verrous, et l'attaquant y sacrifie sa propre mise ; mais sur un contrat immuable qui gardera les NFT de vraies personnes, un bien détruit ne se rattrape pas. Les deux se ferment par des gardes simples, et la décision d'affaires attenante (exclure les actifs gelables) est mesurée et légère. Ce contrat ne devrait pas porter d'argent réel avant que C1 et C2 ne soient fermés.

Audit adverse indépendant · session hotel-saint-michel-bf, réalisé en aveugle, sans lecture des rapports ni de la liste des défauts connus avant d'avoir écrit ce jugement. Bytecode audité a009dd51…, reproduit à l'octet près depuis la source b06215f4…. Toutes les reproductions ont été jouées sur testnet, contre l'application 770802760.