Passer au contenu principal

Comprendre le livre Jaune des spécifications d'EVM

evm
Intermédiaire
qbzzt
15 mai 2022
20 minutes de lecture minute read

Le Livre Jaune(opens in a new tab) est la spécification formelle d'Ethereum. Sauf là où il a été modifié par le processus d'EIP, il contient la description exacte du fonctionnement de tout. Il est rédigé sous forme de document mathématique, qui inclut une terminologie que les programmeurs pourraient ne pas connaître. Dans ce document, vous apprendrez comment le lire, et par extension, d'autres documents mathématiques liés.

Quel Livre Jaune ?

Comme presque tout le reste dans Ethereum, le Livre Jaune évolue avec le temps. Afin de pouvoir se référer à une version spécifique, j'ai téléchargé la version actuelle au moment de la rédaction. Les numéros de section, de page et d'équation que j'utilise se référeront à cette version. Il est recommandé de l'avoir ouvert dans une autre fenêtre pendant la lecture de ce document.

Pourquoi l'EVM ?

Le livre jaune original a été rédigé dès le début du développement d'Ethereum. Il décrit le mécanisme de consensus original basé sur la preuve de travail qui était initialement utilisé pour sécuriser le réseau. Cependant, Ethereum a abandonné la preuve de travail et a commencé à utiliser un consensus basé sur la preuve d'enjeu en septembre 2022. Ce tutoriel se concentrera sur les parties du livre jaune définissant la Machine Virtuelle Ethereum. L'EVM n'a pas été modifié par la transition vers la preuve d'enjeu (à l'exception de la valeur de réponse de l'opcode DIFFICULTY).

9 Modèle d'exécution

Cette section (p. 12-14) comprend la majeure partie de la définition de l'EVM.

Le terme état du système comprend tout ce que vous devez savoir sur le système pour le faire fonctionner. Dans un ordinateur classique, cela signifie la mémoire, le contenu des registres, etc.

Une machine de Turing(opens in a new tab) est un modèle de calcul. En substance, c'est une version simplifiée d'un ordinateur, qui s’est avéré avoir la même capacité d'exécuter des calculs qu'un ordinateur normal (tout ce qu'un ordinateur peut calculer, une machine de Turing peut le calculer et vice versa). Ce modèle facilite la preuve de divers théorèmes sur ce qui est et n'est pas calculable.

Le terme Turing-complet(opens in a new tab) désigne un ordinateur qui peut exécuter les mêmes calculs qu'une machine de Turing. Les machines de Turing peuvent entrer dans des boucles infinies, et l'EVM ne le peut pas car elle manquerait de gaz, elle est donc seulement quasi-Turing-complète.

9.1 Les bases

Cette section présente les bases de l'EVM et comment il se compare à d'autres modèles computationnels.

Une machine pile(opens in a new tab) est un ordinateur qui stocke les données intermédiaires non pas dans des registres, mais dans une pile(opens in a new tab). C'est l'architecture privilégiée pour les machines virtuelles car elle est facile à mettre en œuvre, ce qui signifie que les bugs et les vulnérabilités de sécurité sont beaucoup moins probables. La mémoire dans la pile est divisée en mots de 256 bits. Ce choix a été fait car il est pratique pour les opérations cryptographiques centrales d'Ethereum telles que le hachage Keccak-256 et les calculs de courbes elliptiques. La taille maximale de la pile est de 1024 octets. Lorsque les opcodes sont exécutés, ils prennent généralement leurs paramètres depuis la pile. Il existe des opcodes spécifiquement pour réorganiser les éléments dans la pile, tels que POP (retire l'élément du haut de la pile), DUP_N (duplique le N-ième élément dans la pile), etc.

L'EVM possède également un espace volatile appelé mémoire, qui est utilisé pour stocker des données pendant l'exécution. Cette mémoire est organisée en mots de 32 octets. Tous les emplacements mémoire sont initialisés à zéro. Si vous exécutez ce code Yul(opens in a new tab) pour ajouter un mot à la mémoire, il remplira 32 octets de mémoire en remplissant l'espace vide du mot avec des zéros, c'est-à-dire qu'il crée un mot - avec des zéros aux emplacements 0-29, 0x60 à 30, et 0xA7 à 31.

1mstore(0, 0x60A7)

