ALLER AU CONTENU
STANDARDS ETHEREUM7 minStandards en production / Ép. 9 · Niveau 9-4

Tests d’invariants Foundry : comment détecter le contournement

Sur le code de référence d’ERC-7943 avant le correctif, 2 tests d’invariants aléatoires et 1 test de non-régression ont échoué. Comment le handler repère-t-il le contournement ?

XIN
Scène nocturne en pixel art du poste de contrôle d’un coffre. Une lampe rouge refuse une grande caisse au portail, tandis qu’un tapis roulant fait passer deux petites caisses identiques par le même portail sous une lampe verte, et qu’une étagère cadenassée du coffre est vide.

EN 30 SECONDES

Un test d’invariants Foundry est un test où un outil envoie des appels aléatoires au contrat et vérifie vos règles après chacun. Sur le code de référence d’ERC-7943 d’avant le correctif, mes séquences fautives, une fois réduites, tenaient en quelques appels.

À RETENIR · 3

  • Le handler n’emploie que deux token ids : les répétitions sont fréquentes.
  • Il interroge canTransfer sur le total de chaque id, en tant qu’expéditeur, et signale tout batch passé malgré tout.
  • Sur le contrat de référence d’avant le correctif, les trois tests échouent. Avec la PR n° 2045, les trois passent.
Sur cette page

Fuzzer les règles de transfert d’un token avec Foundry

EN BREF Un petit handler envoie des batchs aléatoires sur deux token ids seulement et signale tout transfert passé après un refus de canTransfer.

LES MOTS À CONNAÎTRE

Test d’invariants
Un test où un outil envoie au contrat des appels aléatoires et vérifie une règle après chacun.
Handler
Le contrat de test qui passe les appels pour l’outil.
Drapeau fantôme
Une variable propre au test, qui retient ce que le contrat n’enregistre pas.

Un Test d’invariants Un test où un outil envoie au contrat des appels aléatoires et vérifie une règle après chacun. Voir le glossaire vérifie deux règles après chaque appel. Rien de ce que canTransfer a refusé pour le total d’un id ne s’exécute, même si l’exécution peut être plus stricte.

ATTENTION

Deuxième règle : les transferts du détenteur n’entament jamais les tokens gelés, si le solde les couvrait au départ. Le gel peut dépasser le solde : ils ne doivent alors pas aggraver l’écart. Transferts forcés et burns privilégiés ont leur propre règle.

Le Handler Le contrat de test qui passe les appels pour l’outil. Voir le glossaire passe les appels, en tant que détenteur.

ÉTAPE PAR ÉTAPE

Diagramme de flux : 6 étapesDiagramme de flux : 6 étapes. Préparer un détenteur en partie gelé, puis Construire un batch aléatoire. Construire un batch aléatoire, puis Consulter le pré-contrôle sur le total de chaque id. Consulter le pré-contrôle sur le total de chaque id, puis Envoyer le batch en tant que détenteur. Envoyer le batch en tant que détenteur, puis Noter tout désaccord. Noter tout désaccord, puis Vérifier les deux règles.1Préparer un détenteur enpartie gelé2Construire un batchaléatoire3Consulter le pré-contrôlesur le total de chaque id4Envoyer le batch en tantque détenteur5Noter tout désaccord6Vérifier les deux règles
canTransfer(holder, to, id, total), appelé en tant que détenteur. safeBatchTransferFrom, l’appel qui envoie le batch.

Étape 1. Préparer. Les ids 1 et 2 démarrent chacun à 100, dont 60 gelés.

Choisissez une étape pour la voir bouger
  1. Préparer Les ids 1 et 2 démarrent chacun à 100, dont 60 gelés.
  2. Construire un batch Entre un et quatre éléments, ids tirés parmi 1 et 2, montants de 0 à 100, envoyés vers un autre compte ou vers le détenteur lui-même.
  3. Interroger la vue En tant que détenteur, interroger canTransfer sur le total de chaque id. Le standard permet à sa réponse de dépendre de l’appelant (msg.sender). La vue et l’envoi passent donc par le même appelant.
  4. Envoyer Envoyer le batch dans un try/catch, pour qu’un refus n’arrête pas la série.
  5. Noter Si le batch est passé alors que la vue avait dit non, lever un drapeau.
  6. Vérifier Après chaque appel, Foundry vérifie les deux règles.
Un appel du handler, puis le contrôle de Foundry.

EN CLAIR

Pourquoi deux ids seulement ? Avec des ids tirés au hasard sur 256 bits, les répétitions seraient rares, et Foundry pourrait mettre longtemps à tomber sur le bug. Le Drapeau fantôme Une variable propre au test, qui retient ce que le contrat n’enregistre pas. Voir le glossaire se compare ensuite à la réponse du token, pas à une formule de mon cru.

Pour cet article (pas sur du code client), j’ai lancé le fichier depuis le dossier assets/erc-7943 du dépôt des ERC, avec sa configuration Foundry (solc 0.8.29, OpenZeppelin v5.1.0). Sur master, la branche principale, au 7 octobre 2026, les trois tests échouent.

Foundry réduit ensuite chaque séquence fautive. Dans cette exécution (les séquences varient selon la graine aléatoire), 225 appels sont devenus cinq pour la règle du solde gelé (« frozen tokens left: 59 < 60 »). Pour la règle de la vue, 70 appels sont devenus un seul.

128 000

appels par invariant, PR n° 2045 appliquée

Source : mon test du 7 oct. 2026, PR alors non fusionnée.

