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.
| Source auditée | contrat/marche/contrat.py |
| Empreinte source (sha256) | b06215f4f36d3f77…b920e970 |
| Bytecode d'approbation | 4 864 octets · a009dd5163…7f9b734f |
| Reproduction de l'outillage | ma 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éthode | attaque 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éfaut | Gravité | Statut |
|---|---|---|---|
| C1 | Enchère en jeton : le remboursement de la mise verrouille le bien du vendeur | Critique | Nouveau · prouvé |
| C2 | NFT gelable : la garde des actifs dangereux ignore l'adresse de gel | Critique | Prouvé (signalé non vérifié ailleurs) |
| N | Collision de réserve d'acceptation (défaut nommé et assumé) | Assumé | Confirmé · arbitrage jugé défendable |
| M1 | refermer_une_position est du code mort (fermeture « vers soi-même » refusée) | Moyen | Nouveau · prouvé |
| F1 | clore_l_enchere sur-facture le vendeur de 1 à 2 millièmes d'ALGO | Faible | Correctif annoncé, encore mesurable |
| F2 | Le trop-versé ALGO d'une offre en jeton n'est pas rendu | Faible | Nouveau · prouvé |
| F3 | Le plancher d'enchère jeton peut faire déborder une multiplication | Faible | Nouveau · 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 :
encherir, qui rend sa mise au précédent meilleur offrant ;annuler_l_enchere, qui rend sa mise à l'enchérisseur en cours ;clore_l_encheresur son chemin de repli, qui rend sa mise au perdant ou au gagnant défaillant.
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 :
clore_l_encherepasse en repli (le gagnant ne peut recevoir les pièces), puis échoue au remboursement en jeton du meneur dés-opté ;annuler_l_enchereéchoue au même remboursement (mesuré avec réserve 5000 non atteinte) ;refermer_une_positionetfermer_le_marcherefusent, le coffre détenant encore les pièces.
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 :
| Adresse | Ajoute un membre ? | Pourquoi |
|---|---|---|
| Clawback | oui | un tiers prend la pièce du coffre |
| Gel | oui | un tiers verrouille l'avoir du coffre |
| Manager | non | peut reconfigurer, mais une adresse mise à zéro ne se rétablit jamais |
| Destruction | non | exige toute la réserve, que le coffre bloque en détenant une unité |
| Reserve | non | cosmétique |
default_frozen | non | soit 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
- La garde anti-double-comptage
_seul_appel_du_groupeest présente sur les six méthodes qui parcourent le groupe (lister,acheter,offrir,accepter_l_offre,mettre_aux_encheres,encherir) : aucun site oublié. La famille du « un seul paiement pour plusieurs achats » est bien fermée. - La borne de lot 4, présentée comme « mesurée », l'est réellement. J'ai prouvé par simulation que la boîte compte dans les 8 références d'un appel (7 actifs et la boîte : accepté ; 8 actifs et la boîte : refusé « MaxAppTotalTxnReferences = 8 »). Un lot de 5 en jeton demanderait 9 références ; 4 est donc la borne réelle. Et l'achat d'un lot de 4 en jeton passe, budget 592/700 (marge 108).
- La borne d'enchère 2, présentée comme « prudente », est honnête. Le budget d'une clôture conclue à 2 pièces en jeton vaut 554/700 (marge 146). Par extrapolation, 3 pièces tomberaient vers ~632 (passerait) et 4 vers ~710 (ne passerait pas). Je n'ai pu mesurer ni 3 ni 4, le contrat plafonnant à 2, exactement la limite qu'avait l'équipe.
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.
- Le budget de la clôture à 3 pièces : impossible, le contrat plafonne les enchères à 2. Extrapolé, non mesuré. C'est le seul chiffre qui empêche d'affirmer que la borne 2 pourrait monter à 3.
- Le chemin ALGO des verrous d'enchère : non éprouvé, mais sûr par construction (un paiement en ALGO ne peut pas échouer « must optin »). Les verrous sont propres au jeton.
- Les budgets et références de
offrir,accepter_l_offre,encherirsur le chemin jeton : non mesurés un à un (moins de transactions internes que l'achat et la clôture, déjà mesurés) ; supposés sous 700, non prouvés. - L'exactitude des événements émis (
arc4.emit, pour l'historique hors chaîne) : hors du périmètre de sécurité des fonds, non vérifiée. - Comptes rekeyés, compositions de groupe exotiques au-delà du double-comptage : non explorés.
Mes propres fausses pistes
Un audit qui tait ses erreurs n'est pas croyable sur ses trouvailles.
- J'ai d'abord soupçonné, d'après une version antérieure, que la borne de lot en jeton serait invendable. Sur la version définitive elle vaut 4 et je l'ai vérifiée saine : soupçon écarté par la mesure, pas confirmé par principe.
- Ma première sonde de références était fausse : ma liste ne portait que huit identifiants, si bien que « 9 » et « 12 » retombaient à huit et « passaient » à tort. Je l'ai vu, corrigé avec un neuvième actif réel, et la conclusion tient sur la sonde propre.
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
- Le statut « corrigé » du sur-compte de sorties (F1) est faux : je le mesure encore sur la version définitive. Une annonce n'est pas une mesure.
- Le défaut nommé (collision de réserve) : je juge l'arbitrage défendable, mais je note qu'il fait porter la perte à un utilisateur alors que le contrat, ailleurs, protège l'utilisateur au détriment de la plateforme. Question de cohérence de principe, à trancher par Karl.
refermer_une_position(M1) : si le chantier la croit fonctionnelle, elle ne l'est pas. Le filet ne tient pas.
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.