mstore est l'un des trois opcodes que l'EVM fournit pour interagir avec la mémoire - il charge un mot en mémoire. Les deux autres sont mstore8, qui charge un seul octet en mémoire, et mload, qui déplace un mot de la mémoire à la pile.

L'EVM a également un modèle de stockage non volatile séparé qui est maintenu dans le système - cette mémoire est organisée en tableaux de mots (par opposition aux tableaux d'octets adressables par mots dans la pile). Ce stockage est l'endroit où les contrats conservent des données persistantes - un contrat ne peut interagir qu'avec son propre stockage. Le stockage est organisé en mappages clé-valeur.

Bien qu'il ne soit pas mentionné dans cette section du Livre Jaune, il est également utile de savoir qu'il existe un quatrième type de mémoire. Calldata est une mémoire en lecture seule adressable par octets utilisée pour stocker la valeur passée avec le paramètre de données d'une transaction. L'EVM dispose d'opcodes spécifiques pour gérer calldata. calldatasize renvoie la taille des données. calldataload charge les données dans la pile. calldatacopy copie les données en mémoire.

L'architecture standard Von Neumann(opens in a new tab) stocke le code et les données dans la même mémoire. L'EVM ne suit pas cette norme pour des raisons de sécurité - le partage de mémoire volatile rend possible la modification du code programme. Au lieu de cela, le code est sauvegardé dans le stockage.

Il n'y a que deux cas où le code est exécuté à partir de la mémoire :

  • Lorsqu'un contrat crée un autre contrat (en utilisant CREATE(opens in a new tab) ou CREATE2(opens in a new tab)), le code pour le constructeur de contrat provient de la mémoire.
  • Lors de la création d'un contrat, le code du constructeur s'exécute puis renvoie avec le code du contrat réel, également à partir de la mémoire.

Le terme exécution exceptionnelle signifie une exception qui provoque l'arrêt de l'exécution du contrat en cours.

9.2 Aperçu des frais

Cette section explique comment les frais de gaz sont calculés. Il y a trois coûts :

Coût des opcodes

Le coût inhérent à l'opcode spécifique. Pour obtenir cette valeur, trouvez le coût de l'opcode dans l'Annexe H (p. 28, sous l'équation (327)), et trouvez le coût dans l'équation (324). Cela vous donne une fonction de coût, qui dans la plupart des cas utilise des paramètres de l'Annexe G (p. 27).

Par exemple, l'opcode CALLDATACOPY(opens in a new tab) fait partie du groupe Wcopy. Le coût de l'opcode pour ce groupe est Gverylow+Gcopy×⌈μs[2]÷32⌉. En regardant l'Annexe G, nous voyons que les deux constantes sont 3, ce qui nous donne 3+3×⌈μs[2]÷32⌉.

Nous devons encore décrypter l'expression ⌈μs[2]÷32⌉. La partie la plus externe, ⌈ \<value>, est la fonction plafond, une fonction qui, étant donnée une valeur, retourne le plus petit entier qui n'est pas inférieur à cette valeur. Par exemple, ⌈2.5⌉ = ⌈3⌉ = 3. La partie interne est μs[2]÷32. En consultant la section 3 (Conventions) à la p. 3, μ représente l'état de la machine. L'état de la machine est défini dans la section 9.4.1 à la p. 13. Selon cette section, l'un des paramètres de l'état de la machine est s pour la pile. En regroupant tout cela, il semble que μs[2] soit l'emplacement n°2 dans la pile. En examinant l'opcode(opens in a new tab), l'emplacement n°2 dans la pile est la taille des données en octets. En regardant les autres opcodes du groupe Wcopy, comme CODECOPY(opens in a new tab) et RETURNDATACOPY(opens in a new tab), ils ont également une taille de données au même endroit. Ainsi, ⌈μs[2]÷32⌉ représente le nombre de mots de 32 octets nécessaires pour stocker les données copiées. En résumé, le coût inhérent de CALLDATACOPY(opens in a new tab) est de 3 gas plus 3 par mot de données copiées.

Coût d'exécution

Il s'agit du coût d'exécution du code que nous appelons.

Coût d'expansion de la mémoire

Il s'agit du coût de l'expansion de la mémoire (si nécessaire).

