Cette page explique ce qui arrive à une transaction une fois que rand_sendTransaction l’a acceptée et avant qu’un bloc ne la porte : pourquoi elle a été refusée le cas échéant, pourquoi elle peut disparaître du pool sans jamais avoir échoué, dans quel ordre un bloc est rempli, et ce qu’un flot de preuves invalides peut et ne peut pas faire à un validator. Le mempool est crates/randprotocol-node/src/mempool.rs ; les garde-fous placés devant lui sont dans admission.rs.
Pas d’expéditeurs, donc pas de nonces
Sur une chaîne à comptes, un mempool ordonne les transactions de chaque expéditeur par nonce. Une chaîne shielded n’a pas d’expéditeurs : un bundle ne porte ni adresse ni signature, et deux bundles ne sont liés que lorsqu’ils touchent la même note. Il ne reste donc aucun ordonnancement par expéditeur à effectuer. Ce qui remplace la gestion des nonces, c’est le suivi des conflits : le pool indexe les nullifiers et les engagements de chaque transaction en attente, et refuse tout nouvel arrivant qui en revendique un. Il en va de même pour les quelques compteurs dont le registre n’accepte qu’une seule transaction par valeur : le nonce de registre d’un validator (un Unbond ou un Withdraw), le mint_nonce d’un token (partagé par TokenMint et SetAuthority), l’index de registre auquel est tenu un RegisterToken, ainsi que le pause_nonce et le list_nonce du bridge. Chacun est un emplacement qu’au plus une transaction du pool peut occuper. Au plus une de deux telles transactions pourrait jamais être incluse, et porter l’autre coûterait au proposeur de l’espace de bloc et à chaque nœud une vérification de preuve par tour de gossip.
Le pool contient au plus 10 000 transactions (Mempool::new(10_000) dans node.rs) et, depuis la v0.6.8, au plus l’équivalent en octets de huit blocs : MAX_POOL_BLOCKS = 8 fois le max_block_bytes de la chaîne, soit 160 MiB sur la chaîne 20 (audit v6, CH-7). Auparavant seul le nombre était plafonné, alors qu’une transaction portant une preuve pèse d’environ 1,3 MB à plus de 12 MB. Chacune est validée par rapport au registre à la pointe de l’arbre de consensus, et non au dernier bloc validé : l’état sur lequel un proposeur construirait réellement.
Admission, le peu coûteux avant le coûteux
Une transaction qui arrive par RPC ou par gossip passe par ces étapes dans l’ordre, et le premier refus est l’erreur que vous voyez :
- Doublon. Le hash est déjà dans le pool :
already in mempool. - Conflit. Un nullifier, un engagement ou l’un des emplacements de nonce et d’index ci-dessus est déjà revendiqué par une transaction en attente :
conflicts with a pending transaction over <hex>. Pour un emplacement de token, l’hex est une clé d’affichage : un mot-étiquette (toknpour unmint_nonce,tidxpour un index d’enregistrement), puis l’index et le nonce. Une attestation de bridge sur laquelle deux relayeurs se sont disputés se distingue par son digest :conflicts with a pending transaction over bridge attestation <hash>. - Plein.
mempool full. Le contrôle de capacité est le dernier des trois. Au plafond en octets, un nouvel arrivant peut prendre la place d’entrées du pool classées strictement en dessous de lui — par frais au-dessus du plancher actuel de chacune par KiB, puis par frais — en évinçant d’abord la moins chère jusqu’à ce qu’il tienne ; si cela ne suffit pas, ou s’il ne fait qu’égaler, c’est lui qui est refusé. Les actions de gouvernance sont hors des deux plafonds et ne sont jamais évincées : les quatre actions de gouvernance du bridgePauseMints,UnpauseMints,RegisterBridgedToken,ListBacking, et, depuis la v0.6.8,RotatePqGuardians,RotatePauseKey, leurs formes…V2,CancelRotation,AdmitValidatoretSlashEquivocation. Leurs octets et leurs entrées comptent néanmoins dans les plafonds.PauseMintsest le frein en cas de compromission d’une clé de gardien : sans bundle, sans frais, et la seule transaction qui doit atteindre un bloc pendant qu’un attaquant mint, de sorte qu’un pool plein ne doit pas pouvoir la tenir dehors. L’exemption est bornée par l’étape de conflit : lepause_nonceet lelist_noncen’admettent chacun qu’une transaction du pool à la fois, si bien que les quatre actions du bridge peuvent ajouter au plus deux entrées au-delà du plafond ; les autres dépensent chacune un nonce ou un quorum qui leur est propre. - La moitié peu coûteuse de
Ledger::validate. Tout ce qui peut devenir périmé entre l’admission et l’inclusion et ne coûte aucune preuve : l’ancre est l’une des 1 024 dernières racines sur la chaîne 20 (proof_window_blocksde la genèse ; 256 sans elle),timeest dans la fenêtre, les nullifiers ne sont pas dépensés, les engagements sont absents, le digest d’une attestation n’est pas revendiqué, le nonce de registre d’une action de staking est à jour, lemint_nonceou l’index d’enregistrement d’une action de token est celui où en est le registre (wrong mint nonce,wrong token indexune fois que la chaîne l’a dépassé), le nonce d’une pause ou d’une inscription est le nonce courant du bridge. Les engagements qu’une transaction crée sont calculés ici aussi, y compris la note que le registre dérive lui-même pour unWithdraw, unBridgeAttestou un paiement d’aggregator, qui ne figure pas sur le réseau mais ne doit pas non plus entrer en collision. Les plafonds de taille sont vérifiés avant tout décodage : une preuve au-delà dumax_proof_bytesde la chaîne (2 MiB par défaut, 4 MiB sur la chaîne 20), une attestation de plus de 16 KiB, ou une transaction plus grande qu’un bloc (max_block_bytes: 4 MiB par défaut, 20 MiB sur la chaîne 20) est refusée d’emblée, de sorte qu’un blob surdimensionné n’achète aucun travail. Une preuve de bundle sous la forme de marqueur scellé (élagué) est refusée à cette même étape en dehors de la synchronisation en forme scellée, sous le nomPrunedFormOutsideSync. Ensuite, toujours sans rien vérifier, le pool lit les en-têtes de preuve. Trois politiques viennent en premier : un appel dont la table d’entrée, de keccak ou de sha256 compte moins de 2^7 lignes n’est pas mis en pool (CallRevealsPrivateInputs), pas plus qu’un déploiement qu’aucun appel ne pourrait tenir (ProgramUncallable), ni une transaction dont la preuve de bundle ou d’appel a un champ d’en-tête que le prover honnête n’écrirait pas (NonCanonicalProof). Les deux dernières sont aussi des règles du registre sur la chaîne 20, soushardening_v6. Depuis la v0.6.8, un en-tête dont le degré déclaré dépasse le plafond de la table la plus haute est refusé commeNonCanonicalProofavant que le contrôle ne le parcoure (audit v6, ZKV-4), et une panique n’importe où dans ce pré-filtrage est un refus de cette transaction (VerifierPanicked), et non la mort du nœud. Vient ensuite le plancher de frais : sur la chaîne 20, le pool décode la limite de gaz que déclare une preuve d’appel ou d’invoke et refuse des frais inférieurs àBUNDLE_BASE + gas_price × limit + byte_price × ⌈bytes/1024⌉aux prix de la pointe, plus les frais de cellule d’un invoke, sous le nomFeeTooLow(Frais). C’est la règle propre du bloc, de sorte que le pool exige exactement ce qu’exige un bloc. - La moitié coûteuse, hors de la boucle de consensus. La transaction est mise en file, 64 de profondeur au plus, pour l’un des quatre workers de vérification. Un worker exécute le reste de
Ledger::validatesur une tâche bloquante : le digest du bundle, la limite de gaz que déclare la preuve de bundle (sur la chaîne 20 exactement 20 479, sinonBundleGasLimit), la preuve de bundle et, depuis la chaîne 17, sa preuve d’autorisation par rapport au binding de la transaction, et pour un appel sa propre preuve et son plancher de frais. Il valide par rapport à un instantané de la pointe cloné paresseusement, au plus une fois par changement de pointe et seulement tant que quelque chose attend. Une transaction reçue par gossip qui trouve la file pleine est abandonnée, ni vérifiée ni relayée, plutôt que de bloquer la boucle qui vote ; le prochain tour de gossip du pair la porte de nouveau. - Revérification et mise en pool.
insert_verifiedrejoue l’étape 4 par rapport à la pointe sur laquelle la transaction est effectivement mise en pool, car la pointe a pu bouger pendant la vérification de la preuve, puis l’admet et, pour le trafic reçu par gossip, la relaie.
Les étapes 1 à 4 forment Mempool::precheck ; l’étape 5 est la seule qui vérifie une STARK. Comme ce coût est payé sur un worker, un flot de preuves invalides occupe quatre threads et jamais la boucle de consensus. Transactions et actions énumère l’ordre d’admission propre au registre, que les étapes 4 et 5 exécutent à elles deux.
Deux garde-fous et un cache pour le gossip
En amont de l’admission, deux mécanismes ne s’appliquent qu’aux transactions venant des pairs ; les soumissions par RPC y échappent, car ce port est celui de l’opérateur lui-même et il est déjà borné par la limite de taille du corps de requête. Le seul chemin RPC qui soit mesuré est le faucet, ci-dessous.
- Un compteur d’octets par pair. Depuis la v0.6.8, chaque trame de gossip est imputée au budget d’octets de son relayeur avant même d’être décodée : une rafale de huit trames et une recharge de deux trames de la taille de transmission maximale par seconde ; au-delà, la trame est abandonnée sans être décodée (audit v6, GOSSIP-1). L’identifiant de transaction, qui hache la preuve entière, n’est calculé que dans la limite de l’allocation du relayeur.
- Un compteur de transactions par pair. Un token bucket par pair relayeur, rafale de 16, recharge de 4 par seconde (
PEER_TX_BURST,PEER_TX_PER_SEC). Il mesure le pair qui a relayé la transaction, et non son auteur, qu’une chaîne blindée ne peut pas nommer. - Un cache de verdicts. Une transaction que ce nœud a déjà refusée pour une raison qui ne dépend que de ses octets, comme une preuve invalide, une signature invalide ou une partie surdimensionnée, est refusée de nouveau depuis un cache FIFO de 8 192 entrées (
REFUSED_CACHE_ENTRIES) au lieu d’être vérifiée une seconde fois. Un verdict qui porte sur l’état de ce nœud, une ancre inconnue ou un nullifier dépensé, n’est jamais mis en cache : un nœud en retard d’un bloc s’empoisonnerait sinon contre des transactions sur le point de devenir valides. La liste est une liste d’autorisation : une nouvelle erreur relève donc de l’état tant que personne n’en décide autrement. Ces entrées montrent où passe la ligne :BadPauseSignature(the pause signature does not verify) n’est pas permanente depuis la v0.6.8 (audit v6, BRG-18). Elle vérifie la signature sous la clé de pause courante de la chaîne, que les règles de pont v2 peuvent faire tourner ; elle relève donc de l’état : un nœud en retard d’un bloc sur une rotation refuserait sinon lePauseMintsdu nouveau titulaire jusqu’au redémarrage. Un index de co-signature PQ hors limites a quitté le cache pour la même raison. Jusqu’à la v0.6.7, sur les chaînes 18 et 19, les deux étaient mises en cache comme permanentes. Une pause invalide répétée coûte désormais une vérification Dilithium2 par réception, bornée par le compteur par pair.PrunedFormOutsideSyncn’est volontairement pas permanente. La forme à marqueur d’un bundle scellé a pour hachage l’identifiant même de la transaction brute, et le cache est indexé sur cet identifiant : mettre le refus en cache permettrait à n’importe quel pair de gossip de censurer la transaction honnête sur l’ensemble du réseau.- La limite de gas déclarée d’une preuve relève de ses seuls octets, comparés à une constante de genèse ;
BundleGasLimitetAuthGasLimitsont donc permanentes.FeeTooLowne l’est pas : sur la chaîne 20, le plancher varie avec les prix. Les trois politiques d’en-tête de l’étape 4 ne sont jamais mises en cache non plus : chacune est une connaissance de cette version, qu’une version ultérieure peut déplacer. - Sur une chaîne dotée d’une liste d’autorisation du faucet (chaîne 20),
FaucetRecipientNotAllowedest permanente : elle compare le destinataire du mint à une constante de genèse.FaucetBudgetExhaustedne l’est pas : elle relève de l’état, et le même mint est de nouveau admis à l’époque suivante. - Le
BadSignatured’une action de jeton n’est pas non plus permanent, contrairement à celui d’une action de staking : l’autorité de mint d’un jeton relève de l’état, unSetAuthoritypeut la confier à une autre clé, et les mêmes octets peuvent être refusés avant que cette rotation ne soit validée, puis acceptés après. Les refus d’octets propres aux jetons (un nom ou un symbole invalide, trop de décimales, un montant nul, l’indice de jeton 0) sont mis en cache ; il en va de même d’un champ de burn que l’action ne peut pas renseigner, d’un mint dont l’engagement ne s’ouvre pas sur son montant, et d’un dépôt de pont dont le blinding n’est pas celui qui est dérivé.
La limite du faucet
rand_mint est sans frais et coûte une transaction en pool par appel ; sur la chaîne 20, le faucet est actif, avec un pont qui détient de la valeur derrière lui. La genèse borne ce qu’il peut faire : un mint ne paie que les 16 portefeuilles autorisés et au plus 10 000 RAND par époque de 1 000 blocs (Staking), et une seule des 18 clés de la liste staking.faucet_minters de la genèse peut en signer un (MinterNotAllowed, jamais mis en cache : règle du registre depuis la chaîne 16, politique de nœud sur la chaîne 15, où la liste était celle des validateurs de la genèse). En outre, chaque nœud mesure son propre faucet : huit mints d’affilée, avec une recharge d’un par seconde (FAUCET_MINT_BURST, FAUCET_MINT_PER_SEC). Au-delà, l’appel répond faucet is rate limited on this node (8 mints back to back, refilling at 1/s); try again shortly. La limite est par processus de nœud, et non par appelant, car le port RPC n’a pas d’identité de pair à mesurer et la ressource protégée est le pool de ce nœud. Avec l’exemption de capacité décrite plus haut, une inondation du faucet ne peut pas remplir le pool avant un PauseMints.
Gossipsub fonctionne avec une validation au niveau applicatif : une transaction n’est donc relayée aux autres pairs qu’une fois vérifiée ici. Un pair qui inonde le réseau de transactions invalides est freiné par le compteur, reçoit ses réponses depuis le cache, et ne coûte rien à la boucle de consensus.
Construire un bloc
Lorsqu’un validateur mène une vue, il demande des candidates au pool. candidates_within ne retient que les transactions qui s’appliquent encore au registre sur lequel il construit, en rejouant la moitié peu coûteuse de l’admission : ancre toujours dans la fenêtre, time toujours dans celle-ci, nullifiers pas encore dépensés, engagements pas encore présents. Elle écarte aussi une action de jeton dont le mint_nonce ou l’indice d’enregistrement a été dépassé par la chaîne, ainsi qu’une pause ou un référencement à un nonce périmé. Depuis la v0.6.8, elle retarife chaque call et chaque invoke aux prix en vigueur sur la tête de chaîne qu’elle prolonge, à partir de la limite de gas retenue à l’admission, et exclut celui dont les frais sont désormais inférieurs à son plancher ; il reste dans le pool et est proposé de nouveau dès que les prix baissent (audit v6, CH-9). Sur la chaîne 20, elle ordonne ensuite le reste d’abord la gouvernance, puis par frais au-dessus du plancher courant, par KiB de transaction, puis par frais, du plus élevé au plus faible, les égalités étant départagées par le hachage, de sorte que tout proposeur honnête construisant sur le même pool choisit le même bloc, et remplit jusqu’au budget de 2 000 transactions et du plafond de bloc de la chaîne (MAX_BLOCK_TXS ; max_block_bytes, 4 MiB par défaut et 20 MiB sur la chaîne 20). Ce sont les octets qui manquent à un bloc, d’où le surplus pris par KiB. Une chaîne sans section gas et un nœud sans politique de gas ignorent cette clé et ordonnent par gouvernance, frais et hachage, comme le faisait la chaîne 15. La gouvernance d’abord est l’autre moitié de la garantie de la pause : un PauseMints ne paie aucun frais par conception, puisqu’il doit fonctionner depuis un portefeuille ne détenant aucun RAND ; un simple ordre par frais classerait donc le frein d’urgence derrière toutes les transactions qui paient.
Une règle supplémentaire s’applique tant qu’un call ou un invoke est prêt (audit v6, POOL-2 ; politique de nœud sur toutes les chaînes, et non règle de validité). Seuls ceux-ci paient le prix à l’octet, et un transfert qui paie un peu plus que sa base forfaitaire les devance par KiB ; un flux de transferts pourrait donc écarter un call prêt de tous les blocs. Les transactions à frais forfaitaires prennent donc au plus les trois quarts des octets du bloc lors d’une première passe ; une fois les calls placés, la place restante est remplie avec les transferts mis de côté, dans leur ordre. Aucun espace de bloc n’est perdu, et sans call prêt rien ne change (Fees).
Une transaction trop volumineuse pour l’espace restant est ignorée, sans mettre fin au bloc : une plus petite derrière elle voyage quand même. Un transfert de la chaîne 18 pèse environ 2,85 MB, soit une preuve de bundle de 1,49 MB et une preuve d’autorisation de 1,36 MB ; son bloc de 20 MiB en accueille donc environ sept, et un bloc de 4 MiB par défaut un seul. Sur la chaîne 15, à environ 1,43 MB par transfert, les mêmes 20 MiB en contenaient quatorze. Sous charge, ce sont les frais qui décident lesquels passent (Fees).
Élagage
Après chaque commit, prune retire du pool exactement ce que candidates_within aurait ignoré : les transactions dont la note a été dépensée par quelqu’un d’autre, dont l’engagement existe désormais, dont le nonce ou l’indice d’enregistrement a été dépassé par la chaîne, ou dont l’ancre ou le time est sorti de la fenêtre (1 024 blocs sur la chaîne 20, 256 jusqu’à la chaîne 19). Un bundle qui perd une course de double dépense disparaît donc de lui-même, sans jamais produire d’erreur pour quiconque. L’acceptation par rand_sendTransaction n’est pas l’inclusion dans un bloc : interrogez rand_getTransaction jusqu’à ce qu’elle renvoie un bloc, et si la fenêtre d’ancre se referme sans résultat, reconstruisez le bundle sur une ancre récente et soumettez-le de nouveau.
Surveillance
rand_status renvoie mempool_size, verify_queue (transactions en attente d’un worker) et refused_cache (entrées du cache de verdicts). Une verify_queue qui reste à 64 signifie que les preuves arrivent plus vite que ne les vérifient quatre workers, soit environ 16 ms chacune avec une clé chaude ; un mempool_size qui grossit alors que les blocs restent pleins signifie que ce sont les frais, et non l’ordre d’arrivée, qui décident de l’inclusion.
Ce qui manque encore
- Pas de remplacement. Sur la chaîne 20, les prix du gas et des octets déplacent chaque bloc le plancher d’un appel, jusqu’à leurs plafonds ; le plancher d’un transfert est fixe. Une transaction en attente ne peut pas être remplacée par une autre à frais plus élevés portant sur les mêmes notes : la règle de conflit le refuse. Un appel dont le prix est devenu insuffisant attend dans le pool, sans être proposé, jusqu’à ce que les prix baissent ou que sa fenêtre d’ancre expire. Sur v0.6.7 (chaînes 18 et 19), un appel en attente conservait le plancher auquel il avait été admis, était encore proposé après une hausse, et échouait lors de l’application d’essai du proposeur avec
FeeTooLow. Le document du fullnode sur l’espace de bloc présente le mempool ordonné par frais comme l’étape qui précède un bloc plus grand, et l’agrégation au niveau du bloc comme ce qui relève le plafond par bloc. - Pas d’expiration par ancienneté. Une transaction en attente ne quitte le pool que par inclusion, par éviction à la limite d’octets, ou par les fenêtres d’ancre et de temps : 1 024 blocs sur la chaîne 20, soit environ 23 minutes à 1,34 s par bloc, valeur relevée par l’explorateur sur cette chaîne. Jusqu’à la chaîne 19, les fenêtres étaient de 256 blocs, soit environ cinq minutes.
- Pas de RPC du mempool. Aucune méthode ne liste les transactions en attente ;
mempool_sizedansrand_status, ainsi que le nombre, les octets et l’ancienneté maximale dansrand_getMempoolInfo, sont les seules vues, et l’explorateur n’indexe que les blocs validés.