ASA Compass · relecture indépendante

Audit adverse du contrat de règlement

Le site annonce que l'échange n'ouvrira qu'une fois le contrat relu par quelqu'un d'autre que son auteur. Voici cette relecture : une tentative de casser le contrat, et ce qu'elle a trouvé.

Cible
contrat/marche/contrat.py, 1 560 lignes
Version figée
empreinte SHA-256 f33e994bdbb80173cbb0082906e190035c24d2880c251b5d70656278b9344238
Méthode
version figée compilée puis déployée en propre sur le testnet (app 770778378), attaques jouées en transactions réelles signées
Réseau
testnet Algorand uniquement
Date
31 août 2026
Suite mise à jour du 31 août, après remise du rapport

Le chantier a corrigé les quatre défauts A à D. La relecture a vérifié dans le code — pas sur parole — que chaque correctif est réellement présent et sain, et que la nouvelle version compile :

Le re-test sur la chaîne de ces correctifs, et la couverture des angles non vérifiés (vente à prix fixe en jeton, lots, enchères, signature matérielle), restent à faire sur une version stable, une fois le compte de test réapprovisionné.

Ce que la relecture a trouvé

Ci-dessous, les défauts tels qu'ils ont été trouvés dans la version auditée (empreinte f33e994b). Voir le bandeau « Suite » pour leur correction.

GravitéDéfautEffet
Critique Une offre ne peut jamais être acceptée Le mécanisme d'amorçage — la fonctionnalité n°1 — est inopérant.
Moyen Réserve d'édition captable par un tiers Un attaquant empoche les 0,1 ALGO de réserve d'un autre vendeur.
Moyen Offre en jeton financée par le coffre Le coffre immobilise son propre ALGO ; griefing possible.
Moyen Marché infermable après une monnaie 0,1093 ALGO piégés par monnaie, sans issue.
Faible Frais d'acceptation à surpayer Le provisionnement de l'offrant n'entre pas dans la réserve de frais.
Faible Bytecode déployé périmé L'app testnet en service ne correspond pas à la source actuelle.

Les défauts, du plus grave au moins grave

DÉFAUT 1Critique

Une offre ne peut jamais être acceptée

Le contrat porte la décision de conception la plus structurante du projet : le marché s'amorce par les offres. Un collectionneur dépose une offre en ALGO sur n'importe quelle pièce ; le détenteur la découvre et l'accepte. Sans cela, « un catalogue reste un musée ». Cette voie ne fonctionne pas.

Le mécanisme

accepter_l_offre exige que le vendeur envoie sa pièce au coffre (asset_receiver == current_application_address), puis le coffre la fait suivre à l'acheteur par transaction interne. Or, sur Algorand, un compte doit avoir accepté (opt-in) un actif pour pouvoir le recevoir. lister et mettre_aux_encheres optent bien le coffre dans les pièces qu'on y dépose ; accepter_un_jeton l'opte dans les monnaies de paiement. Mais aucune méthode n'opte le coffre dans la pièce d'une offre — et seul le contrat le pourrait, l'opt-in étant une transaction interne que l'utilisateur ne peut pas signer à sa place.

L'essai qui le montre

python essai_D_offre_algo.py

  ok   offre en ALGO de 0,1 ALGO créée (offre 3)
 RATÉ  acceptation à frais minimaux
    → motif : receiver error: must optin, asset 770778845 missing from [coffre]
 RATÉ  acceptation à frais suffisants
    → motif : receiver error: must optin, asset 770778845 missing from [coffre]

Testé dans les deux ordres de groupe possibles (pièce d'abord, appel d'abord) : même échec, avant même que la logique du contrat ne tourne. Le protocole rejette le transfert de la pièce vers un coffre non opté.

Contrôle positif — le reste du contrat n'est pas cassé

python essai_E_controle_positif.py

  ok   pièce mise en vente (annonce 2)
  ok   l'acheteur achète
  ok   la pièce est arrivée chez l'acheteur
  ok   le vendeur a reçu le prix (net du remboursement de dépôt)

La vente à prix fixe en ALGO fonctionne de bout en bout, parce que lister opte le coffre au moment du dépôt. Le défaut est donc ciblé sur l'acceptation d'offre, pas généralisé.

Portée et gravité
Piste de correction, hors du rôle de la relecture : soit accepter_l_offre fait aller la pièce directement du vendeur à l'acheteur (le coffre vérifie le transfert sans le recevoir), soit le contrat émet un opt-in interne du coffre dans le groupe avant de recevoir la pièce, puis referme par asset_close_to. La première voie évite d'immobiliser une réserve de plus.
DÉFAUT 2Moyen

La réserve d'acceptation d'une édition est captable par un tiers

Une édition limitée (tirage > 1, forme massivement présente dans le corpus de 2021) peut être détenue par plusieurs comptes. Le coffre n'accepte l'actif qu'une seule fois, au premier dépôt, et lister fait payer cette réserve de 0,1 ALGO au premier vendeur. Mais la réserve n'est libérée qu'au dernier exemplaire qui sort du coffre (asset_close_to). Celui qui referme en dernier l'empoche, qu'il l'ait payée ou non.

L'attaque
L'essai qui le montre

python essai_A_reserve_edition.py

Édition 770778420 (tirage 2), un exemplaire à chacun.
Vendeur : dépose 140 500 µALGO, solde net final −112 000 µALGO.
Attaquant : dépose 40 500 µALGO, solde net final +88 000 µALGO.
La réserve de 0,1 ALGO a bien migré du vendeur vers l'attaquant.