Dans l'équation 324, cette valeur est notée Cmemi')-Cmemi). En consultant à nouveau la section 9.4.1, nous voyons que μi représente le nombre de mots en mémoire. Ainsi, μi est le nombre de mots en mémoire avant le opcode et μi' est le nombre de mots en mémoire après l'opcode.

La fonction Cmem est définie dans l'équation 326 : Cmem(a) = Gmemory × a + ⌊a2 ÷ 512⌋. ⌊x⌋ est la fonction plancher, une fonction qui, étant donné une valeur, retourne le plus grand entier qui n'est toujours pas supérieur à cette valeur. Par exemple, ⌊2.5⌋ = ⌊2⌋ = 2. Lorsque a < √512, a2 < 512, et le résultat de la fonction plancher est zéro. Ainsi, pour les 22 premiers mots (704 octets), le coût augmente linéairement avec le nombre de mots mémoire requis. Au-delà de ce point, ⌊a2 ÷ 512⌋ est positif. Lorsque la mémoire requise est suffisamment élevée, le coût en gas est proportionnel au carré de la quantité de mémoire.

Note : ces facteurs n'influencent que le coût en gas inhérent - cela ne prend pas en compte le marché des frais ou les pourboires aux validateurs qui déterminent combien un utilisateur final doit payer - il s'agit simplement du coût brut d'exécution d'une opération particulière sur l'EVM.

Plus d'information sur le gaz.

9.3 Environnement d'exécution

L'environnement d'exécution est un tuple, I, qui inclut des informations qui ne font pas partie de l'état de la blockchain ou de l'EVM.

ParamètreOpcode pour accéder aux donnéesCode Solidity pour accéder aux données
IaADDRESS(opens in a new tab)address(this)
IoORIGIN(opens in a new tab)tx.origin
IpGASPRICE(opens in a new tab)tx.gasprice
IdCALLDATALOAD(opens in a new tab), etc.msg.data
IsCALLER(opens in a new tab)msg.sender
IvCALLVALUE(opens in a new tab)msg.value
IbCODECOPY(opens in a new tab)address(this).code
IHChamps de l'en-tête de bloc, tels que NUMBER(opens in a new tab) et DIFFICULTY(opens in a new tab)block.number, block.difficulty, etc.
IeProfondeur de la pile d'appels pour les appels entre contrats (y compris la création de contrat)
IwL'EVM est-il autorisé à modifier l'état ou s'exécute-t-il de manière statique

Quelques autres paramètres sont nécessaires pour comprendre le reste de la section 9 :

ParamètreDéfini dans la sectionSignification
σ2 (p. 2, équation 1)L'état de la blockchain
g9.3 (p. 13)Gaz restant
A6.1 (p. 8)Sous-état accumulé (modifications prévues pour la fin de la transaction)
o9.3 (p. 13)Sortie - le résultat retourné dans le cas d'une transaction interne (quand un contrat en appelle un autre) et des appels à des fonctions d'affichage (quand on demande simplement des informations, il n'est donc pas nécessaire d'attendre une transaction)

9.4 Aperçu de l'exécution

Maintenant que nous avons tous les préliminaires, nous pouvons enfin commencer à travailler sur le fonctionnement de l'EVM.

Les équations 137-142 nous donnent les conditions initiales pour faire fonctionner l'EVM :

SymboleValeur initialeSignification
μggGaz restant
μpc0Compteur de programme, l'adresse de la prochaine instruction à exécuter
μm(0, 0, ...)Mémoire, initialisée à zéro
μi0Emplacement mémoire le plus élevé utilisé
μs()La pile, initialement vide
μoLa sortie, un ensemble vide jusqu'à ce que nous nous arrêtions soit avec des données de retour (RETURN(opens in a new tab) ou REVERT(opens in a new tab)) soit sans elle (STOP(opens in a new tab) ou SELFDESTRUCT(opens in a new tab)).

L'équation 143 nous indique qu'il existe quatre conditions possibles à chaque instant lors de l'exécution, et ce qu'il faut faire avec elles :

  1. Z(σ,μ,A,I). Z représente une fonction qui teste si une opération crée une transition d'état invalide (voir l'arrêt exceptionnel). Si elle est évaluée à True, le nouvel état est identique à l'ancien (à l'exception du gaz brûlé) car les changements n'ont pas été mis en œuvre.
  2. Si l'opcode exécuté est REVERT(opens in a new tab), le nouvel état est le même que l'ancien, et une partie du gaz est perdue.
  3. Si la séquence d'opérations est terminée, comme signifié par un RETURN(opens in a new tab), l'état est mis à jour avec le nouvel état.
  4. Si nous ne sommes pas à l'une des conditions de fin 1-3, continuer l'exécution.

