XCM Partie III : Exécution et gestion des erreurs
Source officiel : XCM Part III: Execution and Error Management
Dans les deux premiers articles (Partie I, Partie II) que j’ai écrits sur XCM, j’ai présenté les bases de sa conception et de sa structure de versionnement. Dans cet article, nous allons examiner plus en profondeur sa conception sous-jacente et son modèle d’exécution. Comme XCM est basé sur le jeu d’instructions de la XCVM, une machine virtuelle de très haut niveau, cela revient à se familiariser avec l’architecture de cette machine.
La XCVM est une machine virtuelle complète de très haut niveau, non Turing. Elle est basée sur des registres (plutôt que sur une pile) et possède plusieurs registres à usage spécial, dont la plupart contiennent des données hautement structurées. Contrairement aux processeurs à usage général, les registres de la XCVM ne sont pas libres d’être réglés sur des valeurs arbitraires, mais ont des mécanismes stricts régissant la façon dont ils peuvent changer. Au-delà de certains moyens d’interaction avec l’état local de la chaîne (comme les instructions WithdrawAsset
et DepositAsset
que nous avons déjà vues), il n’y a pas de “mémoire” supplémentaire. Il n’y a pas de possibilité de bouclage ni d’instructions de branchement explicites.
Deux de ces registres nous ont déjà été présentés : le registre de détention, qui peut détenir temporairement un ou plusieurs actifs et qui peut être alimenté par le retrait d’un actif de la chaîne locale ou par la réception d’un actif d’une source externe de confiance (par exemple, une autre chaîne) ; et le registre d’origine, qui, au début de l’exécution, contient l’emplacement du système de consensus d’où provient l’exécution XCM en cours, et qui ne peut être que muté vers un emplacement intérieur ou entièrement effacé.
Parmi les autres registres, trois sont concernés par la gestion des exceptions/erreurs et deux par le suivi du poids d’exécution. Nous les découvrirons tous dans cet article.
🎬 Modèle d’exécution
Comme nous l’avons déjà mentionné, il n’existe pas d’instructions explicitement conditionnelles ou de primitives de bouclage permettant de réexécuter la même instruction plusieurs fois. Il est donc assez facile de prédéterminer le flux de contrôle d’un programme. Cette propriété est utile dans la mesure où nous voulons déterminer combien de temps d’exécution (appelé weight (poids) dans Substrate/Polkadot) un message XCM pourrait utiliser avant le point d’exécution.
La plupart des plates-formes de consensus qui, selon nos prévisions, exécuteront XCM, devront être en mesure de déterminer le temps d’exécution le plus défavorable avant le début de l’exécution. Cela est dû au fait que les blockchains doivent généralement s’assurer que les blocs individuels ne prennent pas plus de temps à traiter qu’une limite prédéterminée, sous peine de bloquer le système dans son ensemble. En outre, si le système a besoin de payer des frais, cela doit nécessairement se faire avant la charge de travail pour laquelle le paiement est effectué et il est important que ce paiement couvre le temps d’exécution le plus défavorable.
Les systèmes qui autorisent les langages complets de Turing (par exemple Ethereum) ne peuvent pas calculer directement le temps d’exécution le plus défavorable du programme en raison de ce caractère complet de Turing. Ils contournent ce problème en demandant à l’utilisateur de prédéterminer les ressources d’exécution du programme, puis en le mesurant pendant son exécution et en l’interrompant s’il dépasse le montant qui a été payé. Parfois, les choses changent avant l’exécution de la transaction et le poids devient incorrect. Heureusement, les machines virtuelles telles que la XCVM, qui ne sont pas complètes en Turing, peuvent éviter la nécessité de mesurer et de prescrire le poids.
🏋️♀️ Poids
Le poids est typiquement représenté comme le nombre entier de picosecondes qu’une pièce de matériel représentative prendrait pour exécuter l’opération donnée. Comme nous l’avons vu avec l’instruction BuyExecution
, la XCVM inclut ce concept de temps d’exécution/poids lorsqu’elle traite certaines instructions.
Il n’y a pas de mesure du poids, mais pour tenir compte de la possibilité qu’un programme XCVM prenne finalement moins que la prédiction de poids la plus défavorable, nous avons un registre appelé le registre de poids excédentaire. La plupart des instructions n’y touchent pas puisque nous pouvons prédire avec précision le poids qu’elles utiliseront. Cependant, il y a parfois des circonstances où la prédiction de poids la plus défavorable est surestimée et ce n’est qu’au moment de l’exécution que nous savons de combien. Tout en comptabilisant le temps d’exécution du bloc avec une surestimation du poids du message XCM, le fait de suivre le montant de la surestimation du poids original et de le soustraire des comptes permet à la chaîne d’optimiser son quota de temps d’exécution du bloc.
Ainsi, le registre de poids excédentaire est utile pour notre comptabilité du temps d’exécution du bloc, mais il ne résout pas à lui seul l’autre problème qui consiste à s’assurer que le montant payé n’est pas une surestimation. Pour cela, nous avons besoin d’une instruction complémentaire à BuyExecution
, qui prend le poids excédentaire et le rembourse. Naturellement, cette instruction existe et s’appelle RefundSurplus
. Elle utilise un second registre appelé RefundSurplus, qui permet de s’assurer que le même excédent de poids n’est pas remboursé plusieurs fois.
😱 Contrôle du flux et exceptions
Deux autres registres ont été plutôt implicites dans notre traitement de la XCVM jusqu’à présent, mais il est néanmoins important de les connaître. Premièrement, il y a le registre de programme qui stocke le programme de la XCVM en cours d’exécution. Deuxièmement, il y a le compteur de programme, qui stocke l’index de l’instruction en cours d’exécution. Il est remis à zéro lorsque le registre de programme est modifié et est incrémenté de un à la fin de chaque instruction exécutée avec succès.
La capacité à gérer la possibilité d’une circonstance “exceptionnelle” est cruciale pour écrire un code robuste. Lorsque quelque chose se produit sur un système distant auquel vous ne vous attendiez pas (ou que vous n’auriez pas pu prévoir), il vous faut un moyen de le gérer, même s’il s’agit simplement d’envoyer un rapport à l’origine en le précisant.
Bien que le jeu d’instructions XCVM ne comprenne pas d’instructions de branchement général explicites, il possède un cadre général de traitement des exceptions intégré dans son modèle d’exécution. Le XCVM comprend deux autres registres de code, chacun contenant un programme XCVM comme le registre de programme. Ces deux registres sont appelés le registre des annexes et le registre de gestion d’erreur. Si vous êtes familier avec le système d’exception try/catch/finally de plusieurs langages populaires, alors ce qui va suivre pourrait vous rappeler quelque chose.
Comme mentionné, l’exécution d’un programme XCVM suit chaque instruction, étape par étape. En suivant ces instructions jusqu’à la fin du programme, l’une des deux choses suivantes se produira : soit il atteindra la fin du programme avec succès, soit une erreur se produira. Dans le premier cas d’une exécution réussie, le registre des erreurs est effacé et son poids est ajouté au registre des poids excédentaires. Le registre des annexes est également effacé et son contenu est placé dans le registre des programmes. Si le registre de programme reste vide, on s’arrête. Sinon, le compteur de programme est remis à zéro. En d’autres termes, nous jetons le programme en cours et le gestionnaire d’erreurs et commençons à exécuter le programme annexe s’il y en a un.
Cette fonctionnalité n’est pas très utile en soi, mais peut l’être lorsqu’elle est combinée avec ce qui se passe en cas d’erreur. Ici, le poids de toutes les instructions qui doivent encore être exécutées est ajouté au registre des poids excédentaires. Le registre de gestion des erreurs est effacé, son contenu est placé dans le registre de programme et le compteur de programme est remis à zéro. En bref, nous jetons le programme en cours et commençons à exécuter le gestionnaire d’erreurs. Comme nous n’effaçons pas le registre des annexes, à moins qu’il ne soit remis à zéro par le gestionnaire d’erreurs, il s’exécutera une fois que celui-ci aura terminé avec succès.
Grâce à sa structure de composition, il permet une “imbrication” arbitraire des gestionnaires d’erreurs : les gestionnaires d’erreurs peuvent, si on le souhaite, avoir aussi des gestionnaires d’erreurs et les annexes peuvent avoir leurs propres annexes.
Il existe deux instructions qui permettent de manipuler ces registres : SetAppendix
et SetErrorHandler
. Comme vous pouvez vous y attendre, l’une d’entre elles définit le registre des annexes et l’autre le registre de gestionnaire d’erreur. Le poids prévu de chacun d’eux est légèrement supérieur au poids de leur paramètre. Toutefois, lors de l’exécution, le poids du message XCM dans le registre qui sera remplacé est ajouté au registre des poids excédentaires, ce qui permet de récupérer le poids de toute annexe ou de tout gestionnaire d’erreur inutilisé.
☄️ Erreurs de lancement
Parfois, il peut être utile de faire en sorte qu’une erreur se produise et de personnaliser certains aspects de cette erreur. Ceci a été utilisé lors de l’écriture de code de test mais il n’est pas impossible que cela puisse être utilisé dans une chaîne réelle. Ceci peut être fait dans la XCVM à travers l’instruction Trap
qui résulte toujours en une erreur qui se produit. Le type d’erreur qui est lancé partage le nom de Trap
. L’instruction et l’erreur portent toutes deux un argument entier permettant de transmettre une certaine forme d’information entre le lanceur d’erreur et un spectateur externe.
Voici un exemple banal :
WithdrawAsset((Here, 10_000_000_000).into()),
BuyExecution {
fees: (Here, 10_000_000_000).into(),
weight: Unlimited,
},
SetErrorHandler(Xcm(vec![
RefundSurplus,
DepositAsset {
assets: All.into(),
max_assets: 1,
beneficiary: Parachain(2000).into(),
},
])),
Trap(0),
DepositAsset {
assets: All.into(),
max_assets: 1,
beneficiary: Parachain(3000).into(),
},
Le Trap
fait sauter le DepositAsset
final et exécute à la place le DepositAsset
du gestionnaire d’erreur, plaçant le 1 DOT (moins le coût d’exécution) sous la propriété du parachain 2000. Nous aurons toujours tendance à utiliser RefundSurplus
au début du code d’un gestionnaire d’erreur, car s’il est exécuté, nous savons qu’il est probable que le poids utilisé prévu (et donc le poids acheté) soit une surestimation.
🗞 Rapports d’erreurs
Pouvoir introduire du code pour gérer les erreurs est très utile, mais une fonctionnalité souvent demandée est de pouvoir rapporter le résultat d’un message XCM à l’expéditeur initial. Nous avons rencontré l’instruction QueryResponse
dans l’article précédent qui permet à un système de consensus de rapporter certaines informations à un autre, tout ce qui reste à faire est d’être capable d’insérer d’une manière ou d’une autre le résultat du XCM dans cette QueryResponse
et de l’envoyer à celui qui attend d’être informé du résultat.
Il s’avère qu’il existe précisément une instruction qui fait cela, appelée ReportError
. Elle fonctionne en utilisant un registre que nous n’avons pas encore rencontré : le registre d’erreur. Le registre d’erreur est un type optionnel (il peut être soit activé soit désactivé). S’il est activé, il contient deux informations : un index numérique et un type d’erreur XCM.
Sa mécanique de fonctionnement est extrêmement simple. Premièrement, il est toujours activé lorsqu’une instruction entraîne une erreur ; le type d’erreur est défini comme le type de cette erreur et l’index numérique est défini comme la valeur du registre du compteur de programme. Deuxièmement, il est effacé uniquement lorsque l’instruction ClearError
est exécutée. Cette instruction est l’une des instructions infaillibles — elle ne peut jamais entraîner une erreur. C’est tout — elle est activée lorsqu’une erreur se produit et est supprimée lorsque vous émettez l’instruction appropriée.
Il devrait maintenant être clair de comprendre comment l’instruction ReportError
fonctionne : elle compose simplement une instruction QueryResponse
en utilisant le contenu du registre d’erreur et l’envoie à une destination particulière. Bien entendu, toute erreur survenant avant elle aurait pour conséquence de faire sauter l’instruction, car l’exécution saute d’abord au code du registre de gestion des erreurs, puis au code du registre des annexes. Cependant, la solution à ce problème est triviale : en plaçant ReportError
dans l’annexe, on s’assure qu’il est exécuté, que le code principal ait donné lieu ou non à une erreur d’exécution.
Prenons un exemple simple. Nous allons téléporter un actif (1 DOT) de la chaîne de relais vers Statemint (parachain 1000), y acheter du temps d’exécution puis, en utilisant Statemint comme réserve, déposer l’actif sur le parachain 2000. Le message original (sans rapport d’erreur) ressemblerait à ceci :
WithdrawAsset((Here, 10_000_000_000).into()),
InitiateTeleport {
assets: All.into(),
dest: Parachain(1000).into(),
xcm: Xcm(vec![
BuyExecution {
fees: (Parent, 10_000_000_000).into(),
weight: Unlimited,
},
DepositReserveAsset {
assets: All.into(),
max_assets: 1,
dest: ParentThen(Parachain(2000)).into(),
xcm: Xcm(vec![
BuyExecution {
fees: (Parent, 10_000_000_000).into(),
weight: Unlimited,
},
DepositAsset {
assets: All.into(),
max_assets: 1,
beneficiary: Parent.into(),
},
]),
},
]),
}
Avec un rapport d’erreur de base, nous utiliserions plutôt ceci :
WithdrawAsset((Here, 10_000_000_000).into()),
InitiateTeleport {
assets: All.into(),
dest: Parachain(1000).into(),
xcm: Xcm(vec![
BuyExecution {
fees: (Parent, 10_000_000_000).into(),
weight: Unlimited,
},
SetAppendix(Xcm(vec![
ReportError {
query_id: 42,
dest: Parent.into(),
max_response_weight: 10_000_000,
},
])),
DepositReserveAsset {
assets: All.into(),
max_assets: 1,
dest: ParentThen(Parachain(2000)).into(),
xcm: Xcm(vec![
BuyExecution {
fees: (Parent, 10_000_000_000).into(),
weight: Unlimited,
},
SetAppendix(Xcm(vec![
ReportError {
query_id: 42,
dest: Parent.into(),
max_response_weight: 10_000_000,
},
])),
DepositAsset {
assets: All.into(),
max_assets: 1,
beneficiary: ParentThen(Parachain(2000)).into(),
},
]),
},
]),
}
Comme vous pouvez le constater, le seul changement est l’introduction de deux instructions SetAppendix
qui garantissent que l’erreur ou l’absence d’erreur dans Statemint et parachain 2000 sera signalée à la Relay Chain. Cela suppose que la Relay Chain est configurée pour pouvoir reconnaître et traiter les messages QueryResponse
provenant de Statemint et de parachain 2000 avec l’ID de requête 42 et une limite de poids de dix millions. Heureusement, il s’agit d’une fonctionnalité bien prise en charge par Substrate, mais elle est hors de portée pour le moment.
🪤 L‘Asset Trap
Lorsque des erreurs se produisent au cours de programmes qui traitent des actifs (comme c’est le cas de la plupart d’entre eux puisqu’ils doivent souvent payer leur exécution avec BuyExecution
), cela peut être très problématique. Il peut y avoir des cas où l’instruction BuyExecution
elle-même entraîne une erreur, peut-être parce que la limite de poids était incorrecte ou que les actifs utilisés pour le paiement étaient insuffisants. Ou peut-être qu’un bien est envoyé à une chaîne qui ne peut pas le traiter de manière utile. Dans ces cas, et bien d’autres, l’exécution XCVM du message se termine avec des actifs restant dans le registre d’attente, qui, comme les autres registres, est transitoire et devrait être oublié.
Les équipes et leurs utilisateurs seront heureux d’apprendre que le XCM de Substrate permet aux chaînes d’éviter totalement cette perte 🎉. Le mécanisme fonctionne en deux étapes. Premièrement, les actifs présents dans le registre d’attente lorsqu’il est vidé ne sont pas complètement oubliés. Si le registre d’attente n’est pas vide lorsque le XCVM s’arrête, un événement est émis contenant trois informations : la valeur du registre d’attente, la valeur originale du registre d’origine et le hachage de ces deux informations. Le système XCM de Substrate place ensuite ce hachage en mémoire. Cette partie du mécanisme s’appelle l’Asset Trap.
🎟 Le système de réclamations
La deuxième étape du mécanisme consiste à pouvoir réclamer certains contenus antérieurs du registre de maintien. Cela ne se fait pas par le biais d’une instruction spécialement conçue à cet effet, mais plutôt par le biais d’une instruction générale que nous n’avons pas encore rencontrée, appelée ClaimAsset
. Voici comment elle est déclarée en Rust :
pub enum Instruction {
/* snip */ ClaimAsset { assets: MultiAssets, ticket: MultiLocation }, /* snip */
}
Le nom de cette instruction peut rappeler certaines autres instructions de “financement” que nous avons rencontrées, telles que WithdrawAsset
et ReceiveTeleportedAsset
. Si c’est le cas, c’est pour une assez bonne raison : elle l’est. Comme les autres, elle tente de placer les actifs (donnés par l’argument assets
ici) dans le registre d’attente. Contrairement à WithdrawAsset
qui réduit le solde des actifs d’un compte sur la chaîne, ClaimAsset
cherche une réclamation valide pour ces assets
disponibles dans le registre d’origine, quelle que soit sa valeur. Pour aider le système à trouver la réclamation valide, des informations peuvent être fournies via l’argument ticket
. Si une réclamation valide est trouvée, elle est supprimée de la chaîne et les actifs sont ajoutés au registre des avoirs.
Maintenant, ce qui constitue exactement une réclamation dépend entièrement de la chaîne elle-même. Différentes chaînes peuvent supporter différents types de réclamations, et Substrate vous permet de les composer facilement. Mais, comme vous pouvez le deviner, un type particulier de réclamation qui est prêt à l’emploi, bien sûr, est celui du contenu du registre d’attente précédemment déposé.
Voyons donc comment cela pourrait fonctionner en pratique. Supposons que le parachain 2000 de notre utilisateur envoie un message à Statemint dans lequel il retire 0,01 DOT de son compte souverain pour payer les frais et lui notifie également un transfert d’actifs de réserve de 100 unités de son propre jeton natif à placer sur son compte souverain sur Statemint. Cela pourrait ressembler à quelque chose comme ceci :
WithdrawAsset((Parent, 100_000_000).into()),
BuyExecution {
fees: (Parent, 100_000_000).into(),
weight: Unlimited,
},
SetAppendix(Xcm(vec![
ReportError {
query_id: 42,
dest: ParentThen(Parachain(2000)).into(),
max_response_weight: 10_000_000,
},
RefundSurplus,
])),
ReserveAssetDeposited((ParentThen(Parachain(2000)), 100).into()),
DepositAsset {
assets: All.into(),
max_assets: 2,
beneficiary: ParentThen(Parachain(2000)).into(),
}
En supposant que 0,01 DOT soit un montant suffisant pour cela et que Statemint supporte les dépôts sur la chaîne de l’actif natif de parachain 2000 (ainsi que l’utilisation de parachain 2000 comme réserve pour celui-ci), alors cela devrait fonctionner parfaitement. Cependant, peut-être que Statemint n’a pas encore été configuré pour reconnaître l’actif natif de parachain 2000. Dans ce cas, le DepositAsset ne saura pas quoi faire avec l’actif et lancera donc une erreur. Après avoir exécuté l’annexe qui notifiera à parachain 2000 cet échec, nous nous retrouverons avec les 100 unités de l’actif natif de parachain 2000 ainsi que potentiellement un certain DOT dans le registre des avoirs. Supposons que les frais ne s’élevaient qu’à 0,005 DOT, laissant 0,005 DOT restant.
Il y aurait alors un événement enregistré par la palette XCM de Statemint sur ces actifs nouvellement réclamables, quelque chose comme :
Event::AssetsTrapped(
/* snipped hash */,
ParentThen(Parachain(2000)),
vec![
(Parent, 50_000_000).into(),
(ParentThen(Parachain(2000)), 100),
].into(),
)
Un message sera renvoyé à parachain 2000 qui ressemblera à ceci :
QueryResponse {
query_id: 42,
response: ExecutionResult(Err((4, AssetNotFound))),
max_weight: 10_000_000,
}
Parachain 2000 pourrait, à un stade ultérieur (peut-être une fois qu’il aura déterminé que le Statemint est en mesure d’accepter des dépôts de son actif natif), être en mesure de récupérer ces 100 unités par un moyen assez simple :
ClaimAsset {
assets: vec![
(Parent, 50_000_000).into(),
(ParentThen(Parachain(2000)), 100),
].into(),
ticket: Here,
}
BuyExecution {
fees: (Parent, 50_000_000).into(),
weight: Unlimited,
},
DepositAsset {
assets: All.into(),
max_assets: 2,
beneficiary: ParentThen(Parachain(2000)).into(),
}
Dans ce cas, aucune information particulière n’est fournie dans l’argument du ticket pour aider à localiser la réclamation. Cela convient généralement aux réclamations de type Asset Trap, mais il peut être nécessaire de l’utiliser pour d’autres types de réclamations.
🏁 Conclusion
C’est tout pour l’instant. J’espère que cet article vous a permis de mieux comprendre la machine virtuelle sous-jacente de XCM et comment elle peut vous aider à gérer et à récupérer des situations inattendues. Les prochains articles de cette série porteront sur les orientations futures de XCM et sur la manière dont des améliorations peuvent être suggérées pour le format. Nous nous plongerons également dans l’implémentation XCM Rust de Substrate et dans la manière dont nous pouvons l’utiliser pour doter une chaîne de la capacité d’interpréter facilement XCM.