Le montant est borné à 0,1 ALGO par collision, mais c'est une perte de fonds réelle d'un utilisateur vers un autre, déclenchable à volonté sur toute édition dont l'attaquant détient un exemplaire.

La cause tient dans une phrase du contrat : le compteur ne compte que les pièces, il n'attribue pas l'unique opt-in partagé au vendeur qui l'a réellement financé.
DÉFAUT 3Moyen

Une offre en jeton est financée par le coffre

Quand une offre est libellée dans un jeton, offrir stocke depot_boite = 0 : la boîte de l'offre (0,0349 ALGO) est immobilisée sur le solde disponible du coffre, l'offrant ne versant aucun ALGO. Le contrat le reconnaît lui-même comme « une limite connue ».

L'essai qui le montre

python essai_BC_jeton_fermeture.py

ALGO déposé par l'attaquant : 0 (il ne verse que du jeton).
Coffre — solde disponible : 865 100 → 830 200 (−34 900 µALGO).
Coffre — champ amount inchangé : c'est le solde minimum qui monte, donc le disponible qui fond.

Conséquence : le coffre subventionne chaque offre en jeton sur son propre solde. Un attaquant peut répéter l'opération pour épuiser l'ALGO disponible du coffre et le rendre incapable d'émettre ses transactions internes — un griefing gratuit, l'offrant ne risquant rien. La porte est ouverte dès qu'une monnaie de paiement est acceptée.

DÉFAUT 4Moyen

Le marché ne peut plus être fermé après avoir accepté une monnaie

fermer_le_marche existe précisément pour éviter que le solde immobilisé reste « prisonnier pour toujours ». Elle exige que le solde minimum du coffre soit retombé à celui d'un compte nu. Mais une fois accepter_un_jeton appelé, le coffre garde pour toujours un opt-in d'actif (0,1 ALGO) et une boîte de plancher (0,0093 ALGO) qu'aucune méthode ne peut défaire. La condition de fermeture devient inatteignable.

L'essai qui le montre

python essai_BC_jeton_fermeture.py

Après accepter_un_jeton : solde minimum du coffre 100 000 → 209 300 (+109 300 µALGO).
fermer_le_marche : REFUSÉassert failed … global MinBalance; ==
0,1093 ALGO immobilisés sans issue, par monnaie acceptée.

C'est exactement le défaut « prisonnier pour toujours » que la méthode prétend éviter. La note de tête promet « l'exploitant ne peut que fermer une caisse déjà vide » ; en pratique, la caisse ne peut plus jamais être vidée de ses opt-in de monnaies.

Piste : prévoir une méthode qui referme un opt-in de monnaie et sa boîte de plancher (comme asset_close_to le fait pour les pièces), ou intégrer cette fermeture à fermer_le_marche.
DÉFAUT 5Faible

Les frais d'acceptation doivent être surpayés

Les commentaires disent que l'offrant provisionne « les deux transactions qui la refermeront ». En réalité, ces frais provisionnés sont retenus par le coffre et n'entrent pas dans la réserve de frais du groupe (le fee pooling). L'acceptant doit donc gonfler lui-même le frais de son appel pour couvrir les transactions internes à fee=0.

 RATÉ  acceptation à frais minimaux
    → motif : group fee too small (needs 1mA more)

Sans gravité pour la sécurité, et une intégration soignée du site le gère en fixant un frais d'appel suffisant. À noter tout de même : le provisionnement décrit ne joue pas le rôle que les commentaires lui prêtent, et le petit surplus retenu par le coffre à chaque offre s'y accumule sans issue (même famille que le défaut 4).

DÉFAUT 6Faible · opérationnel

Le bytecode déployé sur le testnet est périmé

La compilation de la version figée est identique octet pour octet aux artefacts du dépôt (marche/artefacts/), mais différente du bytecode réellement déployé sur l'app de test partagée.

Ma compilation (version figée) : 3 844 octets.
App testnet en service : 3 731 octets.
Le contrat a été édité après le dernier déploiement.

Tout essai lancé contre l'app partagée teste donc du code plus ancien que la source. Il faut redéployer avant de conclure quoi que ce soit sur cette app. La relecture a contourné le problème en déployant sa propre instance depuis la version figée.

Ce que la relecture n'a PAS pu vérifier

Un audit qui ne dit pas ce qu'il n'a pas regardé ne vaut rien. Voici les angles restés dans l'ombre.

Comment lire ce rapport

La version auditée a été compilée depuis la source figée (empreinte f33e994b…), puis déployée en propre sur le testnet (app 770778378). Chaque attaque a été jouée en transactions réelles signées et confirmées sur la chaîne, et non simulée. Les essais reproductibles vivent dans audit-contrat/ : essai_A_reserve_edition.py, essai_BC_jeton_fermeture.py, essai_D_offre_algo.py, essai_E_controle_positif.py. Ils tournent avec le même banc d'essai que les scénarios du chantier (contrat/commun.py), pointé sur l'instance d'audit.

Conclusion : un défaut critique empêche aujourd'hui la fonctionnalité qui devait amorcer le marché. Le chemin à prix fixe, lui, tient. Les trois défauts moyens touchent des fonds réels mais bornés, ou la capacité à retirer le contrat. L'échange ne devrait pas ouvrir avant correction du défaut 1 au minimum, et avant que les angles non vérifiés — les enchères et la signature matérielle en tête — aient été éprouvés.

Relecture indépendante · ASA Compass · testnet uniquement · 31 août 2026
Version auditée figée à l'empreinte f33e994b, compilée et déployée par la relecture.