9.4.1 Machine à états

Cette section explique la machine à états en détail. Elle spécifie que w est l'opcode actuel. Si μpc est inférieur à ||Ib||, la longueur du code, alors ce byte (Ibpc]) est l'opcode. Sinon, l'opcode est défini comme STOP(opens in a new tab).

Comme il s'agit d'une machine à pile(opens in a new tab), nous devons suivre le nombre d'éléments retirés (δ) et insérés (α) par chaque opcode.

9.4.2 Arrêt Exceptionnel

Cette section définit la fonction Z, qui spécifie quand nous avons une terminaison anormale. Il s'agit d'une fonction booléenne(opens in a new tab), donc elle utilise pour un « ou » logique(opens in a new tab) et pour un « et » logique(opens in a new tab).

Nous avons un arrêt exceptionnel si l'une de ces conditions est vraie :

  • μg < C(σ,μ,A,I) Comme nous l'avons vu à la section 9.2, C est la fonction qui spécifie le coût en gaz. Il ne reste pas assez de gaz pour couvrir le prochain opcode.

  • δw=∅ Si le nombre d'éléments extraits pour un opcode est indéfini, alors l'opcode lui-même est indéfini.

  • || μs || < δw Sous-alimentation de la pile, pas assez d'éléments dans la pile pour l'opcode actuel.

  • w = JUMP ∧ μs[0]∉D(Ib) L'opcode est JUMP(opens in a new tab) et l'adresse n'est pas une JUMPDEST(opens in a new tab). Les sauts ne sont valides que lorsque la destination est une JUMPDEST(opens in a new tab).

  • w = JUMPI ∧ μs[1]≠0 ∧ μs[0] ∉ D(Ib) L'opcode est JUMPI(opens in a new tab), la condition est vraie (non nulle) donc le saut devrait se produire, et l'adresse n'est pas une JUMPDEST(opens in a new tab). Les sauts ne sont valides que lorsque la destination est une JUMPDEST(opens in a new tab).

  • w = RETURNDATACOPY ∧ μs[1]s[2]>|| μo || L'opcode est RETURNDATACOPY(opens in a new tab). Dans cet opcode, l'élément de pile μs[1] est le décalage pour lire dans le tampon de données retournées, et l'élément de pile μs[2] est la longueur des données. Cette condition se produit lorsque vous essayez de lire au-delà de la fin du tampon de données retournées. Notez qu'il n'y a pas de condition similaire pour les données d'appel ou pour le code lui-même. Lorsque vous essayez de lire au-delà de la fin de ces tampons, vous obtenez simplement des zéros.

  • || μs || - δw + αw > 1024

    Débordement de la pile. Si l'exécution de l'opcode entraîne une pile de plus de 1024 éléments, abandonnez.

  • ¬Iw ∧ W(w,μ) Exécutons-nous de manière statique (¬ est une négation(opens in a new tab) et Iw est vrai lorsque nous sommes autorisés à modifier l'état de la blockchain) ? Si c'est le cas et que nous essayons une opération qui change l'état, cela ne peut pas se produire.

    La fonction W(w,μ) est définie plus tard dans l'équation 150. W(w,μ) est vraie si l'une de ces conditions est vraie :

    • w ∈ {CREATE, CREATE2, SSTORE, SELFDESTRUCT} Ces opcodes modifient l'état, soit en créant un nouveau contrat, soit en stockant une valeur, soit en détruisant le contrat actuel.

    • LOG0≤w ∧ w≤LOG4 Si nous sommes appelés de manière statique, nous ne pouvons pas émettre d'entrées de journal. Les opcodes de journal sont tous dans la plage entre LOG0 (A0)(opens in a new tab) et LOG4 (A4)(opens in a new tab). Le nombre après l'opcode de journal spécifie combien de sujets l'entrée de journal contient.

    • w=CALL ∧ μs[2]≠0 Vous pouvez appeler un autre contrat lorsque vous êtes statique, mais si vous le faites, vous ne pouvez pas lui transférer de l'ETH.

  • w = SSTORE ∧ μg ≤ Gcallstipend Vous ne pouvez pas exécuter SSTORE(opens in a new tab) à moins d'avoir plus de Gcallstipend (défini à 2300 dans l'Annexe G) de gaz.

