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 ?

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
Étape 1. Préparer. Les ids 1 et 2 démarrent chacun à 100, dont 60 gelés.
- Préparer Les ids 1 et 2 démarrent chacun à 100, dont 60 gelés.
- 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.
- 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.
- Envoyer Envoyer le batch dans un try/catch, pour qu’un refus n’arrête pas la série.
- Noter Si le batch est passé alors que la vue avait dit non, lever un drapeau.
- Vérifier Après chaque appel, Foundry vérifie les deux règles.
EN CLAIR
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).
// 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
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
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 besoinSuivre les notes techniques
Recevez les nouveaux articles sur l’IA en production et les smart contracts par email.