Avec les modifications de la PR n° 2045 (non fusionnée au 7 octobre 2026), les trois passent, sur 256 séries par invariant. Les tests existants du dossier aussi (222 avec ceux ajoutés par la PR).

test/FrozenBalanceInvariant.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.29;

import {Test} from "forge-std/Test.sol";
import {uRWA1155} from "../contracts/uRWA1155.sol";

contract BatchHandler is Test {
    uRWA1155 public immutable token;
    address public immutable holder;
    address public immutable dest;
    bool public viewSaidNoButExecuted; // ghost variable

    constructor(uRWA1155 token_, address holder_, address dest_) {
        token = token_;
        holder = holder_;
        dest = dest_;
    }

    function batch(uint256 len, bool toSelf, uint256[4] calldata seedIds, uint256[4] calldata seedValues) external {
        address to = toSelf ? holder : dest;
        uint256 n = bound(len, 1, 4);
        uint256[] memory ids = new uint256[](n);
        uint256[] memory values = new uint256[](n);
        uint256[3] memory total;
        for (uint256 i; i < n; ++i) {
            ids[i] = 1 + (seedIds[i] % 2);
            values[i] = bound(seedValues[i], 0, 100);
            total[ids[i]] += values[i];
        }

        vm.startPrank(holder);
        bool allowed1 = token.canTransfer(holder, to, 1, total[1]);
        bool allowed2 = token.canTransfer(holder, to, 2, total[2]);
        try token.safeBatchTransferFrom(holder, to, ids, values, "") {
            if (!allowed1 || !allowed2) viewSaidNoButExecuted = true;
        } catch {}
        vm.stopPrank();
    }
}

contract FrozenBalanceInvariantTest is Test {
    uRWA1155 internal token;
    BatchHandler internal handler;
    address internal admin = makeAddr("admin");
    address internal holder = makeAddr("holder");
    address internal dest = makeAddr("dest");

    function setUp() public {
        token = new uRWA1155("ipfs://meta/{id}.json", admin);
        vm.startPrank(admin);
        token.changeSendWhitelist(holder, true);
        token.changeReceiveWhitelist(holder, true);
        token.changeReceiveWhitelist(dest, true);
        for (uint256 id = 1; id <= 2; ++id) {
            token.mint(holder, id, 100);
            token.setFrozenTokens(holder, id, 60);
        }
        vm.stopPrank();

        handler = new BatchHandler(token, holder, dest);
        targetContract(address(handler));
    }

    function invariant_viewMatchesExecution() public view {
        assertFalse(handler.viewSaidNoButExecuted(), "batch executed what canTransfer refused");
    }

    function invariant_frozenTokensStayPut() public view {
        for (uint256 id = 1; id <= 2; ++id) {
            assertGe(token.balanceOf(holder, id), token.getFrozenTokens(holder, id), "frozen tokens left");
        }
    }

    function test_duplicateIdsCannotSpendFrozenTokens() public {
        uint256[] memory ids = new uint256[](2);
        uint256[] memory values = new uint256[](2);
        ids[0] = 1;
        ids[1] = 1;
        values[0] = 40;
        values[1] = 40;

        assertFalse(token.canTransfer(holder, dest, 1, 80));
        vm.prank(holder);
        vm.expectRevert();
        token.safeBatchTransferFrom(holder, dest, ids, values, "");
    }
}

OBJET OBTENU

Peu d’ids, même appelant, comparaison à la vue

Ces trois choix rendent les ids répétés fréquents et gardent une comparaison fidèle.

ATTENTION

La méthode vaut pour d’autres multi-tokens ERC-7943, comme une base ERC-6909. Adaptez les types, le setUp et l’appel de transfert. Qui peut inscrire sur liste blanche, émettre et geler varie selon l’implémentation.

Sources : tests d’invariants Foundry, PR n° 2045 des ERC (ouverte au 7 oct. 2026), contrat de référence uRWA1155.

FAQ

Un handler envoie des batchs aléatoires sur quelques token ids ; après chaque appel, on vérifie que rien de ce que canTransfer a refusé n’est passé.

Pas lors de ma vérification du 7 octobre 2026 : la PR n° 2045 était ouverte et non fusionnée, encore sans revue. Consultez la page de la PR pour son état actuel.

NIVEAU TERMINÉ

Ce que vous savez maintenant

  • Pourquoi la vue et le transfert doivent s’exécuter avec le même appelant.
  • À quoi ressemblent une exécution qui échoue sur l’ancien code et une qui passe avec le correctif.

Vérifiez par vous-même

Touchez ou cliquez une question pour voir la réponse.

Vous laissez le fuzzer tirer n’importe quel token id sur 256 bits. Pourquoi le test pourrait-il rester vert sur le code défectueux ?

Les répétitions seraient rares : un batch avec deux fois le même id pourrait ne jamais sortir pendant l’exécution.

Vous avez fini tous les niveaux publiés de cette série. Retour à la carte de la série

Adam Boudjemaa
Adam Boudjemaa

Ancien CTO d’Integra. Auteur nommé (1 sur 5) d’ERC-3643, premier auteur d’ERC-6960, co-auteur d’ERC-7410 et co-auteur d’ERC-8203, encore à l’état de brouillon. Conçoit des systèmes d’IA en production et des systèmes Web3 régulés.

Un projet à livrer ou un poste à pourvoir ?

Parlons de votre besoin
Suivre les notes techniques

Recevez les nouveaux articles sur l’IA en production et les smart contracts par email.