9.4.3 Validité de la Destination de Saut

Ici, nous définissons formellement ce que sont les opcodes JUMPDEST(opens in a new tab). Nous ne pouvons pas simplement chercher la valeur de byte 0x5B, car elle pourrait se trouver à l'intérieur d'un PUSH (et donc être une donnée et non un opcode).

Dans l'équation (153), nous définissons une fonction, N(i,w). Le premier paramètre, i, est la position de l'opcode. Le second, w, est l'opcode lui-même. Si w∈[PUSH1, PUSH32], cela signifie que l'opcode est un PUSH (les crochets définissent une plage qui inclut les points d'extrémité). Dans ce cas, le prochain opcode est à i+2+(w−PUSH1). Pour PUSH1(opens in a new tab), nous devons avancer de deux bytes (le PUSH lui-même et la valeur d'un byte), pour PUSH2(opens in a new tab) nous devons avancer de trois bytes car c'est une valeur de deux bytes, etc. Tous les autres opcodes EVM ont une longueur d'un byte, donc dans tous les autres cas N(i,w)=i+1.

Cette fonction est utilisée dans l'équation (152) pour définir DJ(c,i), qui est l'ensemble(opens in a new tab) de toutes les destinations de saut valides dans le code c, à partir de la position de l'opcode i. Cette fonction est définie de manière récursive. Si i≥||c||, cela signifie que nous sommes à la fin ou après la fin du code. Nous ne trouverons plus d'autres destinations de saut, donc retournez simplement l'ensemble vide.

Dans tous les autres cas, nous examinons le reste du code en passant à l'opcode suivant et en obtenant l'ensemble à partir de celui-ci. c[i] est l'opcode actuel, donc N(i,c[i]) est la position du prochain opcode. DJ(c,N(i,c[i])) est donc l'ensemble des destinations de saut valides qui commence au prochain opcode. Si l'opcode actuel n'est pas un JUMPDEST, retournez simplement cet ensemble. Si c'est un JUMPDEST, incluez-le dans l'ensemble résultant et retournez-le.

9.4.4 Arrêt normal

La fonction d'arrêt H peut retourner trois types de valeurs.

  • Si nous ne sommes pas dans un opcode d'arrêt, retournez , l'ensemble vide. Par convention, cette valeur est interprétée comme un booléen faux.
  • Si nous avons un opcode d'arrêt qui ne produit pas de sortie (soit STOP(opens in a new tab) ou SELFDESTRUCT(opens in a new tab)), retournez une séquence de bytes de taille zéro comme valeur de retour. Notez que ceci est très différent de l'ensemble vide. Cette valeur signifie que l'EVM s'est vraiment arrêté, il n'y a juste aucune donnée de retour à lire.
  • Si nous avons un opcode d'arrêt qui produit une sortie (soit RETURN(opens in a new tab) ou REVERT(opens in a new tab)), retournez la séquence de bytes spécifiée par cet opcode. Cette séquence est prise de la mémoire, la valeur en haut de la pile (μs[0]) est le premier byte, et la valeur après elle (μs[1]) est la longueur.

H.2 Ensemble d'instructions

Avant de passer à la sous-section finale de l'EVM, 9.5, examinons les instructions elles-mêmes. Elles sont définies dans l'Annexe H.2 qui commence à la page 29. Tout ce qui n'est pas spécifié comme changeant avec cet opcode spécifique est censé rester identique. Les variables qui changent sont spécifiées comme \<quelque chose>′.

Par exemple, examinons l'opcode ADD(opens in a new tab).

ValeurMnemonicδαDescription
0x01ADD21Opération d'addition.
μ′s[0] ≡ μs[0] + μs[1]

δ est le nombre de valeurs que nous retirons de la pile. Dans ce cas deux, car nous additionnons les deux premières valeurs.

α est le nombre de valeurs que nous remettons. Dans ce cas une, la somme.

Ainsi, le nouveau sommet de la pile (μ′s[0]) est la somme de l'ancien sommet de la pile (μs[0]) et de l'ancienne valeur en dessous (μs[1]).

