Anatomie du contrat PatolabsOG
229 lignes de Solidity, un ERC-721 avec art SVG on-chain, un invariant 1-par-tier appliqué aux transferts, et quelques décisions de design qui méritent d'être expliquées.
Le contrat derrière Patolabs OG est vérifié et lisible sur Basescan. 229 lignes de Solidity, pas de proxy, pas de librairie externe au-delà d'OpenZeppelin. Cet article décortique les choix de design, les pièges évités, et ce que je referais différemment.
Le squelette
contract PatolabsOG is ERC721, Ownable2Step, ReentrancyGuard {
Trois héritages, chacun pour une raison précise.
ERC721 — le standard NFT, pas ERC721Enumerable. L'énumération on-chain coûte du gas à chaque transfert pour maintenir des tableaux d'indices. PatolabsOG n'en a pas besoin : le front utilise des events pour lister les tokens d'un wallet, et les vues comme ownsTier(address, uint8) suffisent pour le gate TestFlight. Économie de gas à chaque mint et transfert.
Ownable2Step plutôt que Ownable — c'est le détail que la plupart des contrats NFT négligent. Avec Ownable, un seul appel à transferOwnership(newAddress) et c'est fait. Si tu te trompes d'adresse, le contrat est perdu. Ownable2Step sépare le transfert en deux étapes : le owner actuel propose, le nouveau owner accepte. Tant que l'acceptation n'a pas eu lieu, rien ne change. Pour un contrat qui contrôle des fonds (les revenus de mint), c'est le strict minimum.
ReentrancyGuard — sur la fonction mint(). Le mint envoie un token ERC-721 via _safeMint, qui appelle onERC721Received sur le destinataire. Si le destinataire est un contrat malveillant, il peut tenter de re-entrer dans mint() pendant le callback. Le guard coupe cette possibilité. Coût : un SSTORE supplémentaire par mint. Ça vaut le coup.
L'invariant : un token par tier par wallet
C'est le point central du contrat. Chaque adresse ne peut détenir qu'un seul exemplaire de chaque tier. La contrainte est appliquée sur chaque transfert, pas seulement au mint.
function _update(address to, uint256 tokenId, address auth)
internal override returns (address from)
{
from = super._update(to, tokenId, auth);
uint8 tier = _tokenTier[tokenId];
if (from != address(0)) _tierCount[from][tier]--;
if (to != address(0)) {
require(_tierCount[to][tier] == 0, "Already owns this tier");
_tierCount[to][tier]++;
}
return from;
}
_update() est le hook interne d'OpenZeppelin 5.x qui intercepte tous les mouvements de tokens — mint, transfert, burn. En l'overridant, on garantit que l'invariant est vérifié partout, sans devoir toucher à transferFrom, safeTransferFrom, et leurs variantes.
Le mapping _tierCount est un mapping(address => mapping(uint8 => uint256)). Il remplace un ancien hasMintedTier qui ne vérifiait qu'au moment du mint — le destinataire d'un transfert n'était pas validé, ce qui permettait à un wallet de recevoir un token puis d'en mint un autre du même tier, et de finir avec deux exemplaires. Le fix a déplacé la vérification du mint vers le hook de transfert, qui couvre les deux extrémités de chaque mouvement.
Conséquence concrète : si quelqu'un met un Supporter en vente sur OpenSea et que l'acheteur en possède déjà un, la transaction revert. Pas de workaround possible tant que l'acheteur n'a pas vendu ou transféré le sien.
Le mint
function mint(uint8 tier) external payable nonReentrant {
require(tier <= TIER_FOUNDER, "Invalid tier");
TierConfig storage t = _tiers[tier];
require(_tierCount[msg.sender][tier] == 0, "Already owns this tier");
require(msg.value >= t.price, "Insufficient payment");
require(t.minted < t.maxSupply, "Tier sold out");
uint256 tokenId = _nextTokenId++;
t.minted++;
_tokenTier[tokenId] = tier;
_tokenTierIndex[tokenId] = t.minted;
_safeMint(msg.sender, tokenId);
emit Minted(msg.sender, tokenId, tier);
}
Quelques points :
Le require sur _tierCount est redondant avec le check dans _update() — le _safeMint en fin de fonction déclenchera _update() qui vérifiera la même chose. Mais le check explicite au début de mint() a deux avantages : un message d'erreur clair pour l'appelant, et une économie de gas en cas d'échec (on revert avant d'écrire quoi que ce soit en storage).
_tokenTierIndex stocke la position du token dans son tier (1-based). Le token #7 peut être le "Backer 3 of 300". Cette info est utilisée dans les métadonnées mais pas dans la logique du contrat — c'est purement cosmétique.
Le msg.value >= t.price plutôt que == est un choix classique : si quelqu'un envoie trop d'ETH, il ne perd pas sa transaction. Le surplus reste dans le contrat et sera retiré via withdraw(). On pourrait rembourser automatiquement le surplus, mais ça ajoute un call externe dans le mint — surface d'attaque supplémentaire pour un bénéfice marginal.
Les murs : receive et fallback
receive() external payable { revert("Use mint()"); }
fallback() external payable { revert("Use mint()"); }
Deux lignes, un principe : le seul moyen d'envoyer de l'ETH au contrat, c'est via mint(). Pas de transfert accidentel, pas de send ou transfer qui réussissent silencieusement. Si quelqu'un envoie de l'ETH sans appeler mint(), la transaction revert avec un message explicite.
C'est un filet de sécurité contre les erreurs de manipulation. Ça n'empêche pas un selfdestruct d'un autre contrat de forcer de l'ETH dedans (edge case connu d'EVM), mais ça couvre 99% des cas réels.
L'art SVG on-chain
C'est la partie la plus inhabituelle du contrat. Quand quelqu'un appelle tokenURI(), le contrat construit un SVG complet et le renvoie en JSON base64.
function tokenURI(uint256 tokenId) public view override returns (string memory) {
// ...
string memory svg = _buildSVG(tierIdxStr, maxStr, ti, tName, tColor);
string memory image = string.concat(
"data:image/svg+xml;base64,",
Base64.encode(bytes(svg))
);
string memory json = Base64.encode(bytes(string.concat(
'{"name":"Patolabs OG #', idStr, '",',
'"description":"Original supporter of patolabs. ...",',
'"image":"', image, '",',
'"attributes":[',
'{"trait_type":"Tier","value":"', tName, '"},',
'{"trait_type":"Edition","value":"', tierIdxStr, ' of ', maxStr, '"}',
']}'
)));
return string.concat("data:application/json;base64,", json);
}
Double encodage base64 : le SVG est encodé une première fois pour le champ image, puis le JSON entier est encodé pour le data URI final. C'est le pattern standard pour les métadonnées on-chain, mais il faut savoir que ça coûte en gas de lecture — tokenURI() est une fonction view, donc gratuite en appel externe, mais lourde si appelée depuis un autre contrat on-chain.
Le SVG lui-même est assemblé via string.concat (Solidity 0.8.12+), pas via abi.encodePacked. C'est plus lisible et évite le cast en string à la fin.
function _buildSVG(...) internal pure returns (string memory) {
return string.concat(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400">',
_svgDefs(accent),
'<rect width="400" height="400" rx="32" fill="url(#bg)"/>',
// ... logo, texte, décorations
'</svg>'
);
}
La structure : un fond avec gradient linéaire (#1F1F24 → #0B0B0D), un contour subtil coloré par tier, le logo Patolabs en haut à gauche (un chevron + un P stylisé en SVG paths), le gros "OG" au centre avec un gradient texte, le nom du tier, le numéro d'édition, et la signature "patolabs" en bas à gauche.
Les décorations varient par tier via _tierDecorations() :
function _tierDecorations(uint8 tier, string memory accent)
internal pure returns (string memory)
{
if (tier == TIER_SUPPORTER) return "";
// Backer : un anneau
// Founder : deux anneaux + 4 brackets aux coins
}
Supporter : rien. Backer : un cercle SVG autour du texte. Founder : deux cercles concentriques et quatre polylines formant des brackets aux coins. Les opacités sont dégradées (0.25, 0.15, 0.5) pour garder un rendu sobre.
Les fonctions owner
function setTierPrice(uint8 tier, uint256 price) external onlyOwner {
require(price > 0, "Price must be > 0");
// ...
}
function withdraw() external onlyOwner {
uint256 amount = address(this).balance;
(bool ok,) = owner().call{value: amount}("");
require(ok, "Withdraw failed");
}
Deux fonctions, deux pouvoirs.
setTierPrice permet d'ajuster les prix sans redéployer. Le require(price > 0) empêche de mettre un tier gratuit — un choix délibéré pour éviter le spam de mint. Les maxSupply, eux, sont fixés dans le constructeur et ne peuvent jamais être modifiés. Pas de setter, pas de variable mutable — c'est la garantie la plus forte du contrat.
withdraw utilise call{value} plutôt que transfer — c'est la best practice post-Istanbul (EIP-1884) pour éviter les problèmes de gas stipend. L'ETH va toujours vers owner(), pas vers un paramètre arbitraire.
Ce que je ferais différemment
Le contrat fait ce qu'il doit faire. Mais avec le recul :
Le surplus d'ETH au mint pourrait être remboursé. Le msg.value >= t.price est safe, mais un msg.value == t.price avec un message d'erreur clair ("Send exactly X") serait plus propre pour l'utilisateur. Le surplus est récupérable via withdraw(), mais c'est moi qui le récupère, pas le minteur.
L'absence de fonction burn() est un choix. Si quelqu'un veut se débarrasser de son token, il peut le transférer à l'adresse nulle (le hook _update() le gère). Mais un burn() explicite serait plus ergonomique.
Les events pourraient inclure plus de données — tierIndex dans Minted par exemple, pour éviter un appel supplémentaire à tierPositionOf() côté indexer.
Ce sont des détails. Le contrat tient en 229 lignes, il est lisible, il fait ce qu'il promet, et chaque ligne a une raison d'être. Pour un contrat de support program, c'est exactement ce qu'il faut.
Le code complet est vérifiable sur Basescan.
— Pato