Au lieu de passer en revue tous les opcodes avec une « liste ennuyeuse », cet article n'explique que les opcodes qui introduisent quelque chose de nouveau.

ValeurMnemonicδαDescription
0x20KECCAK25621Calculez le hash Keccak-256.
μ′s[0] ≡ KEC(μms[0] . . . (μs[0] + μs[1] − 1)])
μ′i ≡ M(μis[0]s[1])

Il s'agit du premier opcode qui accède à la mémoire (dans ce cas, en lecture seule). Cependant, il pourrait dépasser les limites actuelles de la mémoire, nous devons donc mettre à jour μi. Nous faisons cela en utilisant la fonction M définie dans l'équation 328 à la page 29.

ValeurMnemonicδαDescription
0x31BALANCE11Obtenez le solde du compte donné.
...

L'adresse dont nous avons besoin pour trouver le solde est μs[0] mod 2160. Le sommet de la pile est l'adresse, mais comme les adresses ne font que 160 bits, nous calculons la valeur modulo(opens in a new tab) 2160.

Si σ[μs[0] mod 2160] ≠ ∅, cela signifie qu'il y a des informations sur cette adresse. Dans ce cas, σ[μs[0] mod 2160]b est le solde de cette adresse. Si σ[μs[0] mod 2160] = ∅, cela signifie que cette adresse n'est pas initialisée et le solde est zéro. Vous pouvez voir la liste des champs d'information du compte dans la section 4.1 à la page 4.

La deuxième équation, A'a ≡ Aa ∪ {μs[0] mod 2160}, est liée à la différence de coût entre l'accès au stockage chaud (stockage qui a récemment été accédé et est susceptible d'être mis en cache) et le stockage froid (stockage qui n'a pas été accédé et est susceptible de se trouver dans un stockage plus lent qui est plus coûteux à récupérer). Aa est la liste des adresses précédemment accédées par la transaction, qui devraient donc être moins chères à accéder, comme défini dans la section 6.1 à la page 8. Vous pouvez en savoir plus sur ce sujet dans l'EIP-2929(opens in a new tab).

ValeurMnemonicδαDescription
0x8FDUP161617Duplique le 16e élément de la pile.
μ′s[0] ≡ μs[15]

Notez que pour utiliser un élément de la pile, nous devons le retirer, ce qui signifie que nous devons également retirer tous les éléments de la pile au-dessus de lui. Dans le cas de DUP<n>(opens in a new tab) et SWAP<n>(opens in a new tab), cela signifie devoir retirer puis remettre jusqu'à seize valeurs.

9.5 Le cycle d'exécution

Maintenant que nous avons toutes les parties, nous pouvons enfin comprendre comment le cycle d'exécution de l'EVM est documenté.

L'équation (155) dit qu'étant donné l'état :

  • σ (état global de la blockchain)
  • μ (état de l'EVM)
  • A (sous-état, changements à effectuer lorsque la transaction se termine)
  • I (environnement d'exécution)

Le nouvel état est (σ', μ', A', I').

Les équations (156)-(158) définissent la pile et le changement en elle en raison d'un opcode (μs). L'équation (159) est le changement de gaz (μg). L'équation (160) est le changement dans le compteur de programme (μpc). Enfin, les équations (161)-(164) spécifient que les autres paramètres restent les mêmes, sauf s'ils sont explicitement modifiés par l'opcode.

Avec cela, l'EVM est entièrement défini.

Conclusion

La notation mathématique est précise et a permis au Livre Jaune de spécifier chaque détail d'Ethereum. Cependant, elle présente quelques inconvénients :

  • Elle ne peut être comprise que par les humains, ce qui signifie que les tests(opens in a new tab) de conformité doivent être écrits manuellement.
  • Les programmeurs comprennent le code informatique. Ils peuvent ou non comprendre la notation mathématique.

Peut-être pour ces raisons, les nouvelles spécifications de la couche de consensus(opens in a new tab) sont écrites en Python. Il existe des spécifications de la couche d'exécution en Python(opens in a new tab), mais elles ne sont pas complètes. Jusqu'à ce que le Livre Jaune soit également traduit en Python ou dans un langage similaire, le Livre Jaune continuera d'être utilisé, et il est utile de pouvoir le lire.

Dernière modification: @XofEE(opens in a new tab), 18 janvier 2024

Ce tutoriel vous a été utile ?