Apogee VMDocs v1.0.0
gweb3networks.com ↗
Illustration principale d’Apogee VM : l’emblème d’Apogee s’élevant au-dessus de l’horizon d’une planète, avec les mots « Higher Compute Horizons »

Apogee VM·v1.0.0·Le premier pas

Toute application peut être native blockchain.

Apogee VM prouve qu’un programme s’est exécuté correctement. Vous écrivez du Rust ordinaire. Apogee l’exécute sur une machine RISC-V, prouve chacune des instructions exécutées et remet à la chaîne une seule preuve, qu’un unique appel de contrat suffit à vérifier. La correction cesse d’être ce que vos utilisateurs croient sur parole pour devenir ce qu’ils vérifient.

Énoncé d’une preuveVérifiée

Programme
Un élément du corps, l’identity : un condensé du code, de l’image mémoire initiale, du point d’entrée et de la configuration des circuits.
Entrée
Les octets publics fournis au programme.
Sortie
Le journal : les octets qu’il a choisi de publier.
Statut
Le statut avec lequel il s’est terminé. 0 signifie la réussite.

Groth16 · BN254un appel de contrat

Preuve de correction

Le logiciel a toujours demandé qu’on le croie sur parole. Apogee permet plutôt de le vérifier.

Chaque fois qu’un programme s’exécute sur la machine de quelqu’un d’autre, ses utilisateurs en acceptent le résultat par un acte de foi : le registre, le carnet d’ordres, le versement des gains, le coup de dés. Les blockchains ont éliminé cet acte de foi pour une catégorie étroite de programmes, en faisant réexécuter chaque transaction par chaque nœud. Cela fonctionne. C’est aussi la façon la plus coûteuse jamais imaginée de s’entendre sur quoi que ce soit.

Une zkVM, machine virtuelle qui prouve sa propre exécution, élimine cet acte de foi pour n’importe quel programme. Le programme s’exécute une seule fois, n’importe où. Il laisse derrière lui un reçu mathématique attestant que ce programme, à partir de cette entrée, a produit cette sortie, et vérifier ce reçu n’exige jamais de réexécuter le programme. C’est ce reçu qui rend une application native blockchain : ses règles vivent dans le code, son état vit sur la chaîne sous forme d’engagement, et chaque changement de cet état arrive avec sa preuve.

La troisième ère

Le travail, puis l’enjeu, puis la correction.

Chaque ère des blockchains a trouvé une nouvelle façon de ne plus avoir à faire confiance à quelqu’un. La preuve de correction est la première à pénétrer jusque dans le calcul lui-même.

01

Énergie

Preuve de travail

L’électricité garantit l’ordre des événements. Réécrire l’histoire suppose de dépenser plus que la facture d’électricité de la majorité honnête.

02

Capital

Preuve d’enjeu

Le capital garantit l’ordre des événements. Toute inconduite est sanctionnée par la destruction de la mise qui s’en portait garante.

03

Mathématiques · aujourd’hui

Preuve de correction

Les mathématiques garantissent les événements eux-mêmes. Chaque changement d’état porte la preuve qu’il a été calculé par le programme sur lequel tous se sont accordés.

Le travail et l’enjeu déterminent quelle histoire fait foi. Ni l’un ni l’autre ne vérifie ce qui s’y est passé; cette tâche a toujours incombé à chaque nœud, qui réexécute tout. Une preuve de validité met fin à ce dernier recours à la force brute. L’énergie, puis le capital, puis les mathématiques : il ne reste aucune quatrième chose à laquelle cesser de se fier.

Ce qui change

Votre produit, sa propre chaîne, et les mathématiques pour arbitre.

L’avenir natif blockchain n’est pas une seule chaîne qui fait tout. Ce sont de nombreux environnements, chacun façonné autour d’une application, qui effectuent tous leur règlement sur la même couche de base. Apogee est le moteur de preuve qui permet, en pratique, de faire fonctionner un tel environnement.

Écrire

Votre logique, en Rust ordinaire

Un programme invité est un binaire Rust no_std pour RISC-V. Il lit son entrée, effectue son travail et consigne sa sortie. Apogee prouve chaque exécution, et vous n’avez jamais à penser en circuits.

Régler

La finalité sans salle d’attente

Une preuve de validité est définitive dès qu’elle est vérifiée. Il n’y a ni délai de contestation de sept jours à laisser passer, ni comité ou enclave qui tienne lieu de mathématiques.

Spécialiser

Un environnement à l’image de votre produit

Une application par rollup, c’est chaque ressource consacrée au seul canal qui compte pour elle. Apogee prouve tout programme construit pour sa machine : la fonction de transition d’état, c’est vous qui la définissez.

Mesuré, pas promis

Un bloc Ethereum complet, du programme invité au contrat.

Avec la version 1.0.0 d’Apogee, le bloc 257 510 de glamsterdam-devnet-8 a été prouvé de bout en bout. Il a été validé sans état à l’intérieur de la VM selon les règles des execution-specs, puis replié par récursion en une seule preuve qu’un contrat Ethereum accepte.

101,5 MgasUn bloc, 60 transactionsPassé par le validateur sans état, à l’intérieur de la VM.
198 MCycles RISC-VSur 207 shards, chaque instruction exécutée est une ligne prouvée.
1Preuve au sommetUn arbre de récursion de 116 shards, replié en une seule preuve Groth16.
3,62 M gasPour vérifier sur la chaîneUn appel à ApogeeVerifier.sol, avec 34 980 octets de calldata.
704 BPar ouverture de shardUne seule preuve Mercury ouvre toutes les colonnes engagées d’un shard.
23Familles de circuitsSept pour les instructions, cinq pour les fenêtres mémoire, six délégations, cinq pour la récursion.
67 251Cas de conformitéChaque paire de tests-zkevm v21.0.1, reproduite nativement par le validateur.
0Cryptographie externeCorps, courbe, couplage, MSM, hachage, PCS, GKR et Groth16 sont écrits dans le dépôt. Les bibliothèques externes ne servent que d’oracles de test.

La preuve de base a pris 2 481 s sur une machine à 32 vCPU, avec un pic à 174 GiB; l’arbre de récursion a pris environ 2 620 s de plus. La génération de preuves est aujourd’hui limitée par la mémoire, et la page sur les performances donne chaque chiffre avec sa source.

Le parcours d’une preuve

Vous écrivez le programme. Apogee se charge de tout ce qui suit.

Entre votre Rust et le true du contrat se trouvent un décodeur, 23 familles de circuits, le moteur GKR, les engagements, un arbre de récursion et un décideur Groth16. Vous n’avez rien de tout cela à construire ni à maintenir.

Le parcours d’une preuve Huit étapes : écriture, chargement, exécution, découpage en shards, preuve, récursion, décision, vérification. Le développeur effectue la première; Apogee effectue les six suivantes; la chaîne effectue la dernière. Sous chaque étape, la taille de ce qui existe à ce stade pour le bloc 257 510. VOUS ÉCRIVEZ APOGEE PROUVE LA CHAÎNE VÉRIFIE Écritureno_std Rust Chargementimage · identité ExécutionRV32IMAC · 1 hart Découpage23 familles PreuveGKR · Mercury Récursionfeuilles → racine DécisionGroth16 · BN254 VérificationApogeeVerifier.sol votre code sourceidentité (32 octets)198 M cycles207 shardspreuve de 14,5 MBracine de 1,03 MB34 980 B de calldatatrue · 3,62 M gas
Le parcours d’une preuve. Tout ce qui se trouve entre les deux bandes extérieures relève d’Apogee. Les chiffres sous chaque étape sont ceux du bloc 257 510, tirés des mesures de la spécification.

Natif blockchain

La pile que vous connaissez déjà, avec un seul remplacement par couche.

Rendre votre application native blockchain ne demande pas d’apprendre une nouvelle discipline. Chaque composant d’une application conventionnelle a son équivalent, et le modèle mental se transpose presque sans changement.

CoucheApplication conventionnelleNative blockchain, sur Apogee
Logique métierUn service sur des serveurs que vous gérezUn programme invité en Rust, prouvé à chaque exécution
Base de donnéesSQL ou un stockage clé-valeurLes données hors chaîne, une racine d’état sur la chaîne
RequêteSELECT … WHERE key = ?Une preuve d’inclusion, vérifiée par rapport à la racine
ValidationCOMMITUne nouvelle racine, publiée avec sa preuve
Piste de vérificationDes journaux d’événements que vous demandez de croire sur paroleUne preuve que n’importe qui peut vérifier

Chaque couche, avec un exemple complet →

Commencez ici

Six portes d’entrée.

Un même système, lu sous six angles. Choisissez celui qui répond à la question qui vous amène.

L’emblème d’Apogee : deux lames qui se rejoignent en un sommet au-dessus d’une planète, avec une étoile en leur centre

Le premier pas est un programme.

Écrivez-le en Rust et exécutez-le sur Apogee. Tout ce qui suit, des shards et des circuits jusqu’à la récursion et au contrat, est l’affaire de la machine. Ce qui parvient à la chaîne est une preuve, et une preuve est tout ce dont la chaîne a besoin.

Vers de plus hauts horizons de calcul

Le premier pas

Natif blockchain

Une application native blockchain est faite des mêmes composants que celle que vous construisez aujourd’hui, avec un remplacement à chaque couche. Voici chacun de ces remplacements, et un registre construit des deux façons.

Voir en Markdown

Une application native blockchain est une activité économique dont le règlement, la garde des actifs et les règles sont sur la chaîne par construction, et non une entreprise conventionnelle à laquelle on aurait greffé un jeton. Cela ressemble à une tout autre ingénierie. L’écart est moindre qu’il n’y paraît.

Chaque composant de la pile que vous construisez aujourd’hui a son équivalent. Cet équivalent fait le même travail, à un changement près : ce qui relevait de la confiance est désormais prouvé. Apogee existe pour rendre ce changement assez peu coûteux pour qu’il devienne la norme.

Le changement en une phrase#

Dans une application conventionnelle, le serveur fait autorité : il détient les données, applique les règles et rapporte le résultat. Dans une application native blockchain, la chaîne détient un engagement sur les données, les règles sont un programme que chacun peut désigner par son condensé, et un résultat n’est accepté qu’accompagné d’une preuve que ce programme l’a produit.

L’opérateur ne disparaît pas. Quelqu’un exécute toujours le programme, stocke les données et répond aux requêtes. Ce qui disparaît, c’est la nécessité de le croire.

Couche par couche#

Couche Application conventionnelle Native blockchain, sur Apogee Ce qui se transpose
Logique métier Un service que vous déployez sur des serveurs que vous gérez Un programme invité : du Rust no_std compilé pour RISC-V et prouvé à chaque exécution Vous écrivez toujours des fonctions sur des données. Le programme est désigné par son identité, un condensé de son code et de sa configuration.
Stockage des données Des tables SQL, un stockage clé-valeur Les données restent hors chaîne; la chaîne stocke une racine d’état, un hachage unique qui résume un instantané de l’ensemble Un instantané désignable en 32 octets, par rapport auquel tout peut être vérifié.
Requête de lecture SELECT balance FROM accounts WHERE id = ? Une preuve d’inclusion, un chemin de Merkle, que le programme invité vérifie par rapport à la racine Une requête renvoie toujours une ligne. La ligne arrive désormais accompagnée de justificatifs, et le programme invité refuse toute ligne qui ne passe pas la vérification.
Écriture UPDATE …; COMMIT; Une transition d’état : le programme invité calcule la nouvelle racine et la publie Valider signifie toujours « rendre durable ». Cela signifie désormais qu’un contrat fait avancer la racine stockée.
Requête Le corps d’une requête HTTP L’entrée publique, que la preuve lie Les entrées entrent, les sorties sortent.
Charge utile volumineuse Téléversements, lignes jointes, documents récupérés Les données auxiliaires (advice) : des octets que le prouveur fournit et que le programme invité vérifie par rapport à quelque chose que la preuve lie Passez les données volumineuses par référence et vérifiez ce qui est arrivé.
Réponse Un corps JSON Le journal : la sortie publique, liée par la preuve N’importe qui peut lire la réponse et savoir qu’elle provient du programme.
Authentification Sessions, jetons, mots de passe Des signatures vérifiées dans le programme invité; la récupération de clé secp256k1 s’exécute sur l’arithmétique de corps et de courbe déléguée L’identité est une clé, et l’autorisation est une vérification que vous pouvez lire dans le code source.
Bibliothèques cryptographiques sha2, ring, OpenSSL guest_sdk::keccak256, sha256, ec_add, chacune acheminée vers un circuit dédié Les mêmes appels, pour une fraction des cycles.
Mise en production Vous poussez un binaire, et le comportement change aussitôt Vous enregistrez la nouvelle identité du programme auprès du contrat vérificateur Les mises en production deviennent explicites : une nouvelle compilation est une nouvelle identité que le contrat doit accepter.
Mise à l’échelle Plus de serveurs, des bases de données partitionnées Une exécution découpée en shards prouvés en parallèle, repliés par récursion en une seule preuve Le débit vient de prouveurs qui travaillent côte à côte, tandis que la chaîne ne vérifie toujours qu’une seule preuve.
Audit Des journaux d’événements et des attestations que vous demandez de croire sur parole La preuve et son journal L’assurance passe de la réputation à la vérification.

La colonne centrale est ce dont est faite une application native blockchain. Apogee fournit la machinerie qui se trouve en dessous : la machine RISC-V, les circuits, les engagements, la récursion et le contrat vérificateur. Rien de tout cela n’apparaît dans votre programme.

Ce qui ne change pas#

  • Vous écrivez toujours du Rust ordinaire. Structures, énumérations, traits, itérateurs, Vec, BTreeMap, et toute crate qui compile sans std. Il n’y a aucun langage de circuits à apprendre.
  • Vous testez toujours sur votre ordinateur portable. L’organisation habituelle place la logique applicative dans une bibliothèque no_std qui s’exécute sur l’hôte, sous cargo test, exactement comme elle s’exécute dans le programme invité. Voir Écrire un programme invité.
  • Vous raisonnez toujours en termes d’état, de requêtes et de réponses. Les formes sont les mêmes; seules leurs garanties changent.
  • Le code déterministe reste déterministe. Un bon code côté serveur évite déjà les entrées cachées. La VM rend cette règle absolue.

Ce qui change#

  • Aucun monde ambiant. Un programme invité n’a ni horloge, ni aléa, ni réseau, ni fichiers. Tout ce qu’il sait lui parvient comme entrée publique ou comme données auxiliaires, et une demande de données à l’hôte est un appel qu’aucune preuve n’admet.
  • Chaque instruction a un prix. Chaque instruction exécutée devient une ligne prouvée. Les copies, les allocations et les boucles inutiles coûtent du temps de preuve : la vieille discipline du décompte des cycles fait son retour.
  • Les données fournies sont vérifiées, pas crues sur parole. Les données auxiliaires sont choisies par le prouveur. Un programme invité les vérifie par rapport à quelque chose que la preuve lie avant que quoi que ce soit qui en dérive ne soit publié.
  • Les sorties sont petites et publiques. Le journal contient au plus 16 380 octets. Un résultat volumineux est publié sous forme de condensé.
  • Rien n’est caché. Les preuves d’Apogee v1.0.0 sont succinctes, pas à divulgation nulle de connaissance. Un programme invité ne doit détenir aucun secret.

Un registre, construit des deux façons#

Un dépôt sur le solde d’un compte : le plus petit changement d’état qui vaille la peine d’être prouvé.

La version conventionnelle#

sql
BEGIN;
SELECT balance FROM accounts WHERE id = $1 FOR UPDATE;    -- read
UPDATE accounts SET balance = balance + $2 WHERE id = $1;  -- write
COMMIT;                                                     -- make it durable

Les utilisateurs font confiance à l’opérateur pour avoir exécuté exactement ceci, sur la vraie table, et pour rapporter le résultat honnêtement.

La version native blockchain#

Les comptes résident dans un arbre de Merkle binaire dont les feuilles sont keccak256(account ‖ balance). Un contrat stocke la racine. Le programme invité reçoit l’ancienne racine et la requête comme entrée publique, reçoit le solde du compte et son chemin de Merkle comme données auxiliaires, vérifie le chemin, puis publie l’ancienne et la nouvelle racine.

guests/ledger/src/main.rsrust
#![no_std]
#![no_main]

guest_sdk::entry!(main);

const DEPTH: usize = 20; // room for 2^20 accounts

/// A leaf commits to one account's balance.
fn leaf(account: &[u8; 20], balance: u64) -> [u8; 32] {
    let mut bytes = [0u8; 28];
    bytes[..20].copy_from_slice(account);
    bytes[20..].copy_from_slice(&balance.to_le_bytes());
    guest_sdk::keccak256(&bytes)
}

/// Fold a leaf up its Merkle path; bit `level` of `index` says whether the
/// node is a right child at that level.
fn root_of(mut node: [u8; 32], index: u32, path: &[[u8; 32]; DEPTH]) -> [u8; 32] {
    let mut pair = [0u8; 64];
    for (level, sibling) in path.iter().enumerate() {
        let (left, right) = if (index >> level) & 1 == 0 { (&node, sibling) } else { (sibling, &node) };
        pair[..32].copy_from_slice(left);
        pair[32..].copy_from_slice(right);
        node = guest_sdk::keccak256(&pair);
    }
    node
}

/// Public input: old_root (32) ‖ account (20) ‖ amount (8, LE)
/// Advice:       balance (8, LE) ‖ index (4, LE) ‖ path (DEPTH × 32)
/// Journal:      old_root ‖ new_root ‖ account ‖ amount
fn main() {
    let input = guest_sdk::public_input();
    let advice = guest_sdk::advice();
    if input.len() != 60 || advice.len() != 12 + 32 * DEPTH {
        guest_sdk::exit(1);
    }
    let old_root: [u8; 32] = input[..32].try_into().unwrap();
    let account: [u8; 20] = input[32..52].try_into().unwrap();
    let amount = u64::from_le_bytes(input[52..60].try_into().unwrap());

    let balance = u64::from_le_bytes(advice[..8].try_into().unwrap());
    let index = u32::from_le_bytes(advice[8..12].try_into().unwrap());
    let mut path = [[0u8; 32]; DEPTH];
    for (i, sibling) in path.iter_mut().enumerate() {
        sibling.copy_from_slice(&advice[12 + 32 * i..12 + 32 * (i + 1)]);
    }

    // The query: the balance the prover supplied is the one the root commits to.
    if root_of(leaf(&account, balance), index, &path) != old_root {
        guest_sdk::exit(2);
    }
    // The write: the same path with the new leaf gives the new root.
    let Some(new_balance) = balance.checked_add(amount) else { guest_sdk::exit(3) };
    let new_root = root_of(leaf(&account, new_balance), index, &path);

    // The commit: publish the transition for the contract to apply.
    guest_sdk::commit(&old_root);
    guest_sdk::commit(&new_root);
    guest_sdk::commit(&account);
    guest_sdk::commit(&amount.to_le_bytes());
}

Lisez-le en regard du SQL. SELECT … FOR UPDATE est devenu un chemin de Merkle vérifié par rapport à la racine. UPDATE est devenu une nouvelle feuille sur le même chemin. COMMIT est devenu quatre appels à commit, qui écrivent le journal que la preuve liera. Le solde vient du prouveur, et cela ne pose aucun problème : un solde sur lequel la racine ne s’engage pas échoue à la vérification, et l’exécution se termine avec le statut 2.

Le contrat qui détient la racine n’accepte une transition qu’accompagnée d’une preuve que ce programme l’a produite et s’est terminé avec le statut 0 :

Ledger.sol (esquisse)solidity
interface IApogeeVerifier {
    function verify(bytes calldata input, bytes calldata output, uint256 exitStatus,
                    uint256[10] calldata proof, uint256[] calldata points) external view returns (bool);
}

contract Ledger {
    IApogeeVerifier public immutable verifier;
    bytes32 public root;

    constructor(IApogeeVerifier v, bytes32 genesis) { verifier = v; root = genesis; }

    function apply(bytes calldata input, bytes calldata journal,
                   uint256[10] calldata proof, uint256[] calldata points) external {
        require(verifier.verify(input, journal, 0, proof, points), "proof");
        require(bytes32(journal[0:32]) == root, "stale root");
        root = bytes32(journal[32:64]);
    }
}

Note

Ceci est une esquisse destinée à montrer la forme, pas un contrat de production. Un déploiement réel traite un lot de requêtes par preuve, que le programme invité replie en une seule transition, et rattache le vérificateur au bon programme et aux bonnes longueurs de valeurs publiques. Régler sur la chaîne traite du vérificateur déployé, de sa clé et de sa cérémonie.

Le modèle en trois lignes#

  1. La chaîne détient une racine.
  2. Le programme invité prouve la transition.
  3. Le contrat fait avancer la racine.

Tout le reste, des shards et des circuits à la récursion et au décideur, relève d’Apogee. Voilà l’abstraction : un programme, son entrée et sa sortie, et une preuve qui les relie.

Pour la suite#

Le premier pas

Apogee en un coup d’œil

Les faits sur une seule page. Ce que prouve Apogee VM, comment, à quel coût, sous quelles hypothèses, et où s’arrête la version 1.0.0.

Voir en Markdown

En un paragraphe#

Apogee VM est une zkVM RISC-V. Elle prouve qu’un programme RV32IMAC, désigné par un condensé de son image, s’est exécuté sur une entrée publique donnée jusqu’à un statut de sortie et a écrit une sortie publique donnée. Elle porte cette preuve, à travers un arbre de récursion, jusqu’à une seule preuve Groth16 que vérifie un contrat Ethereum. Chaque circuit est un circuit GKR en couches sur le corps des scalaires de BN254, chaque colonne engagée est ouverte avec Mercury, et chaque défi provient d’une transcription Poseidon2. Les corps, la courbe, le couplage, la MSM, le hachage, l’engagement polynomial, le prouveur GKR et Groth16 sont tous implémentés dans le dépôt. Sa charge de travail de référence est la validation de blocs Ethereum.

Les faits#

Apogee VM v1.0.0
Ce qu’énonce une preuve Que le programme de cette identité, lancé à son point d’entrée sur son image, avec cette entrée publique et certaines données auxiliaires (advice), s’est exécuté instruction par instruction jusqu’à EXIT avec ce statut, après avoir écrit ce journal
Jeu d’instructions RV32IMAC sur un seul hart : les 59 instructions de RV32IMA (40 de base, 8 M, 11 A), les instructions compressées étant développées au chargement
Langage des programmes invités Rust, #![no_std] avec alloc, stable 1.96.1, cible riscv32imac-unknown-none-elf
Arithmétisation 23 familles de circuits, chacune un circuit GKR en couches : 7 pour les instructions, 5 pour les fenêtres mémoire, 6 délégations, 5 pour la récursion
Arguments Les portes par sumcheck; la mémoire par un seul multiensemble lecture/écriture sur toute l’exécution; les lookups par LogUp
Corps Le corps des scalaires de BN254, 254 bits
Engagements Mercury, multilinéaire sur KZG, une ouverture de 704 octets par shard quel que soit le nombre de colonnes
Mise en place Les puissances de tau perpétuelles de PSE, contribution 80; une seconde cérémonie, propre au circuit, pour le décideur sur la chaîne
Transcription Une éponge duplex Poseidon2 sur Fr, largeur 3, débit 2
Règlement Arbre de récursion → décideur Groth16 → ApogeeVerifier.sol
Niveau de sécurité Environ 100 bits, fixé par BN254
Divulgation nulle de connaissance Non. Les preuves sont succinctes, pas à divulgation nulle de connaissance, et aucun aveuglement n’est appliqué
Opérations déléguées Tours de keccak-f[1600], tours de SHA-256, Poseidon2, arithmétique Fr de BN254, multiplication modulaire sur 256 bits pour quatre modules d’Ethereum, addition complète de points sur secp256k1 et sur G1 de BN254
Valeurs publiques Au plus 16 380 octets d’entrée et 16 380 octets de journal; données auxiliaires jusqu’à 2 GiB
Longueur d’exécution Jusqu’à 2^36 − 1 cycles
Taille du code .text dans la limite de 7,94 MiB pour une hauteur de table de 2^22; image dans la limite de 4 MiB par défaut
Cryptographie tierce Aucune sur un chemin de preuve. arkworks, Plonky3 et zkhash n’apparaissent que comme oracles de test

Mesures#

Tous les chiffres concernent le bloc 257 510 de glamsterdam-devnet-8, passé par le programme invité validateur sans état : 60 transactions, 101,5 Mgas, 198 millions de cycles. Sources : récursion §10 et prouveur en flux §1 dans la spécification.

Étape Résultat
Preuve de base 207 shards, 14,5 MB, 2 481 s sur 32 vCPU et 247,7 GiB, pic de RSS de 173,92 GiB
Arbre de récursion 116 shards : quatre feuilles d’au plus 64 shards de base (2 157 s au total, pic de 92 GiB) et une racine (460 s, 1,03 MB)
Circuit du décideur 7 896 686 contraintes sur un domaine de 2^23
Preuve du décideur 18,5 s et 6,1 GB sur un portable à 18 cœurs, la clé étant lue en 1 s
Vérification sur la chaîne 3 620 026 gas, 34 980 octets de calldata, 358 points repliés par le contrat
Conformité Les 67 251 paires de tests-zkevm v21.0.1 concordent toutes en exécution native

Ce que doit détenir un vérificateur#

Deux valeurs, obtenues par un canal que le prouveur ne contrôle pas :

  • L’identité du programme, un élément du corps. Face à une identité fournie par le prouveur, une preuve montre seulement qu’un programme quelconque s’est exécuté.
  • Le condensé SRS de la cérémonie. Une clé construite sur un τ connu n’est refusée que par cette comparaison.

La clé de vérification elle-même peut provenir de n’importe qui : son chargement recalcule les deux valeurs à partir de son propre contenu et exige que ses circuits soient ceux du registre du vérificateur. Le modèle de sécurité donne la liste complète des hypothèses.

Où s’arrête la v1.0.0#

  • Pas de divulgation nulle de connaissance. Aucun aveuglement dans Mercury, dans GKR ni dans le décideur.
  • Les données auxiliaires ne sont pas liées. Un programme invité les vérifie par rapport à quelque chose qu’une preuve lie.
  • Les déroutements (traps) ne sont pas prouvables. Un accès non aligné, un accès hors de la mémoire projetée, ebreak ou un pc sans instruction termine l’exécution sans preuve.
  • sc.w réussit toujours. C’est le seul écart par rapport à RV32IMAC : il n’y a pas d’état de réservation.
  • Les délégations forment un ensemble fixe de six. MULMOD de l’EVM avec un module arbitraire, MODEXP et BLS12-381 s’exécutent comme des instructions ordinaires.
  • La génération de preuves est limitée par la mémoire. Le bloc mesuré a culminé à 174 GiB; la mémoire suit les shards en cours de traitement, pas la longueur de l’exécution.
  • La clé du décideur est propre à chaque forme de racine, et n’est digne de confiance que dans la mesure où sa cérémonie l’est. La clé de développement est falsifiable.

Qui construit Apogee VM#

Apogee VM est le projet phare du programme de recherche de G Web3 consacré aux environnements d’applications natifs blockchain : un environnement optimisé par application économique, chacun effectuant son règlement sur Ethereum au moyen d’une preuve de validité. La position du programme est exposée dans la thèse; la direction de la prochaine version, dans Saut quantique.

Lancez votre application

Lancez votre application

Le manuel du développeur pour Apogee VM. Comment un programme invité est écrit, compilé, exécuté, prouvé et réglé sur la chaîne, et les habitudes qui le gardent correct, prouvable et peu coûteux.

Voir en Markdown

Un programme invité est le programme que prouve Apogee : un binaire Rust no_std compilé pour riscv32imac-unknown-none-elf, avec un point d’entrée, trois régions mémoire pour ses entrées et ses sorties, et rien d’autre. L’hôte est tout ce qui l’entoure : le code qui fournit l’entrée, demande une preuve à Apogee et remet cette preuve à quiconque la vérifie. Vous écrivez les deux. Apogee fournit la machine, les circuits et le vérificateur.

Cette section s’adresse à deux lecteurs à la fois : un ingénieur devant son clavier et le modèle avec lequel il travaille. Chaque page énonce ses règles sans détour, et le Compagnon IA les condense toutes en un seul fichier que vous pouvez remettre à un assistant avant qu’il n’écrive une ligne.

Le modèle#

Le programme invité, l’hôte et le vérificateur L’hôte transmet une entrée publique et des données auxiliaires (advice) au programme invité qui s’exécute dans Apogee VM. Le programme invité écrit un journal et se termine avec un statut. Apogee produit une preuve qui lie l’identité du programme, l’entrée, le journal et le statut de sortie, et qu’un vérificateur contrôle. HÔTE · VOTRE CODE Votre service prépare l’entrée et les données auxiliaires, demande une preuve APOGEE VM · RV32IMAC · UN SEUL HART INVITÉ · VOTRE CODE Votre programme no_std Rust guest_sdk entrée publique données auxiliaires journal · exit status VÉRIFICATEUR Preuve vérifiée par rapport à une identité issue de son propre canal : un contrat ou un service
Qui fait quoi. L’entrée et le journal sont liés par la preuve; les données auxiliaires (advice), en pointillés, ne le sont pas, et c’est pourquoi un programme invité les vérifie. Le vérificateur ne voit jamais le programme, seulement son identité.

Une preuve dit une seule chose : le programme de cette identité, lancé sur son image avec cette entrée publique et des données auxiliaires choisies par le prouveur, s’est exécuté jusqu’à EXIT avec ce statut, après avoir écrit ce journal. Tout ce que vous construisez repose sur cette phrase.

Le flux de travail#

Étape Ce que vous faites Page
1 N’installez rien à la main : le dépôt fixe la chaîne d’outils. Récupérez le fichier de cérémonie, nécessaire pour prouver Préparer l’environnement
2 Écrivez le programme invité : un point d’entrée, les trois régions, du Rust ordinaire Écrire un programme invité, Entrées, données auxiliaires et journal
3 Recourez au hachage et à l’arithmétique de courbe délégués là où c’est rentable Délégations
4 Compilez-le pour la cible des programmes invités, puis inspectez l’image et son identité Compiler et inspecter
5 Exécutez-le dans l’émulateur et mesurez où vont les cycles Exécuter et profiler
6 Prouvez une exécution et vérifiez-la Prouver et vérifier
7 Compressez la preuve par récursion et vérifiez-la sur Ethereum Régler sur la chaîne

Le Démarrage rapide parcourt une fois la boucle complète avec un programme invité de trois lignes.

Les règles les plus importantes#

Chacune est expliquée, avec ce qui tourne mal et ce qu’il faut faire à la place, dans le guide de programmation des programmes invités.

  • usize et tous les pointeurs font 32 bits. Un dépassement de usize provoque une panique dans le programme invité seulement, x as usize tronque sans rien signaler, et tout ce dont la disposition ou le hachage dépend d’une longueur diffère entre l’hôte et le programme invité.
  • L’allocateur ne libère jamais rien. Il fait monter un pointeur à partir de __heap_start, si bien que ce qui épuise la mémoire d’un programme invité, c’est le total qu’il alloue au cours de l’exécution, et non son pic. Réutilisez les tampons et dimensionnez-les avec with_capacity.
  • Les opérations atomiques fonctionnent, et vous ne devriez pas en écrire. La machine n’a qu’un seul hart : l’extension A est là pour la compatibilité avec le code qui l’utilise déjà. Le code d’un nouveau programme invité n’a rien à synchroniser.
  • Il n’y a pas de monde extérieur. Ni fichiers, ni horloge, ni aléa, ni réseau. Un programme invité connaît son entrée publique, ses données auxiliaires et ce qu’il calcule.
  • Les données auxiliaires sont choisies par le prouveur. Vérifiez-les par rapport à quelque chose que la preuve lie avant que quoi que ce soit qui en dérive n’atteigne le journal.
  • Le journal est petit. 16 380 octets au plus. Publiez un condensé de tout ce qui grandit.
  • Les vérifications de dépassement restent actives en release. Elles font partie de ce que calcule le programme, et le profil des programmes invités les impose donc.
  • Chaque instruction exécutée est une ligne prouvée. Le coût de la preuve suit le nombre de cycles : compilez avec --release et comptez les cycles avant d’optimiser quoi que ce soit d’autre.

Par où commencer#

Lancez votre application

Démarrage rapide

D’une crate vide à une preuve vérifiée. Un programme invité de trois lignes, compilé, exécuté, inspecté et prouvé, avec la sortie réelle de chaque étape.

Voir en Markdown

Cette page parcourt une fois la boucle complète avec le plus petit programme invité qui fasse quelque chose : il lit son entrée publique et la publie comme journal. Chaque sortie ci-dessous a été produite en exécutant exactement ces commandes sur Apogee v1.0.0.

Note

Ce qu’il vous faut. Une copie de travail du dépôt Apogee VM à la v1.0.0, et rustup; le dépôt fixe tout le reste. Les commandes s’exécutent à partir de la racine du dépôt, sauf quand une étape change de répertoire. Les étapes 5 et 6 nécessitent aussi le fichier de cérémonie assets/ptau/ppot_0080_24.ptau, et l’étape 6, une machine dotée de dizaines de GiB de mémoire. Préparer l’environnement traite des deux.

Créer le programme invité#

Un programme invité est une crate binaire no_std dans l’espace de travail guests/. Créez guests/hello :

guests/hello/Cargo.tomltoml
[package]
name = "hello"
version.workspace = true
edition.workspace = true
publish.workspace = true

[dependencies]
guest-sdk.workspace = true
guests/hello/src/main.rsrust
#![no_std]
#![no_main]

guest_sdk::entry!(main);

fn main() {
    // The public input is memory: a slice, with no ecall and no cursor.
    guest_sdk::commit(guest_sdk::public_input());
}

#![no_std], parce que la cible n’a pas de système d’exploitation. #![no_main] avec entry!(main), parce que le code de démarrage du SDK initialise le pointeur de pile, met .bss à zéro et appelle un symbole main que la macro exporte autour de votre fonction. Le retour de cette fonction équivaut à exit(0).

L’ajouter à l’espace de travail des programmes invités#

Ajoutez "hello" à la fin de la liste members dans guests/Cargo.toml :

guests/Cargo.tomltoml
members = ["fib", "echo", … , "recursion", "hello"]

Le compiler#

À partir du répertoire du programme invité lui-même, sans autre option que la cible :

sh
cd guests/hello
cargo build --release --target riscv32imac-unknown-none-elf
cd ../..

L’ELF se retrouve dans guests/target/riscv32imac-unknown-none-elf/release/hello. L’espace de travail des programmes invités fournit le script d’édition de liens et --no-relax : il n’y a rien d’autre à passer.

L’exécuter#

Le profileur exécute un programme invité dans l’émulateur d’Apogee, sans preuve, et indique où sont allés les cycles :

sh
printf 'hello, apogee' > /tmp/hello.in
cargo run --release -p profiler -- elf guests/target/riscv32imac-unknown-none-elf/release/hello --input /tmp/hello.in
workload
  label                        hello
  guest                        hello
  guest cycles                 114
  exit status                  0
  journal bytes                13

cycles by family
  ADD_SUB_LUI_AUIPC            64
  JUMP_BRANCH_SLT              21
  MEM_WORD                     3
  MEM_SUBWORD                  26

Les 114 instructions exécutées deviendront chacune une ligne prouvée. Les 13 octets du journal sont l’entrée, renvoyée telle quelle. Les 26 lignes MEM_SUBWORD sont celles de commit, qui copie l’entrée octet par octet avec lbu et sb.

Voir ce que la VM prouvera#

sh
cargo run --release -p artifact-dump -- tables \
    guests/target/riscv32imac-unknown-none-elf/release/hello \
    --ptau assets/ptau/ppot_0080_24.ptau
program identity  9ead85cee880df30daa8eba657316215107a075640a64ccf2424a054b758b802

VmConfig
--------
  id  family              height     live rows  columns
   0  ADD_SUB_LUI_AUIPC     4194304         33  pc next_pc rs1 rs2 rd imm extra_mask
   1  JUMP_BRANCH_SLT       4194304         12  pc next_pc rs1 rs2 rd imm extra_mask
   4  MEM_WORD              4194304          3  pc next_pc rs1 rs2 rd imm extra_mask
   5  MEM_SUBWORD           4194304          3  pc next_pc rs1 rs2 rd imm extra_mask
   7  INIT_TEARDOWN         4194304          0  none: claims no pc
   8  ZERO_WINDOWS          4194304          0  none: claims no pc
  12  PUBLIC_INPUT             4096          0  none: claims no pc
  13  PUBLIC_OUTPUT            4096          0  none: claims no pc
  14  ADVICE_WINDOWS        4194304          0  none: claims no pc

Voici la forme statique du programme aux hauteurs par défaut : les quatre familles d’instructions qu’utilise son code, chacune avec une table décodée, et les cinq familles de fenêtres que possède tout programme. L’identité du programme est un seul élément du corps qui condense le tout. La vôtre sera différente : un ELF incorpore des chemins absolus dans ses chaînes de panique, si bien qu’une compilation sur une autre machine donne une autre image, et tout changement de hauteurs, une autre identité.

Prouver et vérifier#

Un programme hôte demande la preuve. Placez-le à côté du SDK hôte, sous forme d’exemple :

crates/host/examples/prove_hello.rsrust
use constants::family;
use emulator::GuestIo;
use program::ProgramParams;
use srs::Srs;

fn main() {
    let elf = std::fs::read("guests/target/riscv32imac-unknown-none-elf/release/hello")
        .expect("build the guest with --release first");

    // Small heights for a small program: the seven instruction families at
    // their 2^20 floor, the three RAM-window families at 2^16. Every choice of
    // heights is its own program identity.
    let mut params = ProgramParams::defaults();
    for f in 0..7 {
        params.heights[f] = 1 << 20;
    }
    for f in [family::INIT_TEARDOWN, family::ZERO_WINDOWS, family::ADVICE_WINDOWS] {
        params.heights[f as usize] = 1 << 16;
    }

    // As many ceremony powers as the tallest family has rows: 2^20 here.
    let ptau = std::path::Path::new("assets/ptau/ppot_0080_24.ptau");
    let srs = Srs::from_ptau(ptau, 20).expect("the ceremony file reads");
    let setup = host::setup(&elf, &params, srs).expect("the program registers");

    let io = GuestIo { input: b"hello, apogee".to_vec(), advice: Vec::new() };
    let proven = host::prove(&setup, &io, 2).expect("the run proves"); // two shards in flight
    host::verify(&setup.vk, &proven.block).expect("the block verifies");

    assert_eq!(proven.exit_code, 0);
    assert_eq!(proven.journal, b"hello, apogee");
    let id: String = setup.vk.identity.to_bytes().iter().map(|b| format!("{b:02x}")).collect();
    println!("identity  {id}");
    println!("cycles    {}", proven.cycles);
    println!("shards    {}", proven.report.shards);
    println!("journal   {:?}", core::str::from_utf8(&proven.journal).unwrap());
}
sh
cargo run --release -p host --example prove_hello
identity  606d1f1d720459cc1a078787381656b29c9fce5a9e539b36f899e62b64129c14
cycles    114
shards    7
journal   "hello, apogee"

Sur un portable à 18 cœurs doté de 48 GiB, cela a pris 52 secondes, avec un pic de mémoire de 18 GB, presque entièrement dû aux deux shards 2^20 en cours de traitement. L’identité diffère de celle de l’étape 5 parce que les hauteurs diffèrent : l’identité lie chaque hauteur.

Conserver l’identité#

Un vérificateur ne prend jamais l’identité dans la preuve, dans la clé ni auprès du prouveur. Il détient sa propre copie, obtenue de quiconque a compilé la version publiée, et compare :

rust
assert_eq!(setup.vk.identity.to_bytes(), registered); // `registered` from your own channel

Face à une identité fournie par le prouveur, une preuve montre seulement qu’un programme quelconque s’est exécuté.

Ce qui vient de se passer#

L’émulateur a exécuté les 114 instructions deux fois. La première passe a engagé les colonnes mémoire de chaque shard et fixé l’énoncé. La seconde a rempli chaque shard et l’a prouvé. Il y avait sept shards : un pour chacune des quatre familles d’instructions exécutées, un pour la fenêtre mémoire qui contient l’image du programme, puis un pour l’entrée publique et un pour le journal. Ce programme invité n’a jamais touché à sa pile, si bien qu’aucune autre fenêtre n’a eu besoin d’un shard; un programme typique y ajoute celui de la pile. Chaque shard a été prouvé par le circuit GKR de sa famille et ouvert avec une seule preuve Mercury, et le vérificateur a rapproché les lectures et les écritures mémoire des sept shards en une seule équation. La vue d’ensemble de l’architecture suit le même chemin en détail.

Pour la suite#

Lancez votre application

Préparer l’environnement

La chaîne d’outils que fixe le dépôt, les deux espaces de travail qu’il contient, le fichier de cérémonie dont a besoin la génération de preuves, et la machine qu’exige chaque étape.

Voir en Markdown

Apogee v1.0.0 est un dépôt Rust. Il n’y a rien à installer hormis rustup : le dépôt fixe sa chaîne d’outils, et la chaîne d’outils comprend la cible des programmes invités. Écrire, compiler, exécuter et profiler un programme invité ne demande rien de plus. La génération de preuves ajoute un gros fichier et une machine dotée d’une mémoire à l’avenant.

La chaîne d’outils#

rust-toolchain.toml, à la racine du dépôt, fixe Rust stable 1.96.1 avec rustfmt, clippy et llvm-tools, ainsi que la cible riscv32imac-unknown-none-elf, dont core et alloc sont livrés précompilés. rustup l’applique dans chaque répertoire sous la racine et l’installe à la première utilisation.

sh
cd apogee-vm
rustup show active-toolchain     # 1.96.1, overridden by rust-toolchain.toml
cargo --version

Aucune version nightly ni aucune fonctionnalité instable n’est utilisée nulle part. llvm-tools fournit les llvm-objdump et llvm-nm correspondant au LLVM du compilateur, que le dépôt utilise pour ses listages de désassemblage versionnés et dont vous pouvez vous servir pour lire le code de votre programme invité.

Deux espaces de travail#

La copie de travail contient deux espaces de travail Cargo, et cette séparation compte :

Espace de travail Racine Compile pour Contient
L’espace de travail racine Cargo.toml votre hôte le prouveur, le vérificateur, le SDK hôte, les outils, tout ce qui se trouve dans crates/ et tools/
L’espace de travail des programmes invités guests/Cargo.toml riscv32imac-unknown-none-elf tous les programmes invités, et un répertoire guests/target qui lui est propre

Les programmes invités sont tenus à part parce que chaque membre se compile pour la cible des programmes invités et lie un #[panic_handler]; cargo test --workspace à la racine ne doit jamais les atteindre. L’espace de travail des programmes invités contient aussi ce dont un programme invité a besoin pour être compilé correctement, si bien que vous n’avez jamais à le taper :

  • guests/.cargo/config.toml fixe la cible et passe à l’éditeur de liens -T crates/guest-sdk/link.ld, la carte mémoire, et --no-relax, parce que la relaxation déplacerait des adresses que lie l’identité du programme.
  • guests/Cargo.toml fixe les deux profils de compilation à la même sémantique, vérifications de dépassement comprises (Compiler et inspecter).
  • Sa section [patch.crates-io] redirige k256, ark-ff et revm-precompile vers des copies embarquées qui appellent les délégations d’Apogee (Délégations).

Astuce

Ouvrez guests/ comme un dossier distinct dans votre éditeur. rust-analyzer lit alors le .cargo/config.toml de cet espace de travail et vérifie le code des programmes invités pour la cible des programmes invités plutôt que pour votre hôte.

Le fichier de cérémonie#

Chaque engagement que produit Apogee repose sur les puissances d’un τ secret issu d’une cérémonie publique : les puissances de tau perpétuelles de PSE, contribution 80. Un seul fichier sert à tous les usages :

assets/ptau/ppot_0080_24.ptau        19.3 GB, 2^24 powers; assets/ptau/ is gitignored

Il vous le faut pour calculer une identité de programme, pour construire de vraies clés et pour prouver. Il ne vous le faut pas pour compiler, exécuter ou profiler un programme invité, ni pour lancer les tests de l’espace de travail, qui prouvent sur leur propre mise en place jouet.

Les fichiers de la cérémonie de PSE sont tirés d’une même transcription, si bien que tout fichier de puissance 24 ou plus convient. Le powersOfTau28_hez_final_*.ptau de Hermez provient d’une autre cérémonie, avec un autre τ : le lecteur l’ingère tout aussi facilement, et chaque engagement, chaque clé et chaque identité calculés sur ce fichier en sortent différents. Pour confirmer que vous détenez la bonne cérémonie, voici son [τ]_1, en hexadécimal de son encodage canonique x ‖ y :

9bbb31bedc304e081e2aada4b56c2217e0e94ee16874e3517d14bef5dcec3a16
317ff1589e53513fa333591b318e8f1e55ef7c37d92beb6d9a61d770a2f39506

La page SRS de la spécification énonce exactement ce que vérifie le lecteur et ce qu’il tient pour acquis.

La machine#

Compiler et exécuter, c’est un travail de portable. La génération de preuves est limitée par la mémoire, et la mémoire qu’elle consomme suit les shards prouvés simultanément, pas la longueur de l’exécution.

Étape Besoins
Compiler un programme invité, l’exécuter, le profiler, exporter et inspecter son image N’importe quel portable récent; quelques secondes
Calculer une identité de programme (artifact-dump tables --ptau) Le fichier de cérémonie; environ 25 s sur un portable à 18 cœurs aux hauteurs par défaut
Prouver un petit programme invité à des hauteurs de 2^20 Des dizaines de GiB. Un shard 2^20 de la famille d’instructions la plus large occupe environ 8,4 GiB d’éléments du corps dans sa passe avant, et chaque shard en cours de traitement occupe les siens
Prouver un bloc Ethereum complet Le bloc mesuré a culminé à 174 GiB sur une machine à 32 vCPU et 247,7 GiB

La page sur la génération de preuves explique comment les hauteurs et le nombre de shards en cours de traitement arbitrent entre mémoire et temps : Prouver et vérifier.

Vérifier votre copie de travail#

Ce qu’exécute la CI, le tout sans le fichier de cérémonie :

sh
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
cargo run -p kat-gen && git diff --exit-code      # committed fixtures regenerate identically
(cd guests && cargo clippy --bins -- -D warnings)

Les suites qui prouvent de vrais shards sont marquées #[ignore], parce que chacune nécessite des dizaines de GiB. Lancez-en une par son nom quand vous voulez voir une preuve produite et refusée sur votre propre machine :

sh
cargo test --release -p prover --test acceptance -- --include-ignored --test-threads=1

Ensuite : écrivez un programme invité, ou parcourez une fois la boucle complète dans le Démarrage rapide.

Lancez votre application

Écrire un programme invité

Un programme invité est un binaire Rust no_std doté d’un point d’entrée et de trois régions mémoire. L’organisation de la crate, l’environnement d’exécution sous votre code, les dépendances, et l’organisation qui permet de le tester d’abord sur l’hôte, comme n’importe quel code Rust.

Voir en Markdown

La crate#

Dans la v1.0.0, un programme invité est une crate binaire de l’espace de travail guests/ du dépôt. L’espace de travail fournit la cible, les options de l’éditeur de liens, les profils fixés et les crates embarquées, si bien que le manifeste propre au programme invité reste court :

guests/my-app/Cargo.tomltoml
[package]
name = "my-app"
version.workspace = true
edition.workspace = true
publish.workspace = true

[dependencies]
guest-sdk.workspace = true
guests/my-app/src/main.rsrust
#![no_std]
#![no_main]

extern crate alloc; // Vec, Box, String, BTreeMap, over the SDK's allocator

use alloc::vec::Vec;

guest_sdk::entry!(main);

fn main() {
    let input = guest_sdk::public_input();
    let mut out = Vec::with_capacity(input.len());
    out.extend(input.iter().rev());
    guest_sdk::commit(&out);
}

Ajoutez "my-app" à members dans guests/Cargo.toml, et compilez à partir du répertoire du programme invité lui-même : cargo build --release --target riscv32imac-unknown-none-elf.

Ce qui s’exécute sous votre code#

Le SDK des programmes invités constitue tout l’environnement d’exécution. Il est assez petit pour être décrit en entier :

  • Démarrage. _start se trouve à 0x0001_0000, le premier octet de .text. Il fait pointer sp vers le haut de la RAM, met .bss à zéro octet par octet, et appelle main. entry!(f) exporte ce main comme une enveloppe autour de votre fonction, qui ne prend aucun argument et renvoie ().
  • Sortie. Le retour de main équivaut à exit(0). guest_sdk::exit(code) termine l’exécution avec un statut quelconque. Un statut non nul signale une exécution en échec, et une exécution en échec reste prouvable : l’énoncé porte le statut, et un vérificateur le lit.
  • Panique. Le gestionnaire de panique termine l’exécution avec le statut 101 et n’écrit rien. Il n’y a aucun flux de diagnostic. Un programme invité qui panique a tout de même publié ce qu’il avait consigné avant la panique.
  • Tas. Un allocateur linéaire (bump allocator) croît vers le haut à partir de __heap_start, juste au-dessus de .bss. Il ne libère jamais rien. Voir le tas.
  • Appels système. Les seuls ecalls qu’émet un programme invité sont EXIT et les appels de délégation que le SDK effectue pour vous. L’entrée, les données auxiliaires (advice) et la sortie sont de la mémoire, lue et écrite par des chargements et des rangements ordinaires.

La carte mémoire#

L’espace d’adressage complet de 32 bits, tel que le voit un programme invité :

Plage Ce que c’est
0x0000_0000 – 0x0000_8000 Un trou. Rien ne l’initialise, si bien qu’un pointeur nul ou sauvage provoque un OutOfBounds fatal, et non une lecture silencieuse
0x0000_8000 – 0x0000_C000 La fenêtre de l’entrée publique, 16 KiB
0x0000_C000 – 0x0001_0000 La fenêtre du journal, 16 KiB
0x0001_0000 – … .text (avec _start en premier), puis .rodata, .data et .bss, chacun aligné sur une page
__heap_start et au-dessus Le tas, à partir de la fin de .bss arrondie au multiple de 16 supérieur
0x7F80_0000 – 0x8000_0000 La réserve de 8 MiB de la pile. Aucun bloc du tas ne peut se terminer au-dessus de 0x7F80_0000; la pile croît vers le bas à partir de 0x8000_0000
0x8000_0000 – 2^32 La région des données auxiliaires, jusqu’à 2^29 mots, adressable seulement sur l’étendue que l’hôte a fournie

Le code est statique. L’instruction de chaque pc provient des tables décodées du programme, jamais de la RAM, si bien qu’un rangement dans .text change ce que lit un chargement ultérieur, mais pas ce qui s’exécute.

Le tas#

L’allocateur fait avancer un pointeur, et dealloc ne fait rien. C’est la bonne conception pour un programme court dont chaque cycle coûte du temps de preuve, et cela change votre façon d’écrire du Rust :

  • Ce qui épuise votre mémoire, c’est le total que vous allouez, et non votre pic. Une boucle qui construit puis détruit un Vec à chaque itération consomme du tas neuf à chaque fois.
  • Réutilisez les tampons. Sortez les allocations des boucles, appelez clear() puis remplissez à nouveau au lieu de réallouer, et dimensionnez avec with_capacity les collections qui grandissent, pour qu’elles ne soient pas réallouées et recopiées à mesure qu’elles grandissent.
  • Le plafond, c’est le statut 71. Une allocation qui se terminerait au-dessus de 0x7F80_0000, ou au-dessus du pointeur de pile courant, termine l’exécution avec le statut 71 plutôt que de renvoyer un pointeur nul ou d’écraser la pile.
rust
// Allocates a fresh Vec per record: total heap grows with the record count.
for record in records {
    let fields: Vec<&[u8]> = record.split(|b| *b == b',').collect();
    handle(&fields);
}

// One buffer, reused: total heap is the largest record's field count.
let mut fields: Vec<&[u8]> = Vec::with_capacity(16);
for record in records {
    fields.clear();
    fields.extend(record.split(|b| *b == b','));
    handle(&fields);
}

La pile dispose de sa réserve de 8 MiB, et une récursion profonde à l’intérieur de celle-ci ne pose aucun problème. Ce que rien ne détecte, c’est une pile qui dépasse sa réserve après que le tas a rempli l’espace situé sous elle : des blocs du tas changeraient alors sous une chaîne d’appels profonde. Gardez la récursion bornée, ou rendez-la itérative.

Dépendances#

Toute crate qui se compile pour riscv32imac-unknown-none-elf sans std fera l’affaire. En pratique :

  • Désactivez les fonctionnalités par défaut (default-features = false) et activez alloc là où une crate le propose.
  • Une crate qui entraîne getrandom, une horloge ou std::collections::HashMap avec sa graine aléatoire n’a aucune source où puiser. Un tel appel répond -ENOSYS et rend l’exécution non prouvable. Préférez BTreeMap, ou une table de hachage dotée d’une fonction de hachage fixe et déterministe.
  • La virgule flottante se compile en routines logicielles sur entiers, parce que la cible n’a pas d’extension F ni D. Elle est correcte et déterministe, et coûte de nombreuses instructions par opération. L’arithmétique entière ou en virgule fixe coûte moins cher.
  • Le hachage et l’arithmétique sur courbes elliptiques ont des circuits dédiés. Utilisez les fonctions du SDK ou les crates embarquées pour que vos dépendances les atteignent : Délégations.

Le tester d’abord sur l’hôte#

Un programme invité n’affiche rien : le débogage se fait donc sur l’hôte. L’organisation qui facilite cela place le programme dans une bibliothèque #![no_std] qui transforme des octets en octets, réduit main.rs au transfert des octets vers les régions et depuis celles-ci, et fait du SDK une dépendance de la seule cible des programmes invités :

guests/my-app/Cargo.tomltoml
[package]
name = "my-app"
version.workspace = true
edition.workspace = true
publish.workspace = true

[target.'cfg(target_arch = "riscv32")'.dependencies]
guest-sdk.workspace = true
guests/my-app/src/lib.rsrust
#![no_std]
extern crate alloc;
use alloc::vec::Vec;

/// The whole application: public input and advice in, journal out.
pub fn run(input: &[u8], advice: &[u8]) -> Result<Vec<u8>, i32> {
    let _ = advice;
    let mut out = Vec::with_capacity(input.len());
    out.extend(input.iter().rev());
    Ok(out)
}
guests/my-app/src/main.rsrust
#![no_std]
#![no_main]

guest_sdk::entry!(main);

fn main() {
    match my_app::run(guest_sdk::public_input(), &[]) {
        Ok(journal) => guest_sdk::commit(&journal),
        Err(code) => guest_sdk::exit(code),
    }
}

Le code hôte dépend alors de la bibliothèque par son chemin, comme crates/emulator dépend de guests/revm-block, exécute my_app::run en natif, et compare le résultat au journal que produit l’émulateur pour la même entrée (Exécuter et profiler). Votre logique bénéficie de tests unitaires, d’un débogueur et de println! sur l’hôte, et le binaire du programme invité reste une mince enveloppe autour d’un code que vous avez déjà testé.

Avertissement

Les deux compilations ne s’entendent pas sur usize. Dans le programme invité, usize et tous les pointeurs font 32 bits; sur votre hôte, ils en font 64. Un dépassement de usize provoque une panique dans le programme invité seulement, x as usize y tronque sans rien signaler, et size_of et core::hash de tout ce qui contient une longueur diffèrent entre les deux. Tenez usize à l’écart de tout ce que vous consignez, hachez ou sérialisez, et utilisez explicitement u32 et u64 à ces frontières.

Assembleur et jeu d’instructions#

Le décodeur accepte exactement les 59 instructions de RV32IMA, ainsi que les instructions compressées (C), qui sont développées au chargement. L’assembleur en ligne est permis à l’intérieur de cet ensemble. Tout ce qui en sort, comme un accès à un CSR, fence.i, un encodage à virgule flottante ou RV64, empêche l’enregistrement du programme entier, même si cette instruction n’est jamais atteinte : la dérivation signale Not all opcodes supported: pc=…. Un ebreak, un saut vers un demi-mot sans instruction, ou un accès non aligné à un demi-mot ou à un mot termine l’exécution sans preuve.

Les instructions atomiques se décodent et se prouvent, avec un seul écart : sc.w réussit toujours, parce que la machine ne conserve aucun état de réservation. Le guide de programmation des programmes invités explique pourquoi le code d’un nouveau programme invité ne devrait pas utiliser d’opérations atomiques du tout.

Pour la suite#

Lancez votre application

Entrées, données auxiliaires et journal

Un programme invité n’a aucun appel système d’E/S. Son entrée publique, les données auxiliaires du prouveur et son journal sont trois régions de mémoire. Ce que contient chacune, ce que lie la preuve, et le motif que suit tout programme invité qui reçoit des données.

Voir en Markdown

Un programme invité d’Apogee n’a ni descripteurs de fichiers, ni flux, ni appel système d’E/S. Ses entrées et ses sorties sont trois régions de mémoire, lues et écrites par des chargements et des rangements ordinaires, et la preuve en lie deux.

Les trois régions#

Région SDK Contenu Taille Liée par la preuve
Entrée publique public_input(), read_input(buf) les octets de l’énoncé, choisis par quiconque demande la preuve au plus 16 380 octets oui, son contenu initial
Données auxiliaires (advice) advice() des octets que choisit le prouveur jusqu’à 2 GiB non
Journal commit(bytes), journal() ce que le programme invité y a ajouté au plus 16 380 octets oui, son contenu final
rust
let input: &[u8] = guest_sdk::public_input(); // a slice over the input window, no copy
let data: &[u8] = guest_sdk::advice();        // a slice over the advice region
guest_sdk::commit(b"result");                  // appends to the journal
  • public_input() et advice() renvoient des tranches sur la mémoire; rien n’est copié. read_input(buf) copie min(buf.len(), input.len()) octets et renvoie leur nombre : la fonction peut donc en copier moins que demandé.
  • commit ajoute à la fin du journal et tient à jour un mot de longueur, et c’est ce qui fait que la preuve lie une chaîne d’octets exacte plutôt qu’une fenêtre complétée par des zéros. La fonction termine l’exécution avec le statut 70 plutôt que de faire déborder la fenêtre.
  • advice() dans une exécution sans données auxiliaires provoque un OutOfBounds fatal, et non une tranche vide : une exécution sans données auxiliaires n’a aucune région de données auxiliaires, et ne paie rien pour en avoir une.

Ce que signifie « lié »#

L’énoncé qu’établit une preuve porte les octets de l’entrée publique, les octets du journal et le statut de sortie. La preuve montre que la fenêtre d’entrée contenait exactement l’entrée de l’énoncé avant le premier accès du programme invité, et que la fenêtre du journal contenait exactement la sortie de l’énoncé quand le programme invité s’est terminé. Cela repose sur l’argument de mémoire, et non sur quoi que ce soit que fasse le programme invité : il n’y a aucun hachage que le programme invité doive calculer ni aucune convention qu’il doive suivre.

Les données auxiliaires, c’est autre chose. Le contenu initial de leur région est ce que le prouveur y a écrit, quel qu’il soit, et rien ne le rattache à l’identité du programme, à l’énoncé ni à aucune porte. Une preuve dit qu’il existe certaines données auxiliaires avec lesquelles le programme, sur cette entrée, a publié ce journal. Une telle affirmation vaut exactement ce que valent les vérifications que le programme invité fait lui-même sur les données auxiliaires.

Le motif : engager, fournir, vérifier#

Un programme invité dont l’entrée est volumineuse en reçoit l’essentiel sous forme de données auxiliaires, sur lesquelles l’entrée publique s’engage, puis vérifie la concordance des deux avant que quoi que ce soit qui dérive des données auxiliaires n’atteigne le journal :

Vérifier les données auxiliaires avant de s’y fierrust
fn main() {
    let want = guest_sdk::public_input(); // 32 bytes: keccak256 of the advice
    let data = guest_sdk::advice();       // the prover's bytes, bound by nothing
    if guest_sdk::keccak256(data).as_slice() != want {
        guest_sdk::exit(1); // refused before anything derived from it is committed
    }
    let sum = data.iter().fold(0u32, |s, b| s.wrapping_add(u32::from(*b)));
    guest_sdk::commit(&sum.to_le_bytes()); // the journal: what the proof publishes
}

La vérification n’a pas besoin d’être un hachage de toutes les données auxiliaires. Ce peut être un chemin de Merkle vérifié par rapport à une racine que porte l’entrée, comme dans l’exemple du registre, ou une signature sur les données, ou une contrainte que le résultat lui-même satisfait, comme un ordre trié annoncé que le programme invité vérifie en une seule passe au lieu de trier. Ce qui compte, c’est que ce par rapport à quoi elles sont vérifiées soit lié.

Attention

Consigner une fonction quelconque de données auxiliaires non vérifiées publie une valeur choisie par le prouveur. C’est la façon la plus courante d’écrire un programme invité dont la preuve ne signifie rien.

Données structurées#

Encodez les entrées structurées avec un sérialiseur no_std comme postcard sur serde avec la fonctionnalité alloc, ce qu’utilise le programme invité Ethereum du dépôt lui-même pour son témoin de bloc. Deux habitudes gardent un format honnête :

  • Utilisez des entiers de largeur fixe. u32 et u64, jamais usize, dont la largeur diffère entre votre hôte et le programme invité.
  • Exigez un seul encodage par valeur quand cela compte. Un désérialiseur qui accepte des octets en trop à la fin ou des varints non minimaux admet deux chaînes d’octets pour une même valeur. Là où l’unicité compte, décodez, réencodez et comparez, comme le fait BlockWitness::decode dans le programme invité Ethereum.

Les sorties qui grandissent#

Le journal contient 16 380 octets. Une sortie qui grandit avec le travail, comme un enregistrement par transaction, n’a pas de borne fixe, et l’exécution finira par se terminer avec le statut 70. Publiez plutôt un condensé : hachez les enregistrements à mesure que vous les produisez et consignez le résultat de 32 octets, puis laissez quiconque a besoin des enregistrements les recalculer en natif et comparer. Le validateur Ethereum sans état du dépôt publie ainsi un journal de 43 octets pour un bloc entier.

Pour une preuve vérifiée sur Ethereum, donnez aux deux valeurs publiques une longueur fixe. Le contrat vérificateur déployé est construit pour une seule longueur d’entrée et une seule longueur de sortie, et refuse tout le reste (Régler sur la chaîne).

Le statut de sortie#

Rien n’est publié à la sortie au-delà de ce qui a été consigné, et une exécution qui panique ou se termine avec un statut non nul a une preuve valide de ce qu’elle a fait. Un vérificateur lit donc le statut de sortie avant de lire le journal. Sur la chaîne, le contrat vérificateur prend le statut attendu en argument, et une application passe 0. Les statuts propres au SDK :

Statut Signification
0 retour de main, ou exit(0)
70 commit aurait dépassé 16 380 octets
71 une allocation aurait atteint la pile : voir le tas
72 une délégation a répondu quelque chose que son shim refuse
101 une panique, qui n’affiche rien

Choisissez vos propres codes d’échec en dehors de ceux-ci, comme le fait l’exemple du registre avec 1, 2 et 3.

Ce que la preuve ne dit pas#

  • Rien n’ordonne les écritures du journal, et rien n’oblige un programme invité à lire son entrée. La preuve lie le contenu des fenêtres, pas les accès qui l’ont produit.
  • Les données auxiliaires sont modifiables. Un rangement dans leur région est un rangement ordinaire. Elles restent non liées dans un cas comme dans l’autre.
  • Le journal est tout le contenu final de la fenêtre. commit maintient cette forme. Un programme invité qui écrit directement dans la fenêtre doit la respecter : une longueur d’au plus 16 380, autant d’octets, puis des zéros.

La spécification énonce tout cela avec précision : Valeurs publiques et données auxiliaires.

Lancez votre application

Délégations

Le hachage et l’arithmétique de corps et de courbe disposent de circuits dédiés. Quels appels du SDK les atteignent, ce qu’ils coûtent, les règles sur leurs opérandes, et les crates embarquées qui y acheminent le code des bibliothèques.

Voir en Markdown

Certains calculs sont bien moins coûteux à prouver avec un circuit conçu pour eux que sous forme de suite d’instructions RISC-V. Apogee les appelle des délégations. Une délégation est une famille de circuits qui prouve une fonction d’un cadre de mots en RAM, invoquée par un ecall, et le SDK des programmes invités effectue ces appels pour vous derrière des fonctions ordinaires. Vous n’écrivez jamais d’ecall vous-même.

Ce que vous appelez, et ce que cela atteint#

Vous appelez Délégation Un appel prouve
guest_sdk::keccak256(&[u8]) -> [u8; 32] KECCAK_F un tour de keccak-f[1600]; une permutation représente 24 appels, et l’éponge et le bourrage relèvent du code du programme invité
guest_sdk::sha256(&[u8]) -> [u8; 32] SHA256_COMP quatre tours de la compression; une compression représente 16 appels
guest_sdk::ec_add, ec_mul, ec_identity EC_ADD un tiers d’une addition complète de points sur secp256k1 ou sur G1 de BN254
guest_sdk::poseidon2_permute(&mut [u8; 96]) POSEIDON2 une permutation Poseidon2 de largeur 3 sur Fr
addition, multiplication et inversion de field::Fr FR_ARITH une opération sur Fr, sur la cible des programmes invités, sans rien nommer
transcript::poseidon2_permute POSEIDON2 la même permutation, par la crate transcript
guest_sdk::recursion::mod_mul sur un ModMulFrame MOD_MUL un a·b mod m sur 256 bits, m étant l’un des quatre modules d’Ethereum

Les fonctions sont identiques bit à bit à leurs définitions logicielles. keccak256 est le Keccak d’Ethereum, pas SHA3-256. sha256 est conforme à FIPS 180-4. ec_add utilise la formule complète de Renes, Costello et Batina (2015, algorithme 7), si bien que le doublement, P + (−P), l’élément neutre et tout Z ne demandent aucun cas particulier.

Hachage et arithmétique de courbe depuis un programme invitérust
use guest_sdk::{ec_mul, keccak256, recursion::SECP256K1_GROUPS, ProjectivePoint};

let digest: [u8; 32] = keccak256(b"blockchain-native");

// A point is homogeneous projective (x = X/Z, y = Y/Z), each coordinate eight
// little-endian u32 limbs below the field modulus. The scalar is eight limbs too.
fn times(p: &ProjectivePoint, k: &[u32; 8]) -> ProjectivePoint {
    ec_mul(&SECP256K1_GROUPS, p, k).expect("EC_ADD is implemented on Apogee")
}

Le code des bibliothèques les atteint aussi#

L’espace de travail des programmes invités corrige trois crates pour que leur code appelle les délégations sur la cible des programmes invités, le code amont servant de chemin de repli :

Crate Version Atteint
k256 0.13.4 MOD_MUL depuis la multiplication des éléments du corps et des scalaires; EC_ADD depuis l’addition, l’addition mixte et le doublement de ProjectivePoint
ark-ff 0.6.0 MOD_MUL depuis la multiplication et l’élévation au carré de Montgomery de BN254, dans ses deux corps
revm-precompile 43.0.2 SHA256_COMP pour le précompilé 0x02; EC_ADD pour 0x06 et 0x07

Un programme invité qui dépend de ces crates reçoit automatiquement les copies corrigées, par la section [patch.crates-io] de guests/Cargo.toml. Sans correctif, la multiplication et l’élévation au carré dans le corps de k256 représentaient à elles seules 44 % des cycles d’un bloc du réseau principal. La récupération de clé à partir d’une signature secp256k1 est du code k256 ordinaire, que les correctifs transforment en arithmétique déléguée.

Ce que coûte une délégation#

Une famille de délégation ne fait partie d’un programme que si le programme lie l’un de ses shims, et un appel se paie en shards de la hauteur de cette famille :

  • Liée et jamais appelée : rien. La famille est déclarée et prouve zéro shard.
  • Appelée une fois : un shard entier. Un shard coûte sa hauteur complète, quel que soit son taux d’occupation.
  • Appelée souvent : très peu par appel. La preuve d’un shard ne grandit que d’un tour de sumcheck par variable à mesure que sa hauteur augmente.
Famille Hauteur Unité de travail Appels par unité Unités par shard
KECCAK_F 2^18 keccak-f[1600] 24 10 922
SHA256_COMP 2^18 une compression 16 16 384
EC_ADD 2^16 une addition complète 3 21 845
MOD_MUL 2^16 un a·b mod m 1 65 536
POSEIDON2 2^8 une permutation 1 256
FR_ARITH 2^8 une opération sur Fr 1 256

Le prix se paie en mémoire plus qu’en temps : la passe avant d’un shard KECCAK_F de 2^18 occupe environ 42 GiB d’éléments du corps, et deux de ces shards en cours de traitement ont fixé le pic du bloc Ethereum mesuré.

Règles sur les opérandes#

  • Des opérandes inférieurs à leur module. Un opérande de MOD_MUL ou d’EC_ADD supérieur ou égal au module que désigne son sélecteur n’a pas de preuve : l’exécuteur refuse le cadre par une erreur fatale DelegationFrame. La version embarquée de k256 réduit ses éléments du corps à réduction paresseuse avant l’appel.
  • Les points ne sont pas vérifiés à votre place. EC_ADD prouve l’arithmétique de la formule. Savoir si un point est sur la courbe est l’affaire du code appelant, et un programme invité qui tire des points des données auxiliaires (advice) doit poser la question.
  • Chaque opération en plusieurs appels correspond à une seule fonction du SDK. Une permutation keccak représente 24 appels sur un même cadre, une compression SHA-256 en représente 16, une addition de points 3. Chaque appel prouve sa propre étape, et rien ne refuse des appels passés dans le mauvais ordre : ils calculent autre chose. Utilisez keccak256, sha256 et ec_add, qui émettent les appels dans l’ordre, plutôt que les shims bruts.
  • Le statut 72 signifie qu’une délégation a répondu quelque chose que son shim refuse. Sur l’exécuteur d’Apogee lui-même, cela n’arrive pas pour un cadre bien formé.

Ce qui n’est pas délégué#

  • Le MULMOD de l’EVM avec un module arbitraire, MODEXP, BLS12-381, et toute primitive absente du tableau ci-dessus s’exécutent sous forme d’instructions.
  • Aucun schéma de signature ni aucun couplage n’est délégué en bloc. La récupération de clé secp256k1, c’est k256 sur MOD_MUL et EC_ADD; un couplage BN254, c’est ark-bn254 sur MOD_MUL.
  • Une délégation est le cœur d’une opération. Le bourrage, les éponges, les boucles sur les blocs et l’échelle d’une multiplication scalaire relèvent du code du programme invité, prouvé sous forme d’instructions.

Les schémas de signature pour les programmes invités figurent dans la feuille de route de la v2.0.0.

Une nouvelle délégation en vaut-elle la peine?#

Le profileur de cycles chiffre les candidats évidents dans chaque rapport, sous forme de plafond sur les cycles qu’une délégation pourrait éliminer :

removable = max(0, cycles − calls·(4 + 2·frame_words))

cycles est la part de l’exécution qui revient à la catégorie, calls le nombre d’entrées dans les fonctions du candidat, et 4 + 2·frame_words le shim qu’une délégation laisserait derrière elle : les rangements du cadre, l’ecall et les chargements du résultat. La formule ne compte rien pour les shards de la nouvelle famille : traitez-la comme une borne supérieure. Exécuter et profiler montre un rapport.

La spécification de chaque délégation, colonne par colonne, se trouve sous Circuits de délégation.

Lancez votre application

Compiler et inspecter

Des profils de compilation fixés à une seule sémantique, l’ELF que produit une compilation, la ProgramImage qu’en tire le chargeur, son rapport, et l’identité du programme qu’enregistre un vérificateur.

Voir en Markdown

Compiler#

À partir du répertoire du programme invité, sans autre option que la cible :

sh
cd guests/my-app
cargo build --release --target riscv32imac-unknown-none-elf     # .../release/my-app
cargo build --target riscv32imac-unknown-none-elf               # .../debug/my-app

L’ELF se retrouve dans l’unique répertoire cible de l’espace de travail des programmes invités, guests/target/riscv32imac-unknown-none-elf/. guests/.cargo/config.toml ajoute deux arguments de l’éditeur de liens que vous ne tapez jamais :

  • -T crates/guest-sdk/link.ld, la carte mémoire, qui définit aussi les symboles qu’utilisent le code de démarrage et l’allocateur.
  • --no-relax. La relaxation de l’éditeur de liens réécrit des séquences d’instructions et décale toutes les adresses qui suivent, et l’identité du programme lie ces adresses.

Il n’y a pas de runner : rien en dehors d’Apogee ne projette les régions d’un programme invité, si bien que cargo run n’a rien avec quoi l’exécuter. Vous exécutez un programme invité au moyen de l’émulateur (Exécuter et profiler).

Profils#

guests/Cargo.toml fixe les deux profils à une seule sémantique. Ils ne diffèrent que par l’optimisation et par les assertions de débogage des dépendances :

dev release
opt-level 0 3
overflow-checks activé activé
debug-assertions activé activé dans la crate du programme invité, désactivé dans ses dépendances
panic, codegen-units, debug, incremental abort, 1, désactivé, désactivé identiques

Le profil release par défaut de Cargo désactive les vérifications de dépassement, et dans un programme invité, ce n’est pas un réglage de performance. Cela change l’énoncé : u32::MAX + 1 consignerait 00000000 et se terminerait avec le statut 0, là où la compilation dev panique avec le statut 101. L’espace de travail garde donc les vérifications actives dans les deux profils. Une assertion de débogage d’une dépendance vérifie un invariant propre à cette crate, et une dépendance correcte calcule la même chose sans elle : release les désactive donc et économise ces cycles, soit 6,8 % de l’exécution du programme invité Ethereum sans état.

Prouvez la compilation release. Chaque instruction exécutée est une ligne prouvée, opt-level = 3 retire d’un quart à plus de la moitié de l’image d’un programme invité, et le code de chaque famille doit tenir dans sa table décodée : l’image debug du programme invité Ethereum exige des tables de 2^22 lignes, son image release, des tables de 2^20. L’identité que vous publiez est celle de l’image release.

Reproductibilité#

Deux compilations propres sur une même machine produisent des ELF identiques. Des compilations sur deux machines, en général, non : l’ELF incorpore, dans les chaînes de localisation des paniques, les chemins absolus des sources de core de la chaîne d’outils, de crates/guest-sdk et du registre cargo, tandis que les fichiers propres au programme invité apparaissent relativement à guests/. Une compilation ailleurs donne une autre image, avec une autre identité.

Ce que vous enregistrez et transmettez est donc l’ELF d’une compilation, et non une recette. Conservez l’ELF que vous avez prouvé, et laissez quiconque veut vérifier l’identité la recalculer à partir de cet ELF, des paramètres et du fichier de cérémonie.

Exporter l’image#

sh
cargo run -p artifact-dump -- guests/target/riscv32imac-unknown-none-elf/release/my-app --out artifacts

Cette commande écrit artifacts/my-app.img, la ProgramImage chargée dans son format de sérialisation (postcard, sans en-tête), et artifacts/my-app.img.txt, un rapport produit à partir de l’image relue par le lecteur validant. Elle affiche le point d’entrée, le nombre de segments et d’instructions, ainsi que la taille et le SHA-256 de l’artefact. Si la relecture diffère, ou si le chargeur refuse l’ELF, elle n’écrit rien.

Le .img est la description statique du programme, à conserver et à comparer; rien en aval n’en a besoin, puisque la mise en place et les outils prennent l’ELF. Son SHA-256 fixe des octets. Ce n’est pas l’identité du programme.

Lire le rapport#

Section Contenu
entry and memory le point d’entrée, _start à 0x00010000; la fenêtre de RAM; slot_base et l’étendue des emplacements
segments l’adresse, la fin, mem_len, les octets issus du fichier, le remplissage à zéro et le nombre d’instructions de chaque segment : .text, .rodata s’il existe, et un segment inscriptible jusqu’à 0x80000000 pour .data, .bss, le tas et la pile
instruction stream les instructions de quatre et de deux octets, les emplacements situés au milieu d’une instruction et les emplacements not code, dont la somme donne le nombre d’emplacements
symbols les noms par adresse, tirés de la table des symboles de l’ELF, que l’artefact ne contient pas
listing pour chaque instruction : l’adresse, la longueur, les octets en mémoire, le mot de 32 bits développé, le symbole

Une instruction compressée garde son adresse et ses deux octets; seul len indique si le pc suivant est pc + 2 ou pc + 4. Pour les mnémoniques, utilisez la vue tables ci-dessous ou le désassembleur de la chaîne d’outils fixée :

sh
"$(rustc --print sysroot)"/lib/rustlib/*/bin/llvm-objdump \
    --disassemble --no-print-imm-hex -M no-aliases <elf>

Les demi-mots not code#

Un rapport peut afficher une ligne comme ---- not code: 0x00010f9a .. 0x00010f9c, 1 halfword ----. C’est une sortie ordinaire du compilateur. LLVM a prouvé que la branche par défaut d’un match était inatteignable, rustc a abaissé le bloc inatteignable en unimp, et avec l’extension C, c’est c.unimp, le demi-mot entièrement nul, l’encodage que RVC définit comme illégal. Le chargeur l’enregistre comme une non-instruction et poursuit. Aucun pc ne l’atteint; un pc qui l’atteindrait arrêterait l’exécution avec NotAnInstruction.

Ce que la VM prouvera#

sh
cargo run --release -p artifact-dump -- tables <elf> --ptau assets/ptau/ppot_0080_24.ptau

Cette commande affiche, aux paramètres par défaut, la VmConfig que dérive l’image : la hauteur de chaque famille, ses lignes actives et ses colonnes décodées. Elle affiche ensuite, pour chaque instruction, le pc, next_pc, la famille, le mnémonique et les champs. Avec --ptau et le fichier de cérémonie, elle affiche aussi l’identité du programme, la valeur qu’enregistre un vérificateur. Le Démarrage rapide en montre une réelle.

L’identité est un seul élément du corps. Elle lie chaque instruction avec son pc, sa longueur, ses opérandes et son type, chaque octet de l’image issu du fichier (.text, .rodata, .data), le point d’entrée, l’ensemble des familles, chaque hauteur, le plafond de taille du code et la version du code. Elle ne lie ni la table des symboles, ni .bss, ni rien de ce que choisit une exécution. Un même ELF avec deux réglages de hauteurs a deux identités.

La dérivation refuse le programme dans les cas suivants, en nommant le pc ou la taille :

Refus Cause
Not all opcodes supported: pc=… un mot hors de RV32IMA n’importe où dans le code exécutable, comme un accès à un CSR en assembleur
TableTooShort du code au-delà de la portée d’une famille, pc ≤ 2h − 4 : 1,9375 MiB de code à 2^20 et 7,9375 MiB à 2^22
ProgramTooLarge l’image dépasse bytecode_size_words, 4 MiB par défaut
ImageOutsideWindow un octet issu du fichier se trouve au-delà de la fenêtre de RAM 0 à la hauteur de fenêtre choisie
UnknownDelegation l’image déclare un numéro de délégation auquel aucune famille ne répond

Vérifier une compilation#

Compilez dans un répertoire cible neuf, exportez de nouveau, et comparez :

sh
cd guests/my-app
CARGO_TARGET_DIR=/tmp/fresh cargo build --release --target riscv32imac-unknown-none-elf
cd ../..
cargo run -p artifact-dump -- /tmp/fresh/riscv32imac-unknown-none-elf/release/my-app --out /tmp/again
cmp artifacts/my-app.img /tmp/again/my-app.img
diff artifacts/my-app.img.txt /tmp/again/my-app.img.txt     # differs only in the `source ELF` line

Lancez votre application

Exécuter et profiler

Exécutez un programme invité dans l’émulateur d’Apogee, depuis Rust ou en ligne de commande, comparez-le avec votre compilation pour l’hôte, et découvrez où vont ses cycles avant de payer pour les prouver.

Voir en Markdown

Exécuter un programme invité ne coûte presque rien; le prouver coûte en proportion des cycles qu’il exécute. Exécutez donc d’abord, comparez avec votre compilation pour l’hôte, et examinez le profil des cycles avant de prouver quoi que ce soit.

Depuis Rust : l’émulateur#

emulator::run exécute une image chargée sur une entrée publique et des données auxiliaires (advice), dans du code hôte, sans preuve :

Exécuter un programme invité et le comparer avec la compilation pour l’hôterust
let elf = std::fs::read(elf_path)?;
let image = loader::load_elf(&elf).expect("the ELF loads");
let io = emulator::GuestIo { input: b"hi".to_vec(), advice: Vec::new() };
let run = emulator::run(&image, &io).expect("no fatal error");

assert_eq!(run.exit_code, 0);
assert_eq!(run.io.output, my_app::run(b"hi", &[]).unwrap()); // the host build agrees
println!("{} cycles", run.cycle_count);

run renvoie une Execution : les registres finaux, le statut de sortie, le nombre de cycles et les valeurs publiques. Un statut de sortie non nul est une exécution, pas une erreur, et revient dans exit_code. Une erreur fatale de l’exécuteur, comme OutOfBounds, Misaligned ou NotAnInstruction, revient sous la forme d’une EmuError, et une telle exécution n’a pas de preuve (Dépannage).

L’émulateur est une fonction pure de l’image et de l’entrée : ni horloge, ni aléa, ni fils d’exécution. La même entrée donne la même exécution, cycle pour cycle, et c’est aussi ce qui permet au prouveur d’exécuter deux fois et de découper des shards identiques.

En ligne de commande : le profileur#

sh
cargo run --release -p profiler -- elf <elf> [--input <file>] [--advice <file>] [--top <n>] [--json <path>]

Il exécute le programme invité sur les fichiers donnés, à la plus petite hauteur de table dans laquelle tient son code, et affiche un rapport. Ses chiffres sont des décomptes de cycles exécutés, identiques sur toute machine.

workload
  label                        hello
  guest cycles                 114
  exit status                  0
  journal bytes                13

cycles by semantic workload
  core runtime                             94   82.46%
  unattributed                             20   17.54%

cycles by family
  ADD_SUB_LUI_AUIPC            64
  JUMP_BRANCH_SLT              21
  MEM_WORD                     3
  MEM_SUBWORD                  26

top functions
            94   82.46%          1 calls        94.0 c/call  guest_sdk::commit  [core runtime]
             8    7.02%          1 calls         8.0 c/call  main  [unattributed]

Comment le lire :

  • La section cycles by family montre ce que vous payez. Chaque famille qui a des lignes coûte au moins un shard de sa hauteur, et plus de cycles dans une famille signifie plus de shards de cette famille.
  • La section top functions impute à chaque fonction ses propres cycles, y compris tout ce que le compilateur y a intégré en ligne, mais pas ceux des fonctions qu’elle appelle. Les appels sont comptés à la première instruction de la fonction.
  • La section cycles by semantic workload regroupe les fonctions en quatorze catégories selon leur nom, comme le hachage, les signatures et l’environnement d’exécution de base. La part non attribuée et la répartition des mnémoniques servent de contrôle à cette attribution, puisqu’aucune table des symboles ne peut les étiqueter de travers.
  • La section accelerator candidates chiffre les délégations qu’une version future pourrait ajouter, sous forme de plafond : voir Délégations.

Le profileur a deux autres verbes, pour la charge de travail Ethereum : block <stem> exécute le programme invité revm sur des données de référence enregistrées, et record <number|latest> enregistre un bloc à partir de ETH_RPC_URL et l’exécute.

Réduire le coût#

L’ordre qui est généralement rentable :

  1. Compilez avec --release. L’optimisation retire d’un quart à plus de la moitié des instructions d’un programme invité.
  2. Déléguez le hachage et l’arithmétique de courbe. Utilisez guest_sdk::keccak256, sha256, ec_add et les versions embarquées de k256 et ark-ff, au lieu de compiler une implémentation logicielle dans le programme invité.
  3. Cessez d’allouer dans les boucles. Chaque allocation coûte des instructions, et avec un allocateur linéaire, c’est aussi de la mémoire que vous ne récupérez jamais (le tas).
  4. Vérifiez au lieu de calculer. Si un résultat est coûteux à trouver et peu coûteux à vérifier, comme un ordre trié, une racine carrée ou un chemin dans un arbre, laissez le prouveur le fournir sous forme de données auxiliaires et faites-le vérifier par le programme invité.
  5. Évitez la virgule flottante. Elle se compile en routines logicielles; l’arithmétique entière et en virgule fixe coûte bien moins cher.

Puis mesurez de nouveau. Les décomptes de cycles sont exacts et reproductibles : chaque changement se traduit par un nombre.

Lancez votre application

Prouver et vérifier

Enregistrer un programme, prouver une exécution, vérifier le bloc et conserver la preuve. Les hauteurs, les shards en cours de traitement, les puissances de la cérémonie dont une preuve a besoin, et les deux valeurs qu’un vérificateur doit détenir lui-même.

Voir en Markdown

Les trois appels#

Mise en place, preuve, vérificationrust
let params = program::ProgramParams::defaults();
let ptau = std::path::Path::new("assets/ptau/ppot_0080_24.ptau");
let srs = srs::Srs::from_ptau(ptau, 22).expect("ceremony");

let setup = host::setup(&elf, &params, srs).expect("registers");          // once per program
let proven = host::prove(&setup, &io, 4).expect("proves");                // at most 4 shards in flight
host::verify(&setup.vk, &proven.block).expect("verifies");

assert_eq!(setup.vk.identity.to_bytes(), registered); // from your own channel, never the proof
assert_eq!(proven.exit_code, 0);
  • host::setup charge l’ELF, le décode en ses tables de familles et sa VmConfig, engage les colonnes de mise en place sous la cérémonie, et construit la clé de vérification. Son coût se paie une fois par programme et par choix de hauteurs, et non à chaque exécution.
  • host::prove exécute le programme invité deux fois et prouve chaque shard (ci-dessous). Il renvoie un Proven : le BlockProof, le code de sortie, le nombre de cycles, le journal et un rapport sur l’exécution.
  • host::verify vérifie le bloc par rapport à la clé, à l’aide de l’énoncé que porte le bloc. Il ne compare ni l’identité ni le condensé SRS à quoi que ce soit : cette comparaison vous revient.

Le Démarrage rapide exécute exactement ce code sur un petit programme invité, avec sa sortie réelle.

Ce qu’un vérificateur doit détenir lui-même#

Deux valeurs proviennent d’un canal que le prouveur ne contrôle pas :

  1. L’identité du programme. Face à une identité fournie par le prouveur, une preuve montre seulement qu’un programme quelconque s’est exécuté. Un vérificateur enregistre l’identité de la version à laquelle il fait confiance et la compare à celle de la clé.
  2. Le condensé SRS de la cérémonie. Une clé se charge sous le condensé que donnent ses propres points, quel qu’il soit. Une clé construite sur un τ connu pourrait ouvrir n’importe quoi, et n’est refusée que par la comparaison de son condensé avec celui de la cérémonie.

La clé de vérification elle-même peut provenir de n’importe qui, y compris du prouveur : son chargement recalcule l’identité et le condensé SRS à partir de son propre contenu et exige que ses circuits soient ceux du registre du vérificateur. Lisez ensuite l’énoncé : le statut de sortie d’abord, puis le journal.

Hauteurs#

Chaque famille a une hauteur, le nombre de lignes de l’un de ses shards, choisie parmi 2^8, 2^12, 2^16, 2^18, 2^20, 2^22. Les hauteurs font partie du programme, et non d’une exécution : chaque hauteur est liée dans l’identité.

Groupe de familles Par défaut Plancher Notes
Les sept familles d’instructions 2^22, ou 2^20 pour MUL_DIV et ATOMICS 2^20 le plancher de leurs vérifications d’intervalle sur les horodatages
INIT_TEARDOWN, ZERO_WINDOWS, ADVICE_WINDOWS 2^22 2^16 une seule hauteur de fenêtre partagée; la fenêtre 0 doit contenir chaque octet de l’image issu du fichier
PUBLIC_INPUT, PUBLIC_OUTPUT 2^12 fixée la hauteur place leurs fenêtres
Familles de délégation voir Délégations propre à chaque famille

Une famille qui a des lignes coûte au moins un shard entier de sa hauteur : une exécution courte gaspille donc moins aux petites hauteurs, et une longue a besoin de moins de shards aux grandes hauteurs. Une table décodée doit aussi être assez haute pour atteindre la dernière instruction de la famille : 2^20 atteint 1,9375 MiB de code et 2^22 atteint 7,9375 MiB. Le programme invité Ethereum se prouve à 2^20 pour chaque famille dont la hauteur est au choix.

Familles d’instructions à leur plancher, fenêtres de RAM à 2^16rust
use constants::family;

let mut params = program::ProgramParams::defaults();
for f in 0..7 {
    params.heights[f] = 1 << 20;
}
for f in [family::INIT_TEARDOWN, family::ZERO_WINDOWS, family::ADVICE_WINDOWS] {
    params.heights[f as usize] = 1 << 16; // window 0 is then 256 KiB: the image must fit in it
}

La cérémonie doit fournir autant de puissances que la famille la plus haute a de lignes, et au moins 2^18 pour la table de lookup générique : Srs::from_ptau(path, k) avec 2^k au moins égal à la plus grande hauteur.

Shards en cours de traitement#

Le troisième argument de host::prove est max_in_flight, le nombre de shards prouvés simultanément. C’est le seul réglage qui échange de la mémoire contre du temps :

  • La mémoire suit les shards en cours de traitement, et non le nombre de cycles. Chaque shard en cours de traitement occupe ses lignes, sa passe avant et sa preuve à mesure qu’elle grandit. Un shard 2^20 de la famille d’instructions la plus large occupe environ 8,4 GiB dans sa passe avant; un shard KECCAK_F de 2^18, environ 42 GiB.
  • Le temps suit le nombre de shards qui avancent côte à côte, jusqu’à concurrence de vos cœurs. Au sein d’un shard, le travail s’exécute sur tous les cœurs.
  • La preuve n’en dépend pas. Le bloc est identique octet pour octet avec 1 ou 8 shards en cours de traitement.

Commencez bas sur un portable, à deux ou quatre, et augmentez la valeur sur un serveur jusqu’à ce que la limite soit la mémoire, et non les cœurs. bench prove utilise 8 par défaut.

Les deux passes#

host::prove travaille en flux. Il ne détient jamais la trace d’exécution entière, qui, à environ 300 octets par cycle, serait le plus gros objet du système.

  1. La passe 1 exécute le programme invité et, à mesure que chaque shard se remplit, engage ses colonnes mémoire, conserve les engagements et abandonne les lignes. À la sortie, elle construit l’énoncé et tire les défis que partagent tous les shards.
  2. La passe 2 exécute de nouveau. L’émulateur est déterministe : elle découpe donc les mêmes shards. Chacun est rempli, prouvé et abandonné à son arrivée, et seule sa preuve est conservée.

C’est pourquoi la génération de preuves prend le temps de deux exécutions, avec une mémoire bornée par les shards en cours de traitement. Le prouveur en flux l’explique en profondeur.

Conserver la preuve#

host::proof_archive::write_proof(dir, stem, vk, block) écrit quatre fichiers, chacun contenant les octets bruts de son type :

<stem>.vk         the verifying key
<stem>.identity   the key's identity, 64 lowercase hex digits and a newline
<stem>.public     the statement: input, journal, exit status, the execution's record
<stem>.block      the block proof

read_proof(dir, stem) les relit. Le fichier .identity enregistre ce qu’a affirmé l’exécution; un vérificateur compare tout de même avec sa propre copie. L’outil en ligne de commande verifier vérifie une archive :

sh
cargo run --release -p verifier -- block <stem>.vk <identity-hex> <stem>.public <stem>.block

Il se termine avec le code 0 quand tout est vérifié, 1 en nommant le premier refus, et 2 en cas d’erreur d’utilisation ou d’identité mal formée. Il compare l’identité que vous passez à celle de la clé, et prend le condensé SRS dans le fichier de la clé.

Quand une preuve échoue#

Un prouveur honnête ne produit jamais une preuve qui échoue : un échec signifie donc une entrée qu’il n’aurait pas dû accepter, ou un bogue. Recompilez avec le journal de débogage du prouveur activé, puis relancez :

sh
cargo run --release -p bench --features prover/debug-info -- prove ...
APOGEE_DEBUG=detail <the same run> 2>&1 | tee run.log
grep -E 'FAIL|NOT CANONICAL|UNBALANCED|OVER the|NAMES NO|DISAGREES|NOT LOOPING|ABORTED' run.log

Le journal de débogage nomme le shard qui a échoué, et self_check FAILED nomme la première porte que viole une ligne, avec la valeur de chaque opérande. Outils énumère chaque marqueur. Ce journal n’existe que dans les compilations dotées de la fonctionnalité debug-info et ne change aucun octet de preuve.

Ensuite : Régler sur la chaîne.

Lancez votre application

Régler sur la chaîne

D’une preuve de base de centaines de shards à une seule preuve Groth16 que vérifie un contrat Ethereum. L’arbre de récursion, la cérémonie du décideur, l’interface du contrat, et ce que fixe un déploiement.

Voir en Markdown

Une preuve de base est un bloc de preuves de shards, chacune étant une preuve GKR accompagnée de ses engagements : des mégaoctets de données et des centaines de points de courbe, qu’aucun contrat ne peut vérifier. Le règlement la compresse en trois étapes, chacune lancée avec bench à partir de la racine du dépôt.

De la preuve de base au contrat Les shards de base sont vérifiés par des feuilles, les feuilles par des nœuds internes, les nœuds par une racine; un décideur Groth16 revérifie la racine; le contrat vérifie la preuve Groth16 et le couplage replié. PREUVE DE BASE 207 shards · 14,5 MB FEUILLES ≤ 64 base shards each RACINE 2–4 enfants couvre 0..count DÉCIDEUR Groth16 BN254 7,9 M contraintes CONTRAT verify(…) → true 3,62 M gas
Règlement. Chaque étape vérifie la précédente. Aucun couplage n’est calculé avant le contrat : chaque vérification Mercury est différée et repliée dans un seul accumulateur que le contrat acquitte avec deux couplages. Les chiffres sont ceux du bloc 257 510.

1. L’arbre de récursion#

Un nœud, c’est Apogee qui prouve un programme vérificateur. Une feuille vérifie une suite de shards de base consécutifs; un nœud interne vérifie de deux à quatre preuves enfants; la racine couvre tous les shards de base. Chaque nœud replie aussi chaque vérification Mercury que ses shards et ses enfants diffèrent en une seule paire de points, si bien que l’arbre entier se réduit à une seule affirmation de couplage au sommet.

sh
# the base proof as an archive: host::proof_archive::write_proof from your host
# program, or `bench prove ... --out <dir>` for the Ethereum guests
cargo run --release -p bench -- recurse <dir>/<stem> --out <out> --in-flight 4

recurse écrit les clés des deux programmes de récursion, construit sur elles les binaires feuille et nœud, fixe un plan avant que quoi que ce soit ne soit prouvé (<out>/tree.txt : des feuilles d’au plus --leaf 64 shards de base, puis des nœuds d’au plus --fan-in 4 enfants), et prouve nœud par nœud. Chaque nœud est un processus distinct, qui vérifie ses entrées en natif avant de prouver, si bien qu’une mauvaise entrée est refusée nommément. Une exécution interrompue reprend : les preuves déjà présentes dans <out> sont conservées, et une exécution dont le plan ou les programmes diffèrent est refusée.

La preuve de base n’est en rien touchée par tout ceci. Une feuille vérifie les shards de base exactement tels qu’ils sont.

2. Le décideur#

La racine reste une preuve GKR accompagnée de quelques centaines de points. Le décideur est un circuit Groth16 qui vérifie la racine comme le ferait un nœud, et ne replie rien. Il lie plutôt, sous forme de fils dont le contrat fournit les valeurs, les identités des deux programmes de récursion, le statut de sortie de l’énoncé de base, son entrée publique et son journal octet par octet, et chaque point auquel la racine doit un couplage, avec son scalaire. La preuve Groth16 porte un seul engagement sur l’ensemble de ces fils, et le contrat le vérifie par rapport aux valeurs qu’il détient.

Une clé Groth16 exige une cérémonie. La phase 1 est le même fichier de puissances de tau que celui sur lequel reposent les engagements de l’arbre. La phase 2 est propre au circuit et se déroule en deux tours de contributions :

sh
cargo run --release -p bench -- ceremony <out> init          # once per root shape
cargo run --release -p bench -- ceremony <out> contribute    # round 1: alpha and beta, each contributor in turn
cargo run --release -p bench -- ceremony <out> seal
cargo run --release -p bench -- ceremony <out> contribute    # round 2: gamma, delta and eta
cargo run --release -p bench -- ceremony <out> key
cargo run --release -p bench -- decide <out>                 # the Groth16 proof, checked natively and in an EVM

Chaque contribution multiplie une trappe par un facteur que seul son contributeur connaissait, et l’enregistre avec une preuve de Schnorr, si bien que tout état peut être vérifié par rapport au seul circuit et au seul fichier de cérémonie. Une trappe reste inconnue tant qu’un seul de ses contributeurs a été honnête. L’ordre des tours fait partie de la solidité (soundness) : alpha et beta sont achevés avant que quoi que ce soit ne soit divisé par delta ou eta.

Attention

bench decide --dev-key dérive chaque trappe d’une graine publique, pour le développement et les tests. N’importe qui peut contrefaire une preuve sous cette clé, et ses sorties sont écrites sous le nom development.* pour qu’on ne puisse pas les confondre avec celles d’une cérémonie. Une cérémonie exécutée sur une seule machine n’est pas non plus une cérémonie : il lui faut un contributeur honnête par tour.

decide écrit decision.constructor et decision.calldata : les arguments de déploiement et l’appel, en hexadécimal.

3. Le contrat#

contracts/ApogeeVerifier.sol a un seul point d’entrée :

solidity
function verify(
    bytes calldata input,        // the base program's public input
    bytes calldata output,       // its journal
    uint256 exitStatus,          // the status you require, normally 0
    uint256[10] calldata proof,  // Groth16 A, B, C and the bound wires' commitment D
    uint256[] calldata points    // x, y and scalar of each point, side [1]_2's then side [x]_2's
) external view returns (bool);

Il reconstruit les valeurs liées à partir du calldata, vérifie l’équation de couplage de Groth16, replie les points de chaque côté avec ecMul et ecAdd, ce qui astreint aussi chaque point à la courbe, et vérifie l’affirmation repliée e(A, [1]_2) = e(B, [x]_2). Un contrat d’application l’appelle, puis agit selon le journal : voir l’esquisse du registre.

Ce que fixe un déploiement#

Le constructeur prend la clé Groth16, les deux points G2 de la cérémonie, les identités des programmes feuille et nœud, le nombre de points de chaque côté, et les longueurs en octets de l’entrée publique et du journal. Un vérificateur déployé sert donc :

  • Un seul programme de base. Son identité est une constante de l’image du programme feuille, que lie l’identité de la feuille.
  • Une seule forme de racine. Le circuit du décideur dépend du programme de la racine, de ses nombres de shards et des longueurs des valeurs publiques : une clé et sa cérémonie sont donc propres à chaque forme.
  • Des valeurs publiques de longueur fixe. verify refuse une entrée ou un journal de toute autre longueur. Concevez des programmes invités dont les valeurs publiques sur la chaîne ont une taille fixe, comme un enregistrement fixe ou un condensé de 32 octets.

Le contrat paie environ 9 000 gas par point, parce que le circuit n’en replie aucun.

Mesures#

Bloc 257 510, avec l’arbre sur une machine à 32 CPU et 247 GiB, et la cérémonie et le décideur sur un portable à 18 cœurs :

Étape Résultat
Preuve de base 207 shards, 14,5 MB, 2 481 s
Arbre 4 feuilles d’au plus 64 shards de base et une racine : 116 shards
Feuilles, quatre à la fois 21, 24, 23 et 27 shards; 2 157 s; pic de 92 GiB
Racine, quatre shards en cours de traitement 21 shards, 460 s, 1,03 MB
Circuit du décideur 7 896 686 contraintes, sur un domaine de 2^23
Cérémonie init 65 s; une contribution de 50 à 56 s; key 70 s et 12,7 GB; la clé 2,65 GB
Preuve du décideur clé lue en 1 s, preuve en 18,5 s, 6,1 GB
Contrat 358 points; 3 620 026 gas; 34 980 octets de calldata

La spécification de l’ensemble se trouve dans Récursion et décideur.

Lancez votre application

Guide de programmation des programmes invités

Les habitudes qui gardent un programme invité correct, prouvable et peu coûteux. Chaque piège et chaque préférence réunis en un seul endroit, chacun avec sa raison d’être et ce qu’il faut faire à la place.

Voir en Markdown

Écrire un programme invité, c’est surtout écrire du Rust. Cette page traite du reste : les endroits où une machine prouvée, sans système d’exploitation et à un seul hart, se comporte autrement que l’hôte auquel vous êtes habitué. Chaque règle dit quoi faire, pourquoi, et ce qui tourne mal autrement. Le Compagnon IA reprend les mêmes règles sous une forme que vous pouvez remettre à un modèle.

Types et mémoire#

usize fait 32 bits, comme tous les pointeurs#

La cible des programmes invités est riscv32imac : usize, isize et tous les pointeurs ont une largeur de 32 bits, alors que ceux de votre hôte en ont 64.

  • Un dépassement de usize provoque une panique dans le programme invité, mais pas sur l’hôte.
  • x as usize à partir d’un u64 tronque sans rien signaler dans le programme invité.
  • size_of::<T>(), la disposition des structures et core::hash de tout ce qui contient une longueur ou un pointeur diffèrent entre les deux compilations.

À faire : utilisez explicitement u32 et u64 dans tout ce que vous consignez, hachez, sérialisez ou comparez avec un calcul de l’hôte. Convertissez avec usize::try_from(x) là où une valeur risque de ne pas tenir, pour que l’échec soit bruyant sur les deux compilations. À ne pas faire : consigner un usize, hacher une structure qui en contient un, ou dériver un encodage qui dépend de la disposition en mémoire.

rust
let n = u64::from_le_bytes(input[..8].try_into().unwrap());
let len = usize::try_from(n).expect("length fits the guest"); // not `n as usize`

L’allocateur ne libère jamais rien#

Le tas est un allocateur linéaire (bump allocator) : alloc fait monter un pointeur, dealloc ne fait rien, et la mémoire n’est rendue qu’à la fin du programme. Ainsi, ce qui épuise la mémoire d’un programme invité, c’est le total qu’il alloue au cours de l’exécution, et non son pic. Quand une allocation empiéterait sur la réserve de la pile, ou se terminerait au-dessus du pointeur de pile courant, le programme invité se termine avec le statut 71.

À faire :

  • Allouez une fois et réutilisez : sortez les tampons des boucles et videz-les avec clear() au lieu d’en construire de nouveaux.
  • Dimensionnez les collections dès le départ avec Vec::with_capacity, String::with_capacity, pour qu’elles ne soient pas réallouées et recopiées à mesure qu’elles grandissent. Un Vec qui grandit d’un push à la fois jusqu’à n éléments laisse aussi derrière lui ses tampons antérieurs, plus petits.
  • Préférez l’emprunt (&[u8], &str) au clonage, et les itérateurs aux collections intermédiaires.
  • Traitez sur place les données auxiliaires (advice) volumineuses : advice() est déjà une tranche sur la mémoire, il n’y a donc rien à copier.

À ne pas faire : appeler collect() vers un nouveau Vec dans une boucle critique, cloner des valeurs que vous ne faites que lire, ou reconstruire une table associative à chaque requête alors qu’une seule table peut être vidée et remplie de nouveau.

rust
// Total heap grows with the number of requests:
for req in requests {
    let parts: Vec<u32> = req.chunks(4).map(|c| u32::from_le_bytes(c.try_into().unwrap())).collect();
    process(&parts);
}

// Total heap is one buffer:
let mut parts: Vec<u32> = Vec::with_capacity(MAX_PARTS);
for req in requests {
    parts.clear();
    parts.extend(req.chunks(4).map(|c| u32::from_le_bytes(c.try_into().unwrap())));
    process(&parts);
}

La pile dispose de 8 MiB, et rien ne protège sa limite basse#

La pile croît vers le bas à partir de 0x8000_0000 et dispose d’une réserve de 8 MiB dans laquelle aucun bloc du tas ne peut entrer. Une récursion profonde à l’intérieur de cette réserve ne pose aucun problème. Ce que rien ne détecte, c’est une pile qui dépasse sa réserve après que le tas a rempli l’espace situé sous elle : des blocs du tas changent alors sous une chaîne d’appels profonde, sans que rien ne le signale. À faire : gardez la profondeur de récursion bornée et prévisible, ou écrivez les parcours profonds de façon itérative, avec une liste de travail explicite. À ne pas faire : pousser la récursion jusqu’à une profondeur fixée par une entrée non fiable.

Accès alignés uniquement#

Un accès à un demi-mot ou à un mot par un pointeur non aligné est fatal, n’est jamais scindé, et l’exécution n’a pas de preuve. Le Rust sûr n’en produit jamais. Ne convertissez pas un pointeur d’octets en *const u32 pour le déréférencer; lisez avec u32::from_le_bytes, qui se compile en chargements d’octets, ou avec ptr::read_unaligned.

L’adresse nulle est un trou#

Les adresses inférieures à 0x8000 n’appartiennent à rien : un pointeur nul ou un petit pointeur sauvage provoque donc un OutOfBounds fatal plutôt qu’une lecture de données arbitraires. L’erreur se manifeste par une exécution sans preuve, jamais par une mauvaise réponse.

Concurrence#

Opérations atomiques : prises en charge, mais pas pour du code nouveau#

Important

L’extension A est entièrement prise en charge : lr.w, sc.w et les neuf AMO se décodent, s’exécutent et se prouvent, au moyen de leur propre famille de circuits, et core::sync::atomic se compile vers ces instructions. Écrire un programme invité avec des opérations atomiques reste fortement déconseillé. Apogee s’exécute sur un seul hart, sans interruptions ni fils d’exécution : il n’y a rien à synchroniser. Les opérations atomiques existent pour que le code existant qui les utilise, une bibliothèque avec un compteur atomique ou un verrou spin, se compile et se prouve sans modification. Elles sont une voie de compatibilité, pas une pratique.

Ce qu’il faut savoir si des opérations atomiques atteignent votre programme invité par une dépendance :

  • Sur un seul hart, une opération atomique n’est qu’une lecture-modification-écriture. fetch_add est un amoadd.w qui additionne; rien ne peut s’intercaler.
  • sc.w réussit toujours. La machine ne conserve aucun état de réservation : un rangement conditionnel range donc sa valeur et écrit 0 dans rd. La boucle de nouvelle tentative lr.w/sc.w qu’utilise le code compilé pour compare_exchange n’en est pas affectée, car la réussite du premier coup est légale sur n’importe quel hart. Le code qui compte sur l’échec de sc.w en l’absence de réservation valide n’obtient pas cet échec ici. C’est le seul point sur lequel Apogee s’écarte de RV32IMAC.
  • fence ne fait rien, et les ordonnancements mémoire (aq, rl, SeqCst) n’ordonnent rien sur un seul hart.
  • Elles coûtent une famille de circuits. Une opération atomique ajoute la famille ATOMICS au programme, qui en prouve alors au moins un shard.

À faire : utilisez des variables ordinaires, Cell et RefCell pour l’état dans le code d’un nouveau programme invité. À ne pas faire : ajouter AtomicU32, des verrous actifs à la manière de Mutex ou Arc à un programme invité qui n’a aucun second fil d’exécution avec qui les partager.

Entrées et sorties#

Vérifier les données auxiliaires avant que quoi que ce soit qui en dérive n’atteigne le journal#

Les données auxiliaires sont de la mémoire que le prouveur a remplie, et rien ne les lie. Vérifiez-les par rapport à quelque chose que la preuve lie bel et bien, comme un hachage ou une racine de Merkle dans l’entrée publique, une signature, ou une propriété du résultat, avant de consigner quoi que ce soit qui en dépende. Consigner une fonction de données auxiliaires non vérifiées publie une valeur choisie par le prouveur. Voir le motif.

Garder les valeurs publiques petites, et de taille fixe pour un usage sur la chaîne#

L’entrée et le journal contiennent chacun au plus 16 380 octets. commit termine l’exécution avec le statut 70 plutôt que de déborder. Les grandes entrées ont leur place dans les données auxiliaires, derrière un engagement, et les sorties qui grandissent, derrière un condensé. Un contrat vérificateur est construit pour une seule longueur d’entrée et une seule longueur de journal : un programme invité réglé sur Ethereum devrait donc publier un journal de taille fixe.

Décider à quoi ressemble l’échec#

Un programme invité qui se termine avec un statut non nul, ou qui panique, a tout de même une preuve valide de ce qu’il a fait, et un vérificateur lit le statut avant le journal. Donnez à chaque refus son propre code de sortie, en dehors de ceux du SDK (70, 71, 72 et 101), et ne consignez rien qui dérive de données non vérifiées avant les vérifications qui peuvent les refuser.

Aucun monde extérieur#

Un programme invité n’a ni horloge, ni aléa, ni réseau, ni fichiers, ni environnement. Un appel de bibliothèque qui demande l’un d’eux à l’hôte répond -ENOSYS et rend l’exécution non prouvable. Initialisez HashMap avec une graine déterministe ou utilisez BTreeMap; dérivez l’aléa de l’entrée quand un algorithme en a besoin; transmettez l’heure en entrée.

Coût#

Chaque instruction exécutée est une ligne prouvée#

Le coût de la preuve suit le nombre de cycles, famille par famille. Compilez avec --release, mesurez avec le profileur, et traitez les cycles comme les programmeurs de systèmes embarqués traitent les octets.

Déléguer ce qui a un circuit#

keccak256, sha256, l’addition et la multiplication sur courbes elliptiques, Poseidon2, l’arithmétique dans le corps de BN254 et la multiplication modulaire sur 256 bits ont des circuits dédiés. Atteignez-les par guest_sdk et par les versions embarquées de k256, ark-ff et revm-precompile, et non par une implémentation logicielle compilée dans le programme invité. Voir Délégations.

Vérifier au lieu de calculer#

Quand un résultat est coûteux à trouver et peu coûteux à vérifier, laissez le prouveur le trouver et le transmettre sous forme de données auxiliaires, et faites-le vérifier par le programme invité : un ordre trié, une factorisation, un inverse, un chemin dans un arbre, un résultat de recherche.

Éviter la virgule flottante#

La cible n’a pas d’extension F ni D : f32 et f64 se compilent donc en routines logicielles sur entiers. Elles sont correctes et déterministes, et chaque opération coûte de nombreuses instructions. Utilisez des entiers ou la virgule fixe.

Les hauteurs coûtent des shards entiers#

Une famille qui a des lignes coûte au moins un shard de sa hauteur, si peu de lignes qu’elle remplisse. Un programme qui touche une famille une seule fois paie un shard entier; les familles qu’utilise votre code, et les hauteurs que vous choisissez, fixent le plancher de chaque preuve. Voir Hauteurs.

Code et identité#

Le flux d’instructions est l’image#

Le code est statique : l’instruction de chaque pc provient des tables décodées construites au chargement, jamais de la RAM. Un rangement dans .text change des données, pas le comportement, et un saut vers un demi-mot sans instruction termine l’exécution sans preuve. Il n’y a ni JIT ni code automodifiant.

Un seul mot illégal, où qu’il soit, fait refuser le programme#

Le décodeur prend tout .text, atteignable ou non. Un accès à un CSR, fence.i, un encodage à virgule flottante ou RV64 dans de l’assembleur en ligne, ou des données assemblées dans .text, font refuser le programme entier par la dérivation. ebreak se décode, mais n’a pas de preuve.

Les vérifications de dépassement font partie du programme#

Les profils des programmes invités laissent overflow-checks activé en release, parce que désactiver ces vérifications change ce que calcule un programme invité : u32::MAX + 1 reboucherait à zéro et l’exécution se terminerait avec le statut 0 au lieu de paniquer. Utilisez wrapping_*, checked_* et saturating_* là où c’est votre intention.

Une compilation est une identité#

L’identité lie chaque octet de code et de données, le point d’entrée et chaque hauteur. Recompiler sur une autre machine produit une autre identité, parce que l’ELF incorpore des chemins absolus. Enregistrez et livrez l’ELF que vous avez prouvé, pas la commande qui l’a produit.

Liste de vérification#

Avant de prouver :

  • cargo build --release pour riscv32imac-unknown-none-elf, et le rapport de cycles du profileur examiné
  • aucun usize dans ce qui est consigné, haché ou sérialisé
  • aucune allocation dans les boucles critiques; les collections qui grandissent dimensionnées avec with_capacity
  • aucune opération atomique, aucun verrou ni aucun Arc dans votre propre code de programme invité
  • chaque usage des données auxiliaires vérifié par rapport à quelque chose que la preuve lie, avant toute consignation qui en dépend
  • le journal borné, et de taille fixe s’il est réglé sur la chaîne
  • le hachage et l’arithmétique de courbe acheminés par les délégations
  • un code de sortie distinct pour chaque refus
  • la compilation pour l’hôte et l’émulateur concordent sur le journal pour vos entrées de test
  • l’identité du programme relevée à partir de l’ELF que vous livrerez

Lancez votre application

Dépannage

Toutes les façons dont un programme invité peut s’arrêter avant une preuve vérifiée, classées par symptôme. Statuts de sortie, erreurs fatales de l’exécuteur, ELF refusés, programmes refusés, preuves en échec et erreurs du vérificateur, avec la cause et la correction.

Voir en Markdown

Un programme invité peut s’arrêter en six points avant d’obtenir une preuve vérifiée. Trouvez le symptôme, puis la ligne.

L’exécution se termine avec un statut inattendu#

L’exécution s’est terminée et est prouvable; c’est le programme invité qui a choisi d’échouer. Les statuts propres au SDK :

Statut Cause Que faire
70 commit dépasserait 16 380 octets Consignez un condensé de la sortie au lieu de la sortie
71 une allocation se terminerait au-dessus de __stack_top − 8 MiB ou au-dessus du sp courant Rien n’est libéré : l’allocation totale de l’exécution doit donc tenir entre l’image et 0x7F80_0000. Réutilisez les tampons d’une boucle à l’autre et dimensionnez-les avec with_capacity (le tas)
72 une délégation a répondu ce que son shim refuse : une erreur, ou -ENOSYS après le premier appel d’une opération en plusieurs appels Utilisez les fonctions du SDK plutôt que des cadres bruts, et gardez les opérandes inférieurs à leur module
101 une panique, qui n’affiche rien Passez les mêmes entrées dans la compilation pour l’hôte de votre bibliothèque, où le message de panique s’affiche (tester sur l’hôte)

Tout autre statut est votre propre exit(code).

L’exécution s’arrête sur une erreur fatale#

L’émulateur renvoie une EmuError, et il n’y a ni statut de sortie ni preuve :

Erreur Cause habituelle
OutOfBounds un pointeur nul ou sauvage ([0, 0x8000) est un trou), une lecture de données auxiliaires (advice) au-delà de ce que l’hôte a fourni, advice() dans une exécution sans données auxiliaires, ou un cadre de délégation qui n’est pas entièrement en RAM
Misaligned un accès à un demi-mot ou à un mot par un pointeur non aligné, ou un cadre de délégation mal aligné
NotAnInstruction un saut vers un pc qui ne contient aucune instruction, y compris le demi-mot entièrement nul c.unimp
IllegalInstruction un encodage que la machine n’exécute pas
Ebreak un ebreak, qui n’a pas de preuve
ClockOverflow plus de 2^36 − 1 cycles
PublicInputTooLong, JournalTooLong une entrée, ou le mot de longueur du journal à la sortie, au-dessus de 16 380 octets
DelegationFrame un cadre pour lequel son circuit n’a pas de témoin : un opérande de MOD_MUL ou d’EC_ADD supérieur ou égal à son module, un sélecteur qui ne désigne rien, un tour keccak au-delà de 23, un groupe SHA-256 au-delà de 15, une voie de Poseidon2 supérieure ou égale à p
DelegationFamilyAbsent un numéro de délégation que l’image n’a jamais déclaré, sur un chemin de traçage

L’ELF est refusé#

artifact-dump, host::setup et les outils refusent un ELF que le chargeur ne peut pas prendre, avec une LoaderError :

Refus Cause habituelle
NotAnElf, Truncated ce n’est pas l’ELF du programme invité : un fichier .d, une écriture partielle
NotRiscV, UnsupportedElfType, RelocatableElf, DynamicElf une compilation pour l’hôte, un fichier objet, un PIE ou une compilation à édition de liens dynamique
BadSegment, NoExecutableSegment, EntryNotAnInstruction un link.ld modifié, ou aucun _start lié
RvcIllegal, InstructionTooLong, TextTruncated des données dans .text, comme une table dans de l’assembleur écrit à la main. Jamais le demi-mot nul, qui est attendu

Le programme ne s’enregistre pas#

L’ELF se charge, mais le programme ne peut pas être décodé en une configuration :

Refus Cause et correction
Not all opcodes supported: pc=… un mot hors de RV32IMA n’importe où dans le code exécutable, atteignable ou non, comme un accès à un CSR ou fence.i en assembleur. Retirez-le
TableTooShort du code au-delà de la portée de la table d’une famille. Augmentez la hauteur de cette famille : 2^22 atteint 7,9375 MiB de code
ProgramTooLarge l’image dépasse bytecode_size_words, 4 MiB par défaut. Relevez le plafond dans ProgramParams
ImageOutsideWindow un octet issu du fichier se trouve au-delà de la fenêtre de RAM 0 à votre hauteur de fenêtre. Augmentez la hauteur de fenêtre
HeightNotOnMenu une hauteur qui n’est pas 2^8, 2^12, 2^16, 2^18, 2^20 ou 2^22
UnknownDelegation l’image déclare un numéro de délégation auquel aucune famille ne répond

La construction d’une clé échoue aussi si une famille qu’utilise le programme est réglée sous son plancher, 2^20 pour une famille d’instructions et 2^16 pour les familles de fenêtres de RAM, puisqu’aucun circuit n’existe à ces hauteurs.

Le prouveur échoue#

Un prouveur honnête qui prouve ce qu’a exécuté l’émulateur n’échoue pas : un échec désigne donc une entrée qu’il n’aurait pas dû accepter, ou un bogue. Deux cas expliquent la plupart des échecs :

  • Un appel sans preuve. Un appel système autre qu’EXIT et les délégations, qu’une bibliothèque a effectué pour obtenir des données de l’hôte, répond -ENOSYS et l’exécution continue, mais le remplissage du prouveur refuse cette ligne et nomme le cycle. Trouvez la dépendance qui demande de l’aléa ou l’heure.
  • Mémoire épuisée. Le processus est tué alors que des shards sont en cours de traitement. Réduisez le troisième argument de host::prove, ou abaissez les hauteurs.

Pour tout le reste, recompilez avec le journal de débogage du prouveur et relancez :

sh
cargo run --release -p bench --features prover/debug-info -- prove ...
APOGEE_DEBUG=detail <the run> 2>&1 | tee run.log
grep -c 'begin h=' run.log; grep -c 'gkr done' run.log     # unequal: a shard died
grep 'begin h=' run.log | tail -1                           # which one
grep -E 'FAIL|NOT CANONICAL|UNBALANCED|OVER the|NAMES NO|DISAGREES|NOT LOOPING|ABORTED' run.log

self_check FAILED nomme la première porte en échec de la ligne et la valeur de chaque opérande. Le journal de débogage énumère chaque marqueur.

La vérification échoue#

verify_shard et verify_block renvoient une VerifyError dont la classe indique ce qui a cédé :

Classe Signification
Statement l’énoncé ne correspond pas à la clé : nombres de shards, règles des fenêtres, longueurs des charges utiles, listes, ou le condensé global avec lequel la preuve a été amorcée
Malformed la forme de la preuve n’est pas celle de son circuit : nombre d’engagements, de sorties, de tours ou d’affirmations
Constraint { layer } une porte est violée, ou le sumcheck d’une couche échoue
Lookup { channel } un tuple recherché ne figure dans aucune ligne de sa table
MemoryArgument les multiensembles de lecture et d’écriture ne se rapprochent pas, ou une fenêtre publique ne contient pas les octets de l’énoncé
Opening l’ouverture d’un engagement échoue

Si vous avez construit la preuve avec le prouveur d’Apogee lui-même, à partir d’une exécution que l’émulateur a acceptée, un échec de vérification signifie que le vérificateur et le prouveur sont en désaccord sur le programme : vérifiez que vous chargez la clé sous laquelle la preuve a été produite, aux mêmes hauteurs, sur la même cérémonie.

Identité discordante#

L’identité que vous avez calculée diffère de celle que vous attendiez :

  • La compilation d’une autre machine. L’ELF incorpore des chemins absolus : une recompilation ailleurs donne donc une autre image. Comparez avec l’ELF qui a été prouvé, et non avec une nouvelle compilation.
  • D’autres hauteurs. Chaque hauteur est liée dans l’identité. artifact-dump tables indique l’identité aux hauteurs par défaut; votre mise en place peut en utiliser d’autres.
  • Une autre cérémonie. Un fichier de puissances de tau de Hermez correspond à un autre τ, si bien que chaque engagement diffère. Comparez le [τ]_1 du fichier à celui de la cérémonie.

Lancez votre application

Exemples de programmes invités

Les programmes invités du dépôt, chacun un exemple concret d’une partie du SDK des programmes invités ou de la machine. Où chercher le motif dont vous avez besoin.

Voir en Markdown

L’espace de travail guests/ contient tous les programmes invités que le dépôt compile et teste. Chacun existe pour mettre quelque chose à l’épreuve, ce qui en fait la meilleure référence pour le motif que vous vous apprêtez à écrire. Tous se compilent avec cargo build --target riscv32imac-unknown-none-elf à partir de leur propre répertoire.

Commencez ici#

Programme invité Montre
public-io les trois régions à la fois : des données auxiliaires (advice) vérifiées par rapport à l’entrée publique avant que quoi que ce soit ne soit consigné. Le modèle d’E/S en un seul petit programme
fib le plus petit programme invité qui utilise le SDK : un u32 en entrée, un u32 en sortie, une arithmétique à rebouclage
echo, heap l’allocateur : des données auxiliaires copiées dans des tampons du tas, des Vec et des Box créés puis abandonnés en série dans l’allocateur linéaire

Motifs applicatifs#

Programme invité Montre
amm, orderbook des entiers de 128 et 256 bits sans tas; BTreeMap, le tri, et un ordre trié fourni en données auxiliaires et vérifié plutôt que calculé
vault, recursion-ops crates/field et crates/transcript dans un programme invité, qui délèguent à FR_ARITH et POSEIDON2 sans nommer de shim
revm-block des blocs Ethereum sur revm : les binaires revm-block (un mini-bloc enregistré) et revm-block-stateless (le validateur sans état)

Délégations#

Programme invité Montre
keccak-test, sha256-ops, mod-mul-ops, ec-ops KECCAK_F, SHA256_COMP, MOD_MUL et EC_ADD, chacune vérifiée dans le programme invité par rapport à des valeurs indépendantes
keccak-unused, recursion-unused des shims liés et jamais appelés : les familles sont déclarées et prouvent zéro shard

La machine elle-même#

Programme invité Montre
atomics chaque instruction de l’extension A telle que l’émet core::sync::atomic. Avec un seul hart, chacune est une simple lecture-modification-écriture; ce programme existe pour tester la famille, pas pour recommander la pratique
opcodes chaque instruction RV32IMAC
rvc-dense le développement des instructions compressées : une même séquence assemblée avec et sans compression
addsub, control, alu, mem, shards de l’assembleur écrit à la main, avec son propre _start et sans SDK, qui se termine avec son résultat. shards remplit deux shards de 2^20
recursion les programmes vérificateurs de l’arbre de récursion, binaires leaf et node

Le plus petit programme invité utile#

fib lit un u32, effectue autant d’étapes de Fibonacci avec une arithmétique à rebouclage, et consigne le résultat :

guests/fib/src/main.rsrust
#![no_std]
#![no_main]

guest_sdk::entry!(main);

fn main() {
    let mut n = [0u8; 4];
    assert_eq!(
        guest_sdk::read_input(&mut n),
        4,
        "fib: the public input is one u32"
    );
    let n = u32::from_le_bytes(n);

    let mut a: u32 = 0;
    let mut b: u32 = 1;
    for _ in 0..n {
        let next = a.wrapping_add(b);
        a = b;
        b = next;
    }
    guest_sdk::commit(&a.to_le_bytes());
}

Une entrée trop courte est une erreur, et non une invitation à compléter par défaut : un programme invité qui poursuit avec un tampon partiellement rempli prouve un énoncé sur des zéros. L’addition reboucle à dessein, si bien qu’un n supérieur à 47, au-delà du dernier terme qui tient sur 32 bits, est une entrée ordinaire avec une réponse ordinaire plutôt qu’une exécution en échec. Et il n’y a pas de données auxiliaires, parce que f_n coûte autant à un vérificateur à vérifier qu’à calculer : une réponse fournie en données auxiliaires devrait être recalculée pour être crue.

Lancez votre application

Référence du SDK des programmes invités

Chaque élément public de la crate guest-sdk, avec sa signature exacte et son comportement. Seuls exit et les shims de délégation émettent un ecall; tout le reste n’est que chargements et rangements.

Voir en Markdown

crates/guest-sdk constitue tout l’environnement d’exécution d’un programme invité : le code de démarrage, la macro d’entrée, l’allocateur, le gestionnaire de panique et les shims d’ecall. Elle ne se compile que pour riscv32imac-unknown-none-elf.

Point d’entrée#

rust
guest_sdk::entry!(main);

Exporte le symbole main qu’appelle le code de démarrage, sous la forme d’une enveloppe qui appelle votre fonction, laquelle ne prend aucun argument et renvoie (). Votre fonction garde son propre nom et peut elle-même s’appeler main. Le retour de cette fonction équivaut à exit(0).

Les régions#

Élément Signature Comportement
public_input fn public_input() -> &'static [u8] La charge utile de l’entrée publique, son mot de longueur étant plafonné à la fenêtre. Aucune copie, aucun ecall
read_input fn read_input(buf: &mut [u8]) -> usize Copie min(buf.len(), public_input().len()) octets et renvoie leur nombre. La fonction peut en copier moins que demandé
advice fn advice() -> &'static [u8] La charge utile des données auxiliaires (advice), sa longueur étant plafonnée à la région. Liée à rien : le programme invité la vérifie donc. OutOfBounds fatal dans une exécution sans données auxiliaires
commit fn commit(bytes: &[u8]) Ajoute au journal et met à jour son mot de longueur. Termine l’exécution avec le statut 70 plutôt que de faire déborder la fenêtre de 16 380 octets
journal fn journal() -> &'static [u8] Tout ce qui a été consigné jusqu’ici
exit fn exit(code: i32) -> ! Termine l’exécution avec code comme statut de sortie de l’énoncé. Ne publie rien au-delà de ce qui a été consigné

Hachage#

Élément Signature Comportement
keccak256 fn keccak256(input: &[u8]) -> [u8; 32] Le Keccak-256 d’Ethereum, pas SHA3-256. L’éponge et le bourrage s’exécutent dans le code du programme invité; chaque tour de keccak-f[1600] est un appel à KECCAK_F. Repli logiciel si le premier appel répond -ENOSYS
sha256 fn sha256(input: &[u8]) -> [u8; 32] SHA-256 selon FIPS 180-4. Le bourrage et la boucle sur les blocs s’exécutent dans le code du programme invité; chaque compression représente seize appels à SHA256_COMP. Repli logiciel comme ci-dessus
poseidon2_permute fn poseidon2_permute(state: &mut [u8; 96]) -> bool La permutation Poseidon2 de largeur 3 sur trois voies Fr canoniques petit-boutistes, sur place, par POSEIDON2. Renvoie false sur -ENOSYS, pour que l’appelant emprunte son propre chemin logiciel

Courbes elliptiques#

rust
pub type ProjectivePoint = [[u32; 8]; 3];

Un point en coordonnées projectives homogènes, x = X/Z et y = Y/Z, chaque coordonnée étant formée de huit mots de 32 bits petit-boutistes et inférieure au module du corps de la courbe. Ce type n’est pas jacobien : le Projective d’arkworks l’est, si bien qu’un appelant qui convertit depuis ce dernier applique (X·Z, Y·Z², Z) à l’entrée et (X·Z, Y, Z³) à la sortie. L’élément neutre est (0 : 1 : 0).

Élément Signature Comportement
ec_add fn ec_add(codes: &[u32; 3], p: &ProjectivePoint, q: &ProjectivePoint) -> Option<ProjectivePoint> p + q par la formule complète, au moyen de trois appels à EC_ADD dans l’ordre des groupes. None sur -ENOSYS
ec_mul fn ec_mul(codes: &[u32; 3], p: &ProjectivePoint, k: &[u32; 8]) -> Option<ProjectivePoint> k·p par doublement et addition à partir du bit de poids fort. k est utilisé tel quel; le réduire modulo l’ordre du groupe est l’affaire de l’appelant
ec_identity fn ec_identity() -> ProjectivePoint (0 : 1 : 0)
recursion::SECP256K1_GROUPS, recursion::BN254_GROUPS [u32; 3] L’argument codes : quelle courbe, sous la forme des trois sélecteurs de groupe d’une addition

La formule prouve l’arithmétique, pas l’appartenance à la courbe : vérifiez vous-même les points tirés des données auxiliaires.

Shims de délégation bruts#

guest_sdk::recursion contient les shims sur des types de cadres alignés sur un mot. Chaque type de cadre est #[repr(C, align(4))], si bien que son alignement est celui du type, et non celui de l’endroit où le générateur de code a placé une variable locale. Un shim du format de base renvoie false exactement sur -ENOSYS; toute autre réponse non nulle termine l’exécution avec le statut 72.

Élément Rôle
mod_mul(&mut ModMulFrame) -> bool Un a·b mod m. Construisez le cadre avec ModMulFrame::of(modulus, &a, &b) et lisez frame.result(). Les codes de module sont SECP256K1_P, SECP256K1_N, BN254_P et BN254_R, et les deux opérandes doivent déjà être inférieurs au module
sha256_comp(&mut Sha256Frame) -> bool Une compression entière : seize appels dans l’ordre. Sha256Frame::of(&state, &block), puis frame.working(); ajouter le résultat à la valeur de chaînage revient à l’appelant
ec_add_complete(&mut EcAddFrame, &[u32; 3]) -> bool Une addition complète : trois appels dans l’ordre des groupes. EcAddFrame::of(&codes, &p, &q), puis frame.result()
poseidon2(&mut Poseidon2Frame) -> bool, fr_arith(&mut FrArithFrame) -> bool La permutation et une opération sur Fr, sur des cadres d’octets; field et transcript les appellent pour vous
sha256_rounds, ec_add Des étapes isolées des opérations ci-dessus. Une étape exécutée dans le mauvais ordre n’est pas refusée, elle calcule autre chose : préférez donc les fonctions qui couvrent l’opération entière
fr_op, p2_field, field_io, fq_op, import, import_run, replay Les appels de coprocesseur du format de récursion, qu’utilisent les programmes de l’arbre de récursion eux-mêmes. Ils n’ont pas de chemin logiciel

Chaque shim lit son numéro d’ecall dans l’enregistrement de déclaration de sa famille, un static de 12 octets placé dans sa propre section de l’éditeur de liens. Lier un shim déclare la famille; une famille déclarée qui n’est jamais appelée prouve zéro shard.

Comportement à l’exécution#

Composant Comportement
Démarrage _start, à 0x0001_0000, fait pointer sp vers __stack_top (0x8000_0000), met .bss à zéro octet par octet, appelle main, et termine l’exécution avec le statut 0 si cette fonction revient
Allocateur Monte à partir de __heap_start, ne libère jamais rien. Termine l’exécution avec le statut 71 quand un bloc se terminerait au-dessus de __stack_top − 8 MiB ou au-dessus du sp courant
Gestionnaire de panique Termine l’exécution avec le statut 101 et n’écrit rien. Un programme invité qui panique est prouvable et a publié ce qu’il avait consigné
Statuts de sortie 70 débordement du journal, 71 tas épuisé, 72 une délégation a répondu par une erreur, 101 panique

Délégation transparente#

Deux crates de bibliothèque du dépôt délèguent sur la cible des programmes invités sans nommer le SDK, par une dépendance sur celui-ci réservée à cette cible :

  • field::Fr : l’addition, la multiplication de Montgomery (*, square, pow et les conversions) et inverse d’un élément non nul appellent FR_ARITH. Un programme invité qui utilise l’arithmétique de Fr déclare cette famille.
  • transcript::poseidon2_permute appelle POSEIDON2.

Les versions embarquées de k256, ark-ff et revm-precompile font de même pour secp256k1, BN254 et les précompilés de l’EVM : Délégations.

La spécification de l’ABI qui sous-tend tout cela se trouve dans ABI du programme invité.

Lancez votre application

Compagnon IA

Un seul fichier qui breffe un modèle d’IA sur l’écriture de programmes invités Apogee. Téléchargez-le, placez-le devant votre modèle, et celui-ci part des mêmes règles que celles qu’enseigne ce manuel.

Voir en Markdown

Une bonne part du code écrit pour Apogee sera rédigée par un modèle. Un modèle qui n’a jamais vu Apogee écrira un programme invité plausible qui utilise std, alloue dans chaque boucle, recourt à un compteur atomique, se fie à ses données auxiliaires (advice) et consigne un usize. Le Compagnon IA est un seul fichier Markdown qui place d’emblée en tête tout ce qui prévient ces erreurs : ce qu’est un programme invité, les règles impératives, la surface publique complète du SDK avec ses signatures exactes, des motifs à copier, les erreurs et leurs corrections, et une liste de vérification pour la revue.

Télécharger le compagnon Ouvrir en texte brut

Comment l’utiliser#

  • Dans une conversation : joignez le fichier, ou collez-le comme premier message, avant de décrire ce que vous voulez construire.
  • Dans un agent de programmation : enregistrez-le à la racine de votre projet sous le nom que votre outil lit par convention, comme AGENTS.md ou CLAUDE.md, ou ajoutez-le aux règles de projet de l’outil. L’agent le lit alors au début de chaque session.
  • Pour la revue : demandez au modèle de vérifier un programme invité ligne par ligne par rapport à la section 8 du fichier, la liste de vérification pour la revue.

Le fichier énonce ses règles sous forme de MUST et de MUST NOT, chacune accompagnée de sa raison, parce que les modèles suivent plus fidèlement des contraintes explicites et expliquées que des conventions qu’ils sont censés deviner.

Ce qu’il contient#

Section Contenu
0. Instructions au modèle Traiter les règles comme des contraintes impératives; ne jamais appeler une API qui n’est pas répertoriée; les preuves ne sont pas à divulgation nulle de connaissance
1. Ce qu’est un programme invité La cible, le hart unique, ce qu’énonce une preuve, l’identité du programme, les trois régions mémoire
2. Règles impératives 23 règles : la forme du programme, usize et les pointeurs sur 32 bits, l’allocateur linéaire, la pile, l’alignement, les opérations atomiques, l’absence de monde extérieur, les données auxiliaires, les limites des valeurs publiques, le jeu d’instructions, les flottants, les vérifications de dépassement, le coût
3. Organisation et compilation Les gabarits de crate, l’espace de travail des programmes invités, la commande de compilation, la séparation en une bibliothèque testée d’abord sur l’hôte
4. Le SDK des programmes invités Chaque fonction publique avec sa signature exacte, les faits sur l’environnement d’exécution et la carte mémoire, les opérations déléguées et les crates embarquées
5. Motifs Des données auxiliaires vérifiées par rapport à un hachage; une requête de Merkle et une transition d’état; la réutilisation des tampons; des données auxiliaires structurées; des condensés pour les sorties qui grandissent
6. Côté hôte L’exécution dans l’émulateur, le profilage, l’exportation de l’image, la preuve et la vérification, les hauteurs et les shards en cours de traitement
7. Erreurs et corrections Chaque statut de sortie, erreur fatale et refus que rencontre un programme invité, avec sa cause et sa correction
8. Liste de vérification pour la revue Onze vérifications à effectuer avant de proposer du code de programme invité
9. Faits L’ISA, le système de preuve, le niveau de sécurité, les limites et les résultats mesurés

Les règles sur lesquelles il insiste#

Le compagnon reprend les règles de ce manuel, et trois d’entre elles méritent d’être soulignées, parce que ce sont celles que les modèles enfreignent le plus souvent :

  • Les pointeurs et usize font 32 bits. Un modèle entraîné surtout sur du code 64 bits sérialise usize sans y penser à deux fois. Le programme invité et l’hôte divergent alors sur les octets.
  • Le tas ne libère jamais rien. Le Rust idiomatique alloue sans retenue parce qu’un véritable allocateur rend la mémoire. Ici, chaque allocation est permanente pour le reste de l’exécution : le compagnon demande donc partout la réutilisation et with_capacity.
  • Les opérations atomiques se compilent, et n’ont pas leur place dans le code d’un nouveau programme invité. Elles sont prises en charge pour la compatibilité avec les bibliothèques existantes. Sur un seul hart, elles ne synchronisent rien, et elles ajoutent une famille de circuits à la preuve.

Pour les agents qui lisent directement cette documentation#

  • /llms.txt répertorie chaque page de ce site en Markdown, pour les modèles qui lisent le Web.
  • /docs/llms-full.txt contient toute la documentation anglaise, spécification comprise, en un seul fichier.
  • Chaque page comporte un lien Voir en Markdown et un bouton Copier en Markdown, sous son titre et dans la colonne de droite.

L’anglais est la langue canonique de la documentation, et le compagnon est publié en anglais pour toutes les langues : c’est la langue que les modèles suivent le plus fidèlement, et celle dans laquelle la spécification est écrite.

Architecture

L’architecture

Apogee VM de bout en bout. Ce qu’énonce une preuve, le chemin d’un binaire de programme invité jusqu’à un appel de contrat, la façon dont les grands composants s’articulent, et les choix de conception qui les façonnent.

Voir en Markdown

Apogee prouve des exécutions de programmes RV32IMAC. Cette section décrit le système au niveau de ses grands composants : ce que fait chacun, pourquoi il est construit ainsi, et comment il passe le relais au suivant. La section Auditeurs présente le même système au niveau de chaque colonne et de chaque porte.

Ce qu’énonce une preuve#

Un vérificateur détient trois éléments qu’il ne croit pas sur la seule parole du prouveur :

  • l’identité du programme, un élément du corps qui condense les tables d’instructions du programme, son image mémoire initiale, son pc d’entrée et sa configuration;
  • le condensé SRS de la cérémonie que doit porter une clé de vérification;
  • une clé de vérification, qui peut provenir de n’importe qui, car son chargement recalcule l’identité et le condensé SRS à partir de son propre contenu et exige que ses circuits soient ceux du registre du vérificateur.

L’énoncé de la preuve porte l’entrée publique, la sortie publique (le journal), le statut de sortie, et l’enregistrement de la forme de l’exécution : les nombres de shards, les fenêtres mémoire, les registres et le pc finaux, ainsi que les engagements mémoire et les racines de chaque shard. Une preuve dont la vérification réussit établit que le programme de cette identité, lancé à son pc d’entrée sur son image, avec l’entrée publique dans sa fenêtre d’entrée et des données auxiliaires (advice) choisies par le prouveur, s’exécute instruction par instruction jusqu’à EXIT avec ce statut, après avoir écrit ce journal. Rien n’est affirmé au sujet des données auxiliaires, et rien n’est caché : aucun engagement ni aucune preuve n’est aveuglé.

D’un binaire à un appel de contrat#

Apogee VM, de bout en bout Quatre étapes. Programme : de l’ELF du programme invité à la ProgramImage, puis aux tables décodées et à la VmConfig, puis à l’identité du programme. Exécution : l’émulateur produit des lignes par famille, découpées en shards. Pour chaque shard : engager les colonnes avec Mercury, exécuter la passe arrière GKR, ouvrir toutes les colonnes en un seul lot. Règlement : preuve de bloc, arbre de récursion, décideur Groth16, contrat ApogeeVerifier. 1 · PROGRAMME ELF invitéRV32IMAC, no_std Rust ProgramImagesegments · RVC développé Tables décodées7 familles · VmConfig Identité du programmeun Fr 2 · EXÉCUTION Émulateurun hart · fonction pure de (image, io) Lignes, une table par famillecycles · invocations · mots mémoire Shards2^8 … 2^22 lignes · 23 familles 3 · CHAQUE SHARD Engager les colonnesMercury sur KZG Passe arrière GKRun sumcheck par couche Une ouverture groupéechaque colonne au même point · 704 B 4 · RÈGLEMENT Preuve de blocénoncé + preuves de shards Arbre de récursionfeuilles · nœuds · racine Décideur Groth16fils liés · BN254 ApogeeVerifier.soldeux couplages · true
Les quatre étapes. Le programme est fixé avant que quoi que ce soit ne s’exécute; l’exécution est découpée en shards; chaque shard est prouvé isolément, à l’exception de l’argument de mémoire, qui se referme une seule fois sur l’ensemble des shards; le règlement compresse le bloc pour un contrat.
  1. Le programme. Le chargeur lit l’ELF dans une ProgramImage, en développant sur place les instructions compressées. Le décodeur achemine chaque instruction vers l’une des sept familles d’instructions et construit la table décodée de chaque famille, une ligne par demi-mot de code, puis engage le tout sous la forme de l’identité du programme. Programmes et identité.
  2. Exécution. L’émulateur exécute le programme invité sur un seul hart. Un cycle est une ligne de la famille à laquelle appartient son instruction, et cette ligne enregistre des lectures et des écritures horodatées du pc, des registres et de la RAM. Le hachage et l’arithmétique des grands entiers sont délégués : un ecall désigne un cadre en RAM, et une ligne d’une famille de délégation effectue le travail sur ce cadre. Exécution, familles et shards.
  3. Shards. Les lignes d’une famille sont découpées en shards de la hauteur de la famille, une puissance de deux comprise entre 2^8 et 2^22. La mémoire que touche une exécution est couverte par des shards des familles de fenêtres, qui donnent à chaque mot ses valeurs initiale et finale. Le shard est l’unité de preuve; un bloc en compte des centaines.
  4. La preuve d’un shard. Ses colonnes sont engagées avec Mercury. Le circuit de la famille est parcouru à rebours par le moteur GKR, de ses sorties jusqu’à ces colonnes, à raison d’un sumcheck par couche, et chaque colonne est ouverte au point unique où aboutit cette passe, en une seule ouverture groupée.
  5. Le bloc. Un BlockProof est l’énoncé accompagné des preuves de ses shards. La vérification exécute la transcription globale une fois, les vérifications de chaque shard, et le rapprochement de la mémoire une fois, sur les racines de tous les shards.
  6. Récursion et règlement. Des programmes vérificateurs, prouvés par Apogee lui-même, vérifient des suites de shards et replient leurs couplages différés. Un arbre de tels programmes aboutit à une racine, un circuit Groth16 revérifie la racine, et ApogeeVerifier.sol vérifie cette preuve et le couplage replié. Récursion et règlement.

Le prouveur exécute le programme invité deux fois : une fois pour engager les colonnes mémoire de chaque shard, ce qui fixe l’énoncé et ses défis, et une fois pour prouver chaque shard à mesure qu’il se remplit. Sa mémoire est bornée par les shards en cours de traitement, et non par la longueur de l’exécution. Le prouveur en flux.

Les choix qui le façonnent#

Un seul corps, une seule courbe. Tout est défini sur le corps des scalaires de BN254 : les circuits, la transcription, les engagements et la récursion. C’est ce qui permet à un nœud de l’arbre de récursion de vérifier des shards de base dans sa propre arithmétique, et à l’arbre de se terminer par une preuve Groth16 qu’Ethereum vérifie avec son précompilé de couplage.

Des circuits GKR en couches plutôt que des tables de contraintes engagées. Le circuit d’une famille est un empilement de couches de portes de degré 2 au-dessus de ses colonnes engagées. Seule la couche inférieure est engagée; chaque couche au-dessus est prouvée par sumcheck en une seule passe arrière, sans jamais être engagée. À la fin de la passe, chaque colonne engagée fait l’objet d’une affirmation en un même point : un shard a donc besoin d’exactement une ouverture. Le moteur GKR explique pourquoi c’est là l’économie centrale du moteur.

Une ouverture de taille constante. Mercury ouvre un engagement multilinéaire avec huit points de courbe et six éléments du corps, soit 704 octets, quelle que soit la taille du polynôme et quel que soit le nombre de colonnes qui partagent le point. Ses vérifications ont la forme e(A, [1]_2) = e(B, [x]_2), que la récursion peut replier au lieu d’effectuer le couplage.

Un seul argument de mémoire pour toute l’exécution. Chaque accès, dans chaque shard de chaque famille, est un tuple d’un même multiensemble lecture/écriture, et le vérificateur rapproche les produits une fois par énoncé. Le pc est une cellule de ce multiensemble : l’ordre, la continuité et l’unicité des cycles d’un shard à l’autre ne demandent donc aucun autre argument, et aucun shard n’a besoin de s’enchaîner à son voisin.

Des délégations sous forme de familles, et non d’instructions. Une fonction coûteuse dispose de sa propre famille de circuits, invoquée par un ecall sur un cadre de RAM et appariée une à une avec sa demande par le même multiensemble. Les circuits d’instructions restent petits, et un programme invité ne paie une délégation que s’il l’appelle.

Le flux plutôt que la matérialisation. À environ 300 octets par cycle, la trace serait le plus gros objet du système : elle n’existe donc jamais. Le prouveur exécute deux fois et ne détient que les shards en cours de traitement.

Aucune cryptographie empruntée. Les corps, la courbe, le couplage, la MSM, le hachage, l’engagement polynomial, GKR et Groth16 sont implémentés dans le dépôt et spécifiés page par page. arkworks, Plonky3 et zkhash n’apparaissent que comme oracles de test.

Comment la solidité (soundness) se compose#

La passe GKR et l’ouverture de chaque shard rattachent les sorties de son circuit à des colonnes engagées. Par-dessus, les arguments suivants couvrent toute l’exécution :

Affirmation Portée par
Chaque ligne obéit à son instruction les portes de contrainte du circuit de la famille, nulles sur chaque ligne
L’instruction d’une ligne est celle du programme à son pc un lookup du pc et des champs de la ligne dans la table décodée de la famille, que l’identité engage
Chaque lecture renvoie la dernière écriture un seul multiensemble sur tous les shards; le vérificateur multiplie les racines de lecture et d’écriture de chaque shard avec des facteurs de frontière pour les registres et le pc
Les lignes forment un seul chemin du pc d’entrée jusqu’à la sortie, dans l’ordre du programme le pc est une cellule de ce multiensemble, écrite au moins quatre horodatages après sa lecture
Une valeur est un octet, un mot, un signe, un XOR des canaux LogUp sur des tables d’intervalle, des tables d’octets et la table générique
L’entrée publique et le journal sont les octets annoncés les colonnes initiale et finale des deux fenêtres publiques, astreintes aux extensions multilinéaires des octets
Un calcul délégué est celui de la fonction des lignes d’invocation qui lisent et écrivent le cadre par le même multiensemble, appariées une à une avec leur ecall

Les défis proviennent d’une transcription duplex Poseidon2. La transcription globale absorbe l’énoncé entier, engagements mémoire de chaque shard compris, avant que les défis mémoire n’existent; la transcription de chaque shard est amorcée à partir de son état final. La carte de solidité mène chaque ligne du tableau jusqu’aux sections qui la prouvent.

Le code, couche par couche#

Couche Crates Rôle
Arithmétique field, curve, poly, sumcheck Fr; la tour Fq, G1, G2, le couplage, la MSM; les polynômes multilinéaires; le zerocheck
Fiat–Shamir et mise en place transcript, srs Poseidon2 et la transcription duplex; l’ingestion de la cérémonie, KZG, la phase 1 de Groth16
Engagements pcs, pcs-verify Mercury et sa vérification différée
Le programme loader, isa, program de l’ELF à l’image, le décodeur, les tables décodées, VmConfig et l’identité
Exécution emulator, trace l’exécuteur et ses traceurs; les lignes, l’état de la mémoire, les constructeurs de colonnes
Circuits constraints, gkr-verify, gkr chaque circuit sous forme de données; le vérificateur et le prouveur GKR
Preuve et vérification verifier-core, verifier, prover l’énoncé, les transcriptions, les clés, chaque vérification; le prouveur en flux
Règlement host, groth16, contracts/ le SDK hôte, l’arbre de récursion et le décideur; ApogeeVerifier.sol
Assurance checker, tools/ des validateurs indépendants, la suite de falsification, des bancs d’essai, des profileurs, des oracles

Le vérificateur est la seule partie de confiance : le prouveur ne valide rien, et une entrée erronée coûte à un prouveur honnête une preuve qui échoue. Le modèle de sécurité énumère précisément les crates sur lesquelles repose la solidité.

Architecture

Programmes et identité

Comment un ELF de programme invité devient une description statique du programme, connue du vérificateur, et pourquoi un seul élément du corps, l’identité, suffit à indiquer à un vérificateur de quel programme parle une preuve.

Voir en Markdown

Avant que quoi que ce soit ne s’exécute, Apogee transforme le binaire du programme invité en une description fixe du programme : son image, ses instructions réparties en familles de circuits, la configuration sous laquelle il sera prouvé, et un seul élément du corps qui engage le tout. Chaque étape est une fonction pure de son entrée.

Chargement#

Le chargeur accepte un exécutable RISC-V statique, 32 bits et petit-boutiste, dont les segments chargeables se trouvent dans la RAM du programme invité à des adresses paires, sont disjoints deux à deux et comprennent au moins un segment exécutable. Il lit le type, les décalages, les tailles et le bit d’exécution d’un en-tête de programme, et rien d’autre : la VM n’a ni pages ni permissions, et toute la RAM est adressable, quoi que déclarent les segments.

Il balaie ensuite chaque segment exécutable demi-mot par demi-mot. Un demi-mot qui se termine par 11 en binaire commence une instruction de 4 octets; le demi-mot nul est une non-instruction (LLVM en remplit les blocs inatteignables); tout le reste est une instruction compressée, développée sur place en sa forme de 32 bits. Les adresses ne sont jamais compactées : un c.addi à 0x1002 y reste et occupe deux octets, si bien que chaque adresse résolue par l’éditeur de liens reste valable, et que la longueur d’une instruction est la seule indication permettant de savoir si le pc suivant est pc + 2 ou pc + 4.

Un balayage désynchronisé ne peut pas rendre prouvable une instruction erronée. Un emplacement est une fonction des octets situés à son propre pc : chaque emplacement d’instruction est donc ce que décoderait un hart qui irait y chercher une instruction. Des données qui décalent le balayage par rapport aux vraies frontières ne peuvent que faire perdre de vrais débuts d’instruction, dont les pc n’ont alors aucune ligne de table, ou tomber sur un encodage non reconnu et faire refuser l’image entière.

Décodage et acheminement#

Le décodeur ne prend que des mots de 32 bits et accepte exactement les 59 instructions de RV32IMA : 40 du jeu de base, 8 de M, 11 de A. Tout le reste, des encodages RV64 et de la virgule flottante aux CSR et à fence.i, est une erreur de décodage, et un seul mot indécodable n’importe où dans le code exécutable entraîne le refus du programme, qu’il soit atteignable ou non. Chaque instruction est acheminée vers exactement une des sept familles d’instructions :

Id Famille Instructions
0 ADD_SUB_LUI_AUIPC ecall, ebreak, fence, addi, auipc, add, sub, lui
1 JUMP_BRANCH_SLT slti, sltiu, slt, sltu, les six branchements, jalr, jal
2 SHIFT_BITWISE les six décalages, and, or, xor et leurs formes immédiates
3 MUL_DIV les huit instructions de l’extension M
4 MEM_WORD lw, sw
5 MEM_SUBWORD lb, lh, lbu, lhu, sb, sh
6 ATOMICS lr.w, sc.w et les neuf AMO

Le regroupement suit ce que partagent les circuits. Un seul gadget de comparaison tranche l’ordre signé et non signé pour chaque type de branchement et de slt; une seule identité de produit sert aux quatre multiplications et à la division; un décalage dans un sens ou dans l’autre est un seul produit avec une puissance de deux obtenue par lookup.

Tables décodées#

Chaque famille d’instructions reçoit une table décodée : ses colonnes de mise en place, où la ligne i correspond au pc 2i, avec une ligne par demi-mot de l’espace d’adressage qu’atteint la table. Une ligne active contient l’une des instructions de la famille sous la forme d’un tuple pc, next_pc, rs1, rs2, rd, imm, extra_mask, où le masque est un seul bit qui désigne le mnémonique. Toute autre ligne est du remplissage, −1 dans chaque champ : aucune ligne active n’est donc jamais la ligne de remplissage, et une ligne entièrement nulle ne peut jamais être revendiquée comme instruction au pc 0.

La ligne de chaque cycle se recherche elle-même dans la table de sa famille, par son pc. C’est ce lookup qui lie une exécution au programme : l’instruction d’une ligne est l’instruction du programme à ce pc, et un pc sans ligne active dans aucune table ne peut pas être exécuté de façon prouvable. Le code est par conséquent statique. Un rangement dans .text change ce que lit un chargement ultérieur, jamais ce qui s’exécute.

La configuration#

La forme statique d’un programme est sa VmConfig : les familles qu’il utilise, chacune avec une hauteur, et un plafond sur la taille de son code. L’ensemble des familles ne se choisit pas; il se dérive :

  • une famille d’instructions est présente quand l’image contient l’une de ses instructions;
  • les cinq familles de fenêtres, qui initialisent et finalisent la mémoire, sont toujours présentes;
  • une famille de délégation est présente quand l’image la déclare, au moyen d’un enregistrement de 12 octets que l’édition de liens de son shim laisse parmi les octets de l’image.

La hauteur d’une famille est le nombre de lignes de l’un de ses shards, choisie parmi 2^8, 2^12, 2^16, 2^18, 2^20, 2^22. Chaque hauteur est une puissance paire de deux, parce qu’une ouverture Mercury l’exige. Les hauteurs sont un paramètre du programme, et non d’une exécution, et chaque choix de hauteurs constitue un programme distinct.

Identité du programme#

L’identité du programme est un élément de Fr : un condensé Poseidon2 de la version du code, de la VmConfig, du pc d’entrée et des engagements de mise en place de chaque famille, qui sont des engagements Mercury sur les tables décodées et sur les mots mémoire initiaux de l’image.

Elle lie chaque instruction qu’a trouvée le balayage, avec son pc, sa longueur, ses opérandes et son type, ainsi que le fait qu’aucun autre pc n’en contient; chaque octet de l’image issu du fichier, ce qui inclut les déclarations de délégation; le pc d’entrée; l’ensemble des familles, chaque hauteur, le plafond de taille du code et la version du code. Elle ne lie ni la cérémonie ni la table de lookup générique, que couvre le condensé SRS; ni les circuits, que le chargement d’une clé astreint au registre du vérificateur; ni rien de ce que choisit une exécution; ni rien de l’ELF que le chargeur ne lit pas, comme la table des symboles.

La calculer exige les puissances de la cérémonie, pour engager. La vérifier n’exige que les engagements, que porte une clé de vérification : le chargement d’une clé recalcule l’identité à partir d’eux, et l’ouverture de chaque shard vérifie ses colonnes de mise en place par rapport aux mêmes points. C’est ce qui rattache les tables que lit une preuve à l’identité qu’un vérificateur a enregistrée.

Important

Un vérificateur obtient l’identité par un canal que le prouveur ne contrôle pas. Face à une identité fournie par le prouveur, une preuve montre seulement qu’un programme quelconque s’est exécuté. Quiconque détient l’ELF, les paramètres et le fichier de cérémonie peut la recalculer.

La spécification : Programme et identité.

Architecture

Exécution, familles et shards

Un seul hart, une horloge de 38 bits, chaque accès mémoire sous forme de requête horodatée, 23 familles de circuits, et le shard comme unité de preuve.

Voir en Markdown

La machine#

L’émulateur exécute RV32IMAC sur un seul hart à partir d’une ProgramImage, sans interruptions ni niveaux de privilège. Une exécution est une fonction pure de l’image et de son entrée, sans horloge, sans aléa ni fils d’exécution : deux exécutions découpent donc des shards identiques. Il diffère d’un RV32IMAC hébergé en trois points : sc.w réussit toujours, un accès non aligné à un demi-mot ou à un mot est fatal au lieu d’être scindé, et le flux d’instructions est l’image décodée au chargement.

Toute autre façon dont une exécution peut s’arrêter avant EXIT, comme un accès hors des régions projetées, ebreak ou un saut vers un demi-mot sans instruction, est une erreur fatale qui ne produit aucune trace. Une telle exécution n’a pas de preuve. Un statut de sortie non nul n’est pas une erreur : c’est une exécution comme une autre, et elle est prouvable.

L’horloge et la requête#

Le cycle c occupe les quatre horodatages 4c + Δ, un par créneau Δ ∈ {0, 1, 2, 3}. Chaque instruction est un cycle, et rien d’autre n’en est un : une invocation de délégation emprunte le cycle qui l’a demandée. Les cycles sont numérotés à partir de 1, parce que l’horodatage 0 est l’écriture initiale de chaque adresse. L’horloge fait 38 bits : une exécution compte donc au plus 2^36 − 1 cycles.

Chaque accès mémoire est une requête : la lecture d’une valeur écrite pour la dernière fois à un horodatage antérieur, et une écriture à l’horodatage courant. Une requête qui ne fait que lire réécrit ce qu’elle a lu. Le créneau 0 de chaque cycle est la requête du pc, qui lit pc et écrit next_pc. Viennent ensuite les requêtes de registres et de mémoire de l’instruction, à des créneaux fixes :

Classe Δ = 1 Δ = 2 Δ = 3
registre-immédiat, jalr rs1 rd
branchements rs1 rs2
registre-registre, M rs1 rs2 rd
chargements rs1 le mot, lu rd
rangements rs1 rs2 le mot, avec les octets rangés fusionnés
atomiques rs1 rs2 le mot, et rd
ecall a7 a0 a0, et la requête miroir d’une délégation

Les adresses résident dans des espaces : les 32 registres, la RAM par mots alignés sur 4 octets, le pc, un espace d’ancrage par type de délégation, et les cellules de corps du format de récursion. x0 est un registre ordinaire dans la trace et une constante dans la machine : chaque requête qui le vise lit et écrit 0.

Vingt-trois familles#

Une famille est un circuit et les lignes qu’il prouve. Il en existe quatre catégories :

Catégorie Familles Une ligne est
Exécution 0–6 : ADD_SUB_LUI_AUIPC, JUMP_BRANCH_SLT, SHIFT_BITWISE, MUL_DIV, MEM_WORD, MEM_SUBWORD, ATOMICS une instruction exécutée
Fenêtre 7 INIT_TEARDOWN, 8 ZERO_WINDOWS, 12 PUBLIC_INPUT, 13 PUBLIC_OUTPUT, 14 ADVICE_WINDOWS un mot mémoire, initialisé puis finalisé
Délégation 9 KECCAK_F, 10 POSEIDON2, 11 FR_ARITH, 15 MOD_MUL, 16 SHA256_COMP, 17 EC_ADD une invocation sur un cadre de RAM
Récursion 18 FIELD_WINDOWS, 19 FR_OP, 20 P2_FIELD, 21 FIELD_IO, 22 FQ_OP une cellule de corps, ou une opération de coprocesseur

Chaque cycle va à l’unique famille d’exécution dont la table décodée revendique son pc. Les familles s’entrelacent dans le temps : ADD_SUB_LUI_AUIPC peut posséder les cycles 1 et 3, et JUMP_BRANCH_SLT le cycle 2. Rien n’exige qu’elles soient contiguës, parce que l’argument de mémoire ordonne chaque ligne par son écriture du pc.

Les familles de fenêtres existent parce que l’argument de mémoire exige que chaque adresse que touche une exécution ait exactement une valeur initiale et une valeur finale. INIT_TEARDOWN couvre la fenêtre de RAM 0 et l’initialise avec l’image du programme; ZERO_WINDOWS couvre toutes les autres fenêtres de RAM ordinaire que l’exécution a touchées et les initialise à zéro; la paire publique couvre les fenêtres d’entrée et du journal; ADVICE_WINDOWS couvre la région des données auxiliaires (advice), qu’elle initialise avec les octets du prouveur.

Shards#

Les lignes d’une famille, dans leur ordre d’apparition, sont découpées en shards de la hauteur de la famille. Le dernier est complété par des lignes nulles, que les circuits sont construits pour accepter. Un shard coûte sa hauteur complète, quel que soit son taux d’occupation : les familles que touche un programme et les hauteurs qu’il choisit fixent donc le plancher de chaque preuve.

Famille Hauteur par défaut Pourquoi
Familles d’instructions 2^22, 2^20 pour MUL_DIV et ATOMICS le plancher de leurs vérifications d’intervalle sur les horodatages est 2^20
Fenêtres de RAM 2^22 une seule hauteur de fenêtre partagée, d’au moins 2^16
Entrée publique, journal 2^12 fixée : la hauteur place les fenêtres
KECCAK_F, SHA256_COMP 2^18 quatre fois les appels de leur plancher 2^16, pour 2 % de preuve en plus
MOD_MUL, EC_ADD 2^16 leur plancher
POSEIDON2, FR_ARITH 2^8 aucune table, donc aucun plancher

Un shard est prouvé isolément, par le circuit de sa famille, sauf pour l’argument de mémoire : le circuit de chaque shard produit en sortie le produit de ses tuples de lecture et celui de ses tuples d’écriture, et le vérificateur rapproche ces produits une seule fois sur l’ensemble des shards de l’énoncé. C’est la seule chose qui relie les shards. Il n’y a ni chaînage du pc d’un shard à l’autre, ni frontière partagée entre voisins.

Aux hauteurs par défaut, la taille d’une preuve de shard va d’environ 12,5 KB pour une fenêtre publique à 665 KB pour un shard POSEIDON2; celle d’une famille d’instructions va de 64 à 77 KB. La page Performances en donne le tableau.

La spécification : Trace d’exécution, Circuits et registre.

Architecture

Le moteur GKR

Le moteur de preuve au centre d’Apogee. Pourquoi un circuit GKR en couches n’engage que ses entrées, comment une seule passe arrière de sumchecks ramène un circuit entier à un seul point, et ce que cela rapporte.

Voir en Markdown

Chaque shard d’Apogee est prouvé de la même façon : par le circuit de sa famille, écrit sous forme d’empilement de couches, que le moteur GKR parcourt à rebours, des sorties du circuit jusqu’à ses colonnes engagées. Cette page explique pourquoi ce moteur occupe le centre du système, et pourquoi la famille de systèmes de preuve à laquelle il appartient est celle vers laquelle se déplace la frontière de la génération de preuves.

L’idée#

La façon classique de prouver un calcul consiste à le disposer sous forme de table, à engager chaque colonne, valeurs intermédiaires comprises, et à prouver qu’un ensemble de contraintes s’annule sur la table. Les engagements en sont la partie coûteuse : chaque colonne engagée coûte une multiplication multi-scalaire ou un arbre de Merkle, ainsi qu’une ouverture en chaque point qu’interroge l’argument de contraintes.

GKR, d’après Goldwasser, Kalai et Rothblum, change ce qui doit être engagé. Le calcul est un circuit en couches. Seule la couche inférieure, celle des entrées, est engagée. Chaque couche au-dessus est définie par des portes sur la couche du dessous, et le prouveur ne l’engage jamais. À la place, une affirmation sur la couche supérieure est ramenée par un sumcheck à une affirmation sur la couche située juste en dessous, puis à la suivante, jusqu’à ce que les affirmations aboutissent aux entrées engagées, toutes en un seul point aléatoire. Une seule ouverture les tranche toutes.

Note

Une analogie, pas un nom. Un prouveur conventionnel est une fusée : il transporte jusqu’à destination chaque valeur intermédiaire qu’il produit, engagée, et paie le prix de cette masse. Le moteur GKR se comporte plutôt comme le moteur de distorsion de la science-fiction, qui déplace l’espace autour du vaisseau plutôt que le vaisseau lui-même. Ce qui voyage, c’est l’affirmation, descendue à travers le circuit couche par couche, tandis que les couches intermédiaires ne sont jamais transportées nulle part.

Ce que cela rapporte à une zkVM :

  • Les valeurs intermédiaires ne coûtent aucun engagement. Un circuit de famille peut calculer des centaines de colonnes internes, des arbres de produits et des arbres de fractions, sans qu’aucun ne soit jamais engagé. Seules ses colonnes de trace le sont.
  • Un seul point d’ouverture par shard. La passe arrière se termine avec une affirmation sur chaque colonne engagée, toutes au même point. Un shard a besoin d’exactement une ouverture groupée, de 704 octets, quel que soit son nombre de colonnes.
  • Le travail du prouveur est de l’arithmétique de corps. Le sumcheck de chaque couche est linéaire en la taille de la couche, sur Fr, sans engagement, transformée ni hachage par couche.
  • Les arguments se composent à l’intérieur du circuit. Les grands produits de l’argument de mémoire et les sommes LogUp des lookups ne sont que des couches supplémentaires du même circuit, réduites dans la même passe.

Un circuit de famille, couche par couche#

Les couches d’un circuit de famille La couche 0 est formée des colonnes engagées, à côté des tables virtuelles. La liste de portes 0 construit les feuilles mémoire, les fractions de lookup et les portes de contrainte. Les listes ligne par ligne réduisent les arbres de chaque ligne à un seul nœud. Les listes à réduction de moitié, une par variable, multiplient et additionnent les lignes jusqu’à un sommet sans variables : la racine de lecture, la racine d’écriture, et le numérateur et le dénominateur de chaque canal. La passe arrière va du sommet à la couche 0, avec un sumcheck par transition, et se termine en un seul point, où une seule ouverture Mercury tranche les affirmations sur chaque colonne engagée. Couche 0 · colonnes engagées M ‖ W ‖ S à côté des tables virtuelles V, formes closes qu’évalue le vérificateur · seule couche engagée Liste de portes 0 · ligne par ligne feuilles mémoire · fractions de lookup (num, den) · portes de contrainte, degré ≤ 2 Listes ligne par ligne 1 … r arbres de produits et de fractions : frères combinés jusqu’à un nœud par arbre et par ligne Listes à réduction de moitié, une par variable TreeProduct · TreeCross : lignes (·,0) et (·,1) combinées Sommet · sans variables racines lecture/écriture · (num, den) par canal PASSE ARRIÈRE sorties absorbées d’abord un sumcheck par transition : affirmations sur la couche k+1 ramenées à la couche k réduction : τ sur une droite fusionne les deux enfants chaque colonne engagée annoncée au même point u → UNE OUVERTURE MERCURY PASSE AVANT · PROUVEUR SEUL
Un circuit de famille. Le prouveur calcule chaque couche une fois, en montant (pointillés). La preuve descend : les sorties sont absorbées, puis chaque transition est un sumcheck qui transforme des affirmations sur une couche en affirmations sur la couche du dessous, jusqu’à ce qu’elles se rejoignent toutes en un seul point sur les colonnes engagées.

La couche inférieure est formée des colonnes engagées du shard, de trois sortes qui diffèrent par le moment où elles sont liées : M, les colonnes mémoire, engagées dans la transcription globale de l’énoncé avant qu’existe le moindre défi mémoire; W, les colonnes témoins, engagées dans la transcription propre au shard; et S, les colonnes de mise en place, liées par l’identité du programme ou par la cérémonie. À côté d’elles se trouvent les tables virtuelles : des formes closes comme l’indice de ligne ou l’intervalle de 16 bits, que le vérificateur évalue en n’importe quel point et qui ne sont jamais engagées.

Au-dessus de la couche 0, chaque circuit de famille a la même anatomie :

  1. La liste de portes 0 calcule, ligne par ligne, les feuilles mémoire (les tuples de lecture et d’écriture de chaque requête), les fractions de lookup (une paire (numerator, denominator) par lookup, plus celle de la table), et chaque porte de contrainte : les contraintes de la famille, chacune étant un polynôme qui doit s’annuler sur chaque ligne.
  2. Les listes ligne par ligne combinent les feuilles sœurs : un arbre de produits multiplie des tuples, un arbre de fractions additionne des fractions sous la forme (n_a·d_b + n_b·d_a, d_a·d_b), jusqu’à ce que chaque ligne ne contienne plus qu’un nœud par arbre.
  3. Les listes à réduction de moitié, une par variable de la hauteur du shard, combinent les lignes deux à deux : la première moitié avec la seconde. Après n d’entre elles, le circuit atteint un sommet sans variables : la racine de lecture du shard, sa racine d’écriture, et le numérateur et le dénominateur finaux de chaque canal de lookup.

Un seul circuit prouve donc les contraintes de la famille, calcule sa contribution à l’argument de mémoire et somme ses lookups, en une seule passe. Chaque porte est de degré au plus 2 : chaque polynôme de tour de chaque sumcheck est donc cubique.

La passe arrière#

Le prouveur matérialise chaque couche une fois, en montant. La preuve descend ensuite, et l’ordonnancement de sa transcription est le même pour chaque circuit :

  1. Les sorties sont absorbées en premier : le prouveur est ainsi engagé sur les racines avant qu’existe le moindre défi.
  2. Pour chaque transition de la couche k + 1 vers la couche k, un défi λ regroupe en une seule somme chaque affirmation sur la couche k + 1, ainsi que chaque porte de contrainte de la liste. Un sumcheck ramène cette somme à une évaluation en un point aléatoire ρ, à raison d’un message cubique par variable.
  3. Le prouveur annonce les valeurs des colonnes de la couche k en ρ. Pour une liste à réduction de moitié, il annonce les deux enfants, et un défi supplémentaire τ les fusionne en une seule affirmation par colonne.
  4. À la couche 0, chaque colonne engagée fait l’objet d’une seule affirmation, toutes au même point u.

L’unique ouverture Mercury du shard prouve ces affirmations par rapport aux engagements : ceux des colonnes mémoire, tirés de l’énoncé; ceux des colonnes témoins, tirés de la preuve du shard; ceux des colonnes de mise en place, tirés de la clé de vérification. Les tables virtuelles sont évaluées par le vérificateur lui-même.

Les portes de contrainte voyagent sans frais. Une porte de contrainte affirme 0 partout : elle rejoint donc le lot de la transition où elle se trouve, et une porte violée rend la somme groupée non nulle avec une probabilité écrasante. Une seule erreur LayerInconsistency couvre aussi bien une affirmation descendante erronée qu’une porte violée; une somme groupée ne peut pas les distinguer, et la preuve ne dépense rien pour les distinguer.

Pourquoi c’est solide#

Chaque défi est tiré après tout ce qu’il protège :

  • le point de sortie après les sorties, si bien qu’un prouveur ne peut pas choisir des tables qui ne concordent avec la vérité que là où elles seront vérifiées;
  • λ après les affirmations et le point, si bien qu’une affirmation fausse ou une porte violée ne survit qu’en une racine d’un polynôme non nul en λ;
  • chaque défi du sumcheck après la cubique de son tour, si bien qu’une cubique erronée concorde avec la vraie avec une probabilité d’au plus 3/|Fr|;
  • τ après les valeurs des deux enfants.

Sommée sur chaque transition d’un circuit enregistré à sa hauteur par défaut, l’erreur de solidité (soundness) reste inférieure à 2^14/|Fr|, avec Fiat–Shamir sur la transcription Poseidon2 dans le modèle de l’oracle aléatoire.

Les circuits sous forme de données#

Un circuit n’est pas du code. C’est un CircuitArtifact : ses colonnes engagées, par nom, ses tables virtuelles, ses listes de portes avec sept formes de portes, une liste plate des mêmes relations, ses lookups et sa ligne de remplissage, le tout sérialisé de façon canonique. Quatre lois astreignent chaque artefact à une forme cohérente : chaque opérande est lisible là où il est lu, la largeur de chaque liste est dérivée de ses portes, la couche supérieure correspond exactement aux sorties, et les portes en couches et les relations plates forment un seul ensemble de contraintes. Elles sont vérifiées une seule fois, là où un artefact est construit ou une clé chargée, jamais à chaque preuve.

Deux conséquences comptent pour quiconque évalue le système :

  • Une clé de vérification porte ses circuits, et le vérificateur les astreint à son propre registre. L’identité du programme lie le programme; le registre lie les circuits qui le prouvent.
  • Les circuits peuvent être vérifiés par une seconde implémentation. La crate checker réimplémente les lois, les règles de lookup et le contrat de remplissage sans partager le code des constructeurs, et n’évalue les portes qu’au moyen du seul noyau de portes que les deux côtés tiennent pour l’autorité sémantique.

Le coût#

Le moteur échange des engagements contre de la mémoire. La passe avant détient chaque couche interne sous forme d’éléments du corps : environ 8,4 GiB pour un shard 2^20 de la famille d’instructions la plus large, et 42 GiB pour un shard KECCAK_F de 2^18, dont le circuit calcule 5 490 colonnes internes. C’est pourquoi la génération de preuves est limitée par la mémoire, pourquoi les hauteurs sont un paramètre de réglage, et pourquoi le prouveur en flux borne la mémoire par les shards en cours de traitement. La taille de la preuve ne croît que d’un tour de sumcheck par variable et par couche : la preuve d’un shard KECCAK_F fait 381 100 octets à 2^18, contre 373 276 à 2^16, pour quatre fois plus de travail.

La spécification : Le moteur GKR, et la page propre à chaque famille sous Auditeurs.

Architecture

Engagements

Chaque colonne engagée est ouverte avec Mercury, un engagement multilinéaire sur KZG dont l’ouverture est de taille constante. Ce qu’il coûte, comment un shard regroupe toutes ses colonnes en une seule ouverture, et comment la récursion diffère le couplage.

Voir en Markdown

Chaque colonne qu’engage Apogee est un polynôme multilinéaire, une table de 2^n évaluations sur l’hypercube booléen. Chacune est engagée et ouverte avec Mercury (Eagen et Gabizon, ePrint 2025/385), complété par l’ouverture KZG groupée de BDFG20 (Boneh, Drake, Fisch et Gabizon, ePrint 2020/081). La spécification fixe ce que les articles laissent ouvert et ajoute deux choses : un lot de nombreuses colonnes en un même point, et une forme différée que replie l’arbre de récursion.

L’engagement#

Un engagement Mercury est exactement l’engagement KZG de la table d’évaluations lue comme une suite de coefficients : une seule multiplication multi-scalaire sur les n premières puissances du τ de la cérémonie, un seul point de G1, 64 octets. Il n’y a aucun second schéma derrière. Il en découle deux propriétés, et le reste du système utilise l’une et l’autre :

  • Il est linéaire. L’engagement de Σ ρ^i·f_i est Σ ρ^i·cm_i, ce qui permet à de nombreuses colonnes de partager une seule ouverture.
  • Les coefficients nuls n’ajoutent rien. Une colonne prolongée par des lignes nulles conserve son engagement : les trois engagements de la table de lookup générique servent donc à toute hauteur qui contient la table, et ce sont des constantes de la cérémonie.

Les colonnes sont engagées à leur largeur entière : une colonne de bits, d’octets, de demi-mots ou de mots passe par une MSM sur des scalaires u32, recodés à partir de 32 bits au lieu de 254, ce qui garde peu coûteux l’engagement d’une trace.

L’ouverture#

Mercury scinde en deux moitiés un point d’ouverture u de s = 2t variables, replie le polynôme par un défi α, et prouve les deux produits scalaires qui restent avec un témoin symétrisé, pour finir par une seule ouverture KZG groupée en trois points. La preuve compte huit points de G1 et six éléments du corps : 704 octets, pour toute taille. D’où une exigence : le nombre de variables doit être pair, et c’est pourquoi chaque hauteur au menu est une puissance paire de deux.

Le vérificateur effectue O(t) opérations sur le corps, des MSM de dix et de deux points, et une seule vérification de couplage à deux paires. Les deux relations qu’il vérifie s’écrivent e(A, [1]_2) = e(B, [x]_2) : les deux arguments de G2 sont donc des constantes de la mise en place, et le vérificateur n’effectue aucune arithmétique dans G2. C’est aussi cette forme qui permet à la récursion de différer le couplage au lieu de le calculer.

Les colonnes d’un shard, une seule ouverture#

La passe GKR se termine avec une affirmation sur chaque colonne engagée d’un shard, toutes au même point u. Aucun sumcheck de fusion des affirmations n’est donc nécessaire : l’ouverture les regroupe toutes. Un défi ρ, tiré après chaque engagement et chaque valeur annoncée, pondère la colonne i par ρ^i; le prouveur ouvre Σ ρ^i·f_i une seule fois, et le vérificateur forme Σ ρ^i·cm_i par une MSM à k points. Une affirmation fausse survit avec une probabilité d’au plus (k − 1)/|Fr|.

Les colonnes du lot proviennent de trois endroits, dans un ordre fixe qui fait partie de ce qui est prouvé : les engagements des colonnes mémoire, tirés de l’énoncé; ceux des colonnes témoins, tirés de la preuve du shard; et ceux des colonnes de mise en place, tirés de la clé de vérification. C’est le fait de prendre les engagements de mise en place dans la clé qui fait que l’ouverture lie les tables décodées et l’image qu’engage l’identité du programme.

Vérification différée#

Une vérification différée exécute tout sauf le couplage, et conserve les termes de la relation sous forme de douze entrées (side, scalar, point). La récursion utilise exactement cela : chaque nœud de l’arbre calcule les douze scalaires d’un shard dans sa propre arithmétique, les pondère par de nouveaux défis et les ajoute à une seule paire de points cumulative (A, B). L’ouverture de chaque shard de l’arbre entier se replie en une seule affirmation e(A, [1]_2) = e(B, [x]_2), que seul le contrat sur Ethereum vérifie en fin de compte. Récursion et règlement montre le repliement.

Mesures#

Sur un Apple M5 Pro à 18 cœurs :

Opération Temps
Engager une colonne de 2^22 1,30 s
Ouvrir une colonne de 2^22 2,89 s
16 colonnes de 2^20, ouvertes en un seul lot 1,01 s, vérifiées en 4,8 ms
Les mêmes 16, ouvertes une à une 9,79 s, vérifiées en 62 ms

Sécurité#

La solidité de connaissance (knowledge soundness) tient dans le modèle du groupe algébrique sous q-DLOG, avec Fiat–Shamir sur la transcription Poseidon2 dans le modèle de l’oracle aléatoire, et une SRS dont personne ne connaît le τ. Les termes statistiques, Schwartz–Zippel sur les défis, restent inférieurs à 2^−220 pour chaque instance utilisée : le niveau de sécurité est donc celui de BN254, environ 100 bits. Rien n’est masquant (hiding) et rien n’est aveuglé : Apogee v1.0.0 n’est pas à divulgation nulle de connaissance.

La SRS est celle des puissances de tau perpétuelles de PSE, contribution 80, solide tant qu’un seul contributeur a été honnête. Son fichier est décodé, et l’on vérifie que chaque point se trouve sur sa courbe et dans le bon sous-groupe; rien ne prouve qu’un fichier est bien celui de cette cérémonie, et c’est pourquoi un vérificateur compare le condensé SRS à celui de la cérémonie, obtenu par son propre canal.

La spécification : Mercury, La chaîne de référence structurée.

Architecture

Mémoire et lookups

Deux arguments portent tout ce qui traverse une ligne. Un seul multiensemble lecture/écriture sur toute l’exécution, rapproché une seule fois, fait que chaque lecture renvoie la dernière écriture et ordonne chaque ligne; des canaux LogUp font de chaque valeur un octet, un mot ou une ligne de table.

Voir en Markdown

Les portes d’une famille contraignent une ligne à la fois. Tout ce qui traverse les lignes, les shards ou les familles, comme ce que contient un registre, ce que renvoie un chargement, l’instruction qu’exécute une ligne ou le fait qu’une valeur tienne sur 32 bits, est porté par deux arguments qui résident dans les mêmes circuits GKR.

L’argument de mémoire#

Chaque accès mémoire devient un élément du corps, un tuple :

T(AS, ADDR, TS, VAL) = γ + AS + α_addr·ADDR + α_ts·TS + α_val·VAL

sur l’espace d’adressage, l’adresse, l’horodatage et la valeur, avec quatre défis tirés une fois par énoncé. Une requête apporte son tuple de lecture à un côté et son tuple d’écriture à l’autre. Le circuit de chaque shard produit en sortie deux nombres, le produit de ses tuples de lecture et le produit de ses tuples d’écriture. Le vérificateur vérifie ensuite une seule équation sur l’énoncé entier :

∏ read roots · R_b  =  ∏ write roots · W_b          over every shard of every family

W_b et R_b sont les tuples initiaux et finaux des 32 registres et du pc, qui n’ont pas de lignes propres : le vérificateur les intègre aux produits à partir de 64 scalaires de frontière que porte l’énoncé. La RAM reçoit ses valeurs initiales et finales des shards des familles de fenêtres, qui contiennent un mot par ligne : l’image du programme dans la fenêtre 0, des zéros dans chaque autre fenêtre que l’exécution a touchée, l’entrée de l’énoncé dans la fenêtre de l’entrée publique, les octets du prouveur dans les données auxiliaires (advice).

Si l’équation tient, les multiensembles sont égaux avec une probabilité écrasante. Des multiensembles égaux signifient que chaque lecture renvoie la dernière écriture qui la précède : chaque lecture est appariée à exactement une écriture, une lecture doit suivre strictement l’écriture qu’elle consomme, et chaque adresse a exactement une écriture initiale.

L’ordre sans frais#

Le pc est une cellule mémoire comme une autre, à l’adresse 0 de son propre espace. Chaque ligne lit le pc et écrit le suivant, et son circuit astreint l’écriture à se situer au moins quatre horodatages après la lecture. L’historique du pc est donc un seul chemin qui passe par chaque ligne active de chaque famille, du point d’entrée jusqu’à la ligne de sortie. Ce chemin unique donne :

  • l’ordre du programme, puisque les lignes sont ordonnées par leurs écritures du pc;
  • la continuité d’un shard et d’une famille à l’autre, puisque la lecture du pc de chaque ligne consomme l’écriture du pc d’une ligne;
  • aucun cycle prouvé deux fois, puisqu’aucune écriture ne peut être consommée deux fois.

Aucun shard ne s’enchaîne à son voisin, et aucun n’en a besoin. La fenêtre temporelle annoncée d’un shard ne lie rien; seule sa forme est vérifiée.

Ce qui doit venir en premier#

Les défis mémoire sont tirés une fois par énoncé, à la fin de la transcription globale, après que tout ce qu’un tuple peut lire a été fixé : les engagements mémoire de chaque shard, l’identité du programme (qui fixe le pc d’entrée et l’image), le condensé de la cérémonie, les nombres de shards et la liste des fenêtres, le condensé de l’entrée publique et du journal, et en dernier les 64 scalaires de frontière. Une valeur choisie après les défis pourrait être obtenue par résolution; c’est l’ordre de la transcription qui l’interdit. Pour la même raison, un tuple mémoire ne peut lire que des colonnes mémoire, de mise en place et virtuelles, jamais une colonne témoin, laquelle est engagée dans la transcription propre au shard, après les défis. Les constructeurs de circuits refusent tout artefact qui enfreint cette règle.

Lookups#

Un lookup affirme qu’un tuple de valeurs d’une ligne est une ligne d’une certaine table. Apogee prouve chaque lookup d’un shard avec LogUp : une identité par table, ou canal,

Σ_rows Σ_lookups 1/(E(y) + g)  −  Σ_rows mult(y)/(T(y) + g)  =  0

sommée par un arbre de fractions à l’intérieur du circuit GKR propre à la famille et vérifiée à sa racine : numérateur nul, dénominateur non nul. La colonne des multiplicités n’a besoin d’aucune contrainte : un tuple qui ne figure dans aucune ligne de table laisse un pôle que les multiplicités ne peuvent pas annuler.

Canal Table Utilisé pour
TIMESTAMP [0, 2^19), virtuelle l’écart d’horodatage de chaque requête, en deux fragments de 19 bits
RANGE16 [0, 2^16), virtuelle les valeurs de 32 bits, en deux demi-mots; les retenues; les bornes des cadres
XOR8 toutes les paires d’octets et leur XOR, virtuelle Keccak et SHA-256, octet par octet
GENERIC une table engagée de lignes AND, de signe et de puissances de décalage les opérations bit à bit, les bits de signe, les amplitudes de décalage
DECODER la table décodée de la famille, engagée par l’identité lier chaque ligne exécutée au programme

Trois des tables sont virtuelles : des formes closes de l’indice de ligne que le vérificateur évalue lui-même, et qui ne coûtent aucun engagement. La table générique est engagée une seule fois au moyen des puissances de la cérémonie et couverte par le condensé SRS.

Le lookup du décodeur#

Chaque famille d’exécution effectue un lookup par ligne active dans sa propre table décodée, indexé par le pc que la ligne a lu en mémoire. Ce seul lookup lie le cycle au programme : les opérandes, l’immédiat et le type d’instruction de la ligne sont ceux du programme à ce pc, et ses bits de type sont one-hot, parce que chaque ligne active de la table contient un masque one-hot et chaque ligne de remplissage contient −1, qu’aucune somme de bits de type n’atteint. Une ligne à un pc où le programme n’a pas d’instruction ne trouve aucune ligne de table.

Les clés doivent être bornées#

Un canal prouve l’appartenance à une table, et rien de plus. Plusieurs sous-tables partagent la table générique sous des plages de clés disjointes : une clé non bornée pourrait donc tomber dans la mauvaise sous-table et prouver un AND faux. Chaque famille borne par conséquent chaque clé qu’elle recherche, au moyen d’un lookup d’intervalle sous le même sélecteur, et les constructeurs de circuits vérifient qu’une borne écrite au moyen d’un facteur d’échelle porte aussi une borne directe. La spécification énonce, pour chaque famille, l’attaque que cela empêche.

Comment le tout se compose#

Avec les portes de chaque famille, ces deux arguments donnent son sens à l’énoncé : chaque ligne obéit à son instruction, l’instruction est celle du programme, chaque lecture voit la dernière écriture, les lignes forment un seul chemin de l’entrée à la sortie, chaque valeur est l’entier qu’elle prétend être, et les fenêtres publiques contiennent les octets de l’énoncé. La carte de solidité mène chaque affirmation jusqu’aux sections qui la prouvent.

La spécification : L’argument de mémoire, Lookups.

Architecture

Délégations

Comment une fonction coûteuse obtient un circuit qui lui est propre sans faire grossir les circuits d’instructions. L’appel, l’ancre qui apparie chaque demande à exactement une invocation, les six circuits et leurs aspects économiques.

Voir en Markdown

Le hachage et l’arithmétique des grands entiers dominent les charges de travail réelles : dans un bloc Ethereum du réseau principal, la multiplication et l’élévation au carré dans le corps de secp256k1 représentaient à elles seules 44 % des cycles avant d’être déléguées. Les prouver instruction par instruction est possible, et lent. Une délégation donne à une telle fonction une famille de circuits qui lui est propre, invoquée depuis le programme invité : les circuits d’instructions restent ainsi petits, et un programme ne paie que les délégations qu’il appelle.

L’appel#

Une délégation est invoquée, jamais décodée. Le programme invité écrit en RAM un cadre de mots de 32 bits et émet un ecall avec le numéro de la délégation dans a7 et l’adresse de base du cadre dans a0. L’ecall est une ligne de la famille ADD_SUB_LUI_AUIPC, la demande. Le travail est une ligne de la famille propre à la délégation, l’invocation, qui lit chaque mot du cadre et réécrit chaque mot du cadre, résultats compris, au cycle de la demande. Une invocation ne possède aucun cycle; elle emprunte celui qui l’a demandée.

Les cadres sont alignés sur des mots et se trouvent entièrement en RAM ordinaire : aucun cadre ne chevauche donc une fenêtre publique ni les données auxiliaires (advice), et les lectures et écritures du cadre sont des requêtes mémoire ordinaires. Ce qu’a calculé une délégation est par conséquent lié exactement comme l’est tout rangement : par l’unique multiensemble mémoire.

L’ancre#

Demandes et invocations doivent s’apparier une à une : sinon, plusieurs demandes pourraient se refermer sur une seule invocation et laisser des appels non exécutés, ou une invocation non demandée pourrait réécrire un cadre. Elles s’apparient par le même multiensemble mémoire, dans un espace d’ancrage qui n’appartient qu’au type de délégation et qu’aucune instruction ne peut atteindre :

Lectures Écritures
Demande, cycle c T(s, base, 0, 0) T(s, base, 4c + 3, v)
Invocation T(s, base, 4c + 3, v′) T(s, base, 0, 0)

Trois portes du côté de la demande fixent sa lecture à l’horodatage 0 et à la valeur 0, et lui font écrire 0 dans a0. Les tuples horodatés 0 sont alors exactement les lectures des demandes et les réponses des invocations : il y a donc autant d’invocations que de demandes, sur les mêmes bases; et puisque deux demandes ne partagent jamais un cycle, la lecture de chaque invocation est exactement l’écriture d’une seule demande. Chaque invocation se situe à la base et au cycle de sa demande. Aucune porte d’un circuit de délégation n’a eu à connaître quoi que ce soit des demandes.

Plusieurs appels, une seule opération#

Une opération trop large pour une seule ligne correspond à plusieurs invocations sur un même cadre, un mot du cadre désignant l’étape : une permutation keccak-f[1600] représente 24 appels de tour, une compression SHA-256 16 appels de quatre tours, une addition complète de points trois appels. Aucune porte ne relie deux lignes. Chaque appel prouve son étape sur le cadre tel qu’il le trouve, ses lectures se situant sur l’historique mémoire unique de chaque mot : il lit donc les écritures de l’étape précédente. C’est au code appelant de garantir que chaque étape s’exécute, dans l’ordre, et ce code appelant est du code de programme invité, prouvé sous forme d’instructions. Le SDK émet chaque opération en plusieurs appels à partir d’une seule fonction : un programme invité n’ordonne donc jamais les étapes à la main.

Déclarées statiquement#

Le balayage des instructions ne peut pas voir un appel, parce que le numéro est une valeur de a7 connue à l’exécution. Chaque shim du SDK laisse donc un enregistrement de déclaration de 12 octets dans sa propre section d’édition de liens, conservée seulement si le shim est atteignable. La dérivation du programme recherche ces enregistrements dans l’image, et une famille déclarée rejoint la configuration, liée par l’identité à travers les octets de l’image. Une famille liée mais jamais appelée prouve zéro shard; un numéro appelé dont le programme n’a jamais déclaré la famille n’a pas de preuve.

Les six circuits#

Famille Une invocation Construite à partir de
KECCAK_F un tour de keccak-f[1600] sur un cadre de 51 mots des octets : 1 020 lookups XOR8 par tour; les rotations comme formes linéaires sur des octets et des copies masquées
SHA256_COMP quatre tours et quatre mots de l’expansion du message des octets et XOR8 : 52 obligations par tour, 32 par mot d’expansion; Ch et Maj comme formes linéaires en XOR
POSEIDON2 une permutation de largeur 3 les tours calculés dans les couches propres du circuit, trois listes de portes par tour, sans aucun lookup; la seule délégation qui calcule au-dessus de sa première couche
FR_ARITH une addition, une multiplication ou une inversion dans Fr, sous forme de Montgomery des décompositions en bits et des chaînes de canonicité par rapport à p
MOD_MUL un a·b mod m sur 256 bits, quatre modules d’Ethereum des limbs de 32 bits, un quotient, des retenues, et une chaîne de canonicité qui prouve out < m
EC_ADD un tiers d’une addition complète de points sur secp256k1 ou sur G1 de BN254 la formule complète de Renes–Costello–Batina, sous forme de trois réductions par ligne

Quelques constructions reviennent d’un circuit à l’autre. Une règle du code unique décode un mot du cadre qui désigne l’un de k cas en sélecteurs booléens dont exactement un est activé, parce que les codes s’additionnent : sans elle, les sélecteurs 1 et 3 répondent à une demande de 4. Une chaîne de canonicité prouve qu’une valeur de 256 bits est inférieure à un module au moyen d’emprunts sur des limbs de 32 bits. Enfin, chaque mot écrit est borné sous 2^32, pour que la RAM reste faite de mots, ce sur quoi s’appuie chaque famille d’instructions.

Aspects économiques#

La hauteur d’une famille de délégation fixe le nombre d’appels que contient un shard, et un shard coûte sa hauteur quel que soit son taux d’occupation :

Famille Hauteur Unités par shard Preuve de shard
KECCAK_F 2^18 10 922 permutations 381 100 B
SHA256_COMP 2^18 16 384 compressions 189 988 B
EC_ADD 2^16 21 845 additions 434 916 B
MOD_MUL 2^16 65 536 multiplications 135 220 B
POSEIDON2 2^8 256 permutations 664 780 B
FR_ARITH 2^8 256 opérations 266 292 B

Pour une famille souvent appelée, c’est le shard le plus gros qui revient le moins cher : une preuve KECCAK_F grossit à peine de 2^16 à 2^18. Le prix se paie en mémoire. La passe avant d’un shard KECCAK_F de 2^18 occupe 42 GiB, et deux de ces shards en cours de traitement ont fixé le pic du bloc mesuré.

Ce qui est délégué, et ce qui ne l’est pas#

Le code des bibliothèques atteint les délégations par des copies corrigées de k256, ark-ff et revm-precompile : la récupération de clé secp256k1 devient du code k256 sur MOD_MUL et EC_ADD, et un couplage BN254 devient du code ark-bn254 sur MOD_MUL. Le MULMOD de l’EVM avec un module arbitraire, MODEXP, BLS12-381 et tout schéma de signature complet s’exécutent sous forme d’instructions. La prise en charge dédiée des signatures pour les programmes invités fait partie de la trajectoire de la v2.0.0.

La spécification : ABI de délégation, Circuits de délégation.

Architecture

Le prouveur en flux

Le prouveur exécute le programme invité deux fois et ne détient jamais la trace d’exécution. La mémoire suit les shards en cours de traitement, et non la longueur de l’exécution, et la preuve ne dépend pas de l’ordonnancement.

Voir en Markdown

Un bloc Ethereum complet représente environ 200 millions de cycles. Sa trace, c’est-à-dire chaque ligne de chaque famille et chaque événement mémoire, occuperait environ 300 octets par cycle : des dizaines de gigaoctets avant même qu’une seule colonne soit engagée. Le prouveur d’Apogee ne la construit jamais. Il exécute le programme invité deux fois et ne détient que les shards sur lesquels il travaille.

Deux passes#

Les deux passes La passe 1 exécute le programme invité et, shard par shard, à mesure que chacun se remplit, engage ses colonnes mémoire et ne conserve que les engagements; à la sortie, elle construit l’énoncé et tire les défis partagés. La passe 2 exécute de nouveau et prouve chaque shard à mesure qu’il se remplit, en ne conservant que la preuve. PASSE 1 · ENGAGEMENT Exécution Shard rempliengager M · jeter les lignes À la sortiefenêtres · énoncé · G1–G11 Défis fixésdéfis mémoire · condensé global PASSE 2 · PREUVE Réexécution Shard remplicolonnes · GKR · ouverture Garder la preuvejeter tout le reste BlockProofracines dans l’énoncé chaque transcription de shard part du condensé global
Engager, puis prouver. Les défis mémoire doivent suivre les engagements mémoire de chaque shard : les engagements viennent donc en premier, d’une première exécution, et les preuves d’une seconde.

La passe 1 exécute le programme invité et, à mesure que chaque shard se remplit, engage ses colonnes mémoire, conserve les engagements et abandonne les lignes. À la sortie, elle dérive de l’état final de la mémoire tout le reste dont l’énoncé a besoin : la frontière des registres et du pc, la liste des fenêtres mémoire que l’exécution a touchées, et les shards des familles de fenêtres. Elle exécute ensuite la transcription globale, qui absorbe l’énoncé, chaque engagement mémoire compris, et tire les défis mémoire ainsi que le condensé à partir duquel chaque shard est amorcé.

La passe 2 exécute de nouveau. L’émulateur est une fonction pure de son entrée : il découpe donc les mêmes shards, et la passe 2 vérifie par assertion que son profil de cycles, sa liste de fenêtres et sa frontière sont ceux de la passe 1. Chaque shard reçoit chacune de ses colonnes engagées et est prouvé : sa transcription, sa passe GKR, son ouverture. Les colonnes mémoire ne sont pas engagées de nouveau : l’ouverture prend leurs engagements dans l’énoncé et leurs valeurs dans la passe 2, si bien que des colonnes qui différeraient d’une passe à l’autre donneraient une ouverture que le vérificateur refuse.

L’ordre est imposé par la solidité (soundness). Les défis mémoire doivent suivre chaque valeur que peut lire un tuple mémoire : les colonnes mémoire de chaque shard sont donc engagées avant qu’un shard quelconque puisse être prouvé.

Le pipeline#

Un nombre fixe de fils de travail, max_in_flight, partagent un seul verrou autour de l’exécuteur. Sous le verrou, un fil de travail rend son shard terminé et réclame le suivant : un shard rempli s’il y en a un en attente, sinon il fait lui-même avancer l’exécuteur jusqu’à ce qu’un tampon se remplisse. Hors du verrou, il construit les colonnes du shard, le prouve et l’abandonne.

  • L’exécuteur ne devance jamais la demande. Au plus un shard rempli et non réclamé par famille attend, sous forme de lignes.
  • Au sein d’un shard, le travail est parallèle sur les données, sur tous les cœurs. Un fil de travail bloqué dans ce travail ne prend pas de second shard.
  • Le bloc ne dépend pas de l’ordonnancement. La preuve d’un shard est une fonction de l’état global et de ses propres colonnes; les preuves sont placées selon leur position dans l’énoncé. Les octets sont identiques avec 1 et avec 8 shards en cours de traitement.
  • Les échecs sont déterministes. L’échec renvoyé est le premier dans l’ordre de remplissage, quel que soit le nombre de fils de travail.

Ce que cela coûte#

La mémoire se compose d’un tampon partiel par famille, des tables des derniers accès, des shards en cours de traitement et de la sortie. L’ensemble de travail d’un shard est dominé par sa passe avant, chaque couche GKR interne sous forme d’éléments du corps : 8,4 GiB pour un shard SHIFT_BITWISE de 2^20, 42 GiB pour un shard KECCAK_F de 2^18. Ainsi, max_in_flight borne le nombre de ces ensembles qui coexistent, et les hauteurs fixent la taille de chacun.

Mesures sur le bloc 257 510 (60 transactions, 101,5 Mgas, 198 millions de cycles, 207 shards) avec 32 vCPU et 247,7 GiB, et douze shards en cours de traitement :

Passe 1 191 s; les remplissages sur un seul fil occupent 81 % de ses shard-secondes; mémoire échantillonnée d’au plus 15,9 GiB
Passe 2 2 290 s, avec environ 12 shards détenus sur 12 et 30,4 vCPU occupés jusqu’à la sortie du programme invité, puis une phase finale de 460 s
Pic de mémoire 173,92 GiB : les deux shards KECCAK_F de 2^18, ensemble dans la phase finale, sans rien d’autre en cours de traitement

Le pic est venu de la hauteur d’une seule famille de délégation, et non des douze shards en cours de traitement. C’est là le levier : un bloc avec moins d’appels Keccak, ou avec Keccak à une hauteur inférieure, culmine plus bas.

La spécification : Le prouveur en flux.

Architecture

Récursion et règlement

Comment une preuve de base de centaines de shards devient une seule preuve Groth16 que vérifie un contrat. Apogee qui prouve son propre vérificateur, les bandes qui rendent cela peu coûteux, la transcription chaînée à travers un arbre, et des couplages repliés jusqu’à ce qu’il n’en reste qu’un.

Voir en Markdown

La preuve de base d’un bloc Ethereum se compose de 207 preuves de shards : 14,5 MB, chaque shard étant une preuve GKR accompagnée de ses engagements et d’une ouverture Mercury qui se termine par une vérification de couplage. Un contrat ne peut rien vérifier de tout cela directement. La récursion la compresse, et la façon dont elle le fait est le choix de conception le plus lourd de conséquences après le moteur GKR lui-même.

La preuve de base reste intacte#

La première décision porte sur ce que la récursion ne fait pas. Aucune clé, aucun énoncé ni aucune preuve de base ne change pour rendre la récursion possible : une feuille vérifie les shards de base exactement comme le ferait un vérificateur natif. Tout ce dont la récursion a besoin est ajouté au-dessus de la preuve de base, jamais à l’intérieur. Un bloc peut être vérifié en natif, passé par la récursion, ou les deux, à partir des mêmes octets.

Apogee prouve son propre vérificateur#

Un nœud de l’arbre, c’est Apogee qui prouve un programme vérificateur. Une feuille vérifie une suite de shards de base consécutifs, de from à to; un nœud interne vérifie de deux à quatre enfants, chacun étant une preuve complète d’un programme feuille ou nœud; la racine couvre tous les shards de base. Chaque nœud est prouvé par le même prouveur en flux qu’un bloc de base.

Exécuter le vérificateur Rust sous forme d’instructions RISC-V fonctionnerait, et la mesure a donné 3,0 milliards de cycles pour les 207 shards d’un bloc : quinze fois le bloc lui-même. Les nœuds s’exécutent donc plutôt dans un format de récursion.

Le format de récursion#

Un énoncé est au format de récursion exactement quand son programme déclare des familles de corps. Le format ajoute un espace d’adressage et quatre familles de coprocesseurs qui opèrent sur cet espace, toutes invoquées par l’ABI de délégation ordinaire, et ne change rien d’autre :

Famille Une ligne est
FIELD_WINDOWS une cellule de corps : une cellule mémoire qui contient un élément Fr entier, dans le même multiensemble mémoire que la RAM
FR_OP une opération de corps sur des cellules : multiplication, addition, soustraction, multiplication-accumulation, inversion, assertion d’égalité, et étapes de construction de constantes
P2_FIELD une étape duplex Poseidon2 sur des cellules, si bien qu’une transcription s’exécute à raison d’une ligne par permutation
FIELD_IO huit mots de RAM vers une cellule, ou une cellule vers huit mots
FQ_OP une opération dans le corps de base de BN254, chaque élément tenant en quatre cellules de limbs de 64 bits, si bien que l’arithmétique de courbe s’exécute à raison d’une ligne par opération de corps

Deux autres changements rendent un shard de récursion moins coûteux à vérifier pour son parent. Ses colonnes mémoire et témoins sont engagées sous forme d’empilements d’au plus 2^24 évaluations : un parent replie donc une poignée de points au lieu de centaines. Et une demande de récursion écrit la base de son cadre avancée au-delà du cadre, si bien que des cadres placés bout à bout se rejouent sous forme d’ecall consécutifs, à raison d’une ligne par appel.

Bandes#

Les vérifications d’un shard ont une forme fixe pour sa famille et sa hauteur. L’hôte les compile donc, une fois pour toutes, en une bande : une liste séquentielle d’appels de coprocesseur sur des cellules absolues, dans laquelle rien ne bifurque selon une valeur. La bande d’un shard reprend, appel pour appel, les étapes du vérificateur natif pour ce shard : la transcription du shard, la passe arrière GKR, les vérifications de lookup et de racines, et les douze scalaires de l’ouverture Mercury. Chaque vérification est une assertion d’égalité.

Les bandes, les gabarits de repliement et les constantes de chaque programme de récursion sont construits à la compilation par la crate du vérificateur elle-même, et placés dans les données en lecture seule du programme. L’identité du programme lie donc chaque bande que le programme rejoue : prouver qu’un nœud a exécuté son programme, c’est prouver qu’il a exécuté exactement ces vérifications.

Une transcription chaînée à travers l’arbre#

La transcription globale de l’énoncé de base est une seule éponge sur l’énoncé entier. L’arbre la découpe sans la modifier. Le nœud qui détient le shard 0 exécute le préfixe, jusqu’au condensé de l’entrée publique inclus; chaque nœud absorbe les engagements mémoire de ses propres shards, en repartant de l’état qu’a laissé son prédécesseur; le nœud qui détient le dernier shard exécute le suffixe et tire les défis mémoire que chaque nœud avait pris comme affirmations. Le journal d’un nœud enregistre l’état de la chaîne aux deux extrémités de sa plage, et un parent exige que les états de ses enfants se rejoignent.

Un nœud astreint aussi ses enfants les uns par rapport aux autres : statut de sortie 0, un seul énoncé de base (sa forme, son condensé, ses défis, le condensé de l’entrée et du journal, le statut de sortie et le nombre de shards), des plages de shards adjacentes, des états de chaîne qui se rejoignent, des fenêtres temporelles dans l’ordre de part et d’autre de la jointure, et les identités des programmes de récursion. Un nœud qui détient un énoncé entier établit l’argument de mémoire.

Replier les couplages#

Aucun nœud ne calcule de couplage. La vérification Mercury de chaque shard est différée sous forme de douze entrées (side, scalar, point); après la bande du shard, la transcription propre au nœud absorbe l’état final de la transcription du shard et tire des poids, et chaque entrée est ajoutée, pondérée, à une seule paire de points cumulative (A, B) qui représente l’affirmation e(A, [1]_2) = e(B, [x]_2). La vérification de lot qui rattache l’engagement combiné d’un shard à ses colonnes est repliée à côté. Les points que partagent tous les shards d’une famille, comme [1]_1 et les engagements de mise en place, accumulent chacun un seul scalaire et n’entrent qu’une fois. Le (A, B) d’un enfant entre sous un poids tiré après son journal entier.

Chaque côté est une seule multiplication multi-scalaire sur FQ_OP, exécutée selon un gabarit statique : Pippenger avec des chiffres de 8 bits sur des moitiés GLV, chaque point étant astreint à la courbe, chaque étape fixée à l’avance. Un point coûte environ 400 appels FQ_OP.

À la racine, le contenu entier de l’arbre s’est contracté : chaque shard de base vérifié, la transcription exécutée de bout en bout, l’argument de mémoire établi, et chaque ouverture repliée en une seule affirmation de couplage. Il reste cette affirmation et deux identités de programme.

Le décideur#

La racine reste une preuve GKR accompagnée de quelques centaines de points, ce qu’un contrat ne peut pas vérifier. Le décideur est un circuit Groth16 qui exécute la procédure de nœud sur un seul enfant, la racine, au moyen d’un pilote écrivant des contraintes de rang 1 au lieu d’appels de coprocesseur, et qui astreint le journal de la racine à toute la plage des shards de base. Il ne replie rien : chaque point que la racine doit au couplage final, avec son scalaire, devient un fil lié, une valeur que détient le vérificateur, engagée dans la preuve sous une cinquième trappe plutôt que passée comme entrée publique. Il en va de même des deux identités, du statut de sortie de base, ainsi que de l’entrée publique et du journal de base, octet par octet.

Le Groth16 d’Apogee diffère de celui des manuels sur trois points : l’engagement des fils liés, l’absence d’aveuglement, et une clé de preuve sur la base de Lagrange que publie déjà la cérémonie des puissances de tau. Sa clé provient d’une cérémonie en deux phases : la phase 1 est le même fichier de cérémonie que celui sur lequel reposent les engagements; la phase 2 est propre au circuit, les contributions à α et à β étant achevées avant toute contribution à γ, δ et η, un ordre qui fait lui-même partie de la solidité (soundness).

ApogeeVerifier.sol reconstruit les valeurs liées à partir du calldata, vérifie l’équation de Groth16, replie les points des deux côtés avec ecMul et ecAdd, ce qui astreint aussi chaque point à la courbe, et vérifie l’unique couplage restant. Son constructeur fixe la clé, les deux points G2 de la cérémonie et les identités des deux programmes de récursion. Un déploiement sert un seul programme de base, une seule forme de racine et des longueurs de valeurs publiques fixes.

Mesures#

Bloc 257 510, avec l’arbre sur une machine à 32 CPU, et la cérémonie et le décideur sur un portable à 18 cœurs :

Preuve de base 207 shards, 14,5 MB, 2 481 s
Arbre 4 feuilles d’au plus 64 shards de base et une racine : 116 shards en tout
Feuilles, quatre à la fois 21, 24, 23 et 27 shards; 2 157 s; pic de 92 GiB
Racine 21 shards, 460 s, 1,03 MB
Décideur 7 896 686 contraintes; preuve en 18,5 s et 6,1 GB
Contrat 358 points; 3 620 026 gas; 34 980 octets de calldata

La spécification : Récursion et décideur. Pour l’exécuter vous-même : Régler sur la chaîne.

Architecture

Blocs Ethereum

La charge de travail de référence d’Apogee. Un programme invité revm qui exécute des blocs Ethereum à l’intérieur de la VM, un validateur sans état qui concorde avec chaque cas de la version de tests zkEVM, et ce que dit la preuve d’un bloc.

Voir en Markdown

Apogee prouve des programmes RV32IMAC arbitraires. Sa charge de travail de référence, celle sur laquelle portent ses mesures et ses réglages, est la plus difficile des charges courantes : valider un bloc Ethereum à l’intérieur de la VM avec revm, l’EVM en Rust. Une seule bibliothèque, revm_block, se compile à la fois pour le programme invité et pour l’hôte, et deux binaires prouvent deux énoncés différents.

Deux binaires, deux énoncés#

Binaire Données auxiliaires (advice) Journal Énonce
revm-block un BlockWitness : l’état antérieur que lisent les transactions pour chaque transaction, son statut, son gas et ses données de retour; un engagement sur les journaux d’événements; un résumé de l’état postérieur un certain témoin canonique fait produire ce journal à revm_block::run : la preuve d’une exécution, et non de la validité d’un bloc
revm-block-stateless l’entrée sans état du format de banc d’essai zkEVM 43 octets : la racine de la charge utile, le verdict, l’identifiant de chaîne, l’identifiant de schéma la charge utile de cette racine est, ou n’est pas, un bloc valide sur cette chaîne sous ce fork

Le validateur sans état est celui qui prouve des blocs. Il implémente verify_stateless_new_payload des spécifications d’exécution d’Ethereum : il décode la requête, vérifie les en-têtes des ancêtres et les règles d’en-tête par rapport au parent, récupère chaque expéditeur, exécute chaque transaction sur un état antérieur rattaché à la racine d’état du parent par des hachages, applique les retraits et les requêtes, et recalcule la racine des reçus, le filtre de Bloom, le gas, le hachage des requêtes, la liste d’accès du bloc et la racine de l’état postérieur. Le témoin n’a besoin d’aucune liaison propre : la racine publiée fixe la charge utile, et le témoin y est rattaché par des hachages, si bien qu’un mauvais témoin ne peut pas rendre valide une charge utile invalide. Un verdict false signifie seulement que cette entrée n’a pas passé la validation.

Forks et conformité#

Le validateur désigne le fork par l’identifiant de schéma de l’entrée, sans calendrier d’activation compilé : Osaka, BPO1, BPO2 et Amsterdam. Les 67 251 paires de la version v21.0.1 de tests-zkevm concordent toutes en natif, et la CI astreint la bibliothèque à un sous-ensemble versionné de 34 cas couvrant chaque règle qu’atteint la version, dans les deux dispositions d’entrée.

Les délégations en pratique#

Les deux binaires déclarent KECCAK_F, SHA256_COMP, MOD_MUL et EC_ADD. Keccak atteint son circuit par le crochet native-keccak d’alloy-primitives. SHA-256, secp256k1 et BN254 atteignent les leurs par des copies corrigées de revm-precompile, k256 et ark-ff, chacune ayant le code amont comme chemin de repli. Chaque expéditeur est récupéré dans le programme invité selon les règles d’EIP-2, avec une arithmétique k256 que les correctifs acheminent vers MOD_MUL et EC_ADD.

Les binaires sont compilés en --release et prouvés à 2^20 pour chaque famille dont la hauteur est au choix : le .text du binaire sans état fait environ 1,96 MB, soit 96,6 % de ce qu’atteint une table de 2^20.

Le bloc mesuré#

Le bloc 257 510 de glamsterdam-devnet-8, passé par revm-block-stateless : 60 transactions, 101,5 Mgas, 198 millions de cycles, découpés en 207 shards. La preuve de base a pris 2 481 s sur une machine à 32 vCPU et 247,7 GiB, avec un pic à 174 GiB, la récursion a ajouté environ 2 620 s, et le contrat a accepté le résultat pour 3 620 026 gas. La page Performances détaille chaque étape.

Ce que ne fait pas la preuve d’un bloc#

  • Le validateur reçoit son entrée d’un producteur de témoins externe. eth_getProof renvoie les nœuds du trie situés sur le chemin de chaque clé, mais une suppression qui provoque l’effondrement d’une branche a besoin d’un nœud frère qui ne se trouve sur le chemin d’aucune clé modifiée. L’enregistreur du dépôt ne peut donc pas produire d’entrées sans état; elles proviennent d’une version de tests-zkevm ou des jeux de données du banc d’essai zkEVM.
  • Le binaire mini-bloc prouve une exécution sur un état antérieur enregistré, en général les quelques premières transactions d’un bloc, et n’affirme rien sur la racine d’état. Son journal grandit d’un enregistrement par transaction et finit par dépasser la fenêtre publique, et c’est pourquoi les blocs complets passent par le validateur sans état.

La spécification : Blocs Ethereum.

Architecture

Modèle de sécurité

Ce qu’établit une preuve, ce qu’elle suppose, ce qu’un vérificateur doit détenir lui-même, le code sur lequel repose la solidité, et les limites de la version 1.0.0.

Voir en Markdown

Ce qu’établit une preuve#

Une preuve dont la vérification réussit établit que le programme d’une identité donnée, lancé à son pc d’entrée sur son image, avec l’entrée publique dans sa fenêtre d’entrée et des données auxiliaires (advice) choisies par le prouveur, s’exécute instruction par instruction jusqu’à EXIT avec un statut donné, après avoir écrit un journal donné. Rien n’est affirmé au sujet des données auxiliaires. Rien n’est caché.

Hypothèses#

Hypothèse Où elle intervient
La solidité de connaissance (knowledge soundness) de Mercury et de KZG dans le modèle du groupe algébrique sous q-DLOG chaque ouverture d’engagement
Poseidon2 comme oracle aléatoire pour Fiat–Shamir chaque défi, dans la preuve de base et dans la récursion
Les hypothèses propres à Groth16 le décideur, la dernière étape vers le contrat
Un contributeur honnête aux puissances de tau perpétuelles de PSE la SRS sur laquelle repose chaque engagement
Un contributeur honnête par tour de la cérémonie de phase 2 du décideur la clé du décideur

BN254 offre environ 100 bits de sécurité. L’erreur statistique de chaque couche du protocole, des sumchecks et de l’ouverture groupée jusqu’aux arguments de mémoire et de lookup, est très en deçà : moins de 2^14/|Fr| pour toute la passe GKR d’un circuit, moins de 2^−190 pour chaque canal de lookup, moins de 2^−220 pour chaque instance de Mercury.

Ce qu’un vérificateur doit détenir lui-même#

Deux valeurs, obtenues par un canal que le prouveur ne contrôle pas :

  • L’identité du programme. Face à une identité fournie par le prouveur, une preuve montre seulement qu’un programme quelconque s’est exécuté.
  • Le condensé SRS de la cérémonie. Une clé se charge sous le condensé que donnent ses propres points, quel qu’il soit : une clé construite sur un τ connu n’est donc refusée que par cette comparaison.

La clé de vérification elle-même peut provenir de n’importe qui. Son chargement recalcule l’identité et le condensé SRS à partir de son propre contenu et exige que ses circuits soient ceux du registre du vérificateur : l’identité lie le programme, le registre lie les circuits. L’outil en ligne de commande verifier ne compare que l’identité, et prend le condensé SRS dans la clé; host::verify ne compare ni l’un ni l’autre, et laisse aussi à son appelant le soin de vérifier l’entrée, le journal et le statut de sortie de l’énoncé.

Code de confiance#

La solidité relève du seul vérificateur. Elle repose sur constants, field, curve, transcript, poly, sumcheck, pcs-verify, pcs, gkr-verify, verifier-core, verifier, et constraints, car les circuits font partie de l’énoncé et une porte manquante est un défaut de solidité. Le calcul d’une identité à partir d’un ELF fait aussi confiance à loader, isa et program. La dernière étape vers la chaîne ajoute les programmes de récursion, groth16, le circuit du décideur et le contrat.

Le prouveur, l’émulateur, les constructeurs de trace et la moitié du SDK hôte consacrée à la preuve ne bénéficient d’aucune confiance. Le prouveur ne valide rien; une entrée erronée coûte à un prouveur honnête une preuve qui échoue, et un prouveur malhonnête n’exécute de toute façon rien de ce code.

Ni temps constant, ni divulgation nulle de connaissance#

Rien dans le code n’est à temps constant : les réductions, les exponentiations, les additions de points et les échelles scalaires bifurquent selon leurs opérandes. C’est sans conséquence ici, car aucune preuve n’est à divulgation nulle de connaissance : la génération de preuves ne garde donc rien de secret. Le seul secret que manipule le code est le facteur d’un contributeur à la cérémonie du décideur, qui passe par la même échelle à temps variable; effectuez les contributions sur une machine que vous contrôlez.

Limites de la v1.0.0#

Limite Détail
Pas de divulgation nulle de connaissance aucun aveuglement dans Mercury, dans GKR ni dans le décideur
Données auxiliaires non liées un programme invité les vérifie par rapport à quelque chose qu’une preuve lie
Valeurs publiques au plus 16 380 octets chacun pour l’entrée et le journal
sc.w réussit toujours le seul écart par rapport à la sémantique de RV32IMAC; il n’y a pas d’état de réservation
Les déroutements (traps) ne sont pas prouvables un accès non aligné, un accès hors de la mémoire projetée, ebreak ou un pc sans instruction termine l’exécution sans preuve
Le code est statique le flux d’instructions est l’image décodée au chargement; un seul mot indécodable dans le code exécutable entraîne le refus du programme
Taille du code .text dans la portée d’une table décodée, soit 7,94 MiB à 2^22; l’image dans la limite de 4 MiB par défaut
Longueur d’exécution 2^36 − 1 cycles
Les délégations forment un ensemble fixe six au format de base; une délégation prouve une étape de sa fonction, et la composition des étapes, dont la validation des points de courbe, relève du code appelant
Mémoire du prouveur déterminée par les shards en cours de traitement : le bloc mesuré a culminé à 174 GiB
Témoins de bloc le validateur sans état reçoit son entrée d’un producteur de témoins externe
La clé du décideur une par forme de racine, et digne de confiance seulement dans la mesure où sa cérémonie l’est; la clé de développement est falsifiable
Coût sur la chaîne environ 3,6 M gas pour le bloc mesuré

Ce que change la prochaine version#

Chacune des hypothèses ci-dessus qui fait intervenir BN254, de q-DLOG et du couplage jusqu’à Groth16, tombe face à un ordinateur quantique suffisamment grand. La trajectoire de la v2.0.0 est un cœur de preuve dont la solidité repose plutôt sur des problèmes de réseaux.

Pour la vue d’un auditeur sur le même modèle, crate par crate et argument par argument : Guide d’audit, Carte de solidité.

Architecture

Performances

Chaque chiffre mesuré de la v1.0.0, avec sa source : la preuve de base d’un bloc Ethereum complet, l’arbre de récursion, le décideur et le contrat, la preuve de shard de chaque famille, et les coûts propres à Mercury.

Voir en Markdown

Tous les chiffres de bout en bout concernent le bloc 257 510 de glamsterdam-devnet-8, prouvé au moyen du programme invité validateur sans état : 60 transactions, 101,5 Mgas, 198 millions de cycles. Chaque chiffre provient des mesures de la spécification.

De bout en bout#

Étape Machine Résultat
Preuve de base 32 vCPU, 247,7 GiB, 12 shards en cours de traitement 207 shards, 14,5 MB, 2 481 s; pic de 173,92 GiB
Feuilles de récursion 32 CPU, quatre feuilles à la fois 21, 24, 23 et 27 shards; 2 157 s; pic de 92 GiB
Racine de récursion la même machine, quatre shards en cours de traitement 21 shards, 460 s, 1,03 MB
Cérémonie du décideur portable à 18 cœurs init 65 s; une contribution de 50 à 56 s; key 70 s et 12,7 GB; la clé 2,65 GB
Preuve du décideur portable à 18 cœurs clé lue en 1 s, preuve en 18,5 s, 6,1 GB; 7 896 686 contraintes sur 2^23
Vérification sur la chaîne revm 3 620 026 gas; 34 980 octets de calldata; 358 points

La preuve de base, passe par passe#

Passe 1, engagement 191 s; 25,7 vCPU occupés en moyenne; les remplissages sur un seul fil occupent 81 % de ses shard-secondes; mémoire échantillonnée d’au plus 15,9 GiB
Passe 2, preuve 2 290 s; en moyenne 11,95 shards détenus sur 12 et 30,4 vCPU occupés jusqu’à la sortie du programme invité; puis une phase finale de 460 s, dont les plus longues séquences sont les remplissages sur un seul fil des deux shards KECCAK_F, 200 s et 279 s
Pic de mémoire 173,92 GiB : les deux shards KECCAK_F de 2^18, ensemble dans la phase finale, sans rien d’autre en cours de traitement

Le pic a été fixé par la hauteur d’une seule famille de délégation, et non par le nombre de shards en cours de traitement.

Un petit programme invité#

Le programme invité du Démarrage rapide, 114 cycles répartis sur quatre familles d’instructions, prouvé avec des hauteurs d’instructions de 2^20 et des hauteurs de fenêtres de 2^16, avec deux shards en cours de traitement : 7 shards, 52 s et un pic de 18 GB sur un portable à 18 cœurs et 48 GiB, presque entièrement dû aux deux shards de 2^20 en cours de traitement. Le plancher d’une preuve est fixé par ses familles et ses hauteurs, et non par son nombre de cycles.

Le shard de chaque famille#

Aux hauteurs par défaut. La taille de la preuve d’un shard est fixée par la forme et la hauteur de son circuit; son coût de preuve suit le produit de sa hauteur par la largeur de son circuit, quel que soit le nombre de lignes actives.

Famille Hauteur Engagées M/W/S Portes de contrainte Colonnes internes Preuve de shard
ADD_SUB_LUI_AUIPC 2^22 27 / 35 / 7 63 314 64 764 B
JUMP_BRANCH_SLT 2^22 21 / 44 / 10 42 392 69 436 B
SHIFT_BITWISE 2^22 21 / 61 / 10 48 478 76 644 B
MUL_DIV 2^20 21 / 54 / 9 54 444 67 412 B
MEM_WORD 2^22 31 / 24 / 7 33 314 63 836 B
MEM_SUBWORD 2^22 31 / 55 / 10 53 472 76 196 B
ATOMICS 2^20 26 / 54 / 9 46 472 68 468 B
INIT_TEARDOWN 2^22 2 / 0 / 1 0 46 36 316 B
ZERO_WINDOWS 2^22 2 / 0 / 0 0 46 36 284 B
KECCAK_F 2^18 208 / 1 556 / 0 385 5 490 381 100 B
POSEIDON2 2^8 100 / 4 092 / 0 4 248 2 020 664 780 B
FR_ARITH 2^8 104 / 2 576 / 0 2 701 142 266 292 B
PUBLIC_INPUT, PUBLIC_OUTPUT 2^12 3 ou 2 / 0 / 0 0 26 12 556 B, 12 524 B
ADVICE_WINDOWS 2^22 3 / 0 / 0 0 46 36 316 B
MOD_MUL 2^16 104 / 221 / 0 125 2 244 135 220 B
SHA256_COMP 2^18 104 / 520 / 0 119 2 802 189 988 B
EC_ADD 2^16 392 / 1 028 / 0 637 8 772 434 916 B

Une hauteur ne change que le nombre de listes à réduction de moitié et de tours de sumcheck, pas les portes : ADD_SUB_LUI_AUIPC à 2^20 a 298 colonnes internes et une preuve de 57 196 octets, contre 314 et 64 764 à 2^22.

Mémoire de la passe avant#

Le prouveur GKR détient chaque couche interne sous forme d’éléments du corps, à raison de 32 octets par cellule. Ensembles de travail représentatifs :

Shard Passe avant
SHIFT_BITWISE à 2^20 8,4 GiB
MOD_MUL à 2^16 4,6 GB
EC_ADD à 2^16 18,3 GB
SHA256_COMP à 2^18 22,6 GB
KECCAK_F à 2^18 42 GiB

Mercury#

Sur un Apple M5 Pro à 18 cœurs :

Engagement, n = 2^22 1,30 s
Ouverture, n = 2^22 2,89 s
16 colonnes de 2^20 en un seul lot ouvertes en 1,01 s, vérifiées en 4,8 ms
Les mêmes 16, ouvertes une à une 9,79 s, vérifiées en 62 ms

Lire ces chiffres#

La génération de preuves est limitée par la mémoire, et sa mémoire suit les shards en cours de traitement et leurs hauteurs, jamais la longueur de l’exécution. Le temps suit le nombre de cycles, famille par famille. Le coût sur la chaîne suit le nombre de points que la racine doit au couplage final, à environ 9 000 gas par point. Les nombres de cycles eux-mêmes sont exacts et indépendants de la machine : le profileur de cycles est donc le bon premier outil pour estimer tout le reste.

Saut quantique

Saut quantique

La prochaine étape d’Apogee. La trajectoire vers la v2.0.0 — un cœur de preuve sur réseaux euclidiens, un corps choisi pour leur correspondre, des signatures que les programmes invités peuvent vérifier, et un système de déploiement qui mène une application du dépôt à une chaîne en fonctionnement.

Voir en Markdown
BreffageProgramme : Apogee VMCible : v2.0.0Statut : développement actifFenêtre de publication :

La version 1.0.0 tranche la question de savoir si l’architecture tient à pleine échelle : un bloc Ethereum entier, d’un programme invité en Rust jusqu’à un contrat qui répond true. La version 2.0.0 change ce sur quoi repose cette architecture et ceux qu’elle sert. Son cœur de preuve passe à des mathématiques qu’un ordinateur quantique ne casse pas, et sa surface pour les développeurs passe d’un dépôt à un système qui déploie des applications.

Important

Cette section décrit des travaux en cours. Rien ici ne modifie les garanties de la v1.0.0, énoncées intégralement dans le modèle de sécurité.

Les quatre initiatives#

La trajectoire#

v1.0.0, aujourd’hui v2.0.0, la trajectoire
Engagements Mercury sur KZG : couplages, q-DLOG Sur réseaux euclidiens, liants sous Module-SIS
Corps Le corps des scalaires de BN254, 254 bits Un petit corps premier avec des défis dans un corps d’extension, adapté à l’engagement
Face à un adversaire quantique Chaque hypothèse est un logarithme discret Un cœur de preuve reposant sur des problèmes de réseaux
Signatures dans les programmes invités secp256k1 par l’arithmétique de corps et de courbe déléguée Des schémas adaptés au ZK et post-quantiques, sous forme d’appels du programme invité
Pour les développeurs Un dépôt, ses outils et ce manuel Le Système de déploiement : portail, ponts canoniques, télémétrie, une passerelle d’IA

Ce qui est conservé#

Le saut se fait dans les fondations, pas dans le modèle pour lequel un développeur écrit :

  • Le programme invité. Rust, RISC-V, trois régions mémoire, une identité, un journal. Les programmes écrits pour la v1.0.0 conservent leur forme.
  • Le moteur GKR. Les circuits en couches et le sumcheck sont définis sur n’importe quel corps. Le moteur qui ramène un circuit à un seul point est la partie d’Apogee qui passe le plus directement au nouveau corps.
  • Les arguments. Un seul multiensemble mémoire sur toute l’exécution, ainsi que les canaux LogUp, sont conservés en tant que constructions; leurs tables et leurs arguments d’intervalle sont redérivés pour la taille du nouveau corps.
  • La discipline. La spécification d’abord, chaque couche vérifiée par rapport à un oracle indépendant, chaque classe de contrefaçon couverte par un jumeau falsifié.

Pourquoi maintenant#

Une preuve de validité ne résiste au quantique qu’autant que le système qui la produit. Une preuve sur BN254 repose sur des couplages et des logarithmes discrets : un ordinateur quantique suffisamment grand pourrait donc en contrefaire une sans toucher à rien de ce que le programme invité a calculé. Les applications natives blockchain sont destinées à conserver de la valeur pendant des décennies. Le socle sur lequel elles effectuent leur règlement doit survivre aux machines qui casseront un jour les courbes d’aujourd’hui, et c’est avant que ces machines n’existent qu’il faut le déplacer.

Lire les initiatives : Preuve post-quantique · Signatures pour les programmes invités · Le Système de déploiement.

Saut quantique

Preuve post-quantique

Initiatives QL-01 et QL-02. Un engagement sur réseaux euclidiens à la place de celui à base de couplages, et un corps choisi pour lui correspondre, afin que le cœur de preuve ne repose plus sur des logarithmes discrets.

Voir en Markdown
QL-01 · QL-02Engagements sur réseaux euclidiensChangement de corpsStatut : développement actif

Ce qui casse, et où#

Chaque hypothèse cryptographique sous-jacente à Apogee v1.0.0 fait intervenir BN254. Mercury et KZG sont solides sous q-DLOG dans le modèle du groupe algébrique; l’arbre de récursion replie des vérifications de couplage; le décideur est Groth16. L’algorithme de Shor résout le logarithme discret sur un ordinateur quantique de taille suffisante, et, avec lui, chacune de ces hypothèses. Le calcul du programme invité resterait ce qu’il était; la preuve qu’il s’est exécuté correctement ne voudrait plus rien dire.

C’est dans l’engagement que se concentre la dépendance. Chaque colonne de chaque shard est engagée avec lui, chaque ouverture se termine par son couplage, et la récursion existe pour replier ces couplages. Remplacez l’engagement, et le reste du cœur de preuve n’a plus rien qui dépende d’un logarithme discret.

QL-01 · Engagements sur réseaux euclidiens#

Un engagement sur réseau est une application linéaire, t = A·s mod q, appliquée à un vecteur s à petits coefficients. Il est liant tant que personne ne peut trouver un vecteur court que la matrice envoie sur zéro : c’est Module-SIS, la famille d’hypothèses qui sous-tend ML-DSA et ML-KEM, les normes post-quantiques du NIST, avec derrière elle des réductions au pire cas et des décennies de cryptanalyse.

Il conserve ce qui rendait KZG si utile à Apogee et que les engagements à base de hachage abandonnent : il est homomorphe. Les engagements de nombreux fragments se combinent sous des coefficients de défi, et la combinaison s’ouvre par un seul vecteur. Regrouper les colonnes d’un shard, différer des vérifications et les replier le long d’un arbre sont des opérations linéaires, et les opérations linéaires survivent à la transition. Les chemins de Merkle, eux, ne se combinent pas du tout.

Le prix à payer est une contrainte sans équivalent ailleurs : l’engagement ne lie que des vecteurs courts, chaque combinaison allonge le vecteur, et le prouveur doit montrer qu’il reste assez court. Les schémas des deux dernières années diffèrent surtout par la façon dont ils paient ce prix, et ils ont progressé vite. Pour des polynômes de 2^30 coefficients, les schémas Module-SIS publiés donnent des preuves d’évaluation de 53 à 72 KB, et la vérification est passée de 2,8 secondes en 2024 à 8 à 16 millisecondes en 2026. L’exposé Lattice-Based Polynomial Commitment Schemes les passe en revue schéma par schéma et met les chiffres en regard de ceux du côté à base de hachage.

QL-02 · Changement de corps#

Les schémas sur réseaux ne vivent pas dans le monde de BN254. Les principales constructions travaillent sur de petits modules premiers, avec des points d’évaluation tirés d’un corps d’extension pour préserver la solidité, ce qui convient à un système de preuve sur petit corps et ne convient pas à un système sur 254 bits. Le changement d’engagement entraîne donc celui du corps : la v2.0.0 change le corps de l’arithmétisation, en faisant passer chaque circuit du corps des scalaires de BN254 à un petit corps adapté à l’engagement.

Le changement se rentabilise de lui-même :

  • Chaque couche coûte moins cher. Un prouveur GKR passe son temps en arithmétique de corps, et une multiplication dans un petit corps coûte une fraction de ce que coûte une multiplication dans un corps de 254 bits. L’économie centrale du moteur, le fait que les couches intermédiaires ne sont jamais engagées, se cumule avec une arithmétique moins coûteuse sur chaque couche qui reste.
  • Les engagements coûtent moins cher. Engager une colonne de la trace revient à appliquer une application linéaire à de petits chiffres, payée par entrée non nulle, plutôt qu’à effectuer une multiplication multi-scalaire sur une courbe.
  • Le moteur est conservé. GKR et le sumcheck sont définis sur n’importe quel corps. Les défis passent dans un corps d’extension; la passe arrière, le modèle en couches et les arguments construits dessus conservent leur structure.

Ce qu’il faut reconstruire, c’est tout ce qui supposait un grand corps : les valeurs au niveau du mot qui tiennent largement dans un seul élément de BN254, les arguments d’intervalle et les retenues dimensionnés en fonction d’un module de 254 bits, les chaînes de canonicité, et les cellules de corps du format de récursion. Chacun de ces éléments est redérivé pour le nouveau corps et spécifié comme l’ont été ceux de la v1.0.0, avec son propre oracle et ses jumeaux falsifiés.

Règlement#

Les précompilés de vérification d’Ethereum reposent aujourd’hui sur les couplages. La façon dont un cœur de preuve post-quantique effectue son règlement sur cette chaîne, et la part de l’étape finale qui peut reposer sur des réseaux, font partie du même programme de travail, et seront spécifiées avec le même soin que le cœur avant sa livraison.

Retour au breffage de mission.

Saut quantique

Signatures pour les programmes invités

Initiative QL-03. La vérification de signatures adaptées au ZK et post-quantiques, offerte à tout programme invité sous forme d’appel, pour que l’autorisation dans une application native blockchain tienne en une ligne de code.

Voir en Markdown
QL-03Signatures pour les programmes invitésStatut : développement actif

Presque toute application native blockchain pose la même question à chaque requête : est-ce la bonne clé qui a autorisé ceci? Dans la v1.0.0, un programme invité y répond par du code. La récupération de clé secp256k1, c’est k256 qui s’exécute sur les circuits délégués MOD_MUL et EC_ADD, ce qui rend abordables les signatures propres à Ethereum; tout le reste passe par des instructions ordinaires. QL-03 fait de la vérification de signatures une opération de premier rang des programmes invités.

Les schémas#

Qu’un schéma de signature soit peu coûteux à prouver ou non tient presque entièrement à son algorithme de vérification : quelle arithmétique il effectue, dans quel corps, et quelle fonction de hachage il appelle. Le signataire ne s’exécute jamais dans la preuve. Quatre conceptions couvrent l’espace :

Schéma Idée Pourquoi c’est important pour un programme invité
Schnorr sur une courbe native Le protocole de Schnorr sur une courbe dont le corps de base est le corps même du système de preuve, comme Grumpkin pour BN254 le moins coûteux par construction : arithmétique et hachage tous deux natifs au circuit
ML-DSA (FIPS 204) Schnorr transposé aux réseaux, avec une réponse courte maintenue uniforme par échantillonnage par rejet la principale signature post-quantique du NIST; un coût dominé par son hachage et, sauf si la clé est fixe, par l’expansion de sa matrice
FN-DSA (Falcon) hacher-puis-signer avec une trappe de réseau, masquée par échantillonnage gaussien le moins d’arithmétique et le moins de hachage des trois schémas post-quantiques
SLH-DSA (FIPS 205) des signatures construites à partir d’une fonction de hachage seulement aucune algèbre, et quelque deux mille appels de hachage; l’hypothèse la plus conservatrice

Les quatre vérificateurs procèdent par calcul puis comparaison, sans secret ni branchement sur des secrets, et c’est ce qui rend chacun d’eux prouvable. L’exposé ZK-Friendly Signature Schemes les examine un à un sur un exemple jouet et compare leurs coûts en circuit.

Ce qui détermine le coût#

Deux leviers font bouger chaque chiffre :

  • Le corps sur lequel travaille le prouveur. Un schéma est natif quand son arithmétique est celle du circuit. Le schéma le moins coûteux dépend donc du changement de corps de QL-02, et le choix se fait en même temps que lui.
  • Le hachage. Les vérificateurs post-quantiques sont dominés par leur hachage normalisé, non par leur algèbre. Le remplacer par un hachage arithmétique fait sortir de la norme, et c’est la variante que retiennent les constructions orientées ZK; le conserver est ce qu’exige l’interopérabilité avec les clés existantes. Les deux ont leur place, et c’est le programme invité qui décide.

Pour les développeurs#

Le but : un programme invité qui vérifie une autorisation comme il calcule aujourd’hui un hachage, en un seul appel délégué à un circuit, sans cryptographie dans le code propre de l’application. Cela ouvre la voie aux modèles sur lesquels se construisent les applications natives blockchain : des comptes dont les clés ne sont pas celles de la chaîne, l’approbation multipartite au sein de la fonction de transition d’état, les clés de session, et des identités qui restent valides après la chute des courbes sur lesquelles elles sont nées.

Retour au breffage de mission.

Saut quantique

Le Système de déploiement

Initiative QL-04. Le Système de déploiement natif blockchain d’Apogee, un portail et une chaîne d’outils qui mènent une application du code source à un environnement natif blockchain en fonctionnement.

Voir en Markdown
QL-04Système de déploiement natif blockchain d’ApogeeStatut : développement actif

La version 1.0.0 fournit au développeur un dépôt, ses outils et ce manuel. Tout ce qui sépare un programme invité prouvé d’une application en production, des clés et des cérémonies aux contrats vérificateurs, aux ponts et aux opérations, reste à assembler par le développeur. Le Système de déploiement natif blockchain d’Apogee est la seconde moitié du saut : un portail et une chaîne d’outils qui transforment un programme invité en un environnement natif blockchain en fonctionnement, et le maintiennent en fonctionnement.

Les modules#

ConsoleEn cours

Toutes les ressources de déploiement au même endroit

Les programmes et leurs identités, hauteurs et paramètres, les clés de vérification, les clés du décideur et leurs cérémonies, les contrats vérificateurs et les réseaux où ils résident, organisés par application et par version.

PontsEn cours

Des contrats canoniques sur la chaîne

Des gabarits réutilisables pour ce que chaque application reconstruit aujourd’hui : un registre de racines d’état piloté par des preuves, des ponts de dépôt et de retrait, des chemins de mise à niveau qui passent d’une identité de programme à la suivante.

TélémétrieEn cours

Les signes vitaux d’une application

Les preuves produites et réglées, les cycles par requête, la latence et le coût de la preuve, les profils par shard et par famille, le gas dépensé en vérification, et l’historique des racines d’état de l’application.

PasserelleEn cours

Une porte pour votre propre IA

Une interface par laquelle l’agent d’IA local d’un développeur peut inspecter une application, interroger sa télémétrie, proposer et exécuter des modifications par le pipeline de déploiement, et relire chaque résultat, sous le contrôle du développeur.

GabaritsPrévu

Des applications qui partent d’une forme qui fonctionne

Des projets de programmes invités hors du dépôt, avec les profils, les réglages de l’éditeur de liens et les crates embarquées déjà corrects, et le Compagnon IA déjà en place.

CérémoniesPrévu

Les cérémonies comme un service, pas comme une corvée

La coordination des contributions de phase 2 à la clé du décideur, chacune vérifiable par rapport au circuit et au fichier de cérémonie, afin que la clé d’un déploiement ait les contributeurs honnêtes dont sa solidité a besoin.

Pourquoi un système, et non davantage d’outils#

La thèse qui sous-tend Apogee est celle d’un environnement optimisé par application économique. Cela multiplie le nombre d’environnements, et avec lui la surface opérationnelle : chacun a son programme, ses clés, sa cérémonie, ses contrats et ses métriques. Une approche qui demande à chaque équipe d’assembler cette surface à la main ne passe pas à l’échelle des nombreux environnements dont la thèse a besoin. Le Système de déploiement rend chacun d’eux routinier, de sorte que la partie difficile du lancement d’une application native blockchain soit l’application elle-même.

La Passerelle découle du même raisonnement. Les développeurs travaillent déjà aux côtés de modèles d’IA, et le Compagnon IA breffe ces modèles sur l’écriture de programmes invités. La Passerelle leur donne, sous le contrôle du développeur, un moyen d’agir sur ce qu’ils écrivent : déployer, observer et itérer.

Retour au breffage de mission.

Auditeurs

Guide d’audit

Tout ce qu’il faut à un auditeur d’Apogee VM v1.0.0 pour commencer : le périmètre, la spécification normative et son organisation, la notation, la frontière de confiance, un ordre de lecture, et les propriétés qui méritent d’être vérifiées en premier.

Voir en Markdown

Cette section présente la construction complète d’Apogee VM v1.0.0, organisée pour l’évaluation. Son cœur est la spécification normative, reproduite textuellement : une page par sujet, avec chaque colonne engagée par indice et par nom, chaque porte sous forme de polynôme, chaque lookup et son canal, chaque message de la transcription dans l’ordre, et chaque octet de chaque format de sérialisation. Autour d’elle, ce guide et la carte de solidité offrent à l’auditeur une porte d’entrée.

Périmètre#

Dans le périmètre Où
L’énoncé qu’établit une preuve, et ce que doit détenir un vérificateur Système, de bout en bout, La preuve
L’arithmétique : Fr, la tour Fq, G1 et G2, le couplage, la MSM, les polynômes multilinéaires, le sumcheck Primitives
Fiat–Shamir : Poseidon2, l’éponge duplex, chaque étiquette Transcription
La mise en place et le schéma d’engagement Chaîne de référence structurée, Mercury
Le programme : chargement, décodage, tables, configuration, identité Programme et identité, ABI du programme invité
Le modèle d’exécution et la trace Trace d’exécution, Valeurs publiques et données auxiliaires
Le système de preuve : GKR, le registre des circuits, l’argument de mémoire, les lookups Moteur GKR, Circuits, Argument de mémoire, Lookups
Chaque famille de circuits, colonne par colonne les sept familles d’instructions et les six circuits de délégation
La structure du prouveur Prouveur en flux
La récursion, le décideur Groth16 et sa cérémonie, le contrat Récursion et décideur
La charge de travail Ethereum Blocs Ethereum

Les pages de la spécification sont reproduites à partir du répertoire docs/ du dépôt Apogee VM à la révision source 3571370, les liens relatifs étant transformés en liens vers ce site et chaque renvoi § en lien vers sa section. Une expression du glossaire est reformulée pour concorder avec le reste du site; rien d’autre n’est modifié. Là où une page de la spécification et le code divergent, c’est le code qui a raison, et la divergence constitue une constatation.

Comment lire la spécification#

Les pages sont écrites pour être lues en regard du code. Chacune nomme la crate et la fonction qui implémentent ce qu’elle énonce, et les commentaires du code source renvoient à la spécification par section (docs/spec/memory.md §2.4). Quelques conventions récurrentes :

Notation Signification
M[i], W[i], S[i] les colonnes engagées d’un circuit : colonnes mémoire (liées dans la transcription globale, avant les défis mémoire), colonnes témoins (liées dans la transcription propre au shard), colonnes de mise en place (liées par l’identité du programme ou par le condensé SRS)
V[…] une table virtuelle : une forme close de l’indice de ligne, jamais engagée
L{k}[j], C{k}[j], scratch[i] la colonne interne j de la couche k; une entrée en cache; une valeur intermédiaire d’une relation plate
W[8..14] un intervalle semi-ouvert d’indices de colonnes, de W[8] à W[13]
T(AS, ADDR, TS, VAL) un tuple mémoire, γ_M + AS + α_addr·ADDR + α_ts·TS + α_val·VAL
4c + Δ l’horodatage du créneau Δ du cycle c
G1–G11, S1–S6 les étapes de la transcription globale et de celle du shard
étapes 1–12, B1–B6 les vérifications du vérificateur, dans l’ordre, pour un shard et pour un bloc
2^n une puissance de deux; les hauteurs sont 2^8, 2^12, 2^16, 2^18, 2^20, 2^22

Une porte écrite sous forme d’expression est astreinte à valoir 0. Un lookup s’écrit par son canal, son sélecteur et son tuple. « Borné » signifie vérifié par intervalle, et une valeur appelée mot est un entier dans [0, 2^32).

Ordre de lecture#

Pour une première lecture qui construit l’argument entier avant de descendre dans les circuits :

  1. Système, de bout en bout. L’affirmation, la table de composition, les hypothèses, les limites.
  2. La preuve. L’énoncé, les deux transcriptions, l’ordre de vérification, la clé et ses règles de chargement.
  3. Moteur GKR. Le modèle en couches, l’artefact et ses lois, la passe arrière et ce qui la rend solide.
  4. Argument de mémoire et Lookups. Les deux arguments sur lesquels repose tout ce qui traverse une ligne, avec leurs règles appliquées à la construction.
  5. Circuits. Le registre, les formes, et la façon dont le circuit d’une famille est assemblé.
  6. Les familles d’instructions, en commençant par ADD_SUB_LUI_AUIPC, qui porte chaque ecall et le côté demande de chaque délégation.
  7. ABI de délégation et Circuits de délégation.
  8. Programme et identité, Valeurs publiques, Trace d’exécution.
  9. Transcription, SRS, Mercury, Primitives.
  10. Récursion et décideur, puis le contrat.

La frontière de confiance#

La solidité (soundness) relève du seul vérificateur, et le code du vérificateur est un ensemble défini de crates :

Confiance requise pour Crates
Vérifier un bloc constants, field, curve, transcript, poly, sumcheck, pcs-verify, pcs, gkr-verify, verifier-core, verifier, et constraints, car les circuits font partie de l’énoncé et une porte manquante est un défaut de solidité
Calculer une identité à partir d’un ELF loader, isa, program
La dernière étape vers la chaîne guests/recursion, groth16, le circuit du décideur, contracts/ApogeeVerifier.sol
Aucune confiance prover, emulator, trace, la moitié de host consacrée à la preuve : le prouveur ne valide rien

Les hypothèses sont la solidité de connaissance (knowledge soundness) de Mercury et de KZG dans le modèle du groupe algébrique sous q-DLOG, Poseidon2 comme oracle aléatoire, les hypothèses propres à Groth16 pour la dernière étape, et un contributeur honnête à chaque cérémonie. Rien n’est à temps constant, et aucune preuve n’est à divulgation nulle de connaissance. Un vérificateur doit obtenir l’identité du programme et le condensé SRS de la cérémonie par un canal que le prouveur ne contrôle pas.

Par où commencer#

Voici les propriétés dont la défaillance constituerait une contrefaçon, avec l’endroit où chacune est argumentée. La carte de solidité traite de la même façon chaque affirmation de l’énoncé.

Propriété Argumentée dans
Chaque défi est tiré après tout ce qu’il protège : les défis mémoire après chaque engagement M, la liste des fenêtres, io_digest et les 64 scalaires de frontière; g et β après les engagements W du shard preuve §2, §4; mémoire §6.1
Aucun tuple ni aucune racine de la mémoire ne lit une colonne W, laquelle est engagée après les défis mémoire mémoire §8
Un cadre n’astreint ses masques qu’à la booléanité; chaque famille fixe chaque masque à m_pc multiplié par les types qui effectuent la requête mémoire §2.1; la page de chaque famille
Chaque clé que recherche un canal de table est bornée par sa famille, et toute borne écrite au moyen d’une copuissance porte aussi une borne directe lookup §4, §11; shift-bitwise §3
Un sélecteur décodé à partir d’un mot du cadre est one-hot, puisque les codes s’additionnent circuits de délégation §1
Chaque demande de délégation est appariée à exactement une invocation délégation §5
Seule la ligne de sortie peut écrire HALT_PC; next_pc est astreint à être pair là où une famille le calcule mémoire §5; jump-branch-slt §5
Une seule valeur initiale par adresse : les règles des fenêtres mémoire §3.5, §9
Les fenêtres d’entrée et de journal contiennent les octets de l’énoncé; le journal n’a pas de colonne initiale valeurs publiques §5
L’ouverture prend les engagements de mise en place dans la clé, liant ainsi les tables et l’image que l’identité engage preuve §5; mémoire §6.2
Les circuits d’une clé sont ceux du registre, et son condensé SRS est comparé à celui de la cérémonie preuve §3, §7
Récursion : bandes liées par l’identité du programme, chaînage des transcriptions, poids de repliement tirés après ce qu’ils pondèrent, fils liés du décideur, et ordre des tours de la cérémonie récursion §7, §8, §9

Reproduire#

Rien de ce qu’exécute la CI n’a besoin du fichier de cérémonie. Les suites qui prouvent de vrais shards s’exécutent sur leur propre SRS jouet et nécessitent des dizaines de GiB; elles sont donc lancées par leur nom :

sh
cargo test --workspace                                   # every unit, law and row suite
cargo run -p kat-gen && git diff --exit-code             # fixtures regenerate identically
cargo test --release -p prover --test <suite> -- --include-ignored --test-threads=1
#   acceptance, control, alu, mem, fills, block, streaming, keccak, recursion, public_io, revm
cargo test --release -p checker --test tamper -- --include-ignored --test-threads=1   # every tamper twin
cargo run -p checker -- laws <artifact>                  # Laws 1–4 and the lookup rules, independently

Vérifier l’implémentation décrit ce qu’établit chaque oracle et chaque suite, et ce qu’aucun d’eux n’établit.

Signalement#

Signalez vos constatations à admin@gweb3networks.com, en indiquant la section de la spécification ou le chemin dans le code, la propriété en jeu et, si possible, un jumeau falsifié : un témoin contrefait, prouvé comme un prouveur honnête le prouverait, et qui passe la vérification.

Auditeurs

Carte de solidité

Chaque affirmation que fait une preuve vérifiée, l’argument qui la porte, et les sections exactes de la spécification où cet argument est énoncé et justifié.

Voir en Markdown

Un bloc vérifié établit une seule phrase : le programme de cette identité, lancé à son pc d’entrée sur son image, avec cette entrée publique et certaines données auxiliaires (advice), s’exécute instruction par instruction jusqu’à EXIT avec ce statut, après avoir écrit ce journal. Cette page décompose cette phrase en les affirmations qui la constituent et mène chacune jusqu’à l’argument qui la prouve.

Le programme#

Affirmation Argument Spécification
La clé décrit le programme enregistré le chargement d’une clé recalcule l’identité à partir de sa propre configuration, de son pc d’entrée et de ses engagements de mise en place; le vérificateur la compare à sa propre copie preuve §7.2, programme §8
Les tables que lit une preuve sont celles que l’identité engage l’ouverture groupée de chaque shard prend ses engagements de mise en place dans la clé preuve §5
Chaque ligne exécutée est l’instruction du programme à son pc le lookup du décodeur, indexé par la lecture du pc propre à la ligne, dans une table dont les lignes actives sont one-hot et dont les lignes de remplissage valent −1 lookup §10, programme §5, §6
La mémoire part de l’image du programme la colonne d’initialisation de la famille INIT_TEARDOWN est une colonne de mise en place que l’identité engage; aucun octet issu du fichier ne se trouve hors de la fenêtre 0 mémoire §6.2, §3.4
L’exécution commence au pc d’entrée le tuple initial du pc utilise le pc d’entrée de la clé, que l’identité lie mémoire §4.2, §6.2
Les circuits sont les bons les circuits d’une clé doivent être identiques au registre du vérificateur à leurs hauteurs et satisfaire les lois, les règles de mémoire et la règle d’acquittement preuve §7.2, circuits §1, gkr §4.2

Chaque ligne#

Affirmation Argument Spécification
Une ligne obéit à son instruction les portes de contrainte de la famille, nulles sur chaque ligne; l’argument de solidité (soundness) de chaque famille add-sub §4, jump-branch-slt §5, shift-bitwise §5, mul-div §5, memory-ops §3–§6
Une ligne effectue exactement les requêtes de son instruction chaque masque fixé à m_pc multiplié par les types qui effectuent cette requête mémoire §2.1
x0 lit et écrit 0 le gadget x0 et les écritures en retour mémoire §2.4
Les valeurs des registres et de la RAM sont des mots chaque écriture de registre est bornée sur sa propre ligne; chaque écriture en RAM d’une famille d’exécution est un mot; les valeurs initiales sont des mots, sauf les données auxiliaires, sur lesquelles aucune famille ne s’appuie memory-ops §5
Une ligne de remplissage n’ajoute aucun événement mémoire chaque masque d’une ligne de remplissage vaut 0, donc ses feuilles valent 1 mémoire §2.3, gkr §4.3

Mémoire et ordre#

Affirmation Argument Spécification
Chaque lecture renvoie la dernière écriture un seul multiensemble lecture/écriture sur tous les shards, rapproché une seule fois avec la frontière des registres et du pc mémoire §4, §9
Une lecture suit strictement l’écriture qu’elle consomme l’écart d’horodatage de chaque requête est formé de deux fragments TIMESTAMP de 19 bits mémoire §2.4, §7
Chaque adresse a exactement une valeur initiale les règles des fenêtres : une seule hauteur, des fenêtres disjointes, un seul shard pour chacune des fenêtres fixes mémoire §3.5, §9
Le multiensemble ne peut pas se refermer par une boucle les horodatages sont des entiers sur des chemins bornés : une boucle nécessiterait plus de 2^215 arêtes mémoire §4.2
Les lignes forment un seul chemin de l’entrée à la sortie, dans l’ordre du programme le pc est une cellule mémoire écrite au moins quatre horodatages après sa lecture mémoire §5, §9
L’exécution se termine à la ligne de sortie HALT_PC = 1 est impair; seule la ligne de sortie l’écrit; JUMP_BRANCH_SLT vérifie par intervalle que son next_pc est pair mémoire §5, jump-branch-slt §5, add-sub §4
La fenêtre temporelle d’un shard n’ajoute rien seule la forme des fenêtres temporelles est vérifiée; l’ordre provient du seul multiensemble preuve §8

Valeurs et lookups#

Affirmation Argument Spécification
Chaque tuple sous sélecteur est une ligne de sa table une identité LogUp par canal, sommée par un arbre de fractions dans la passe GKR; numérateur de la racine égal à 0 et dénominateur non nul lookup §1, §6, §8
Les sélecteurs sont booléens le sélecteur de chaque lookup est astreint à s − s² par une porte de contrainte de la liste 0 lookup §2
Un lookup répond à partir de sa propre sous-table une seule largeur par canal, des plages de clés disjointes avec le décalage +1, et la borne que chaque famille impose à sa clé lookup §4, §9, §11
Les tables sont celles prévues les tables virtuelles sont les formes closes du vérificateur; la table générique est liée par le condensé SRS; les tables décodées, par l’identité lookup §3, §12
Chaque obligation déclarée est acquittée la règle d’acquittement, à l’assemblage et à chaque chargement de clé lookup §11

Valeurs publiques#

Affirmation Argument Spécification
La fenêtre d’entrée contenait l’entrée de l’énoncé la colonne initiale de PUBLIC_INPUT est égale aux mots de l’entrée en un point aléatoire, après que G7 a fixé les octets valeurs publiques §5
Le journal est ce qu’ont laissé les écritures du programme invité la colonne finale de PUBLIC_OUTPUT est égale aux mots du journal; la fenêtre n’a pas de colonne initiale qu’un prouveur pourrait remplir valeurs publiques §5
Le statut de sortie est la valeur finale de x10 la ligne de sortie réécrit le a0 qu’elle a lu; le vérificateur astreint v_10 au statut de l’énoncé add-sub §4, mémoire §4.1, preuve §6

Délégations#

Affirmation Argument Spécification
Chaque demande est exécutée exactement une fois l’ancre : demandes et invocations s’apparient une à une par le multiensemble, dans l’espace propre au type délégation §5
Une invocation calcule sa fonction l’argument de solidité de chaque circuit, cadre et chaînes de canonicité compris circuits de délégation §2–§7
Une opération en plusieurs appels est la composition de ses étapes le liant RAM : chaque étape lit les écritures de l’étape précédente sur un même historique mémoire; l’ordre est celui du code appelant circuits de délégation §1

Le système de preuve#

Affirmation Argument Spécification
Les sorties d’un shard sont son circuit évalué sur ses colonnes engagées la passe arrière GKR, chaque défi étant tiré après ce qu’il protège gkr §5.4
Les valeurs de colonnes annoncées sont celles des polynômes engagés une seule ouverture Mercury groupée au point de la passe mercury §5, §7
Un énoncé n’est vérifié que par l’ensemble de ses shards l’exactitude de l’ensemble des shards au décodage et dans verify_block; le rapprochement lit les racines de chaque shard preuve §1.3, §6
Les défis suivent chaque engagement qu’ils protègent la transcription globale G1–G11 et la transcription du shard S1–S6 preuve §2, §4, transcription §3
La mise en place est celle de la cérémonie le condensé SRS, que le vérificateur compare à celui de la cérémonie preuve §3, srs §3

La récursion et le contrat#

Affirmation Argument Spécification
Un nœud a exécuté exactement les vérifications du vérificateur de base les vérifications sont compilées en bandes dans l’image du programme du nœud, que son identité lie récursion §7, §8.1
L’arbre couvre un seul énoncé de base, chaque shard, dans l’ordre le chaînage des transcriptions entre les nœuds, des plages de shards adjacentes, et les vérifications que chaque nœud effectue sur ses enfants récursion §8.1, §8.2
Chaque ouverture différée tient chacune repliée sous des poids tirés après tout ce qu’ils pondèrent, et acquittée par un seul couplage dans le contrat récursion §8.3, mercury §6
Le décideur lie ce que détient le contrat des fils liés engagés avant leur défi; le circuit astreint le journal à toute la plage de base récursion §9
La clé du décideur n’a pas de trappe connue une cérémonie en deux phases avec un contributeur honnête par tour, les tours se succédant dans l’ordre récursion §9, srs §7

Délibérément non affirmé#

  • Quoi que ce soit au sujet des données auxiliaires. Les données auxiliaires ne sont liées à rien, par conception; un programme invité les vérifie.
  • La divulgation nulle de connaissance. Aucun aveuglement n’est appliqué.
  • La sémantique d’échec de sc.w. sc.w réussit toujours; un programme qui compte sur son échec sort du cadre de l’affirmation.
  • Les déroutements (traps). Une exécution qui déclenche un déroutement n’a aucune preuve.
  • Que le prouveur est correct. Le prouveur n’est pas digne de confiance; seules les crates du vérificateur portent la solidité.
  • Qu’un fichier de cérémonie est bien celui de la cérémonie, ni que le τ d’une clé est inconnu, sans la comparaison du condensé SRS effectuée par le vérificateur lui-même.

Auditeurs

Vérifier l’implémentation

Comment le code est vérifié par rapport à autre chose que lui-même. L’oracle indépendant de chaque couche, la seconde implémentation des règles des circuits, les jumeaux falsifiés qui prouvent que les contrefaçons sont refusées, et ce qu’aucune vérification ne couvre.

Voir en Markdown

Aucun composant d’Apogee n’est vérifié par rapport à une seconde implémentation du système entier. Chaque couche a plutôt son propre oracle, choisi pour que la vérification partage le moins de code possible avec ce qu’elle vérifie.

Chaque couche et son oracle#

Couche Vérifiée par rapport à
Corps, courbe, couplage, MSM des vecteurs à réponse connue générés à partir d’arkworks, que les tests exécutent aussi en direct; les tests redérivent chaque constante arithmétique que lisent les crates
Poseidon2 et la transcription tools/transcript-ref : le Poseidon2 de Plonky3 paramétré avec les constantes de tour de zkhash, et une traduction directe de la spécification exécutée à côté du challenger duplex de Plonky3, en accord à chaque essorage
Le décodeur les 2^30 mots de 32 bits dont les bits de poids faible valent 11, par rapport à des décomptes d’acceptation dérivés des tables de l’ISA et à un encodeur indépendant; llvm-objdump sur les programmes invités versionnés
Le développement RVC l’encodeur même de LLVM, sur un programme invité assemblé à la fois avec et sans compression
Les circuits sous forme de données checker : les quatre lois, les règles de lookup et le contrat de remplissage réimplémentés sans le code de constraints, en ne partageant que le noyau des portes
Les portes de chaque famille des suites par ligne qui construisent des lignes avec l’arithmétique entière de Rust et les évaluent avec le checker; les cœurs arithmétiques, de façon exhaustive, à des largeurs de mot réduites
Les arguments de mémoire et de lookup des évaluateurs natifs dans checker, exécutés sur des traces d’exécutions réelles
L’exécuteur l’autovérification de sa propre trace et les arguments ci-dessus; il n’y a pas de second exécuteur
Le programme invité revm revm natif, compilé à partir des crates amont non corrigées
Le validateur sans état un sous-ensemble versionné de tests-zkevm v21.0.1, en natif dans la CI; la version complète en natif et le sous-ensemble à travers le binaire invité, à la main; tools/stateless-ref pour l’encodage des entrées
Le décideur la preuve vérifiée en natif, et le contrat exécuté dans revm

Vérifications exhaustives à petites largeurs#

Plusieurs cœurs arithmétiques sont écrits avec leur largeur de mot comme paramètre, afin que l’encodage puisse être vérifié sur toutes les entrées à une largeur assez petite pour être énumérée :

  • le gadget de comparaison à 6 bits, sur chaque paire d’opérandes, signée et non signée, qui trouve exactement un (lt, gap), celui de l’ISA;
  • l’arithmétique de MUL_DIV à 4 bits, sur chaque dividende, chaque diviseur et chaque type de division, qui admet exactement un (q, r), celui de RV32M;
  • l’insertion de MEM_SUBWORD sur un mot de 4 bits, qui admet exactement un (high, sub, low) pour chaque mot, chaque décalage et chaque largeur.

Les règles des circuits, deux fois#

CircuitArtifact::validate et les règles de construction de la mémoire et des lookups s’exécutent partout où un artefact est construit ou une clé est chargée. crates/checker applique les mêmes règles une seconde fois avec son propre code, sans jamais appeler validate, et n’évalue les portes qu’au moyen de gkr_verify::eval_gate, le seul noyau que les deux côtés tiennent pour l’autorité sémantique. Ses validateurs vérifient les lois par évaluation en des points échantillonnés là où validate compare des développements normalisés, recalculent la somme de chaque canal ligne par ligne plutôt que par un arbre et nomment tout tuple qu’aucune ligne de table ne contient, et reconstruisent les colonnes mémoire d’une famille d’exécution à partir du journal d’événements plutôt qu’à partir des lignes d’un shard.

sh
cargo run -p checker -- laws <artifact>       # Laws 1–4, then the lookup rules
cargo run -p checker -- padding <artifact>    # the padding contract
cargo run -p checker -- dump <artifact>       # the circuit, readably

Jumeaux falsifiés#

Un jumeau falsifié est une contrefaçon prouvée exactement comme un prouveur honnête la prouverait. La suite de falsification (checker::TamperHarness) prouve de nouveau un énoncé dont des cellules du témoin ou des scalaires de frontière ont été modifiés : les multiplicités de chaque canal sont recomptées, les colonnes mémoire modifiées sont engagées de nouveau dans une nouvelle phase d’engagement global, chaque shard est prouvé de nouveau. Elle vérifie ensuite un shard ou le bloc et contrôle par assertion la classe du refus, Constraint, Lookup avec son canal, ou MemoryArgument, ou bien contrôle qu’une modification qui ne casse rien passe la vérification.

Les jumeaux reposent sur le fait que le prouveur ne vérifie rien, ce qui est voulu : un témoin contrefait obtient la meilleure preuve qu’un prouveur honnête pourrait en faire, et le vérificateur doit le refuser dans la classe attendue. La suite contient aussi les contrefaçons de l’ancre de délégation et, sur le mini-bloc du réseau principal, elle montre l’autre face de la règle des données auxiliaires (advice) : une cellule de données auxiliaires corrompue est refusée par l’argument de mémoire, et une cellule corrompue de façon cohérente passe la vérification, car les données auxiliaires ne sont liées à rien.

sh
cargo test --release -p checker --test tamper -- --include-ignored --test-threads=1

Des données de référence qui se régénèrent#

kat-gen écrit chaque vecteur à réponse connue, listage, artefact de circuit et identité versionnés, chacun avec son SHA-256, que fixent les tests qui le lisent. La CI régénère les groupes par défaut et les deux oracles de référence, et échoue à la moindre différence dans les répertoires de vecteurs :

sh
cargo run -p kat-gen && git diff --exit-code

Un ELF de programme invité n’est pas reproductible d’une machine à l’autre, car rustc incorpore des chemins absolus dans les chaînes de localisation des paniques; deux compilations propres sur une même machine concordent. Les ELF des programmes invités sont donc régénérés à la main sur une seule machine, et la CI ne régénère que ce qui en dérive.

Ce qu’aucune vérification ne couvre#

  • Il n’y a pas de second exécuteur. L’émulateur est confronté à une reformulation de sa propre table de cadres et aux arguments de mémoire et de lookup, et non à une implémentation RISC-V indépendante, et aucun exécuteur ici n’emprunte le repli logiciel d’un shim de délégation.
  • Les règles de construction que le checker ne répète pas : les règles de construction de la mémoire, la règle de copuissance, et les autres règles de construction de validate, dont le plafond de degré, ne sont appliquées qu’une fois.
  • La correction du prouveur n’est pas vérifiée, seulement sa complétude, par les suites qui prouvent de vrais shards, lesquelles s’exécutent hors de la CI parce que chacune nécessite des dizaines de GiB.
  • Les entrées sans état de la famille Osaka n’ont pas d’oracle de bout en bout. La version des tests ne remplit qu’Amsterdam; la disposition Electra/Fulu est confrontée à eth-act/ere-guests, et les règles d’en-tête, à deux blocs du réseau principal.

Auditeurs/Système

Le système, de bout en bout

Spécification normativedocs/architecture.mdVoir en Markdown

Résumé

La vue d’ensemble d’Apogee VM que donne la spécification elle-même. Elle énonce exactement ce qu’établit une preuve vérifiée et les trois valeurs qu’un vérificateur détient indépendamment du prouveur; suit un programme de son ELF jusqu’à sa preuve; présente sous forme de tableau l’argument qui porte chaque partie de l’énoncé; énumère les hypothèses cryptographiques, de mise en place et de code, y compris les crates sur lesquelles repose la solidité; expose chaque limite de la v1.0.0; et nomme l’oracle indépendant par rapport auquel chaque couche du code est vérifiée.

Le texte normatif ci-dessous est tenu à jour en anglais, langue canonique de la spécification.

Apogee proves executions of RV32IMAC programs. This page is the system end to end: what a proof states, how one is made and checked, what it assumes and where it stops. Each paragraph names the page that specifies its subject; glossary.md indexes the vocabulary.

1 What a proof states#

A verifier holds three things it does not take from the prover's word, and two of them from a channel the prover does not control (proof.md §1, §3):

  • the program identity, one field element: a digest of the program's instruction tables, its initial memory image, its entry pc and its VmConfig — the circuit families it uses and their heights (program.md §8);
  • the SRS digest of the ceremony, which a verifying key must carry;
  • a verifying key: that config, each family's circuit and setup commitments, the SRS's verifier points and the generic lookup table's commitments. Loading it recomputes the identity and the SRS digest from its own contents and holds its circuits to the registry's bytes (proof.md §7).

The proof's statement, PublicInputs, carries the public input bytes, the public output bytes (the journal), the exit status, and the record of the execution's shape — shard counts, memory windows, the final registers and pc, every shard's memory commitments and roots (proof.md §1).

A proof that verifies establishes that the program of that identity, started at its entry pc over its image, with the public input in its input window and some advice of the prover's choosing, executes instruction by instruction to EXIT with that status, having written that journal. Nothing is claimed of the advice, and nothing is hidden: no commitment or proof is blinded.

2 From a binary to a proof#

  1. The program. loader reads the ELF into a ProgramImage, expanding compressed instructions in place; isa decodes; program routes each instruction to one of seven instruction families, builds every family's decoded table — a row per halfword of code — and commits to them as the identity (program.md).
  2. Execution. emulator runs the guest. A cycle is one row of the family that owns its instruction, recording its memory queries: timestamped reads and writes of the pc, registers and RAM (execution-trace.md). A guest issues no system call but EXIT: its input, journal and advice are regions of memory (public-values.md, ecall-abi.md). Hashing and big-integer arithmetic are delegated: an ecall names a frame in RAM, and a row of a delegation family does the work on it (delegation.md, delegation-circuits.md).
  3. Shards. A family's rows are cut into shards of the family's height, a power of two between 2^8 and 2^22. The memory an execution touches is covered by shards of the window families, which give each word its initial and final tuple (memory.md §3). A shard is the unit of proving; a block is hundreds (circuits.md §1).
  4. A shard's proof. Its columns are committed with Mercury (mercury.md). The family's GKR circuit is run backward from its outputs to those columns, a sumcheck a layer (gkr.md), and every column is opened at the one point that pass ends on, in one batched opening (proof.md §5).
  5. The block. A BlockProof is the statement and its shard proofs. verify_block runs the global transcript once, each shard's checks, and once the memory reconciliation over every shard's roots (proof.md §6).
  6. Recursion. Verifier programs, proved by this VM in a format of its own, verify runs of shards and fold their deferred pairings; a tree of them ends in a root, a Groth16 circuit re-verifies the root, and ApogeeVerifier.sol checks that proof and the folded pairing (recursion.md).

The prover executes the guest twice: once to commit every shard's memory columns, which fixes the statement and its challenges, and once to prove each shard as it fills. Its memory is bounded by the shards in flight, not by the execution (streaming.md).

3 How soundness composes#

Each shard's GKR pass and opening tie its circuit's outputs to committed columns. On top of that, these arguments span the execution:

claim carried by
every row obeys its instruction the family circuit's enforcing gates, zero on every row the family pages, circuits.md
a row's instruction is the program's at its pc a lookup of the row's pc and fields in the family's decoded table, which the identity commits lookup.md §10
every read returns the last write one multiset over all shards: an access reads a tuple (space, address, timestamp, value) and writes one with a later timestamp; the verifier multiplies every shard's read and write roots against boundary factors for the registers and the pc memory.md
the rows are one path from the entry pc to the exit, in program order the pc is a cell of that multiset: a row reads its pc and writes the next one at least four timestamps later, so shard order, cycle uniqueness and continuity need no other argument memory.md §5, §9
a value is a byte, a word, a sign, an XOR LogUp channels over range, byte and generic tables lookup.md
the public input and the journal are the claimed bytes the two public windows' initial and final columns, held to the bytes' multilinear extensions public-values.md §5
a delegated computation is the function's invocation rows that read and write the frame through the same multiset, paired one to one with their ecall by an anchor tuple delegation.md §5

Challenges come from a Poseidon2 duplex transcript (transcript.md). The global transcript absorbs the whole statement, every shard's memory commitments included, before the memory challenges exist; each shard's transcript is seeded from its final state (proof.md §2, §4).

4 What it assumes#

  • Cryptography. Mercury's and KZG's knowledge soundness in the algebraic group model under q-DLOG (mercury.md §7); Poseidon2 as a random oracle for Fiat–Shamir; for the last step, Groth16's own assumptions. BN254 gives about 100 bits.
  • Setup. The SRS is the PSE perpetual powers of tau, sound while one contributor was honest. The code checks a file's structure and decodes every point; nothing proves it is that ceremony's, and no proving path runs Srs::validate (srs.md §3). The decider's Groth16 key comes from a second, circuit-specific ceremony (recursion.md §9).
  • What a verifier must obtain itself. The program identity and the ceremony's SRS digest. A key loads under whatever digest its own points give, so a key built over a known τ is refused only by that comparison; the verifier CLI compares identity only, and host::verify neither (proof.md §1, §3).
  • Trusted code. Soundness is the verifier's alone: constants, field, curve, transcript, poly, sumcheck, pcs-verify, pcs, gkr-verify, verifier-core, verifier, and constraints — the circuits are part of the statement, and a missing gate is a soundness bug. Computing an identity from an ELF trusts loader, isa and program. The last step adds guests/recursion, groth16, the decider's circuit and the contract. prover, emulator, trace and the proving half of host are untrusted: the prover validates nothing, and a wrong input costs an honest prover a proof that fails.
  • Nothing is constant-time (primitives.md). No proof is zero-knowledge, so proving keeps nothing secret; the one secret the code handles is a Groth16 ceremony contributor's factor, which groth16::phase2 multiplies in with the same variable-time ladder.

5 Limits#

not zero-knowledge no blinding in Mercury, GKR or the Groth16 decider
advice is unbound a guest checks it against something a proof binds (public-values.md §6)
public values at most 16,380 bytes each of input and journal (public-values.md §9)
sc.w always succeeds the one deviation from RV32IMAC's semantics; there is no reservation state (memory-ops.md §6)
traps are not provable a misaligned access, an access outside mapped memory, ebreak or a pc with no instruction ends an execution with no proof (execution-trace.md §10)
code is static the instruction stream is the image decoded at load; one undecodable word in an executable segment refuses the program (program.md)
code size .text within a decoded table's reach of its load address, 7.94 MiB at 2^22, and the image within bytecode_size_words, 4 MiB by default (program.md §5, §7)
execution length timestamps are 38 bits: 2^36 − 1 cycles (execution-trace.md §1)
delegations are a fixed set six in the base format; an EVM MULMOD with an arbitrary modulus is not one; a delegation proves one step of its function, and composing steps, validating curve points among them, is the calling code's (delegation.md §11, delegation-circuits.md)
prover memory set by the shards in flight: the measured full block peaked at 174 GiB (streaming.md §1)
block witnesses the stateless validator takes its input from an external witness producer; the built-in recorder cannot record every block (ethereum.md §4, §6)
the decider's key one per root shape, and only as trustworthy as its ceremony; the development key is forgeable (recursion.md §9)
on-chain cost about 3.6M gas for the measured block (recursion.md §10)

6 How the code is checked#

No component is checked against a second implementation of the whole system; each layer has its own independent oracle.

layer checked against
fields, curve, pairing, MSM known-answer vectors generated from arkworks, which the tests also run live
Poseidon2 and the transcript tools/transcript-ref: Plonky3 and zkhash
the decoder every 32-bit word of the instruction space against counts from the ISA; llvm-objdump over the committed guests
circuits as data checker: the circuit laws, the lookup rules and the padding contract re-implemented without constraints' code, sharing only the gate kernel (circuits.md §3)
each family's gates row suites that build rows with Rust's own integer arithmetic and evaluate them through the checker; the arithmetic cores exhaustively at reduced word widths; tamper twins, a forged witness proved as an honest prover would and refused in the expected class
the memory and lookup arguments native evaluators in checker over executed traces
the executor its own trace's self-check and the arguments above; there is no second executor, and no executor here takes a delegation shim's software fallback
the revm guest native revm, built from unpatched upstream crates
the stateless validator a committed subset of tests-zkevm v21.0.1 natively in CI; the whole release natively and the subset through the guest binary by hand; tools/stateless-ref for the input encoding
the decider the proof checked natively, and the contract executed in revm

Committed fixtures are regenerated and compared in CI (tools.md §7). The suites that prove real shards, over a toy SRS, need tens of GiB and run outside CI (README).

7 Cost#

recursion.md §10 has the end-to-end measurements for one block, from the base proof to the contract call; streaming.md §1 breaks the base proof down; and circuits.md §1 gives every circuit's width and proof size, which a shard's cost follows.

References#

  • L. Eagen, A. Gabizon. MERCURY: A multilinear polynomial commitment scheme with constant proof size and linear field work. ePrint 2025/385. publication/2025-385.pdf
  • D. Boneh, J. Drake, B. Fisch, A. Gabizon. Efficient polynomial commitment schemes for multiple points and polynomials. ePrint 2020/081. publication/2020-081.pdf
  • J.-L. Beuchat et al. High-speed software implementation of the optimal ate pairing over Barreto–Naehrig curves. ePrint 2010/354. publication/2010-354.pdf

Auditeurs/Fondations

Primitives : corps, courbe, couplage, polynômes, sumcheck

Spécification normativedocs/spec/primitives.mdVoir en Markdown

Résumé

L’arithmétique sur laquelle tout le reste est construit, entièrement implémentée dans le dépôt, sans traits, sans code unsafe ni assembleur. La page définit Fr et ses trois formes en octets, la tour Fq jusqu’à Fq12, les groupes G1 et G2 avec leurs encodages non compressés et leurs décodeurs avec validation, le couplage ate optimal et son exponentiation finale exacte, la MSM de Pippenger fenêtrée, la convention d’indexation des polynômes multilinéaires, et le sumcheck du zerocheck, dont le moteur GKR réutilise le format de tour. Rien n’est à temps constant.

Le texte normatif ci-dessous est tenu à jour en anglais, langue canonique de la spécification.

BN254's scalar field Fr, its base field Fq and the tower to Fq12, the groups G1 and G2, the optimal ate pairing, multi-scalar multiplication, multilinear polynomials and the zerocheck. The byte encodings of field elements and points (§1–§3) and the polynomial index convention (§6) are defined here.

  • All of it is this repository's code: concrete types, no field trait, no unsafe, assembly or intrinsics. field, poly and sumcheck are #![no_std] and build for the guest target; curve is std, with rayon, and no guest links it.
  • arkworks is a test oracle only, for field, curve and poly, live and through vectors tools/kat-gen generates; the tests of field and curve re-derive every arithmetic constant those crates read.
  • Nothing is constant-time: reductions, exponentiations, point additions and scalar ladders branch on their operands. No proof is zero-knowledge, so no witness is secret; the one secret this code handles, a decider ceremony contributor's factor (recursion.md §9), goes through the same variable-time ladder (§3).

1 Fr#

p = 21888242871839275222246405745257275088548364400416034343698204186575808495617

field::Fr is the integers mod p (constants::FR_MODULUS), 254 bits; p is also the order of G1 and G2, r in §3–§4. In memory an element is four little-endian 64-bit limbs of x·R mod p, R = 2^256 mod p, always reduced below p, so equal limbs are equal values. Multiplication is CIOS Montgomery over u128 intermediates. Fr::inverse is x^(p−2), None at 0; field::batch_inverse is Montgomery's trick and leaves a 0 entry 0. p − 1 = 2^28·c with c odd, and every FFT domain is a subgroup of the one constants::FR_TWO_ADIC_ROOT_OF_UNITY generates.

byte form used in
wire the value, not x·R, as 32 little-endian bytes: Fr::to_bytes. Fr::from_bytes is None for a value ≥ p and never reduces; serde goes through both every proof, key and artifact
source literal 0x and exactly 64 lowercase hex digits, big-endian: Fr::from_hex, None for any other spelling or a value ≥ p constants in crates/constants
memory the four limbs: Fr::to_memory_bytes, Fr::from_memory_bytes, None at or above p the FR_ARITH delegation's frame alone

On the guest target (cfg(target_arch = "riscv32")) addition, Montgomery multiplication and inversion call the FR_ARITH delegation through guest_sdk::recursion::fr_arith (delegation.md §10). Its circuit proves these three functions of the memory form the frame carries (delegation-circuits.md §4), so a delegated result is the software result bit for bit.

2 The Fq tower#

q    = 21888242871839275222246405745257275088696311157297823662689037894645226208583
Fq2  = Fq[u]/(u^2 + 1)
Fq6  = Fq2[v]/(v^3 − ξ)       ξ = 9 + u
Fq12 = Fq6[w]/(w^2 − v)

curve::Fq is the field of coordinates (constants::FQ_MODULUS). Its limb arithmetic is Fr's, copied literally over q's constants, and so are its wire and source-literal forms. Fq2 encodes as c0 ‖ c1; nothing above it has a byte form.

Products are schoolbook and squarings above Fq2 are products. A Frobenius map multiplies coefficients by powers of ξ tabulated in constants (FQ6_FROBENIUS_C1, FQ6_FROBENIUS_C2, FQ12_FROBENIUS_C1); Fq12::conjugate is the q^6 one. Nothing in the tower or the pairing is sparse, cyclotomic or precomputed: a pairing is only ever computed to verify something, and the code is written to be read.

3 G1, G2 and their encodings#

G1 = E(Fq)                E:   y^2 = x^3 + 3        #E  = r             generator (1, 2)
G2 ⊂ E′(Fq2), order r     E′:  y^2 = x^3 + 3/ξ      #E′ = r·(2q − r)    generator EIP-197's

G1Affine { x, y, infinity } is a point and G1Projective its Jacobian form, Z = 0 the identity, under the EFD formulas dbl-2009-l, add-2007-bl and madd-2007-bl, with the identity, P = Q and P = −Q branched on explicitly. Scalar multiplication is a fixed 4-bit window. G2Affine and G2Projective are the same code over Fq2.

A point's wire form is uncompressed affine, and there is no compressed one:

G1Affine    64 bytes    x ‖ y
G2Affine   128 bytes    x.c0 ‖ x.c1 ‖ y.c0 ‖ y.c1        x = x.c0 + x.c1·u
infinity                every byte zero

Each coordinate is an Fq in wire form; (0, 0) is on neither curve, so zero is unambiguous. G1Affine::from_bytes and G2Affine::from_bytes return None unless the bytes are all zero, or every coordinate is below q, the point satisfies its curve's equation and, in G2, whose cofactor 2q − r is not 1, [r]P is the identity — by the window ladder, with no endomorphism.

Nothing else validates: the affine structs' fields are public, and the group law, msm and the pairing compute on whatever they are given. A point's transcript form is transcript.md §4's; a .ptau file (srs.md §2) and the contract (recursion.md §9) have encodings of their own.

4 The pairing#

e(P, Q) = f_{6x+2, Q}(P)^((q^12 − 1)/r)        x = 4965661367192848881

curve::pairing::miller_loop(pairs) is Algorithm 1 of Beuchat et al. (ePrint 2010/354) with homogeneous projective line formulas: the 66-digit NAF of 6x + 2 (constants::ATE_LOOP_NAF), then the two lines adding ψ(Q) and −ψ(ψ(Q)), ψ the untwist-Frobenius-twist map. For a Q of order r no step adds a point to itself, to its negative or to the identity, so the line formulas have no exceptional case.

final_exponentiation returns exactly f^((q^12 − 1)/r): the easy part (q^6 − 1)(q^2 + 1), then the hard exponent (q^4 − q^2 + 1)/r as its base-q expansion λ0 + λ1·q + λ2·q^2 + q^3, by three exponentiations (constants::FINAL_EXP_LAMBDA_0 to FINAL_EXP_LAMBDA_2, the two negative ones conjugated) and three Frobenius maps. The Fuentes-Castañeda hard part, which arkworks uses, returns this value raised to 2x(6x^2 + 3x + 1): the two libraries agree on every pairing check and on no pairing value but 1, and the test vectors are arkworks' Miller outputs raised to the literal exponent.

pairing_check(pairs) is Π e(P_i, Q_i) = 1 by one Miller loop, whose Fq12 squarings the pairs share, and one final exponentiation: the form of every pairing equation in the system. A pair holding a point at infinity contributes 1 and is skipped; an empty product is 1.

5 MSM#

curve::msm::msm(bases, scalars) is Σ scalars_i·bases_i in G1 by windowed Pippenger, and msm_small_u32 the same sum over u32 scalars, recoded from 32 bits instead of 254. Neither looks at a scalar's size: the caller chooses, and pcs::commit chooses by a column's backing (§6). G2 has no MSM here; crates/groth16 carries its own.

  • Width. w = 3 below 32 points, otherwise ⌊0.69·⌈log2 n⌉⌋ + 2: arkworks' rule.
  • Digits. A scalar is recoded into signed digits in [−2^(w−1), 2^(w−1)], one a window, over ⌈(bits + 1)/w⌉ windows; the sign costs a negated base and halves the buckets to 2^(w−1). At 2^20 points w = 15: 17 windows for an Fr, 3 for a u32.
  • Parallelism. Each (window, chunk of the input) is a rayon task that adds bases into buckets by mixed addition and reduces them by a running sum; the tasks' sums are combined serially, w doublings a window. Group sums are exact, so the point does not depend on the thread count.

6 Multilinear polynomials#

poly::MultilinearPoly is a table of 2^n evaluations over {0,1}^n, the type of every column.

Index convention. Variable j is bit j of the index: the evaluation at y = (y_0, …, y_{n−1}) is entry Σ_j y_j·2^j. bind(r) fixes variable 0, the low bit,

f′(i) = f(2i) + r·(f(2i + 1) − f(2i))

and the old variable 1 becomes variable 0. So binding r_0, r_1, … in order leaves evaluate(&[r_0, r_1, …]), whose point[j] is variable j. Sumcheck round i binds variable i (§7), so a claim's point lists its challenges in variable order, the order evaluate and a Mercury opening (mercury.md §1) take.

Backing. PolyBacking holds the table as a bitset (U1), u8, u16, u32 or Fr. A trace column is filled and committed at its integer width (§5). get and evaluate embed an entry in Fr as they read it and leave the table alone; the first bind folds the integer table straight into an Fr table of half the length, and the backing is Fr from then on.

eq. eq_table(r) tabulates eq(r, ·) over the cube in the same index order; eq_eval(r, y) is its closed form, for any r and y.

new, get, bind, evaluate and eq_eval panic on a table, index or point of the wrong size rather than return an error.

7 The sumcheck#

crates/sumcheck proves that a gate vanishes on the cube. A sumcheck::Gate is a sum of GateTerms coef·x_a·x_b, the second factor optional, over input columns of n variables: degree at most 2 in each variable, by construction.

G(y) = 0 on all of {0,1}^n is proved as the sumcheck 0 = Σ_y eq(r, y)·G(y) at a random r. eq·G has degree at most 3 in each variable, so a round polynomial is a cubic and a round message its four coefficients [c0, c1, c2, c3], ascending — four whatever the gate, so a proof's shape depends on n and the number of inputs alone.

prove_zerocheck and verify_zerocheck run one schedule, under the tags of transcript.md §5:

1   the caller binds the columns to the transcript
2   r_0 … r_{n−1}                                      n × SUMCHECK_CHALLENGE
3   for i in 0..n:   g_i, one message of four          SUMCHECK_ROUND
                     ρ_i, binding variable i           SUMCHECK_CHALLENGE
4   final_evals: each input column at ρ, one message   SUMCHECK_FINAL_EVALS

The verifier checks the proof's shape, then g_0(0) + g_0(1) = 0 and g_i(0) + g_i(1) = g_{i−1}(ρ_{i−1}), each before absorbing g_i, and, with final_evals absorbed, g_{n−1}(ρ_{n−1}) = eq(r, ρ)·G(final_evals). It returns SumcheckClaim { point: ρ, final_evals } or a SumcheckError, and does not panic on a proof.

That last check is one equation over all the claimed evaluations and ties none of them to its column: the caller owes an opening of each at ρ, as it owes step 1. The step 1 its callers use is witness_digest, a hash and not a commitment: a sponge of its own absorbs [column count, n] and each column's cells under WITNESS_DIGEST, and its raw squeeze enters the transcript under the same tag.

What uses it. No proof in the system is this zerocheck, and no circuit is made of its Gate (gkr.md §3). The proving stack takes one type from the crate, SumcheckProof { rounds: Vec<[Fr; 4]>, final_evals }, as each layer of a gkr_verify::GkrProof. The GKR layer sumcheck (gkr.md §5) repeats step 3's rounds and checks under the same two tags, from a batched claim instead of 0 and to a final check of its own, in gkr::prove_sumcheck and gkr_verify::verify_sumcheck. prove_zerocheck and verify_zerocheck are called only by tests and tools/bench.

Auditeurs/Fondations

La transcription

Spécification normativedocs/spec/transcript.mdVoir en Markdown

Résumé

Comment chaque défi du protocole est tiré. La page spécifie la permutation Poseidon2 de largeur 3 sur Fr avec ses constantes de tour fixées, l’éponge duplex (débit 2, capacité 1) et ses règles d’absorption et d’essorage, le cadrage des messages typés qui rend injectif chaque flux absorbé, la façon dont un point de G1 est absorbé sous forme de quatre limbs, et le tableau des 45 étiquettes de transcription, avec le type et la place de chacune.

Le texte normatif ci-dessous est tenu à jour en anglais, langue canonique de la spécification.

Every challenge in the protocol is drawn from a Poseidon2 duplex sponge over Fr through a typed message layer. This page specifies the permutation, the sponge, the framing, a G1 point's transcript form and every tag. Implementation: crates/transcript, #![no_std].

1 The Poseidon2 permutation#

Width 3 over Fr, S-box x^5, 4 full rounds, 56 partial rounds (S-box on lane 0 only), 4 full rounds. The round constants are RC3 of HorizenLabs/poseidon2, plain_implementations/src/poseidon2/poseidon2_instance_bn256.rs at commit 055bde3f4782731ba5f5ce5888a440a94327eaf3.

E(s) = s + (s₀+s₁+s₂)·(1,1,1)                 circ(2, 1, 1)
I(s) = s + (s₀+s₁+s₂)·(1,1,1) + (0,0,s₂)      1 + diag(1, 1, 2)

poseidon2_permute(s):
  s ← E(s)
  RC3 rows 0–3:    s_i ← (s_i + c_i)^5, every lane;   s ← E(s)
  RC3 rows 4–59:   s₀ ← (s₀ + c₀)^5;                  s ← I(s)
  RC3 rows 60–63:  s_i ← (s_i + c_i)^5, every lane;   s ← E(s)

poseidon2_permute([0, 1, 2])₀ = 0x0bb61d24daca55eebcb1929a82650f328134334da98ea4f847f760054f4a3033

constants::POSEIDON2_RC3_INITIAL, _INTERNAL and _TERMINAL hold the 80 entries read (upstream's partial rows are zero in lanes 1 and 2) as upstream's big-endian hex literals, character for character, decoded by Fr::from_hex on every call. They are pinned through the permutation, by the oracle's 128 vectors (§2), each of which reads every constant.

On riscv32, poseidon2_permute is one POSEIDON2 delegation call over the lanes' canonical bytes, falling back to these rounds when the executor answers -ENOSYS (delegation.md §10).

2 The duplex sponge#

state  [Fr; 3]   lanes 0, 1 the rate, lane 2 the capacity; zero in Transcript::new()
input  [Fr; 2]   absorbed, not yet permuted: 0 or 1 pending between operations
output [Fr; 2]   squeezed, not yet handed out: 0 to 2

observe(x):  output ← []; input.push(x); if |input| = 2: duplex()
sample():    if |input| > 0 or |output| = 0: duplex(); return output.pop()
duplex():    n ← |input|; state[0..n] ← input; input ← []
             if n > 0: state[n..2] ← 0; state[2] += n
             poseidon2_permute(state); output ← [state[0], state[1]]
  • Absorption overwrites the rate. A short absorb zero-fills the rest of it and adds its length to the capacity, so [a] and [a, 0] differ; with nothing pending, a duplex is a pure squeeze and does neither.
  • Squeezed lanes leave from the end: the first sample after an absorb is state[1], the second state[0], and a third permutes again.
  • observe drops unread output and lanes past a buffer's length stay zero, which moves no challenge and makes the state a function of the operation sequence alone.

This is Plonky3's DuplexChallenger at width 3 and rate 2. The vectors crates/transcript is tested against come from tools/transcript-ref, which shares no code with it: Plonky3's Poseidon2 keyed with zkhash's own RC3, and a transcription of this section and §3 run beside that type, agreeing with it on every squeeze. The recursion format replays the same sponge over field cells, one P2_FIELD row a duplex step (recursion.md §4).

3 Typed messages#

append_scalars(tag, xs):  observe(tag); observe(|xs|); observe(x) for x in xs
append_scalar(tag, x)  =  append_scalars(tag, [x])
append_bytes(tag, b):     observe(tag); observe(|b|); observe(c) for each 31-byte chunk c of b,
                          zero-padded to 32 bytes, read little-endian
challenge_scalar(tag):    observe(tag); return sample()

The length, the scalar count or for bytes the byte count, delimits a message: "abc" and "abc\0" are each one chunk, below 2^248 < p, and differ. A challenge absorbs its tag, so it always comes from a fresh permutation.

The framing carries no kind, so each tag names exactly one of scalars, bytes or a challenge (§5): a tag of two kinds would make append_bytes(T, b"") and append_scalars(T, []) the same T, 0. So every digest — program identity, the SRS digest, transcript::io_digest, sumcheck::witness_digest, pcs::accumulator_digest — is a fresh sponge of typed messages ended by a raw sample(), never by a challenge under one of its message tags.

snapshot() captures the state and both buffers, and Transcript::restore resumes the same challenge stream. Its postcard form is 226 bytes, state[3], input[2], input_len: u8, output[2], output_len: u8, each Fr canonical; decoding refuses input_len ≥ 2, output_len > 2 and a nonzero lane past either length. The archived path's phase files hold the global transcript, and each shard's after its GKR pass, in this form (streaming.md §6). A shard transcript is no restored global sponge but a fresh one whose first message carries the global state digest (proof.md §4).

Each typed operation appends Absorb { tag, n_scalars } (payload elements: scalars, or chunks) or Challenge { tag } to event_log(). Raw observe and sample are not logged, the log never feeds the sponge and a snapshot omits it; checker::tape holds the global transcript's log to the order G1–G11 (tools.md §4).

4 G1 points#

A point is absorbed as four Fr limbs of its 64-byte encoding x ‖ y (primitives.md §3), with no curve arithmetic (transcript::g1_limbs):

[ x[0..16], x[16..32], y[0..16], y[16..32] ]   each half read little-endian, below 2^128 < p
[ S, S, S, S ]                                 the 64 zero bytes of infinity; S = 2^128

A coordinate is an Fq element and q > p, hence the halves. S is constants::G1_INFINITY_SENTINEL: no 16-byte half reaches 2^128, so the limbs determine the 64 bytes whether or not they encode a point on the curve. The absorber never refuses; a point is validated where it is decoded, before a pairing reads it.

transcript::append_g1_points(tr, tag, points) absorbs k points as one message of 4k limbs, never k messages, so the framed length binds k. pcs::append_g1_list is it over G1Affine::to_bytes, and pcs::append_g1 a list of one.

5 Tags#

Tag = u64: constants::transcript_tags, 45 tags numbered from 1 and named by transcript_tags::NAMES[tag − 1]; 0 is not a tag. Kinds: S scalars, B bytes, C challenge. Where: G1–G11 and the shard transcript are proof.md §2, §4, the SRS digest §3 there; identity program.md §8; Mercury mercury.md; GKR gkr.md §5; io_digest public-values.md §5; stacks and nodes recursion.md §1.3, §8.3. † marks a tag on no proof path.

tag where
1 PROTOCOL_SUITE S G1: [PROTOCOL_VERSION]
2 PUBLIC_INPUTS B G7: io_digest's 32 canonical bytes
3 COMMITMENT S a commitment list: identity, G8, shard witness, Mercury
4 SUMCHECK_ROUND S a sumcheck round's coefficients
5 SUMCHECK_CHALLENGE C a round's challenge; first, a zerocheck's eq-randomizers
6 EVALUATION_CLAIM S Mercury: the point, then the claimed values
7 PCS_OPENING S Mercury: proof points and evaluations
8 WITNESS_DIGEST S sumcheck::witness_digest's sponge, and its result †
9 SUMCHECK_FINAL_EVALS S the zerocheck's final evaluations †
10 MERCURY_INSTANCE S Mercury: [n]
11 MERCURY_ALPHA C Mercury: α
12 MERCURY_GAMMA C Mercury: γ
13 MERCURY_Z C Mercury: z, redrawn while 0
14 BDFG_BATCH C Mercury: δ
15 BDFG_POINT C Mercury: z′
16 PAIRING_MERGE C Mercury: the pairing merge ρ
17 MERCURY_BATCH C Mercury: the column batch ρ
18 ACCUMULATOR_DIGEST S pcs::discharge: the entry words' sponge, and its result †
19 ACCUMULATOR_MERGE C pcs::discharge: the per-check weight †
20 PUBLIC_INPUT_STREAM B io_digest: the input
21 PUBLIC_OUTPUT_STREAM B io_digest: the output
22 PROGRAM_IDENTITY S identity: [code_version]; G6: [identity]
23 VM_CONFIG S identity; G3
24 SHARD_COUNTS S G4
25 GKR_OUTPUTS S GKR: the output tables
26 GKR_OUTPUT_POINT C GKR: the top point
27 GKR_BATCH C GKR: a transition's claim batch
28 GKR_LAYER_CLAIMS S GKR: a transition's claimed values
29 GKR_CHILD C GKR: a halving transition's line point
30 MEMORY_WINDOWS S G5
31 MEMORY_BOUNDARY S G9
32 PROGRAM_ENTRY S identity: [entry_pc]
33 LOOKUP_CHALLENGE C shard: g, then β (lookup.md §2)
34 SRS_DIGEST S G2
35 SRS_VERIFIER B the SRS digest: the 320-byte SrsVerifier
36 MEMORY_GROUP S G8: [family, shard count]
37 MEMORY_CHALLENGE C G10, four times
38 GLOBAL_STATE_DIGEST C G11
39 SHARD_SEED S shard: [digest, family, index]
40 SHARD_TS_WINDOW S shard: [start, end]
41 GENERIC_TABLE S the SRS digest: the generic table's 3 points, 12 limbs
42 STACK_CHALLENGE C a recursion-format shard: its σ stack challenges
43 FOLD_STATE S a node: a verified shard's final transcript state
44 FOLD_WEIGHT C a node: a shard's w, w′, or a child's weight
45 FOLD_CHILD S a node: a child's journal

Auditeurs/Fondations

La chaîne de référence structurée

Spécification normativedocs/spec/srs.mdVoir en Markdown

Résumé

La mise en place du système entier. La page nomme la cérémonie, les puissances de tau perpétuelles de PSE, contribution 80, donne son [τ]_1 à des fins d’identification, spécifie comment un fichier .ptau est ingéré et ce que prouve le décodage, énonce ce qui est présumé plutôt que vérifié, et définit l’archive SRS, les trois points que détient un vérificateur, KZG sur les puissances, et les bases de Lagrange à partir desquelles est construite la clé Groth16 du décideur.

Le texte normatif ci-dessous est tenu à jour en anglais, langue canonique de la spécification.

The powers of τ every commitment is made under: the ceremony they come from, how its file is read and what is checked, the archive an SRS is cached in, the three points a verifier holds, KZG over them, and the Groth16 first phase read from the same file. Implementation: crates/srs.

1 The ceremony#

The SRS is PSE's perpetual powers of tau, contribution 80: files ppot_0080_<p>.ptau, kept in assets/ptau/, which is gitignored. Hermez's powersOfTau28_hez_final_*.ptau is another ceremony with another τ; the reader ingests it as readily, and every commitment, key and identity over it differs. This ceremony's [τ]_1, as the hex of its canonical encoding x ‖ y:

9bbb31bedc304e081e2aada4b56c2217e0e94ee16874e3517d14bef5dcec3a16
317ff1589e53513fa333591b318e8f1e55ef7c37d92beb6d9a61d770a2f39506

PSE's files are cut from one ceremony: the first 2^k powers, and the Lagrange bases of domains up to 2^k, agree in every file of power k or more. One file, ppot_0080_24.ptau (19.3 GB), serves every use. A base key needs as many powers as its tallest family has rows, at most 2^22, the menu's top, and at least the generic table's 2^18 (lookup.md §9); bench prove reads 2^22. The recursion format reads 2^24, its largest stack (verifier_core::STACK_LOG, recursion.md §1.3), and the decider its domain's Lagrange bases (§7).

2 Ingesting a .ptau file#

Srs::from_ptau(path, k) reads snarkjs's .ptau container, the one ingestion format. Integers are little-endian.

0    4    "ptau"
4    4    version: 1
8    4    section count: at most 64
12   ..   sections: id u32 | size u64 | payload

id 1   header, 44 bytes: n8 = 32 | q (n8 bytes) = BN254's Fq modulus | power p | ceremonyPower
id 2   tauG1: 2^(p+1) − 1 G1 points, [τ^0]_1 first
id 3   tauG2: 2^p G2 points, [1]_2 then [τ]_2

Sections 1–3 must each occur once, at the sizes p implies; the others (alpha, beta, the contribution record, the Lagrange bases of §7) are not read here. from_ptau takes the first 2^k points of section 2 (k ≤ p) and the first two of section 3.

A point is uncompressed affine in little-endian Montgomery form: each 32-byte coordinate holds coord·R mod q, R = 2^256; G1 is x ‖ y, G2 x.c0 ‖ x.c1 ‖ y.c0 ‖ y.c1. It is the only non-canonical point encoding the code reads. A coordinate is read as a canonical Fq (refused at or above q), multiplied by R^−1 and re-encoded, and the canonical bytes go through G1Affine::from_bytes or G2Affine::from_bytes (primitives.md §3), the one validating decoder. All-zero bytes are infinity in both forms.

from_ptau never panics: every refusal is an SrsError — Io, Truncated, BadMagic, BadVersion, BadSection (over 64 sections, sections 1–3 not each present once, a header not 44 bytes, a power outside 1..=30, a section size p does not imply), WrongCurve, PowerTooLarge (k > p), and InvalidPoint { index }, a failing point but not necessarily the first.

3 What is validated, and what is presumed#

Decoding proves every point canonical, on its curve and in the order-r subgroup. Srs::validate adds that they are powers of one τ:

g1[0] = G1 generator      g2_gen = G2 generator      no point is infinity
e(Σ_i c_i·g1[i], g2_tau) = e(Σ_i c_i·g1[i+1], g2_gen)       i < n − 1

with each c_i 31 bytes from /dev/urandom, so that no file can be built to pass: a power that is not τ times the one before survives with probability at most 2^−248. The infinity check excludes τ = 0: pairing_check skips a pair at infinity (primitives.md §4), so such an SRS would pass vacuously and kzg_verify over it accept any opening. validate identifies nothing, and no proving path runs it.

Soundness needs nobody to know τ, which this code presumes of the ceremony. A statement binds the SRS only through the SRS digest (proof.md §3), which covers the SrsVerifier and the generic table's three commitments, not the powers, which only a prover reads. A key's loader recomputes the digest from the key's own points, so a key whose SrsVerifier has a known τ loads under its own digest: a verifier takes the ceremony's digest from a channel the prover does not control, or recomputes it from the ceremony. Program identity covers neither the SrsVerifier nor the table; in the recursion tree the digest is a constant of both programs' images, which their identities bind (recursion.md §8.1).

4 The SRS archive#

Srs::save and Srs::load keep an ingested SRS in a file of their own, integers little-endian and points canonical (primitives.md §3); bench recurse caches its 2^24 powers in one.

0     8          "APOGESRS"
8     4          version: 1
12    4          power k, at most 30
16    8          G1 count: 2^k
24    128        g2_gen
152   128        g2_tau
280   64·2^k     g1, [τ^0]_1 first

load requires exactly 280 + 64·2^k bytes before reading a point (the cap on k keeps the product from wrapping) and decodes every point through from_bytes, which catches a corrupted coordinate, not a substituted archive. It refuses with Truncated, BadMagic, BadVersion, BadSection and InvalidPoint.

5 SrsVerifier#

The only SRS material a verifier takes: g1_gen = [1]_1, g2_gen = [1]_2 and g2_tau = [τ]_2, what Mercury's pairings read. A verifier never commits; the generic table's commitments reach it as given points. The wire form is 320 bytes, g1_gen ‖ g2_gen ‖ g2_tau, canonical, unframed — its postcard form, VerifyingKey's srs_verifier and verifier::encode_srs_verifier alike — and every reader decodes it through the validating from_bytes.

6 KZG#

srs::kzg, over coefficients little-endian in the degree (coeffs[i] multiplies X^i, as g1[i] is [τ^i]_1):

kzg_commit(f)   = Σ_i f_i·[τ^i]_1                               one MSM
kzg_open(f, z)  = (f(z), [q(τ)]_1), q = (f − f(z))/(X − z)      one Horner pass gives both
kzg_verify(cm, z, v, w):  e(cm − v·[1]_1 + z·w, [1]_2) · e(−w, [τ]_2) = 1

More coefficients than powers is an error, never a truncation. The zero polynomial commits to infinity and opens to (0, infinity), which verifies. A Mercury commitment is exactly kzg_commit of the evaluation table read as coefficients (mercury.md §2). Mercury calls neither kzg_open nor kzg_verify, but its pairing relations take their shape, e(A, [1]_2) = e(B, [τ]_2) with both G2 arguments SRS constants, which is what lets recursion fold them instead of pairing (recursion.md §8.3).

7 Phase 1#

srs::Phase1::from_ptau(path, m), for m ≤ p and m ≤ 28, reads what a Groth16 key takes from the ceremony at a domain of n = 2^m: tau_g1, [τ^i]_1 for i < 2n − 1, from section 2; and lagrange_g1 and lagrange_g2, [L_j(τ)] in each group, L_j the Lagrange polynomial at ω^j and ω of order n squared down from constants::FR_TWO_ADIC_ROOT_OF_UNITY, from sections 12 and 13, which hold the bases of domains 1, 2, 4, … in turn, domain n from point n − 1. It refuses a basis that is not this domain's: tau_g1[0] and each basis's sum must be the generator, and Σ_j ω^j·[L_j(τ)]_1 = [τ]_1. The G2 basis is held to the curve, not the subgroup. The decider's key is made over it (recursion.md §9).

Auditeurs/Fondations

Mercury

Spécification normativedocs/spec/mercury.mdVoir en Markdown

Résumé

Le schéma d’engagement polynomial. La page fixe ce que les articles Mercury et BDFG20 laissent ouvert : le découpage des variables, l’engagement en tant qu’engagement KZG de la table d’évaluation, le protocole d’ouverture complet et son ordonnancement de transcription en seize étapes, la preuve de 704 octets et la vérification de couplage fusionnée à deux paires du vérificateur. Elle ajoute un lot de nombreuses colonnes en un même point, utilisé par chaque shard, et une forme différée de douze entrées d’accumulateur, que l’arbre de récursion replie au lieu d’effectuer le couplage. Les coûts et la sécurité concluent la page.

Le texte normatif ci-dessous est tenu à jour en anglais, langue canonique de la spécification.

Every committed column is opened with Mercury (Eagen and Gabizon, ePrint 2025/385), finished by the batched KZG opening of BDFG20 (Boneh, Drake, Fisch and Gabizon, ePrint 2020/081). This page pins what the papers leave open, and adds a batch of k columns at one point and the deferred form the recursion tree folds. crates/pcs is the prover, the curve side and the pairings; crates/pcs-verify, no_std, is the verifier's field side, which the recursion guest links.

1 Parameters and the variable split#

n = 2^{2t} evaluations with 1 ≤ t ≤ 27, b = 2^t = √n, s = 2t variables; u ∈ Fr^s is the opening point and v the claimed value. pcs_verify::check_num_vars refuses every other variable count (PcsError::UnsupportedNumVars) and never pads, which is why every trace height is an even power of two (program.md §7). The ceiling, pcs_verify::MAX_NUM_VARS = 54, is where Fr's 2-adicity of 28 runs out of the 2b-th roots of unity §3.1 needs, and it keeps 2^{|u|} in range for a u the verifier is handed.

The evaluation table is read as coefficients, and variable m is bit m of an index (primitives.md §6). Write an index i + j·b with i the low t bits, as Mercury §3.1 does; its evaluation is the coefficient of X^{i+j·b}. The point splits the same way: u1 is its first half, u_0..u_{t−1}, and pairs with i; u2 is u_t..u_{2t−1} and pairs with j.

f(X) = Σ_{i<b} X^i·f_i(X^b),    f_i(X) = Σ_{j<b} f_{i+j·b}·X^j
f̂(u) = Σ_{i,j<b} eq(i, u1)·eq(j, u2)·f_{i+j·b}

pcs::open returns what poly::MultilinearPoly::evaluate gives at u, and a verifier handed the two halves swapped rejects.

2 Commitment#

pcs::commit returns [f(x)]_1 for §1's f(X), an MSM over the first n SRS powers: exactly the KZG commitment of the evaluation table read as coefficients (srs::kzg::kzg_commit), with no second scheme behind it. It refuses an SRS of fewer than n powers (SrsTooSmall). A column backed by U1, U8, U16 or U32 (poly::PolyBacking) is widened to u32 and committed through curve::msm::msm_small_u32, never lifted to Fr; an Fr backing goes through curve::msm::msm.

The map from a table to its commitment is Fr-linear, which §5 uses, and a zero coefficient adds nothing: a column extended by zero rows keeps its commitment. So the generic table's commitments serve every height that holds the table (lookup.md §9), and pcs::commit_stack commits a recursion stack without building it.

3 The opening protocol#

3.1 The polynomials#

definition coefficients sent as
h Σ_i eq(i, u1)·f_i(X); its X^j coefficient is f̂(u1, j) b h
q, g f = (X^b − α)·q + g, so g = Σ_i f_i(α)·X^i n − b, b q, g
S the symmetrized witness below b − 1 s
D X^{b−1}·g(1/X): g reversed b d
H (f − (z^b − α)·q − g_z)/(X − z) n − 1 pi_z
W, W′ §3.3 b − 1 each w, w_prime

P_u(X) = Σ_{i<b} eq(i, u)·X^i = Π_{m<t}(u_m·X^{2^m} + 1 − u_m), so ⟨P_u, g⟩ = ĝ(u) for g of fewer than b coefficients (Mercury §4.2). The prover uses its coefficients, poly::eq_table(u); the verifier evaluates the product in O(t).

The fold (Mercury §5) divides every f_i by X − α, b Horner divisions advanced together in one pass over the rows, with no transform. Then ĝ(u1) = h(α) and ĥ(u2) = f̂(u) = v, and one S proves both inner products (Mercury §4.1), the left side's constant coefficient being 2·(⟨g, P_u1⟩ + γ·⟨h, P_u2⟩):

g(X)·P_u1(1/X) + g(1/X)·P_u1(X) + γ·(h(X)·P_u2(1/X) + h(1/X)·P_u2(X))
    = 2·(h(α) + γ·v) + X·S(X) + S(1/X)/X

S is coefficients b..2b−2 of X^{b−1} times the left side, computed with four forward transforms of size 2b and one inverse; no transform in an opening is larger (crates/pcs/src/fft.rs, over constants::FR_TWO_ADIC_ROOT_OF_UNITY).

3.2 The transcript schedule#

pcs::open and pcs_verify::scalars run this Fiat–Shamir schedule step for step. A point or a list of points is one message (transcript.md §4).

# tag message
1 absorb MERCURY_INSTANCE n
2 absorb COMMITMENT cm, as passed: open never recommits it
3 absorb EVALUATION_CLAIM u_0..u_{s−1}, then v
4 absorb PCS_OPENING h
5 squeeze MERCURY_ALPHA α
6 absorb PCS_OPENING [q, g]
7 squeeze MERCURY_GAMMA γ
8 absorb PCS_OPENING [s, d]
9 squeeze MERCURY_Z z, by §3.4's rule
10 absorb PCS_OPENING g_z, g_{1/z}, h_z, h_{1/z}, s_z, s_{1/z}, one message
11 absorb PCS_OPENING pi_z, before δ although the batch does not read it
12 squeeze BDFG_BATCH δ
13 absorb PCS_OPENING w
14 squeeze BDFG_POINT z′
15 absorb PCS_OPENING w_prime
16 squeeze PAIRING_MERGE ρ, after all eight points and six values

The prover draws ρ too and discards it, so both sides leave the transcript in one state and an opening composes inside a larger transcript, the shard transcript (proof.md §4).

3.3 The BDFG20 batch#

Mercury §6 step 4(e) leaves the batched KZG opening to BDFG20 §4. The point set is T = {z, 1/z, α}, and the four polynomials are batched in this order, which fixes the power of δ each carries (pcs_verify::bdfg::items, which both sides read):

i f_i S_i Z_{T∖S_i} r_i interpolates
0 g {z, 1/z} X − α g_z, g_{1/z}
1 h {z, 1/z, α} 1 h_z, h_{1/z}, h_α
2 S {z, 1/z} X − α s_z, s_{1/z}
3 D {z} (X − 1/z)(X − α) D_z
F(X) = Σ_i δ^i·Z_{T∖S_i}(X)·(f_i(X) − r_i(X))                          W  = [(F/Z_T)(x)]_1
L(X) = Σ_i δ^i·Z_{T∖S_i}(z′)·(f_i(X) − r_i(z′)) − Z_T(z′)·(F/Z_T)(X)    W′ = [(L/(X − z′))(x)]_1

Both divisions are exact for an honest prover, and open asserts it (pcs_verify::bdfg::{quotient, linearization}).

3.4 Challenges and derived values#

Mercury draws z ∈ F*; here z is drawn again under MERCURY_Z while it is zero (pcs_verify::challenge_z). T needs three distinct points, so both sides refuse with PcsError::DegenerateChallenge when z² = 1, z = α or z·α = 1 (pcs_verify::degenerate): probability about 2^−252, and a loss of completeness only. The recursion tape draws z once and asserts all four conditions (verifier_core::tape::mercury_scalars).

The verifier is not sent h(α) or D(z): it derives them, as Mercury §6 step 4(c) does (pcs_verify::derive_h_alpha), and the prover builds the batch around the same derived values.

D_z = z^{b−1}·g_{1/z}
h_α = (g_z·P_u1(1/z) + g_{1/z}·P_u1(z) + γ·(h_z·P_u2(1/z) + h_{1/z}·P_u2(z) − 2v)
       − z·s_z − s_{1/z}/z) / 2

Opening D at z to D_z is the degree check on g (Mercury §4.3); opening h at α to h_α is §3.1's identity at z.

4 The proof and the verifier's checks#

pcs::MercuryProof is eight points and six values. Its field order is its byte order and its transcript order, and to_bytes writes pcs::PROOF_BYTES = 704 bytes for every n and k:

h  q  g  s  d  pi_z  w  w_prime                 8 × 64 bytes, G1 uncompressed (primitives.md §3)
g_z  g_inv_z  h_z  h_inv_z  s_z  s_inv_z        6 × 32 bytes, canonical Fr (primitives.md §1)

from_bytes returns None unless every point decodes through curve::G1Affine::from_bytes (canonical and on the curve; G1's cofactor is 1) and every value through field::Fr::from_bytes.

Two relations are checked, each written e(A, [1]_2) = e(B, [x]_2) so that both G2 arguments are SRS constants: the fold identity at z (Mercury §6 step 4(f), its z term moved into G1) and the BDFG20 batch (BDFG20 §4.1). They merge under ρ into one curve::pairing::pairing_check of two pairs:

A1 = cm − (z^b − α)·q − g_z·[1]_1 + z·pi_z                  B1 = pi_z
A2 = Σ_i c_i·cm_i − K·[1]_1 − Z_T(z′)·w + z′·w_prime        B2 = w_prime
     cm_i = g, h, s, d    c_i = δ^i·Z_{T∖S_i}(z′)    K = Σ_i c_i·r_i(z′)
     Z_T(z′) = (z′ − z)(z′ − 1/z)(z′ − α)
accept iff  e(A1 + ρ·A2, [1]_2)·e(−(B1 + ρ·B2), [x]_2) = 1

If either relation is false the merged one holds for at most one ρ, and ρ follows every proof element. The verifier reads three SRS points, srs::SrsVerifier's [1]_1, [1]_2 and [x]_2, and does no G2 arithmetic.

pcs::verify refuses, in order: a u whose length is not an instance's (§1), before anything is absorbed (UnsupportedNumVars); a proof point or cm off the curve (InvalidPoint), checked again because a proof built in memory has met no decoder; a degenerate T (DegenerateChallenge); a failed pairing check (VerificationFailed), which does not say which relation failed.

5 Batching k columns at one point#

Not in the papers. k commitments to columns of one size, opened at one point u, are one Mercury instance with one proof (pcs::batch_open, pcs::batch_verify); a shard proof's opening is one such batch (proof.md §5). Three steps precede §3.2's sixteen (pcs_verify::batch_preamble):

# tag message
B1 absorb COMMITMENT cm_0..cm_{k−1}, as passed, one message of 4k limbs
B2 absorb EVALUATION_CLAIM u_0..u_{s−1}, then v_0..v_{k−1}
B3 squeeze MERCURY_BATCH ρ

The opening then runs on (cm*, u, v*), with cm* = Σ_i ρ^i·cm_i and v* = Σ_i ρ^i·v_i.

  • ρ follows every commitment and every claimed value. Column i carries ρ^i, column 0 carrying 1, so a reordered or shortened list is a different statement.
  • The list is one message, so its length 4k fixes k, and then s from B2's s + k scalars: the absorbed stream is injective.
  • ρ = 0 is not redrawn: it checks column 0 alone, and is one of the roots the bound below counts.
  • A batch of one is a different transcript from a bare opening; their proofs do not interchange.

The batch is sound: by §2's linearity cm* commits to f* = Σ_i ρ^i·f_i, and evaluation at u is linear, so v* − f̂*(u) = Σ_i (v_i − f̂_i(u))·ρ^i, a polynomial in ρ of degree at most k − 1 fixed before ρ is drawn. A false claim survives with probability at most (k − 1)/|Fr|.

The prover builds f* as one Fr column and opens it once; mixed sizes are refused (MixedColumnSizes). The verifier refuses an empty list (EmptyBatch) or a value count that differs (BatchLengthMismatch), checks every cm_i on the curve before summing, derives cm* by a k-point MSM and runs §4 on it. pcs::batch_open_stacked opens recursion stacks at u ‖ r (recursion.md §1.3); batch_open is it at r = [], one column a stack.

6 Deferred verification and the accumulator#

6.1 The twelve entries#

Deferring a verification runs every check of §4 but the pairing and keeps the relation's terms: twelve pcs::AccumulatorEntry { side, scalar, point }, side a pcs::PairingSide, G2One for [1]_2 or G2X for [x]_2. The points are [cm, h, q, g, s, d, pi_z, w, w_prime, [1]_1], as pcs_verify::ENTRY_POINTS indexes them, and the scalars are pcs_verify::scalars's, in §4's notation:

# side point scalar # side point scalar
0 G2One cm 1 6 G2One pi_z z
1 G2One h ρ·c_1 7 G2One w −ρ·Z_T(z′)
2 G2One q −(z^b − α) 8 G2One w_prime ρ·z′
3 G2One g ρ·c_0 9 G2One [1]_1 −(g_z + ρ·K)
4 G2One s ρ·c_2 10 G2X pi_z 1
5 G2One d ρ·c_3 11 G2X w_prime ρ

The G2One terms sum to A1 + ρ·A2 and the G2X terms to B1 + ρ·B2; entry 9 carries both relations' [1]_1, and entry 2 is zero exactly when z^b = α, which is legal. A batch derives cm* first, so entry 0 is cm* and a check is ENTRIES_PER_CHECK = 12 entries whatever k. pcs::verify and pcs::batch_verify spend the entries at once; pcs::verify_deferred and pcs::batch_verify_deferred return them.

6.2 What uses it#

  • Base verification pairs: crates/verifier runs pcs::batch_verify for each shard, and no ShardProof or BlockProof carries an entry.
  • The recursion tree folds. A shard's tape computes the twelve scalars over field cells (verifier_core::tape::mercury_scalars), cm* being a hint; the node folds them with the batch check cm* = Σ_i ρ^i·cm_i (recursion.md §8.3), and one pairing check at the top discharges every shard's (recursion.md §9). Natively, host::recursion runs pcs::batch_verify_deferred on each shard for its cm*.
  • Nothing else: pcs::verify_deferred, §6.3's word form, pcs::accumulator_digest and pcs::discharge are called only by crates/pcs's tests and tools/kat-gen.

6.3 The word form and discharge#

A list is grouped into deferred checks, checks[j] being group j's entry count, and written as canonical Fr words (pcs::accumulator_words, inverse pcs::accumulator_from_words):

group:  count  entry_0 .. entry_{count−1}
entry:  side  scalar  x_lo  x_hi  y_lo  y_hi       side 0 = G2One, 1 = G2X; ENTRY_WORDS = 6

A word is 32 bytes, so an entry is 192, and the limbs are the point's transcript form (transcript.md §4). There is no header, so two lists concatenate into a list whose checks keep their groups. Decoding refuses a count of 2^64 or more or one that overruns, a side other than 0 or 1, a limb of 2^128 or more other than the sentinel, a partial sentinel, the all-zero quadruple (infinity has one spelling), and a point that is not canonical or not on the curve. The digest is the words as one ACCUMULATOR_DIGEST message in a fresh sponge, then a raw sample; covering the count words, it binds the grouping.

discharge(vsrs, entries, checks):
  every entry's point on the curve, before anything else
  ν = fresh sponge: absorb ACCUMULATOR_DIGEST [digest], challenge ACCUMULATOR_MERGE
  A = Σ_j ν^j·(group j's G2One terms)      B = Σ_j ν^j·(group j's G2X terms)
  accept iff e(A, [1]_2)·e(−B, [x]_2) = 1

An entry's point is a claim: absorption binds only its limbs, and an entry built in memory has met no decoder. The weight keeps the checks apart: at weight 1, two checks with equal and opposite errors pass together, and weighted, a false group passes only where ν is a root of a nonzero polynomial of degree below the group count. ν is a function of the words because discharge takes no transcript. An empty list discharges.

7 Cost and security#

prover, field O(n): a pass for h, the fold, H's division; S in O(b log b)
prover, MSMs 2n + 5b − 4 scalar multiplications: q n − b, pi_z n − 1, h, g, d b each, s, w, w_prime b − 1 each. A commitment is one more MSM of n
batch of k k multiply-adds a coefficient for f* and a k-point MSM for cm*, then one opening
verifier O(t) field operations, MSMs of ten points and of two (and of k), one two-pair pairing check
measured n = 2^22: commit 1.30 s, open 2.89 s. 16 columns of 2^20: a batch opens in 1.01 s and verifies in 4.8 ms, 16 single openings take 9.79 s and 62 ms. 18-core Apple M5 Pro; bench mercury, bench mercury-batch

Knowledge soundness holds in the algebraic group model under q-DLOG (Mercury §6, BDFG20 §4), with Fiat–Shamir over the Poseidon2 transcript in the random-oracle model and an SRS whose x nobody knows (srs.md §3). The statistical terms are Schwartz–Zippel over α, z and z′, of order a committed polynomial's degree over |Fr|, a few 1/|Fr| for γ, δ and the merge ρ, (k − 1)/|Fr| for a batch and the group count over |Fr| for ν: each is below 2^−220 for every instance in use, and the level is BN254's (architecture.md §4). Nothing is hiding and nothing is blinded.

Mercury's SRS has exactly n powers; here one SRS serves every size, so a prover can commit to a polynomial of degree n or more, and no degree bound is checked. None is needed: Mercury §6's argument goes through with its Schwartz–Zippel terms over that degree, and the opening at u is the multilinear extension of the polynomial's first n coefficients. A commitment binds that truncation, which is linear, so §5's argument holds for it too.

Auditeurs/Programme et exécution

Le programme : de l’ELF à l’identité

Spécification normativedocs/spec/program.mdVoir en Markdown

Résumé

Comment un binaire de programme invité devient la description statique que connaît un vérificateur. La page spécifie le chargement ELF, le balayage par demi-mots qui développe les instructions compressées sans déplacer aucune adresse, le format de sérialisation de ProgramImage, le décodeur et son acheminement des 59 instructions de RV32IMA vers sept familles, les tables décodées avec une ligne par demi-mot, le masque d’instruction one-hot, la VmConfig et ses hauteurs, et l’identité du programme : exactement ce qu’elle lie et ce qu’elle ne lie pas.

Le texte normatif ci-dessous est tenu à jour en anglais, langue canonique de la spécification.

How a guest binary becomes the static, verifier-known description of a program. crates/loader reads an ELF into a ProgramImage, crates/isa decodes its instructions, and crates/program routes them into per-family decoded tables, derives the VmConfig and commits to all of it as the program identity. Every step is a pure function of its input.

1 Loading#

loader::load_elf accepts a static executable — ELFCLASS32, little-endian, ET_EXEC, EM_RISCV — whose PT_LOAD segments lie inside guest RAM (constants::guest_memory) at even addresses, pairwise disjoint, with p_filesz ≤ p_memsz, at least one of them executable. Anything else is a named LoaderError: DynamicElf for ET_DYN, PT_DYNAMIC or PT_INTERP, EntryNotAnInstruction for an e_entry that is not the first halfword of an instruction, and the sweep's refusals (§2).

Of a program header it reads p_type, p_offset, p_vaddr, p_filesz, p_memsz and the PF_X bit, and nothing else: the VM has no pages, and all of RAM is addressable whatever the segments declare. The address map, and the segment layout a guest ELF keeps for host loaders, are ecall-abi.md §6.

2 RVC expansion and slots#

slots holds one Slot per halfword from slot_base, the lowest loaded address, to the end of the highest executable segment: the slot of pc is slots[(pc − slot_base)/2]. load_elf sweeps each executable segment's file bytes from its start, by the halfword at pc:

low bits 11   pc += 4   Instruction { word: the four bytes, compressed: false }, MidInstruction
0x0000        pc += 2   NonInstruction
otherwise     pc += 2   Instruction { word: rvc::expand(halfword), compressed: true }

Every other halfword is NonInstruction. An encoding longer than 32 bits (InstructionTooLong), one cut off by the end of the file bytes (TextTruncated) or a halfword rvc::expand refuses (RvcIllegal) refuses the image; 32-bit words are decoded in §5.

  • Addresses are never compacted. A c.addi at 0x1002 stays there and occupies two bytes, so linker-resolved addresses hold; compressed, the instruction's length, is the only record of whether the next pc is pc + 2 or pc + 4.
  • rvc::expand takes the base C extension in its RV32 form and refuses the floating-point forms, the RV64-only forms (c.addw, c.subw, a shift with shamt[5]), the reserved code points and the Zc* encodings. A HINT such as c.addi x0, 5 is expanded; its 32-bit form writes x0.
  • 0x0000, RVC's defined-illegal encoding, is not refused: LLVM pads unreachable blocks with it. Reaching it is fatal at run time.

A desynchronised sweep cannot make a wrong instruction provable. A slot is a function of the bytes at its own pc, so every Instruction slot is what a hart fetching there would decode; data that shifts the sweep off the true boundaries can only lose true instruction starts, whose pcs then have no table row (§5), or meet an unclaimed encoding and refuse the image. crates/loader/tests/differential.rs holds committed guests' slots to llvm-objdump's listing, and the expansion to LLVM's own encoder over guests/rvc-dense, one sequence assembled compressed and not: a wrong expansion would be a valid proof of another program.

3 ProgramImage and its wire form#

The wire form is postcard over ProgramImage's four fields in order, with no header; every integer but kind is a LEB128 varint:

ProgramImage = entry ‖ n ‖ n × Segment ‖ slot_base ‖ m ‖ m × Slot
Segment      = vaddr ‖ mem_len ‖ len ‖ bytes    mem_len is p_memsz; bytes, the p_filesz file bytes
Slot         = kind: u8 ‖ word                  kind 0 a four-byte instruction, 1 a two-byte one,
                                                2 MidInstruction, 3 NonInstruction; word 0 for 2, 3

The reader re-checks what load_elf establishes — segments at even addresses, sorted, disjoint and inside RAM; slot_base the lowest segment's address; each four-byte Instruction followed by its MidInstruction; entry an Instruction slot — but not slots against the bytes. artifact-dump writes this form (tools.md §5); no prover or verifier reads it, host::setup starting from the ELF.

ProgramImage::initial_word(addr) is the little-endian word at addr before the first cycle: file bytes where a segment has them, zero elsewhere. The image column (§8) and the trace's initial RAM values are read from it.

4 The instruction set and family routing#

isa::decode takes 32-bit words only and accepts exactly RV32IMA's 59 instructions — 40 of RV32I, 8 of M, 11 of A — with any value in an operand field, x0 destinations included, and the one legal value in every fixed field: funct7, jalr's funct3, all of ecall and ebreak, an atomic's .w width, lr.w's rs2 = 0. Everything else is a DecodeError: RV64 encodings, F, D, Zicsr, fence.i, privileged instructions. crates/isa/tests/sweep.rs holds it, over all 2^30 words with low bits 11, to accepted counts derived from the ISA's tables and to an independent encoder.

  • fence is every MISC-MEM word with funct3 = 000, 2^22 of them, whatever its rd, rs1, fm, pred and succ: the ISA has a base implementation treat a reserved setting as a normal fence (llvm-objdump prints those <unknown>), and on one hart a fence does nothing.
  • An immediate is the value the instruction uses: sign-extended for I, S, B and J, the shifted word for U, the amount for a shift immediate. B and J displacements are even by encoding; nothing asks for 4-byte alignment.

program::row_kind, a total function, routes an instruction to one family and one bit of that family's mask (§6):

id family mnemonics, from mask bit 0 up
0 ADD_SUB_LUI_AUIPC system (ecall ebreak fence), addi auipc add sub lui
1 JUMP_BRANCH_SLT slti sltiu slt sltu beq bne blt bge bltu bgeu jalr jal
2 SHIFT_BITWISE slli xori srli srai ori andi sll xor srl sra or and
3 MUL_DIV mul mulh mulhsu mulhu div divu rem remu
4 MEM_WORD lw sw
5 MEM_SUBWORD lb lh lbu lhu sb sh
6 ATOMICS amoadd amoswap lr sc amoxor amoor amoand amomin amomax amominu amomaxu, each .w

5 Decoded tables#

program::decode_program(image, params) decodes every Instruction slot — a word isa::decode refuses fails the program, reachable or not (NotAllOpcodesSupported) — and builds a table for each family of the config. An instruction family's columns are committed setup columns, which each cycle's decoder lookup reads (lookup.md §10); other families' tables have none.

  • One row per halfword, absolute. Row i is pc 2i, and a table has exactly its family's height h (§7).
  • A live row holds one of the family's instructions in the fields of its lookup tuple (program::lookup_tuple): pc, next_pc, rs1, rs2, rd, imm, extra_mask, without imm for MUL_DIV and ATOMICS; no tuple holds funct3, the mask saying more. next_pc is the fall-through, pc + 2 or pc + 4 by the slot's length, never a branch target. A register the form lacks is 0; imm is the two's complement of §4's value, 0 where the form has none, or a system code (§6).
  • Every other row is padding, Fr::MINUS_ONE in every field (FamilyTable::column_poly). An all-zero row would be a claimable instruction at pc 0 with an empty mask; a live field is below 2^32, so no live row is the padding row.
  • Reach. Derivation fails (TableTooShort) unless the family's own last instruction has pc ≤ 2h − 4; another family's code may lie beyond it. Code is linked from RAM_ORIGIN = 2^16, so a family reaches 1.9375 MiB of it at 2^20 and 7.9375 MiB at 2^22, the largest height.

Every Instruction slot is a live row of exactly one table, and no table has another (check_partition).

Code is static. A cycle's instruction comes from these tables, never from RAM: a store into .text changes what a load reads, not what executes, and a pc that is not an Instruction slot has no row, so reaching it is fatal and unprovable (execution-trace.md §10).

6 The extra mask#

A tuple's last field, family_extra_mask, is 1 << kind, the kind being the instruction's position in its row of §4's table (constants::extra_mask). A kind is a mnemonic, except family 0's bit 0, the system kind, whose three instructions are told apart by imm (constants::extra_mask::system_code): ecall 0, ebreak 1, fence 2. A fence's fm, pred and succ, and an atomic's aq and rl, are not recorded; on one hart they order nothing.

One-hotness is the table's, not a gate's: a circuit holds each bit it extracts boolean, and the decoder lookup, which admits only the table's rows, is what excludes an empty or many-bit mask (lookup.md §10).

7 VmConfig and heights#

verifier_core::VmConfig { families: Vec<(family, height)>, bytecode_size_words } is a program's static shape: its families, ascending by id (constants::family), each with its height, the row count of one of its shards; an execution's shard counts are not in it. decode_program derives the family set, and nothing selects it:

  1. an instruction family (0–6), whose rows are cycles, is present when the image holds one of its instructions;
  2. a window family, whose rows are memory locations — INIT_TEARDOWN (7), ZERO_WINDOWS (8), PUBLIC_INPUT (12), PUBLIC_OUTPUT (13), ADVICE_WINDOWS (14) — is always present, and FIELD_WINDOWS (18) when one of families 19–22 is, which puts the config in the recursion format (VmConfig::is_recursion), the one whose registry VmConfig::circuit reads for 18–22 (recursion.md §1.1, §1.2);
  3. a delegation family (9–11, 15–17, 19–22), whose rows are invocations, is present when the image declares it by a record among its file bytes (program::declared_delegations, delegation.md §7); a record naming a number no family answers is UnknownDelegation.

A height is a parameter (ProgramParams::heights, defaulting to constants::family::DEFAULT_HEIGHTS, circuits.md §1), except the public families', pinned at 2^12 because it places their windows (public-values.md §2). Four things constrain it:

  • the menu, constants::family::HEIGHT_MENU: 2^8, 2^12, 2^16, 2^18, 2^20, 2^22 (HeightNotOnMenu), even powers of two as a Mercury opening needs (mercury.md §1);
  • an instruction family's code (§5);
  • the window rules (verifier_core::window_height, WindowRule; memory.md §3.5), and RAM window 0, [0, 4·h_w), holding every file byte of the image (ImageOutsideWindow);
  • the floor of the family's lookup channels, below which the registry has no circuit and no key can be built: 2^20 for an instruction family (lookup.md §3), its own for a delegation family (delegation.md §9).

bytecode_size_words, 2^20 (4 MiB) by default, is a declared ceiling on the words from RAM_ORIGIN to the image's last file byte (ProgramTooLarge); no circuit reads it.

The wire form is 8k + 8 bytes; VmConfig::from_bytes refuses a wrong length, a family id above 22, ids not strictly ascending, a height off the menu, and what window_height refuses:

k: u32 LE ‖ k × (family: u32 LE ‖ height: u32 LE) ‖ bytecode_size_words: u32 LE

In a transcript a config is one VM_CONFIG message, [f_1 … f_k, h_1 … h_k, bytecode_size_words]: the second message of the identity (§8) and the first of a statement's descriptor (proof.md §2).

8 Program identity#

One Fr, ProgramIdentity, on the wire its canonical 32 bytes (primitives.md §1): the raw squeeze of a fresh transcript after these messages (verifier_core::identity_digest; tag values in transcript.md §5):

# tag message
1 PROGRAM_IDENTITY [code_version]: constants::family::CODE_VERSION, 0, the only one derivation builds (UnsupportedCodeVersion)
2 VM_CONFIG §7's
3 PROGRAM_ENTRY [entry_pc]
4 COMMITMENT, one per family of the config, ascending its setup commitments, four limbs a point (transcript.md §4)

A family's setup commitments (program::setup_commitments) are Mercury commitments (mercury.md §2): of an instruction family's decoded-table columns in tuple order, 7 or 6 points; of INIT_TEARDOWN's image column (program::image_init_column), row y being image.initial_word(4y) over RAM window 0, one point; none, an empty message, for every other family.

Committing needs the ceremony's SRS, with as many powers as the tallest table has rows (srs.md §1). The digest over given points (program::identity_from_commitments) needs no SRS and no curve arithmetic: a verifying key carries the lists, its load recomputes the identity from them, and every shard opens its setup columns against the same points (proof.md §5, §7), which is what ties the tables a proof reads to the identity.

It binds every instruction the sweep found, with its pc, length, operands and kind, and that no other pc holds one; every file byte of the image (.text, .rodata, .data, the delegation declarations among them); the entry pc; the family set, every height, bytecode_size_words and the code version. One ELF at two settings of the heights has two identities.

It does not bind:

  • the SRS its commitments are under, or the generic lookup table: those are the SRS digest's (proof.md §3);
  • the circuits, which a key's load holds to the registry (proof.md §7);
  • anything an execution chooses: its input, advice, shard counts, window list;
  • memory past a segment's file bytes (.bss, the heap and the stack): mem_len enters nothing, and such memory starts at zero whatever is declared;
  • the symbol table, which loader::function_symbols and loader::symbol_names read beside the image for the profiler and listings, or anything else of the ELF §1 does not read.

A verifier takes the identity from a channel the prover does not control and compares it with its key's; it never sees an ELF. Against a prover-supplied identity a proof shows only that some program ran. Whoever holds the ELF, the parameters and the ceremony file recomputes it.

Auditeurs/Programme et exécution

L’ABI du programme invité

Spécification normativedocs/spec/ecall-abi.mdVoir en Markdown

Résumé

L’interface entre un programme invité et la machine. La page spécifie la convention d’appel ecall, les plages de numéros et chaque numéro implémenté (EXIT et dix délégations), les numéros retirés et pourquoi ils ne sont jamais réutilisés, ce que répond tout autre numéro, la carte complète de l’espace d’adressage 32 bits avec la disposition des segments qu’impose le script d’édition de liens, et la surface publique du SDK des programmes invités avec ses statuts de sortie.

Le texte normatif ci-dessous est tenu à jour en anglais, langue canonique de la spécification.

The ecall convention, every ecall number, the guest's address space and the SDK over them. A guest has no file descriptors and no I/O syscall: its public input, journal and advice are memory (public-values.md), so an ecall only ends the execution or hands a frame to a circuit. crates/constants/tests/ecall_abi.rs holds this page's tables to constants::ecall and its MEMORY line to link.ld.

1 The calling convention#

Register Role
a7 the number
a0 in: the one argument, an exit status or a delegation's frame base (delegation.md §4)
a0 out: the result, 0 or a negated errno

a1–a5 are reserved for a call that needs more arguments; none does. A recursion-format delegation answers its frame base advanced past the frame (recursion.md §1.4). An ecall preserves every register but a0: its row writes no other (execution-trace.md §6), so the memory argument carries the rest across it.

2 The number ranges#

Constant Value What
ZKVM_IO_FIRST 0x0400 first host call
ZKVM_IO_LAST 0x04FF last host call
PRECOMPILE_FIRST 0x0500 first precompile
PRECOMPILE_LAST 0x05FF last precompile

Both ranges lie above 1023, the whole Linux number space, and are disjoint, so a number says its class: a host call would return a value the prover chose, a precompile is a deterministic function of guest memory that its circuit proves. The host-call range is reserved and empty: advice is a memory region the prover fills and the guest checks (public-values.md §6), inside the memory argument, where a value returned in a register would be bound to nothing.

3 Syscall numbers#

Every number this VM implements. All but EXIT are delegations, whose families, anchor spaces and frames are delegation.md §3's registry: the first six are the base format's, the last four the recursion format's (recursion.md §2).

Number Constant Class What
93 EXIT deterministic end the execution with status a0, the statement's exit status; nonzero is a failed execution, still provable
0x0500 PRECOMPILE_POSEIDON2 deterministic the width-3 Poseidon2 permutation over canonical Fr lanes
0x0502 PRECOMPILE_FR_ARITH deterministic one Fr add, multiply or inverse over Fr's in-memory form
0x0504 PRECOMPILE_MOD_MUL deterministic a·b mod m, m one of four Ethereum moduli a selector names
0x0506 PRECOMPILE_EC_ADD deterministic one third of a complete point addition, secp256k1 or BN254 G1
0x0507 PRECOMPILE_KECCAK_F deterministic one round of keccak-f[1600]; a permutation is 24 calls
0x0508 PRECOMPILE_SHA256_COMP deterministic four rounds of SHA-256's compression; a compression is 16 calls
0x0509 PRECOMPILE_FR_OP deterministic one operation over field cells
0x050A PRECOMPILE_P2_FIELD deterministic one transcript duplex step over field cells
0x050B PRECOMPILE_FIELD_IO deterministic eight RAM words into a field cell, or back
0x050C PRECOMPILE_FQ_OP deterministic one BN254 base-field operation over field cells

A class says who chooses the result. deterministic: a function of the guest's own state, which a circuit proves. advice: chosen by the prover; no number has it (§2). These are exactly the ecalls a proof admits: ADD_SUB_LUI_AUIPC holds every ecall row's a7 to 93 or to a registered delegation number, the base format's circuit knowing the first six (add-sub.md, recursion.md §1.2).

4 Retired numbers#

Number Constant Was
63 none POSIX read(fd, buf, len)
64 none POSIX write(fd, buf, len)
0x0501 RETIRED_KECCAK_F_WHOLE_PERMUTATION a whole keccak-f[1600] over a 200-byte frame, which 0x0507 replaces
0x0503 RETIRED_MOD_MUL_WITNESSED_MODULUS a·b mod m over a 128-byte frame carrying m, which 0x0504 replaces
0x0505 RETIRED_SHA256_COMP_WHOLE_COMPRESSION a whole compression over a 96-byte frame, which 0x0508 replaces

A number is assigned once. A retired one is never reassigned and answers -ENOSYS (§5): given a second meaning, it would run an old binary with its frame misread to a plausible wrong answer.

5 Every other number#

Constant Value What
ENOSYS 38 answered as -ENOSYS in a0

A number not in §3 answers -ENOSYS and falls through: the retired numbers, the host-call range, and every syscall a library might make for host data — getrandom, clock_gettime, the seeding of std's RandomState. Host data is prover advice, and a guest that needs it takes it from the advice region, where checking it is visibly the guest's job. Such a call executes and cannot be proved: the ADD_SUB_LUI_AUIPC fill refuses its row.

-ENOSYS is also the delegation ABI's "no circuit" answer, on which a base-format shim runs its software path (delegation.md §2); this executor never gives it to a §3 number. A registered number the image did not declare is the fatal DelegationFamilyAbsent on the tracing paths (delegation.md §7).

6 The memory map#

crates/guest-sdk/link.ld declares one region, constants::guest_memory's RAM_ORIGIN and RAM_LENGTH:

ld
MEMORY { RAM (rwx) : ORIGIN = 0x00010000, LENGTH = 0x7FFF0000 }

The whole 32-bit address space:

[0x0000_0000, 0x0000_8000)  hole: no family initializes it; an access is a fatal OutOfBounds
[0x0000_8000, 0x0000_C000)  public input window    PUBLIC_INPUT_ORIGIN    16 KiB
[0x0000_C000, 0x0001_0000)  journal                PUBLIC_OUTPUT_ORIGIN   16 KiB
[0x0001_0000, 0x8000_0000)  RAM                    RAM_ORIGIN, RAM_LENGTH
    0x0001_0000             .text, _start first; .rodata, .data, .bss, each page-aligned
    __heap_start            .bss's end rounded up to 16; the heap grows up from here
    0x7F80_0000             __stack_top − STACK_RESERVE (8 MiB): no heap block ends above it
    0x8000_0000             __stack_top, the initial sp; the stack grows down
[0x8000_0000, 2^32)         advice                 ADVICE_ORIGIN          up to 2^29 words
  • A load or store reaches the four regions alike, every word carrying the RAM tag; the windows' and the advice's layouts, families and binding are public-values.md §2–§6. None is in the ELF, so no linker symbol names them. Advice is addressable only up to the words the host supplied, and not at all when it supplied none.
  • The hole makes a null dereference a fatal error rather than a trace nothing could prove.
  • A delegation frame lies wholly in RAM (delegation.md §4). crates/loader refuses a PT_LOAD outside RAM (program.md §1), and a decoded table's height bounds how far .text reaches (program.md §5).
  • crt0's _start sets sp, zeroes [__bss_start, __bss_end) byte by byte, so that a zero .bss is the image's property and not the executor's, calls main, and exits 0 if it returns.
  • Nothing detects a stack that grows past its reserve after the heap has filled below it.

6.1 The segment layout#

A guest ELF loads under two loaders. crates/loader lays its PT_LOADs into a flat space the executor makes addressable whatever the headers say, with no pages and no permissions. A host loader maps exactly the PT_LOADs, page by page, at their permissions, and nothing else exists. The headers are the image's account of its own memory, read by every tool but this VM, so link.ld makes them true:

  • Every writable byte is declared. .bss runs to ORIGIN(RAM) + LENGTH(RAM), so the heap and the stack lie in one writable segment ending at __stack_top, whose file bytes stop at or before .bss, the 2 GiB reservation being NOBITS. Undeclared, the first stack push would fault.
  • No two segments share a page. .text, .rodata, .data and .bss are each 4096-aligned: a page two mappings share takes the second's permissions, stripping execute from .text's tail or putting zero fill on a read-only page, which a host loader refuses.

crates/loader/tests/layout.rs holds the committed guest ELFs to both by parsing their headers.

7 The guest-sdk surface#

crates/guest-sdk is the guest's runtime. Only exit and the delegation shims issue an ecall; the rest is loads and stores.

Item What
entry!(f) exports the main crt0 calls, a wrapper calling f
public_input() the public input payload, its length word clamped to the window
read_input(buf) copies min(buf.len(), public_input().len()) bytes and returns the count: it may return short
commit(bytes) appends to the journal and its length word; exits 70 rather than overflow the window
journal() what has been committed
advice() the advice payload, its length clamped to the region; bound by nothing, so the guest checks it
exit(code) EXIT; publishes nothing beyond what was committed
keccak256, sha256 over KECCAK_F and SHA256_COMP, with a software fallback on -ENOSYS from the first call
poseidon2_permute over POSEIDON2; false on -ENOSYS, for the caller's own permutation
ec_add, ec_mul, ec_identity homogeneous projective points over EC_ADD; None on -ENOSYS
recursion::* the raw shims over word-aligned frame types, false on -ENOSYS; the recursion format's (fr_op, p2_field, field_io, fq_op and the tape helpers import, import_run, replay) have no software path
allocator bumps up from __heap_start, never frees; exits 71 when a block would end above __stack_top − STACK_RESERVE or the live sp
panic handler exits 101 and writes nothing: a panicking guest is provable, having published what it committed

A delegation answer other than 0 or -ENOSYS exits 72, as do -ENOSYS after the first call of a multi-call operation and a recursion call that does not leave a0 past its frame. Each shim reads its number from its declaration record (delegation.md §7); which library code reaches which shim is delegation.md §10's.

Auditeurs/Programme et exécution

La trace d’exécution

Spécification normativedocs/spec/execution-trace.mdVoir en Markdown

Résumé

Ce qu’est chaque requête mémoire d’une exécution et quand elle a lieu. La page définit le cycle à quatre horodatages et l’horloge de 38 bits, les espaces d’adressage, une requête en tant que lecture et écriture à une même adresse, le cadre de chaque classe d’instructions, la règle x0, la ligne ecall, l’ordre du journal d’événements, l’acheminement des cycles vers les familles, l’autovérification de la mémoire au niveau de la trace, l’émulateur et ses trois écarts par rapport à un RV32IMAC hébergé, et les conteneurs qui contiennent une trace.

Le texte normatif ci-dessous est tenu à jour en anglais, langue canonique de la spécification.

What every memory query of an execution is, when it happens and the order the trace records it in; the emulator that produces a trace and the containers that hold one. The memory argument (memory.md) and every family's frame are built on this convention.

1 The clock#

Cycle c occupies the four timestamps 4c + Δ, one per slot Δ ∈ {0, 1, 2, 3} (constants::memory::TS_STEP). Every instruction is one cycle and nothing else is: a delegation invocation rides the cycle that requested it, so the cycle count is the instruction count.

  • Cycles are numbered from 1. Timestamp 0 is every address's initial write, and a read must strictly precede its write, so a cycle-0 pc query could not follow the value it reads.
  • The clock is 38 bits (TS_BITS): every timestamp is below 2^38, the last cycle is 2^36 − 1, and the cycle that would pass it is the fatal ClockOverflow, raised before it is recorded.

2 Address spaces#

Tag Space Address At timestamp 0
1 REG a register index, 0..32 0, x0 included
2 RAM the byte address of a 4-aligned word in RAM, a public window or the advice region the image's bytes in RAM, 0 past them; the public input's and the advice's layouts; 0 in the journal
3 PC 0 the entry point
4–9 DELEGATION_KECCAK_F … DELEGATION_EC_ADD, a base-format delegation family's anchor each a frame base no initial write
10 FIELD a cell, any u32 0
11–14 DELEGATION_FR_OP … DELEGATION_FQ_OP, a recursion family's anchor each a frame base no initial write

The tags are nonzero so that no real tuple is all zeros, as x0's initial write would be. A byte or halfword access queries its word, and trace::InitialMemory is what RAM starts from. An anchor space, in delegation.md §3's order, is a delegation family's type, not memory: a query there reads the tuple stamped 0 with value 0, whatever came before; requests pair with invocations and nothing chains (delegation.md §5). Field cells hold whole Fr elements, reached only by the recursion families (recursion.md §2).

3 A query#

A memory query is one event at one address (trace::MemoryEvent): a read of read_value, last written at read_ts, and a write of write_value at ts = 4c + Δ.

  • A query that only reads writes back what it read: a register read or a load is one query.
  • read_ts < ts, strictly; the gap ts − read_ts − 1 is below 2^38.
  • Queries at distinct addresses may share a slot; two at one address never do. An address may be queried at two slots of a cycle — add a0, a0, a1 reads a0 at slot 1 and writes it at slot 3 — which is why the log is ordered by slot.

4 The frame of each instruction class#

Slot 0 is the pc query, every cycle: pc read, next_pc written. A register query exists for every register field of the decoded instruction, whatever register it names, x0 included.

Class Δ = 1 Δ = 2 Δ = 3
lui, auipc, jal rd
jalr, register-immediate rs1 rd
branches rs1 rs2
register-register, M rs1 rs2 rd
loads rs1 the word, read rd
stores rs1 rs2 the word, the stored bytes merged in
lr.w rs1 the word, written back; rd ← it
sc.w rs1 rs2 the word ← rs2; rd ← 0
AMOs rs1 rs2 the word ← op(old, rs2); rd ← old
fence
ecall a7 a0 (§6) a0 ← the result; a delegation's mirror query

ebreak has no row (§10). An atomic's row and a delegation request's carry two queries at slot 3, at distinct addresses. A family's frame is the union of its instructions' queries (memory.md §2). next_pc is the fall-through — pc + 2 after a compressed instruction, pc + 4 otherwise — except a jal's or taken branch's pc + imm, a jalr's (rs1 + imm) & !1, and the exit row's HALT_PC (memory.md §5).

An invocation's accesses ride its requesting cycle but belong to its own family's row: its frame words in RAM at slot 0 (constants::delegation::FRAME_DELTA), a FIELD_IO invocation's eight data words in RAM at slot 1 (constants::field_io::DATA_DELTA), and a recursion family's field cells at slots of its own (delegation.md §4, recursion.md §2.1).

5 The x0 rule#

x0 is an ordinary register in the trace and a constant in the machine: it starts at 0, a read of it is a REG query at address 0, and an instruction whose rd is x0 logs its slot-3 write with value 0, whatever it computed. So every query at x0 reads and writes 0, which the x0 gadget enforces (memory.md §2).

6 ecall#

An ecall's row is one cycle of ADD_SUB_LUI_AUIPC. It reads a7 at slot 1 and writes a0 at slot 3; the rest depends on the number (ecall-abi.md):

a7 Δ = 2 a0 written next_pc Besides
EXIT a0, the status the status HALT_PC the execution stops
a delegation number a0, the frame base 0, or for a recursion type the base past the frame (recursion.md §1.4) fall-through the mirror query at the frame base (§7); the invocation (§4)
any other none -ENOSYS fall-through no proof admits the row (ecall-abi.md §3)

7 The order of the log#

Events are recorded in cycle order and, within a cycle, by slot and then by role: the pc query; then an invocation riding the cycle, its frame words in frame order and a FIELD_IO invocation's data words after them; then one query per role the row has, in trace::ROLES order:

Role Slot Space What
rs1 1 REG rs1; an ecall's a7
rs2 2 REG rs2; an ecall's argument a0
load 2 RAM a load's word
ram 3 RAM a store's or an atomic's word
rd 3 REG rd; an ecall's result a0
delegate 3 the requested family's anchor space a delegation request's mirror query

ROLES is in slot order, so the log is in timestamp order, which MemoryState::record asserts; trace::Row::present holds one bit per role in a u8, and no two roles share a (space, slot) pair. The atomics family keeps its word at slot 3 for every instruction, lr.w included, so one frame serves the whole A extension.

8 Routing#

Every cycle goes to the one family whose decoded table claims its pc (program.md §4); a pc no table claims, or one claimed by a family not its instruction's, panics the tracer. An invocation goes by its type to its family's buffer.

9 The trace-level memory check#

MemoryEventLog::self_check(&InitialMemory) runs the memory argument natively over a whole log. First the timestamp rules: every address one its space has, every timestamp on the clock and in order, every read before its write, one query per address and timestamp. Then the balance: as multisets of (space, address, timestamp, value), an initial write at timestamp 0 of every touched address plus every query's write equals every query's read plus a teardown read of every address's last write. With one write per address and timestamp and no negative gap, this pairs each read with the last write before it: sequential consistency. What it cannot see:

  • Teardown is each address's last write, taken from the log, so everything after an address's last honest query balances by construction: a final value changed, a final query moved later or added, trailing cycles removed. In a proof the final values are the boundary scalars and the window families' teardown columns, fixed before any memory challenge, and the verifier fixes x0's and the pc's (memory.md §4, §5).
  • An anchor-space query is credited with its invocation's two tuples and balances alone; that requests and invocations pair 1:1 is the circuits' (delegation.md §5).
  • Field-cell accesses are not events (recursion.md §2.1).

10 The emulator#

crates/emulator runs RV32IMAC on one hart over a ProgramImage, with no interrupts and no privilege levels; aq/rl and fence order nothing. emulator::run returns an Execution: the registers, the exit status, the cycle count and the public values. emulator::trace_run returns the family buffers, the MemoryEventLog and the CycleProfile too, and emulator::StreamingRun, the prover's pull-based tracer, hands over a family's buffer as a ShardChunk the moment it reaches its height (streaming.md §2). The three differ only in what records a cycle, and a run is a pure function of (image, io), with no clock, randomness or threads, so two runs cut the same shards. A nonzero exit status is an execution, not an error.

Three points differ from a hosted RV32IMAC. sc.w always succeeds, storing and writing 0, as the circuits do (memory-ops.md §6). A misaligned halfword or word access is fatal, never split. The instruction stream is the image decoded at load, so a store into .text changes RAM and not what executes.

Every other stop is a fatal EmuError, and run and trace_run return no trace beside one: NotAnInstruction (the all-zero halfword included), IllegalInstruction, Ebreak, Misaligned (a frame base too), OutOfBounds (an access outside ecall-abi.md §6's regions, an advice word past what the host supplied, or a frame not wholly in RAM), ClockOverflow, PublicInputTooLong and JournalTooLong (the input, or the journal's length word at exit, above a window's payload), DelegationFamilyAbsent (on the tracing paths, a delegation number the image did not declare) and DelegationFrame (a frame its family has no witness for, delegation.md §6). An unassigned ecall number is not an error but -ENOSYS (§6).

There is no second executor: crates/emulator/tests/trace.rs restates §4's table and checks every traced row against it, and §9's check and the checker's multiset, memory and family-row suites hold the rest.

11 Trace containers#

crates/trace holds what an execution leaves; the emulator is its only producer.

  • Family buffers. trace::FamilyTraces holds one buffer per family of the VmConfig. A FamilyTrace, empty for a window family, is raw live rows, column-major, in small integer types: cycle, pc, next_pc, present, and per role addr, read_ts, read_value, write_value; no padding, no polynomial. A row stores everything its queries carry but a write timestamp, 4c + Δ, and the pc query's read timestamp, 4(c − 1). A delegation family's DelegationTrace has a row per invocation: the requesting cycle, the frame base, the frame words, and a recursion family's cell and data-word accesses.
  • RowSlice, FrameSlice. One shard's rows, [i·h, min((i + 1)·h, len)), borrowed: what the memory column builders read, never the log (memory.md §2). Row::delegation_space recovers a mirror query's space from the a7 the row read.
  • MemoryState, the last-access tables: each register's, the pc's, each RAM word's and each field cell's last (ts, value). O(touched addresses), and all the register and pc boundary, the RAM window list (trace::init_windows) and the window families' teardown need.
  • MemoryEventLog, the events and a MemoryState: O(cycles), kept only by trace_run, read by §9's check, the TraceArchive and checker::memory_columns_from_log, the independent reading the column builders are held to.
  • TraceArchive, the post-execution snapshot: buffers, log, profile, public values and advice. Its file is two postcard values, five phase sections and then their timings, so the deterministic payload is a byte prefix of it, and only a canonical encoding of self-consistent parts is read back. No proving path reads one; checker::TamperHarness and the retained archived path do (streaming.md §6).
  • CycleProfile, ShardPlan. The profile counts rows per family, cycles for a cycle-owning family (summing to the cycle count) and invocations for a delegation family. trace::plan_shards is ⌈count / height⌉ per family; a window family plans 0 there, its count being the prover's (streaming.md §4).

Auditeurs/Programme et exécution

Valeurs publiques et données auxiliaires

Spécification normativedocs/spec/public-values.mdVoir en Markdown

Résumé

Comment les entrées et les sorties d’une exécution deviennent partie de son énoncé. La page place l’entrée publique, le journal et les données auxiliaires (advice) dans des fenêtres mémoire, définit leur disposition préfixée par la longueur et les familles de fenêtres qui les initialisent, et énonce la liaison : le condensé de l’entrée et du journal entre dans la transcription avant tout défi, le multiensemble fait des premières et dernières valeurs des fenêtres celles de l’exécution, et une vérification en un point aléatoire astreint ces fenêtres aux octets de l’énoncé. Les données auxiliaires ne sont liées à rien, par conception.

Le texte normatif ci-dessous est tenu à jour en anglais, langue canonique de la spécification.

How an execution's public input and public output, the journal, are bound to its proof, and what the prover's advice is. All three are regions of guest memory, each initialized by a window family of its own and carried by the memory argument (memory.md).

1 Three regions, no I/O syscall#

region contents chosen by bound by
public input the statement step 10c, to the statement's input (§5)
journal the guest's stores step 10c, to the statement's output (§5)
advice the prover nothing (§6)

There is no I/O syscall: a guest uses ordinary loads and stores, and a provable guest's only ecalls are EXIT and delegation numbers (the retired POSIX numbers: ecall-abi.md §4). A host supplies emulator::GuestIo's input and advice and reads the journal from emulator::Execution::io. A byte-moving syscall would need cross-row constraints tying each transfer row to its buffer and length, which this arithmetization has no place for, while a window is bound by the multiset and one comparison (§5) and asks nothing of the guest. Nor does the guest hash its output: nothing rests on its honesty, and a panic loses nothing it committed.

2 The memory map#

region bytes family windows
public input [0x8000, 0xC000) PUBLIC_INPUT PUBLIC_INPUT_WINDOW = 2, at 2^12
journal [0xC000, 0x1_0000) PUBLIC_OUTPUT PUBLIC_OUTPUT_WINDOW = 3, at 2^12
advice [0x8000_0000, 2^32) ADVICE_WINDOWS from 2^29/h, at the window height h

The full address map is ecall-abi.md §6. The public windows take the upper half of [0, RAM_ORIGIN), 64 KiB that no RAM window family initializes (INIT_TEARDOWN masks window 0's rows below RAM_ORIGIN and no ZERO_WINDOWS id is 0, memory.md §3), so they cost RAM nothing, and [0, 0x8000) stays a hole in which a null dereference cannot balance.

A window's first address is 4·height·id, so the pinned height constants::family::PUBLIC_WINDOW_HEIGHT = 2^12 is what makes the origins windows 2 and 3, 16 KiB each, ending flush against RAM_ORIGIN. It is the ceiling: at the next menu height, 2^14, two windows need 128 KiB, and the one window in the hole is window 0, which would initialize address zero. Anything larger means moving RAM_ORIGIN, which moves every program's load address and shortens every decoded table's pc reach (program.md §5).

program::decode_program assigns that height whatever its caller asks, and the verifier refuses any other, and any RAM window height that would let a zero window reach the public windows (memory.md §3.5).

3 Layout and the length word#

word 0       the payload's byte length
words 1 …    the payload, little-endian, zero-padded to the end of the window

A public window is 2^12 words, so a payload is at most guest_memory::PUBLIC_PAYLOAD_BYTES = 16,380 bytes. verifier_core::public_io_words is the one spelling: the executor seeds the input window with it, the prover commits it and the verifier evaluates it. The length word makes the binding exact: without it [1, 2, 3] and [1, 2, 3, 0] fill the same window. verifier_core::derive_global_phase refuses an input or output longer than 16,380 bytes as Statement, and the executor refuses such an input before the first cycle.

4 The window families#

family id height shards init leaf step 10c holds
PUBLIC_INPUT 12 2^12 exactly 1 M[2] init_value M[2] to input
PUBLIC_OUTPUT 13 2^12 exactly 1 literal 0 M[1] teardown_value to output
ADVICE_WINDOWS 14 h k ≥ 0 M[2] init_value nothing

All three are in every VmConfig and own no cycles. Each public family proves exactly one shard in every statement (memory.md §3.5), so step 10c always runs: an unread input is still the window's initial contents, and an unwritten journal is empty.

The circuits are memory.md §3.3's. PUBLIC_OUTPUT's is ZERO_WINDOWS' byte for byte, whose init leaf writes the literal 0, so no column holds an initial journal (§5). PUBLIC_INPUT's and ADVICE_WINDOWS' initial values are M[2], one execution's values, committed before the memory challenges and bound by no program identity.

All three regions' tuples carry constants::address_space::RAM; which family initializes an address is what makes a word public, advice or heap. A space of their own would need an address-space column, and a gate pinning it, on the memory path of MEM_WORD, MEM_SUBWORD and ATOMICS; under RAM those circuits need nothing for them, their addressing already covering every 4-aligned address below 2^32 (memory-ops.md §2).

5 The binding#

io_digest absorbs the statement's two strings in a transcript of its own (transcript::io_digest):

t ← Transcript::new()
t.append_bytes(PUBLIC_INPUT_STREAM,  input)      tag, byte length, 31-byte limbs
t.append_bytes(PUBLIC_OUTPUT_STREAM, output)
io_digest ← t.sample()                           one raw squeeze

The framing (transcript.md §3) parses back to exactly one ordered pair, and the squeeze is raw, as every digest's is. The guest never computes it. G7 absorbs it before the memory commitments (G8) and challenges (G10) (proof.md §2), so both strings are fixed before any challenge exists.

The multiset. At a window address the init leaf is the only write at timestamp 0, every access consumes a write and produces a strictly later one, and the teardown balances only against the last (memory.md §9). So PUBLIC_INPUT's M[2] holds each word's value before its first access, and PUBLIC_OUTPUT's M[1] its value at the end.

Step 10c of verifier_core::verify_shard_local (proof.md §6). Of a public shard's base claims, which share one point u and each name a column, the verifier takes the one on M[2] (PUBLIC_INPUT) or M[1] (PUBLIC_OUTPUT), refusing its absence as Malformed, and compares it with its own evaluation at u of the multilinear extension of public_io_words(input) or public_io_words(output). A mismatch is MemoryArgument; the shard's opening then holds the claim to the committed column. Column and string are fixed before u is drawn, so a column other than the window passes with probability at most 12/p.

PUBLIC_INPUT's teardown is free: a guest may overwrite its input. PUBLIC_OUTPUT has no init column, and that is the point: with one, a prover could place the journal there at timestamp 0 and the teardown would match without the guest storing a byte.

5.1 The argument, stated plainly#

G7 fixes input and output, and G8 the window columns, before any challenge. Step 10c says the columns are those strings' windows; the multiset says they are the execution's first values in the input window and its last values in the journal window. So the guest found the statement's input in its input window, and the statement's output is what its stores left in the journal window. That rests on no cooperation, hash or register convention of the guest's, and says nothing about advice.

Recursion carries the binding unchanged: a node recomputes io_digest from the windows' words and repeats step 10c over them, and the decider binds the contract's input and output calldata to io_digest (recursion.md §8.1, §9).

6 Advice#

Advice is memory whose initial values the prover chose: ADVICE_WINDOWS initializes [ADVICE_ORIGIN, ADVICE_ORIGIN + 4hk) from an M[2] that nothing binds, not identity, not the statement, not a gate. A guest reads it with ordinary loads.

  • Layout. §3's framing over 1 + ⌈len/4⌉ words (trace::advice_region_words), spelled once by trace::advice_word for the executor and the prover; guest_sdk::advice reads it back.
  • Windows. At the window families' one height h, shard i is window verifier_core::advice_first_window(h) + i, and advice_first_window(h) = 2^29/h is the first window above RAM. Consecutive, they need no list: a statement carries only their count k = ⌈words/h⌉ (trace::advice_window_count), which covers what the host supplied, an untouched word's tuples cancelling. check_memory_windows asks only 2^29/h + k ≤ 2^30/h, the top of the address space, and ZERO_WINDOWS ids stay below 2^29/h (memory.md §3).
  • No advice, no region. Then k = 0 and there is no shard; guest_sdk::advice on such a run is a fatal emulator::EmuError::OutOfBounds.
  • Not read-only. A store there is an ordinary store. Refusing it would need a space selector and a gate on three families' memory path, and would buy nothing: advice is unbound either way.

What a guest owes. A proof says that some advice exists under which the program, given the public input, published the journal; advice that changes the journal unchecked is a value the prover chose. The check is against something the proof binds: a commitment in the public input (guests/public-io, at toy scale, with a position-weighted checksum standing in for a hash), or one the journal publishes. revm-block-stateless publishes the root of the payload it validated and holds its witness to that payload by hashes (ethereum.md §4).

7 The guest's view#

A guest reaches the regions with loads and stores at the constants::guest_memory constants, through guest_sdk::public_input, guest_sdk::commit and guest_sdk::advice, none of which issues an ecall; ecall-abi.md §7 is the API and the guest program manual the walkthrough. Nothing is published at exit, so a guest that panics has published what it committed, and its run is proved like any other.

8 Cost#

  • No address space, transcript message, tag, challenge or statement field; no gate elsewhere.
  • Two 2^12-row shards a statement, five committed columns between them; one h-row shard of three columns per advice window.
  • The native verifier: two 4,096-point multilinear evaluations, 4,095 multiplications each. A recursion node's cost follows the payload instead: it evaluates the payload's words alone, times 1 − r_j for each variable above them (verifier_core::chain::public_value).
  • The guest: nothing at exit; a byte store per journal byte and a word store per commit.

9 Limits#

  • 16,380 bytes each, and no larger window (§2). A journal that grows with the execution has no fixed bound: the mini-block binary's, a 13-byte record plus return data per transaction (ethereum.md §3), holds at most 1,255 transactions, and one record can exceed it. Large outputs belong behind a digest (the stateless binary's journal is 43 bytes), large inputs in advice.
  • The journal is the window's whole final contents. Anything but a length of at most 16,380, that many bytes, then zeros, matches no statement: the executor refuses an oversized length (EmuError::JournalTooLong), and a nonzero byte past it fails step 10c. commit keeps that form; a guest writing the window directly must.
  • Nothing orders the journal's writes, and nothing forces a guest to read its input. The proof binds a window's contents, not its accesses.
  • Read the exit status first. It is x10's final value (memory.md §4): a failed run, a panic included, has a verifying proof and a journal too (§7).
  • A deployed contract fixes both lengths, a decider key being per shape (recursion.md §9).

Auditeurs/Système de preuve

Le moteur GKR

Spécification normativedocs/spec/gkr.mdVoir en Markdown

Résumé

Le moteur avec lequel chaque circuit de famille est prouvé. La page définit le modèle en couches avec ses listes de portes ligne par ligne et à réduction de moitié, l’adresse de chaque polynôme, les tables virtuelles, les sept formes de portes et le plafond de degré de 2, l’artefact de circuit avec son format de sérialisation, ses quatre lois et son contrat de remplissage, ainsi que la passe arrière : ce que doit fournir l’appelant, l’ordonnancement de la transcription, le sumcheck de couche, pourquoi chaque défi est tiré au moment où il l’est, et les erreurs que renvoie un vérificateur.

Le texte normatif ci-dessous est tenu à jour en anglais, langue canonique de la spécification.

The layered-circuit model every family circuit is written in, the artifact that carries one, its laws, and the backward pass reducing a circuit's outputs to claims on its committed columns at one point, which the shard's opening discharges (proof.md §5).

crates/constraints is §1–§4; crates/gkr-verify is §5's verifier half and the verifier's helpers for the memory argument (memory.md §3, §4) and LogUp (lookup.md §2, §8). Both are no_std, as verifier-core and the recursion guest build on them. crates/gkr, std and rayon, is the prover half and re-exports gkr-verify. crates/checker enforces §4.2–§4.3 again (circuits.md §3).

1 The layer model#

Layer k, 0 ≤ k ≤ N, N ≥ 1, is w_k columns of n_k variables, indexed as primitives.md §6 fixes. Layer 0 is the committed columns M, W, S in layout order at n_0 = trace_vars, beside the virtual tables the artifact lists (§2.1), which count in no width. Gate list k reads layer k and writes layer k + 1; the top, layer N, is exactly the outputs. A list is row-wise, n_{k+1} = n_k, or halving, n_{k+1} = n_k − 1.

A halving list halves each column of its layer: it writes w_k columns by halving shapes (§3) reading layer-k columns at both children — child 0 is rows [0, h), child 1 rows [h, 2h), h = 2^{n_k−1}, the child bit being the highest variable. An entry may read any column, as a fraction tree's numerator reads its denominator (lookup.md §6), but every column is read (§4.2). Only halving lists hold halving shapes; a halving list is never list 0, has no cached or enforcing entries and needs n_k ≥ 1. Every relation has the one template checker dump prints:

producing, row-wise   L{k+1}[j](x) = Σ_y eq(x, y)·G(layer k at y)
producing, halving    L{k+1}[j](x) = Σ_y eq(x, y)·G(layer k at (y, 0) and (y, 1))
enforcing             0 = G(layer k at y)   for every y ∈ {0,1}^{n_k}

2 Addresses#

constraints::PolyAddress names every polynomial; dumps use its Display notation:

variant notation read by
Memory(i), Witness(i), Setup(i) M[i], W[i], S[i] committed columns list 0, relations, lookups
Virtual(kind) V[row], … virtual tables, §2.1 the same, if virtuals lists it
Inner { layer, offset } L{k}[j] column j of layer k ≥ 1 list k
Cached { layer, offset } C{k}[j] cached entry j of list k, §3.1 list k
Scratch(i) scratch[i] an intermediate of the flat relation list, §4 relations

The scratch bijection maps each scratch[i] to one L{k}[j], covering every inner column once. A committed value needed above layer 1 is carried up by copy gates. M, W and S differ in when they are bound (memory.md §8).

2.1 Virtual tables#

A virtual table is a closed form, evaluated per row by gkr_verify::virtual_at_row and at a point by virtual_at_point, never materialized, committed or claimed. Each form is its table's multilinear extension, so the verifier evaluates what the prover sums (crates/gkr/tests/{lookup,ram_live}.rs check all but V[row]). Wire form: a u32, in table order from 0.

kind notation value at row y closed form at (y_0, …, y_{n−1})
RowIndex V[row] y Σ_{j<n} 2^j·y_j
RamLive V[ram_live] 1 if y ≥ 2^14, else 0 1 − Π_{14≤j<n} (1 − y_j); 0 if n ≤ 14
Range19 V[range19] y mod 2^19 Σ_{j<min(19,n)} 2^j·y_j
Range16 V[range16] y mod 2^16 Σ_{j<min(16,n)} 2^j·y_j
Xor8A V[xor8_a] a = y mod 2^8 Σ_{j<8} 2^j·y_j
Xor8B V[xor8_b] b = ⌊y/2^8⌋ mod 2^8 Σ_{j<8} 2^j·y_{j+8}
Xor8Out V[xor8_out] a ⊕ b Σ_{j<8} 2^j·(y_j + y_{j+8} − 2·y_j·y_{j+8})

14 is constants::memory::RAM_LIVE_BIT (memory.md §3); the range and XOR8 kinds are channel tables (lookup.md §3). Xor8Out's form is multilinear because y ⊕ z = y + z − 2yz is.

3 Gate shapes#

constraints::GateDef is a closed enum. A coefficient is Coeff::Literal(Fr) or Coeff::Challenge(slot), a constants::challenge_slot read from the pass's ExternalChallenges, of degree 0.

tag variant value
0 Linear { terms, constant } Σ c_i·x_i + c_0
1 Product { coeff, left, right } c·x·y
2 MaskIntoIdentity { input, mask } x·m + (1 − m)
3 AffineProduct { left, left_constant, right, right_constant } (Σ a_i·x_i + a_0)·(Σ b_j·y_j + b_0)
4 TreeProduct { input } x(·,0)·x(·,1)
5 Quadratic { constant, linear, products } c_0 + Σ a_i·x_i + Σ b_j·y_j·z_j
6 TreeCross { left, right } p(·,0)·q(·,1) + p(·,1)·q(·,0)

Quadratic spells degree-2 relations, such as a·b + c·d − e·f, that no product of affine forms does. The kernel, gkr_verify::eval_gate, takes one value per operand in GateDef::operands order, a halving shape's each at child 0 then child 1, and is the semantic authority. Both passes reach it through gkr_verify::ResolvedList, crates/checker calls it over the relations, and verifier_core::tape transcribes it for the recursion nodes (recursion.md §7).

3.1 Cached entries and the degree ceiling#

A cached entry C{k}[j] = H is a sub-expression of row-wise list k over its layer's columns, not another cached entry, substituted into the gates of its list naming it, with no table, claim or width. The prover evaluates H at every round node and never binds it: a bound table is the extension of H's values, which for a degree-2 H is not H of the extensions. No registered circuit has one. CircuitArtifact::inline_cached writes a Product with one Linear cached factor as an AffineProduct and refuses any other reference; both prove the same bytes.

Degree is read from the shape after substitution — a column or virtual table 1, a challenge 0, C{k}[j] its expression's, a halving shape 2, a Quadratic its widest term — and validate holds every gate, cached entry and relation to at most 2, so a higher relation is split across layers. With eq multilinear, every round polynomial is then a cubic (§5.3).

4 The circuit artifact#

constraints::CircuitArtifact holds a circuit twice: as layered gates, which the engine proves, and as a flat relation list over M, W, S, V and scratch, which the row-local checks read (circuits.md §3). Law 4 makes them one constraint set. In wire order:

CircuitArtifact = (format_version = 1, coefficient_encoding = 0, trace_vars ≤ 30,
                   memory, witness, setup: [name], virtuals: [(VirtualKind, name)],
                   layers: [LayerSpec], relations: [Relation], lookups: [LookupExpr],
                   scratch: [(name, L{k}[j])], outputs: [L{N}[j]],
                   padding: (row: [Fr], zero_row_valid: bool))
LayerSpec       = (halving, num_vars, width,
                   cached:    [(name, C{k}[j], GateDef)],
                   producing: [(relation, L{k+1}[j], GateDef)],
                   enforcing: [(relation, GateDef)])
Relation        = (name, output: Option<scratch index>, GateDef)
LookupExpr      = (name, channel, selector: PolyAddress, tuple: [GateDef])

validate holds the first three to those values and every name to non-empty [a-z0-9_], unique in the artifact; names mean nothing to the engine. Encoding 0, COEFFICIENT_ENCODING_CANONICAL_LE, is every Fr canonical 32-byte little-endian, and 30 is MAX_TRACE_VARS. outputs orders the top layer as OutputClaims lists it; a relation with an output defines that slot, one without is enforcing; lookups are lookup.md §1's.

4.1 Wire form#

postcard over §4's tuples, hand-written serde: a u32 is a varint, a u8 tag and a bool a byte, an Option a tag byte, a sequence a varint count then its elements, a name a str, an Fr its 32 canonical bytes.

PolyAddress  (tag u8, a u32, b u32): 0 M, 1 W, 2 S, 5 scratch (a = index); 3 V (a = kind);
             4 L, 6 C (a = layer, b = offset); unused fields 0
Coeff        (tag u8, slot u32, value Fr): 0 literal (slot 0), 1 challenge (value 0)
GateDef      (tag u8, split u32, coefficients [Coeff], operands [PolyAddress] in operands() order)
  0 Linear            split 0  c_1..c_t, c_0                 x_1..x_t
  1 Product           split 0  c                             x, y
  2 MaskIntoIdentity  split 0  —                             x, m
  3 AffineProduct     split t  a_1..a_t, a_0, b_1..b_u, b_0  x_1..x_t, y_1..y_u
  4 TreeProduct       split 0  —                             x
  5 Quadratic         split t  c_0, a_1..a_t, b_1..b_u       x_1..x_t, y_1, z_1, …, y_u, z_u
  6 TreeCross         split 0  —                             p, q

CircuitArtifact::from_bytes refuses a format_version other than 1 before decoding the rest, postcard not being self-describing; refuses an unknown tag, a nonzero unused field, a gate with counts its shape lacks and a non-canonical Fr; re-encodes and compares, as postcard admits overlong varints and trailing bytes; never panics or reserves what a declared length asks; and checks no law.

4.2 The laws#

CircuitArtifact::validate runs once where an artifact is built or loaded, never per proof: each constraints constructor panics on a refusal, and verifier_core::VerifyingKey::check applies it to a key's circuits, for prover and verifier (proof.md §7). checker::check_laws enforces Laws 1–4 and the lookup rules again, sharing no code with crates/constraints/src/laws.rs (circuits.md §3).

  1. Locality. Every operand of list k is in range and readable at layer k (§2): a V only if listed, a C{k}[j] only one of list k's own, from a producing or enforcing gate.
  2. Derived width. A list's stored width is its producing count, entry j writes L{k+1}[j], and its stored num_vars is n_k, or n_k − 1 if halving.
  3. Top layer. outputs is a permutation of L{N}[0..w_N).
  4. Single source of truth. Relations and gate entries correspond one to one, a producing entry's relation defining the slot the bijection maps to its output, an enforcing entry's none, and each pair is one polynomial, scratch read through the bijection and cached entries substituted: validate compares normalized expansions, checker evaluations at random points.

validate also refuses, each a ConstraintError naming what broke: §4's bounds, no gate list, padding.row not w_0 long, a virtual kind listed twice, §1's halving rules, degree above 2, a relation reading anything but M, W, S, listed V and existing scratch, a scratch list that is no bijection onto the inner columns or not defined once each, a slot outside constants::challenge_slot, and a relation constructed and then dropped — an inner column below the top the list above never reads, a cached entry no gate names, an enforcing gate whose expansion is zero. Reads are decided on normalized expansions: x − x and 0·x read nothing.

The lookup rules. A lookup's channel is in constants::lookup_channel; its tuple is one expression on a range channel, else 1 to lookup_channel::MAX_TUPLE (7), as wide as its channel's other lookups'; its selector is an in-range committed column some enforcing gate of list 0 holds to booleanity (x − x² up to normal form); and each expression is Linear over in-range committed columns and listed virtual tables, with literal coefficients, unit and constant-free above position 0 (lookup.md says what each protects).

4.3 The padding contract#

The engine gates nothing, an enforcing gate being a zerocheck over the whole cube, so a family switches relations off with its own columns (memory.md §2). On padding.row, a committed row, the row-local scratch values, those of producing relations not at or above a halving shape, make every row-local enforcing relation vanish at every challenge value and row index; zero_row_valid says whether the all-zero row does too. The product-tree clause: where shards have inactive rows, every column the first halving list reads is 1 on padding.row, so padding leaves each product unchanged; the RAM window families (memory.md §3) and the columns a TreeCross reads (lookup.md §6) are exempt. This is completeness, not soundness: a cheating prover's padding rows are its family's gates' business. Nor is padding.row the row a prover writes, multiplicities and setup columns differing; no prover or verifier reads it, and checker::check_padding and checker::check_padding_identity test it.

5 The backward pass#

gkr::forward materializes every layer from the committed columns; gkr::prove proves those values as they stand, one sumcheck::SumcheckProof per transition; gkr_verify::verify replays the schedule, checking, from OutputClaims, one table per output, to BaseClaims or a GkrError. gkr::self_check, naming the first failing gate, row and relation, and gkr::explain_self_check, listing that row's operands, are a debugging hook costing a second forward pass (tools.md §3). Rayon splits rows and row pairs, never lists or rounds: proofs do not depend on the thread count.

5.1 What the caller owes#

  • The base is bound into the transcript before prove or verify, which absorb none of it (proof.md §4 binds a shard's commitments).
  • Each challenge is drawn after every committed column its gates reach is bound, or is derived: a fixed function of such challenges and of statement data bound before them, computed by the verifier. That suffices for GKR; the memory argument needs more (memory.md §8).
  • The artifact has passed validate (§4.2) and is not checked again; on a lawless one the engine may panic, and verify may accept.
  • The prover's inputs have the artifact's shape; it checks none, nor that its values satisfy the gates. Soundness is verify's alone and a cheating prover runs none of this code, so a bad input costs the honest prover only a panic or a failing proof.

5.2 The transcript schedule#

prove and verify run these steps and end in one sponge state; the tags are transcript.md §5's. p is the claim point, v_j the claim on column j of the layer the next list writes.

step op tag message
O1 absorb GKR_OUTPUTS the output tables in output-map order, rows in index order: one message of w_N·2^{n_N} scalars
O2 squeeze ×n_N GKR_OUTPUT_POINT p = r, r_i binding variable i; v_j = tables[i](r) for outputs[i] = L{N}[j]
L1 squeeze GKR_BATCH λ; the claim is c = Σ_j λ^j·v_j
L2 ×n_{k+1}: absorb, squeeze SUMCHECK_ROUND, SUMCHECK_CHALLENGE a round's cubic, then ρ_i, binding variable i
L3 absorb GKR_LAYER_CLAIMS row-wise: L{k}[j](ρ) per j in offset order, layout order at k = 0; halving: L{k}[j](ρ,0), L{k}[j](ρ,1) per j
L4 squeeze, halving only GKR_CHILD τ; p = (ρ, τ); v_j = L{k}[j](ρ,0) + τ·(L{k}[j](ρ,1) − L{k}[j](ρ,0))

L1–L4 run for k = N − 1 down to 0; after a row-wise list p = ρ and v is L3's message. The base claims are layer 0's, in layout order at one point. Every registered circuit halves to a top with no variables (circuits.md §2), so O2 draws nothing and O1 fixes the roots before λ.

5.3 The layer sumcheck#

Transition k proves c = Σ_{y∈{0,1}^{n_{k+1}}} eq(p, y)·S_k(y), where

row-wise   S_k(y) = Σ_j λ^j·G_j(layer k at y) + Σ_e λ^{w_{k+1}+e}·E_e(layer k at y)
halving    S_k(y) = Σ_j λ^j·G_j(layer k at (y, 0) and (y, 1))

G_j writes L{k+1}[j] and E_e, the list's e-th enforcing gate, claims 0: enforcing gates are zerochecks sharing the descending point and its batch. The rounds are primitives.md §7's cubics, run from c, one per variable of layer k + 1, a halving list's two children being separate tables. After L3 the verifier checks claim = eq(p, ρ)·S_k(values), layer-k operands taking L3's values, virtual tables their closed form at ρ, cached entries their expression; with n_{k+1} = 0 there are no rounds and the check is c = S_k(values). A zero claim is legal. gkr::prove_sumcheck and gkr_verify::verify_sumcheck run L2.

5.4 Why it is sound#

Each challenge is drawn after what it protects:

  • r after the outputs, or a prover predicting r claims another table agreeing with the true one there.
  • λ after the claims and p. If some v_j is not the true v̂_j, or some E_e is nonzero on the cube, Σ_j λ^j·(v_j − v̂_j) − Σ_e λ^{w_{k+1}+e}·Ê_e(p) is a nonzero polynomial in λ of degree below w_{k+1} + |E_k|; Ê_e, the extension of E_e's values, is fixed before p is drawn and vanishes there with probability at most n_{k+1}/|Fr|.
  • ρ_i after round i: a wrong cubic agrees with the true one there with chance ≤ 3/|Fr|.
  • τ after both children: a wrong pair's line meets τ ↦ L{k}[j](ρ, τ) in at most one point.

Summed over a registered circuit's transitions at its default height, these stay under 2^14/|Fr|. The random-oracle assumption is architecture.md's.

5.5 Shapes and errors#

Transition k carries n_{k+1} rounds and w_k claims, 2·w_k if halving, so a proof's shape is the artifact's alone (wire form: proof.md §9). verify checks, in order and before touching the transcript, and on a validated artifact never panics on proof or claim data:

GkrError when
MissingChallenge { slot } a gate names a slot not supplied
OutputShape OutputClaims mismatches the output map in count or variables
ProofShape { layer } layer = N: a wrong transition count; else transition layer, lowest first, has a wrong round or claim count
LayerInconsistency { layer } a round or the final check of transition layer fails

One LayerInconsistency covers a wrong descending claim and a violated enforcing gate alike: a batched sum cannot tell them apart, and the proof spends nothing on it. proof.md §6 maps these errors to its classes.

Auditeurs/Système de preuve

Circuits

Spécification normativedocs/spec/circuits.mdVoir en Markdown

Résumé

Le registre des 23 circuits de familles, avec la hauteur, les colonnes engagées, les portes, les lookups, les colonnes internes, la taille d’artefact et la taille de preuve de shard de chacun; la façon dont un circuit de famille est assemblé à partir de feuilles mémoire, de fractions de lookup, de portes de contrainte, d’arbres ligne par ligne et de listes à réduction de moitié; et la façon dont le checker indépendant revalide les lois, le contrat de remplissage et les règles de lookup, ainsi que la suite de falsification qui prouve que les contrefaçons sont refusées.

Le texte normatif ci-dessous est tenu à jour en anglais, langue canonique de la spécification.

Every shard is proved by its family's circuit, a constraints::CircuitArtifact in gkr.md's model, fixed by the format, the family and the height. This page lists the circuits and their shapes (§1), how one is assembled (§2) and how crates/checker checks one independently (§3); each family's own page specifies its columns, gates and lookups.

1 The registry#

constraints::family_circuit(family, trace_vars) is the base format's registry, constraints::recursion_circuit the recursion format's, and VmConfig::circuit picks one by format (recursion.md §1.1). Each returns a FamilyCircuit, the artifact and its channel specs (lookup.md §11). A verifying key loads only if its circuits are the registry's at its heights (proof.md §7), and the prover registers the same (§2).

Families 0–6 (constants::family) are the execution families, one executed instruction a row (add-sub.md, jump-branch-slt.md, shift-bitwise.md, mul-div.md, memory-ops.md §3, §4, §6); 7–8 and 12–14 the window families, one memory word a row (memory.md §3, public-values.md §4); 9–11 and 15–17 the delegation families, one invocation a row (delegation-circuits.md §2 to §7, by id); 18–22 the recursion format's (recursion.md §2 to §6).

Shapes at the default height 2^n (constants::family::DEFAULT_HEIGHTS): committed columns, enforcing gates, obligations per channel (TIMESTAMP/RANGE16/GENERIC/DECODER/XOR8), row-wise gate lists (the halving ones are n), inner columns, artifact bytes, and a base-format shard proof's bytes, proof.md §9's layout over the shape:

id family n M W S gates lookups row-wise inner bytes proof
0 ADD_SUB_LUI_AUIPC 22 27 35 7 63 10/4/0/1/0 5 314 72,064 64,764
recursion format 22 27 39 7 75 10/4/0/1/0 5 314 79,077 —
1 JUMP_BRANCH_SLT 22 21 44 10 42 8/11/2/1/0 5 392 76,980 69,436
2 SHIFT_BITWISE 22 21 61 10 48 8/24/6/1/0 6 478 102,837 76,644
3 MUL_DIV 20 21 54 9 54 8/16/2/1/0 6 444 92,640 67,412
4 MEM_WORD 22 31 24 7 33 12/5/0/1/0 5 314 60,383 63,836
5 MEM_SUBWORD 22 31 55 10 53 12/22/1/1/0 6 472 98,846 76,196
6 ATOMICS 20 26 54 9 46 10/19/6/1/0 6 472 101,593 68,468
7 INIT_TEARDOWN 22 2 0 1 0 — 1 46 3,907 36,316
8 ZERO_WINDOWS 22 2 0 0 0 — 1 46 3,418 36,284
9 KECCAK_F 18 208 1,556 0 385 0/210/0/0/1,020 11 5,490 1,900,468 381,100
10 POSEIDON2 8 100 4,092 0 4,248 — 193 2,020 2,056,361 664,780
11 FR_ARITH 8 104 2,576 0 2,701 — 6 142 1,063,214 266,292
12 PUBLIC_INPUT 12 3 0 0 0 — 1 26 2,455 12,556
13 PUBLIC_OUTPUT 12 2 0 0 0 — 1 26 2,338 12,524
14 ADVICE_WINDOWS 22 3 0 0 0 — 1 46 3,535 36,316
15 MOD_MUL 16 104 221 0 125 0/274/0/0/0 10 2,244 550,391 135,220
16 SHA256_COMP 18 104 520 0 119 0/114/0/0/336 10 2,802 845,456 189,988
17 EC_ADD 16 392 1,028 0 637 0/1,110/0/0/0 12 8,772 2,350,670 434,916
18 FIELD_WINDOWS 20 2 0 0 0 — 1 42 2,758 —
19 FR_OP 20 31 31 0 44 0/36/0/0/0 7 370 89,741 —
20 P2_FIELD 18 45 382 0 372 0/58/0/0/0 7 392 294,425 —
21 FIELD_IO 18 43 39 0 24 0/70/0/0/0 8 650 164,713 —
22 FQ_OP 20 48 73 0 38 30/50/0/0/0 7 630 158,326 —

Heights. Both registries return None above MAX_TRACE_VARS = 30, and below the floor lookup.md §3 derives from the family's channels: 19 with TIMESTAMP, else 16 with RANGE16 or XOR8, else 0. A height changes trace_vars, each list's variable count and the number of halving lists, one per variable and as wide as the outputs, and no gate below them: at 2^20 ADD_SUB_LUI_AUIPC has 298 inner columns, 70,974 bytes and a 57,196-byte proof.

Shared circuits. The registries agree on families 1–17; the recursion format's ADD_SUB_LUI_AUIPC is add_sub::recursion_artifact (add-sub.md §2). PUBLIC_OUTPUT's circuit is ZERO_WINDOWS' and ADVICE_WINDOWS' is PUBLIC_INPUT's, byte for byte at one height, and FIELD_WINDOWS' is the zero window at a stride of one cell, all constraints::memory constructors (memory.md §3). Every other family's is its own module's artifact.

2 How a family circuit is assembled#

layer 0        M ‖ W ‖ S in layout order, beside the V tables' closed forms
gate list 0    memory leaves: the read side, then the write side, each padded to a power of
                 two with the literal 1
               per channel, in spec order: (−mult, T + g), then (1, E_l + g) per lookup,
                 then (0, 1) up to a power of two                      (lookup.md §6)
               every enforcing gate
lists 1 … r    row-wise: each tree combines sibling nodes, a product by a·b, a fraction by
                 (n_a·d_b + n_b·d_a, d_a·d_b); a tree already at one node is copied up
lists r+1 …    halving, one per variable: TreeProduct on a product, TreeCross (num) and
                 TreeProduct (den) on a fraction
top            no variables: read_root, write_root, then (num, den) per channel

r is the largest tree's depth, so the circuit has r + 1 row-wise lists; every registered circuit, POSEIDON2 included, ends in a top with no variables. crates/constraints/src/build.rs assembles it, writing the flat relation list and an all-zero padding row, zero_row_valid read off the gates' constants, and validating (gkr.md §4). constraints::memory::assemble gives it the product trees and lookup::channel_trees' fraction trees (lookup.md §11), then runs memory::check_memory (memory.md §8) and lookup::check_discharge: a constructor panics on a refusal, so every circuit that exists has passed them. Its callers:

  • memory::frame_with_channels_artifact(queries, trace_vars, FamilySpec), the execution families: memory.md §2's frame over memory::frame_queries(family), then the family's witness columns after the frame's w + 3, setup columns from S[0], virtual tables, enforcing gates after the frame's, lookups after its 2w gap obligations, and a non-empty channel list;
  • the window constructors (memory.md §3);
  • the delegation and recursion families, every gate in list 0, from constraints::delegation's shared columns, leaves and gates (delegation-circuits.md §1) — but POSEIDON2, which builds its own lists (delegation::Assembly): 192 row-wise lists of rounds beside its product trees, the last holding three gates on the output lanes.

Beyond the frame, each execution family has m_pc as the row's liveness and every other mask held to m_pc times the kinds making that query (<q>_mask_rule, memory.md §2); its decoded row as W columns, bound by decode_row to its table at the row's pc, and decoded_mask_bits, the mask as boolean kind bits, one-hot by the table's domain (lookup.md §10, program.md §6); a next_pc_rule (memory.md §5); a bound on each register value it writes (memory-ops.md §5); and channels ordered TIMESTAMP, RANGE16, GENERIC if read, DECODER.

prover::family_fill(family) is the prover's side: a prover::Fill writes a shard's committed columns but the multiplicities, which trace::build_multiplicities counts. prover::register pairs fill and circuit for each family of a VmConfig (ProverError::Unregistered if either is missing).

3 Checking a circuit independently#

crates/checker's validators enforce the rules again in code sharing nothing with crates/constraints/src/laws.rs, never calling validate. They evaluate a gate only through the kernel gkr_verify::eval_gate (gkr.md §3), so they re-read the rules, not the gates' meaning. Sampled checks use eight pseudo-random points from fixed seeds.

checks
check_laws (check_law1 … check_law4) the four laws, then the lookup rules (gkr.md §4); Law 4 and selector booleanity by evaluation, where validate compares expansions
check_padding, check_padding_identity the padding contract and its product-tree clause, fraction trees exempt
check_lookup_discharge lookup.md §11's discharge rule, gating and compression re-derived
violated_relations, violated_lookups a witness row's row-local relations and range obligations
channel_sums, check_channel_roots each channel's sum and denominator product, folded row by row rather than by a tree, naming every tuple no table row holds; then the circuit's root pairs against them
memory_roots the two roots as products over the rows the halving phase reads
memory_columns_from_log, frame_witness_from_log an execution family's frame columns from the memory event log, where trace builds them from a shard's rows

They do not re-implement check_memory, the copower rule (lookup.md §11), or validate's other construction rules, the degree ceiling among them.

checker::TamperHarness re-proves a statement with witness cells or boundary scalars changed, as an honest prover would prove the changed witness — each channel's multiplicities recounted unless one is what changed or the changed tuple is in no table, changed M columns recommitted in a fresh global commit phase, every shard re-proved — then verifies a shard or the block and asserts the refusal's class (a Lookup's channel too), or that a change breaking nothing verifies. It relies on the prover checking nothing (gkr.md §5), runs on the archived path (streaming.md §6), and carries the delegation anchor's forgeries (checker::assert_anchor_twins_refused, delegation.md §5).

A dump (checker::dump, CLI in tools.md §4) prints the columns by address and name, each list's gates in gkr.md §1's template with their relations, the flat relations over scratch[i], the scratch bijection, outputs, lookups and padding row. Relations are numbered list by list, producing before enforcing; a producing one is define_<column>, an enforcing one bears its gate's name; a node is named for its tree and layer (range16_3_1_num, read_root), a leaf for what it holds (write_pad_0, rd_hi_range_den). A literal below 2^32 prints in decimal, p − k for such a k as -k, any other as 0x and 64 big-endian hex digits; a challenge as its constants::challenge_slot::NAMES entry.

Auditeurs/Système de preuve

L’argument de mémoire

Spécification normativedocs/spec/memory.mdVoir en Markdown

Résumé

L’argument selon lequel chaque lecture renvoie la dernière écriture, sur tous les shards d’un énoncé. La page définit le tuple mémoire, les colonnes de cadre, les feuilles et les produits de chaque famille d’exécution ainsi que les gadgets que porte chaque famille, les familles de fenêtres de RAM et les règles auxquelles le vérificateur les astreint, la frontière des registres et du pc et l’unique équation de rapprochement, la façon dont l’arrêt est fixé, ce qui doit précéder les défis mémoire, les obligations d’intervalle, les règles appliquées à la construction, et ce sur quoi repose l’argument, y compris pourquoi le chemin du pc donne gratuitement l’ordre du programme.

Le texte normatif ci-dessous est tenu à jour en anglais, langue canonique de la spécification.

Offline memory checking over a whole statement. Each shard's circuit outputs the product of its read tuples and of its write tuples; the verifier checks, once per statement, that all reads times the register and pc finals equal all writes times their initial values. RAM is initialized by window families over fixed address windows; registers and the pc have no rows. The section numbers are the ones the code cites.

1 The tuple#

T(AS, ADDR, TS, VAL) = γ_M + AS + α_addr·ADDR + α_ts·TS + α_val·VAL

The parts are in the order of constants::memory::{PART_AS, PART_ADDR, PART_TS, PART_VAL}. AS, an address-space tag (execution-trace.md §2), is unweighted; a RAM address is a 4-aligned word's byte address. γ_M, α_addr, α_ts, α_val are constants::challenge_slot slots 1–4, MEM_GAMMA to MEM_ALPHA_VAL, drawn once per statement after everything §6.1 lists; slot 5, MEM_WINDOW_CONSTANT, is derived per window shard by the verifier and never read from a proof (§3.3). A gate coefficient is one literal or one slot (gkr.md §3), so α_ts·4·cycle is the term (α_ts, cycle) four times.

Every memory artifact outputs its read product at outputs[READ_ROOT = 0] and its write product at outputs[WRITE_ROOT = 1] (constants::memory), before any channel's roots (lookup.md §6). All tuples of a statement form one multiset, over REG, RAM and PC, one anchor space per delegation type, where a request meets its invocation (delegation.md §5), and the recursion format's FIELD cells (recursion.md §2.1).

2 An execution family's memory subtree#

2.1 The frame columns#

A row of an execution family is one cycle; its accesses are queries, each a read and a write at one address, the write at 4·cycle + Δ (execution-trace.md §1). The query table is constraints::memory::{FRAME_NAMES, FRAME_SPACE, FRAME_DELTA}:

id query space Δ
0 pc PC 0 address 0; reads pc, writes next_pc
1 rs1 REG 1 read-only; an ecall's a7
2 rs2 REG 2 read-only; an ecall's a0
3 load RAM 2 read-only; a load's word
4 ram RAM 3 a store's or an atomic's word
5 rd REG 3 the x0 rule (§2.4)
6 deleg the row's 3 a delegation request's mirror (delegation.md §5)

A family's frame is exactly the queries its instructions make (execution-trace.md §4), in table order: constraints::memory::frame_queries, which crates/trace/tests/memory.rs holds to the union over all 59 instructions. A missing query would leave an instruction's written value unconstrained. No instruction routed to ADD_SUB_LUI_AUIPC touches RAM, and ATOMICS keeps every RAM access at Δ = 3, lr.w included. Window and delegation families have no frame (§3.3; delegation-circuits.md §1).

family queries, in slot order w leaves a side
ADD_SUB_LUI_AUIPC pc rs1 rs2 rd deleg 5 8
JUMP_BRANCH_SLT, SHIFT_BITWISE, MUL_DIV pc rs1 rs2 rd 4 4
MEM_WORD, MEM_SUBWORD pc rs1 rs2 load ram rd 6 8
ATOMICS pc rs1 rs2 ram rd 5 8

Columns are addressed by slot s, a query's position in its family's list:

M[0]                  cycle
M[1 + 5s + f]         slot s's <q>_mask, <q>_addr, <q>_read_ts, <q>_read_value, <q>_write_value
M[1 + 5w]             deleg_space, in the one frame holding deleg
W[s]                  <q>_gap_hi, for s < w
W[w], W[w+1], W[w+2]  rd_inv, rd_is_zero, rd_selected

That is 1 + 5w M columns, plus deleg_space, and w + 3 W columns, the family's own following (circuits.md §2). One deleg query serves every delegation type, so its space is the value of deleg_space, an M column the family pins to its type selectors: a leaf may read no W column (§8). The honest fill (trace::build_memory_columns, trace::build_frame_witness, over a shard's trace::RowSlice) sets a mask to 1 where the row is live and has the query, and every column of an absent query or a padding row to 0.

A frame holds a mask only to booleanity, so on the frame alone a padding row's rd query could rewrite x10, the exit status, after the exit row, and a live row could drop a query or carry one its instruction lacks. Every execution family makes m_pc the row's liveness and its decoder lookup's selector (lookup.md §10), and holds each other mask to m_q = m_pc·uses_q (its <q>_mask_rule gates), uses_q the sum of the row's kind and ecall-type selectors that make the query.

2.2 The leaves#

For the query at slot s with mask m, space AS and in-cycle slot Δ:

read_<q>    m·T(AS, addr, read_ts, read_value) + 1 − m
write_<q>   m·T(AS, addr, 4·cycle + Δ, write_value) + 1 − m

Each is one flat Quadratic of gate list 0, built from the unmasked tuple, a Linear whose AS and Δ terms sit on m (constraints::memory::read_tuple is the read one): constant 1; linear terms (γ_M, m), (−1, m), (AS, m) and, on the write side, (α_ts, m) Δ times; every other term multiplied by m, as is deleg's AS, the product (1, deleg_space, m). At m = 0 a leaf is 1 whatever its columns hold, at m = 1 the tuple, and it is one or the other only at a boolean m (§2.4).

2.3 The product#

Each side is padded to w rounded up to a power of two with read_pad_<i> and write_pad_<i>, the literal 1, reading no column. Row-wise Product lists reduce each side to one value a row, and trace_vars halving lists of TreeProduct multiply the rows (gkr.md §1), so the two roots are the products of the shard's read and write tuples. A padding row has every mask 0 and so every leaf 1, the padding contract's product-tree clause (gkr.md §4). The family's channel trees share the layers (circuits.md §2).

2.4 The gadgets every execution family carries#

Gate list 0's first enforcing gates, in this order, and the circuit's first 2w obligations:

<q>_mask_boolean       m − m·m = 0                       every query
<q>_writes_back        write_value − read_value = 0      rs1, rs2 and load, where held
rd_is_zero_inverse     addr·rd_inv + z − m = 0           on rd; z = rd_is_zero
rd_is_zero_at_nonzero  addr·z = 0
rd_is_zero_boolean     z − z·z = 0
rd_write_masked        write_value − sel + z·sel = 0     sel = rd_selected

gap_hi_<q>   TIMESTAMP, selector m:   hi                                   hi = <q>_gap_hi
gap_lo_<q>   TIMESTAMP, selector m:   4·cycle + δ_q − read_ts − 2^19·hi        δ_q = Δ − 1; δ_pc = −4
  • Booleanity. At m = −1 a pc query's leaves are each −T(REG, …): one sign flip a side, so the products balance and the pc access reads as a register access.
  • Write-back. Without it a read of x0 could write 5 there.
  • x0. The first two rd gates (constraints::gadgets::is_zero) make z = m·[addr = 0] and the last write_value = (1 − z)·sel: every write to x0 writes 0, whatever the family computed into sel, and with write-backs and x0's init 0 every read of it returns 0. The boundary's final x0 = 0 (§4.1) pins only its last write: a write of 5, a read of 5 and a write of 0 would otherwise balance.
  • Gap. Both chunks below 2^19 put gap = 4·cycle + δ_q − read_ts in [0, 2^38), so read_ts < 4·cycle + Δ as integers, every timestamp being a canonical integer by §4.2's count. The pc query's δ = −4 puts a row's pc write at least 4 after the one it reads, so consecutive rows' timestamps never interleave (§9). The frame's construction asserts two obligations per query.

3 RAM windows#

3.1 Geometry#

Window w at height h = 2^n covers the bytes [4h·w, 4h·(w + 1)), its row y being the word at 4h·w + 4y; the windows tile [0, 2^32) from 0. Ordinary RAM is [RAM_ORIGIN, ADVICE_ORIGIN) = [2^16, 2^31) (trace::in_ram), ending where window N = 2^29/h begins (verifier_core::advice_first_window). Window 0's rows y < 2^14 (constants::memory::RAM_LIVE_BIT) lie below RAM_ORIGIN at every height, and INIT_TEARDOWN masks them (§3.3).

3.2 The window families#

A window family's shard initializes and tears down one window; its rows are addresses.

region family id windows init value
[0, 0x8000) none: a hole
[0x8000, 0x10000) PUBLIC_INPUT, PUBLIC_OUTPUT 12, 13 2 and 3 at their pinned 2^12 the statement's input; 0 (public-values.md §4)
[RAM_ORIGIN, 4h) INIT_TEARDOWN 7 0, one shard S[0], the image column
[4h, 2^31) ZERO_WINDOWS 8 the listed w_1 < … < w_k in [1, N − 1] 0
[2^31, 2^32) ADVICE_WINDOWS 14 N … N + k_a − 1 M[2], bound to nothing (public-values.md §6)
FIELD cells FIELD_WINDOWS 18 0 … k_f − 1 0 (recursion.md §2.2)

INIT_TEARDOWN, ZERO_WINDOWS and ADVICE_WINDOWS share the window height h, 2^22 by default (§3.5). An unlisted RAM window is initialized by nothing. Every statement proves window 0 and, in practice, the stack's window N − 1, the initial sp being ADVICE_ORIGIN: two h-row shards however small the program, besides the public pair.

3.3 The artifacts#

INIT_TEARDOWN   image_window_artifact   M[0] teardown_ts, M[1] teardown_value, S[0] init_value
  read    live·(WC + α_addr·4·row + α_ts·M[0] + α_val·M[1]) + 1 − live     live = V[ram_live]
  write   live·(WC + α_addr·4·row + α_val·S[0]) + 1 − live
ZERO_WINDOWS, PUBLIC_OUTPUT    zero_window_artifact     M[0], M[1]
  read    WC + α_addr·4·row + α_ts·M[0] + α_val·M[1]        write   WC + α_addr·4·row
PUBLIC_INPUT, ADVICE_WINDOWS   value_window_artifact    M[0], M[1], M[2] init_value
  read    as above                                          write   WC + α_addr·4·row + α_val·M[2]
FIELD_WINDOWS   field_window_artifact: zero_window_artifact over α_addr·row, one cell a row

All are constraints::memory constructors: one leaf a side, then n halving lists; no witness column, enforcing gate or lookup; every init timestamp the literal 0; row is V[row]. V[ram_live] is [y ≥ 2^14], boolean on the cube by construction (gkr.md §2). The window enters only through WC, so one artifact serves every window:

WC = γ_M + RAM + α_addr·4h·w        gkr_verify::window_challenges
WC = γ_M + FIELD + α_addr·h·w       gkr_verify::field_window_challenges

w is verifier_core::shard_window's: 0 for INIT_TEARDOWN, the list's i-th id for ZERO_WINDOWS shard i, 2 and 3 for the public pair, N + i for advice shard i, i for field shard i. An init column is S where program identity binds it and M where it is one execution's, committed before the challenges (§6.1). A window shard has no inactive rows.

3.4 The columns a prover fills#

trace::init_windows(state, h) is ZERO_WINDOWS' list: the distinct ⌊a/4h⌋ over touched words a of ordinary RAM, ascending, without 0. A public or advice word is a RAM tuple too, and a zero window over it would be its second init row. trace::build_init_teardown_columns fills INIT_TEARDOWN and ZERO_WINDOWS, and trace::build_value_window_columns the value windows with their M[2], from the last-access tables (trace::MemoryState):

row y, a = 4h·w + 4y teardown_ts teardown_value
w = 0, y < 2^14 (masked) 0 0
a touched its last write's timestamp its last write's value
a untouched 0 its init value

An untouched row's two tuples are equal and cancel. The image column, program::image_init_column(image, h), has row y = ProgramImage::initial_word(4y): the word assembled byte by byte from file-backed bytes, 0 elsewhere, which is the trace's initial RAM value too. decode_program refuses an image with a file-backed byte at or above 4h (ProgramError::ImageOutsideWindow): it would sit in a zero window, read as 0, bound by nothing.

3.5 The verifier's window rules#

verifier_core::check_memory_windows, step 2 of derive_global_phase, before the global transcript (program::check_memory_windows wraps it):

rule why
INIT_TEARDOWN, ZERO_WINDOWS, ADVICE_WINDOWS at one height h a lower zero-window height would re-initialize image words; an advice height of its own is a grid advice_first_window(h) does not describe
PUBLIC_INPUT, PUBLIC_OUTPUT at PUBLIC_WINDOW_HEIGHT = 2^12 the height places their windows (public-values.md §2)
4h ≥ PUBLIC_OUTPUT_ORIGIN + PUBLIC_WINDOW_BYTES = 0x10000: h ≥ 2^16 on the menu the public windows lie in window 0's masked rows, out of every zero window's reach
one shard each of INIT_TEARDOWN, PUBLIC_INPUT, PUBLIC_OUTPUT (public-values.md §4 for the pair)
one id per ZERO_WINDOWS shard, strictly increasing, in [1, N − 1] disjoint windows; id 0 is unmasked over [0, RAM_ORIGIN); N up is advice
N + k_a ≤ 2^30/h, k_a the advice shard count advice ends by 2^32; it needs no list, starting where the zero ids stop
k_f·h ≤ 2^32 field cells recursion.md §2.2

The first three are verifier_core::window_height, which VmConfig::from_bytes runs too: a config breaking them does not decode.

4 The register and pc boundary#

Registers and the pc have no rows: the verifier multiplies in their initial and final tuples, once per statement. Rows for them would repeat the init tuples in every shard holding them, and a stale read would balance against the copy.

4.1 The boundary scalars#

The statement carries 64 scalars, gkr_verify::BoundaryFinals, absorbed as one MEMORY_BOUNDARY message in this order (verifier_core::boundary_scalars):

positions
0–31 t_0 … t_31 x_r's final timestamp: its last query's write, 0 if never queried
32 t_pc the pc's: the exit row's pc write
33–63 v_1 … v_31 x_r's final value, 0 if never queried

The final values of x0, 0, and of the pc, HALT_PC, are constants, not carried. PublicInputs::from_bytes refuses t ≥ 2^38 or v ≥ 2^32, and verify_global_memory re-checks the timestamps and holds v_10 to the exit status; no other register carries a public value. t_pc is not a cycle count: the pc's timestamps increase but need not be consecutive. trace::build_boundary_finals(state) is the fill.

4.2 The factors and the reconciliation#

W_b = ∏_{r=0}^{31} T(REG, r, 0, 0) · T(PC, 0, 0, entry_pc)
R_b = T(REG, 0, t_0, 0) · ∏_{r=1}^{31} T(REG, r, t_r, v_r) · T(PC, 0, t_pc, HALT_PC)

∏ read roots · R_b  =  ∏ write roots · W_b  ≠  0       over every shard of the statement

entry_pc is the verifying key's (§6.2). gkr_verify::boundary_factors evaluates each tuple through gkr_verify::eval_gate on the circuits' own tuple gate, read_tuple of pc or rs1, so the boundary and the circuits cannot disagree on the parts; gkr_verify::reconciles is the equation, which verifier_core::verify_global_memory runs once per statement (proof.md §6). A shard's roots are its GKR outputs, held to the statement's entry by its own verification.

The count. Read each query as an edge from its read tuple to its write tuple. Inits are only written and finals only read, so a balanced multiset is paths from inits to finals plus loops. An edge advances the timestamp by an integer in [1, 2^38 + 3] (the gap plus the query's least advance, 4 at the pc and 1 elsewhere), so a loop needs more than p/(2^38 + 3) > 2^215 edges, and a statement has fewer than 2^67 tuples: under 2^32 shards a family (a u32 count), 23 families, at most 2^22 rows (the menu's top), at most 196 tuples a row (EC_ADD's 97 frame words and its anchor, both sides), and 66 boundary tuples. So nothing loops: every path starts at an init at timestamp 0 and ends at a final, and every timestamp on it is an integer below 2^105.

5 Halting#

constants::memory::HALT_PC = 1. The exit row, ecall with a7 = 93, writes next_pc = HALT_PC instead of its fall-through (execution-trace.md §6), and R_b fixes the pc's final value to it. Nothing else writes it: HALT_PC is odd, every other next_pc even, and "odd" is a constraint only where a family makes it one.

  • A family copying the decoded fall-through, which is even, holds next_pc − decoded_next_pc = 0 and needs no bound.
  • JUMP_BRANCH_SLT, the one family computing a pc, range-checks every next_pc it writes even; otherwise a jalr whose rs1 + imm is 1 could write HALT_PC (jump-branch-slt.md).
  • ADD_SUB_LUI_AUIPC writes HALT_PC on its exit row alone (add-sub.md).

HALT_PC is below RAM_ORIGIN, so no decoded-table row claims it and no live row reads it (lookup.md §10). The pc's path therefore ends with the exit row's write, consumed by the final read. With a free final pc every prefix of an execution would balance.

6 Binding#

6.1 What precedes the memory challenges#

The four challenges are squeezed once per statement, at the end of the global transcript (proof.md §2 is the schedule), after everything a tuple or the reconciliation reads, because what is chosen after them can be solved for:

  • every shard's M commitments, every column a leaf may read but S and V (§8);
  • program identity, fixing entry_pc and the image column (§6.2), and the SRS digest, fixing the generic table's S columns (proof.md §3);
  • the shard counts and MEMORY_WINDOWS, the zero-window ids, fixing every window shard's addresses through WC: a list chosen afterwards is a union over up to 2^(N − 1) lists, 2^127 at h = 2^22 and no bound at all at 2^20;
  • io_digest, fixing the public windows' contents (public-values.md §5);
  • last, the 64 boundary scalars: a final value chosen afterwards reconciles any trace, v_r = (target − γ_M − REG − α_addr·r − α_ts·t_r)/α_val.

The roots are not absorbed: each shard's GKR proof binds its own.

6.2 The image column and the entry pc#

Program identity (program.md §8 is the recipe) binds INIT_TEARDOWN's one setup commitment, the image column's, and entry_pc, under PROGRAM_ENTRY. Recomputing identity binds a commitment, not the column a proof reads; the INIT_TEARDOWN shard's batched opening closes that by taking S[0]'s commitment from the verifying key, the list identity is recomputed over (proof.md §5, §7). Without it a statement over another image, with a trace consistent with that image, would verify. Without entry_pc in identity, a key carrying the registered identity beside another entry pc would verify an execution starting elsewhere. Identity binds nothing an execution chooses: no shard count, window list, public or advice word.

7 Range obligations#

A range obligation holds where its selector is 0 or its one expression is below its channel's bound (lookup.md §1, §3). Every circuit bounds a value one way:

  • a 32-bit value v: a witnessed high halfword h and RANGE16 obligations on h and on v − 2^16·h, under the row's selector, and no gate;
  • a result r = e mod 2^32 of an exact 0 ≤ e < 2^33: a witnessed wrap, the gates wrap − wrap·wrap = 0 and e − r − 2^32·wrap = 0, and r bounded as above; a wider carry is a family's own construction;
  • a timestamp gap: two 19-bit TIMESTAMP chunks, no wrap (§2.4). Delegation and recursion families decompose theirs their own way (delegation-circuits.md §1).

8 Construction-time rules#

constraints::memory::check_memory refuses, naming the gate, a memory artifact with:

  1. provenance: a gate or output whose cone both names a memory slot (1–5) and reads a W column, computed forward with two flags a column, so a tuple times a copy of a W column two layers up is refused too;
  2. a root over W: outputs[READ_ROOT] or outputs[WRITE_ROOT] whose cone reads a W column at all, slot or not, which rule 1 does not see;
  3. a memory slot over anything but M, S and V: a gate carrying one reads no W, inner or cached column;
  4. an unconstrained mask: a leaf — a producing Quadratic of gate list 0 with constant 1 and a slot-weighted linear term — whose mask, that term's operand, is an M, W or S column with no m − m·m enforcing gate in gate list 0, or a virtual column but V[ram_live].

It runs beside CircuitArtifact::validate, whose laws it assumes, wherever a memory artifact is built (constraints::memory's assembly panics on a refusal) and in VerifyingKey::check. A W column is committed in a shard's own transcript, after the memory challenges, so a tuple or root over one is chosen after them and balances any trace. M columns precede the challenges and V columns are closed forms; S columns are admitted because they precede them too, bound by identity or, for the generic table, by the SRS digest (§6.1).

9 What the argument rests on#

Both sides of §4.2 are products of linear forms in (γ_M, α_addr, α_ts, α_val), one per distinct tuple, every tuple fixed before those are drawn (§6.1, §8). By Schwartz–Zippel they agree on unequal multisets with probability at most N/p, N < 2^67 (§4.2), and on equal ones §4.2's count gives:

  • One init per address of REG, PC, RAM and FIELD: the 33 boundary inits once per statement, and §3.5's windows, disjoint and of one height. A second init would let a stale read balance. An anchor space has no init: each invocation's answer, stamped 0, starts a path one request long (delegation.md §5).
  • Coverage. Every query lies on a path from an init, so nothing reaches an address no family initializes: the hole [0, 0x8000), where a null dereference does not balance, an unlisted window, a register above x31, a pc address but 0. A query reading its own write would balance with no init; the gap forbids it.
  • Consistency per address: on its one path every read returns the write before it, and the final tuple holds the last.
  • Initial values: the image's, by §3.4's refusal and §6.2's opening of S[0]; 0 in every zero window and the journal; the statement's input in its window (public-values.md §5). Advice is bound to nothing by design (public-values.md §6).
  • Order across rows, shards and families. The pc's path runs from T(PC, 0, 0, entry_pc) through every live row of every execution family, each m_pc = 1 row one edge, to the exit row (§5). That is pc continuity; it orders the rows by their pc writes 4·cycle, which are therefore distinct, so no cycle is proved twice. Nothing else carries it: there is no per-shard pc chaining, and a shard's time window ties to no row (proof.md §8).

Per address, the order is timestamp order, and it is program order: the pc query's gap puts consecutive pc writes at least 4 apart (§2.4), so each cycle's four timestamps precede the next cycle's whatever value cycle takes, and a row never reads an address before its predecessor's write there. An invocation rides its requesting row's cycle (delegation.md §5) and is ordered with it.

Auditeurs/Système de preuve

Lookups

Spécification normativedocs/spec/lookup.mdVoir en Markdown

Résumé

Comment les obligations de lookup sont prouvées : une identité LogUp par table et par shard, sommée par un arbre de fractions à l’intérieur de la passe GKR propre au circuit et vérifiée à sa racine. La page définit ce qu’affirme un canal, ses défis, les cinq tables, la façon dont une ligne désactivée recherche un tuple neutre, le dénominateur, l’arbre de fractions, les multiplicités, la vérification de la racine, la table générique compactée, le canal du décodeur qui lie chaque ligne au programme, les règles de construction, et ce sur quoi repose l’argument, y compris pourquoi chaque clé doit être bornée par sa famille.

Le texte normatif ci-dessous est tenu à jour en anglais, langue canonique de la spécification.

How a circuit's lookup obligations are proved: per shard, by one LogUp channel per table, each summed by a fraction tree inside the circuit's own GKR pass and checked at its root.

1 What a channel claims#

A lookup is LookupExpr { name, channel, selector, tuple }: a channel of constants::lookup_channel, a committed M, W or S column as selector, and a tuple of Linear expressions with literal coefficients over committed columns and the circuit's virtual tables. It holds on a row where the selector is 0, or

  • on a range channel, where its one expression's canonical integer is below 2^BITS[channel];
  • on a table channel, where its tuple is a row of the channel's one table: 1 to MAX_TUPLE = 7 expressions, the same number for every lookup of the channel.

memory.md §7 is the convention range obligations follow. A channel discharges all of a shard's lookups on it as one identity over the shard's rows y:

Σ_y Σ_l 1/(E_l(y) + g)  −  Σ_y mult(y)/(T(y) + g)  =  0

E_l(y) is lookup l's gated tuple (§4) and T(y) the table's row y, both compressed by β (§5); mult is the channel's multiplicity column (§7). A range table is the one column [0, 2^BITS).

2 The challenges#

slot challenge_slot value
6 LOOKUP_G g, drawn
7 LOOKUP_BETA β, drawn
8–12 LOOKUP_BETA_2 … LOOKUP_BETA_6 β^2 … β^6, derived
13 LOOKUP_DECODER_NEUTRAL g − Σ_{j<W} β^j, derived; W the decoder tuple's width

g and β are shard-local: the shard's transcript draws them, in that order under the tag LOOKUP_CHALLENGE (33), right after absorbing its witness commitments, multiplicities included (proof.md §4). M columns are committed in the global transcript the shard is seeded from and S columns are bound by identity or the SRS digest, so every column a channel reads is fixed before either challenge exists.

β^0 is the literal 1, so a one-column tuple names no slot. A gate coefficient is one literal or one slot (gkr.md §3), so each higher power is a slot of its own, computed by the verifier and never read from a proof (gkr_verify::insert_lookup_challenges, which reads W off the artifact's decoder lookup).

Selectors are boolean: CircuitArtifact::validate refuses a lookup whose selector no enforcing gate of gate list 0 holds to s − s·s = 0. The selector multiplies the tuple inside the denominator (§5), so a channel proves the gated tuple s·(e + o) + n is a table row, which is the obligation only at s ∈ {0, 1}. At any other s a scaled tuple is looked up instead: on a range channel, s = t·e⁻¹ lands any nonzero e on any table value t.

3 Tables#

channel id kind table, at row y width table_vars
TIMESTAMP 0 range V[range19]: y mod 2^19 1 19
RANGE16 1 range V[range16]: y mod 2^16 1 16
GENERIC 2 table, committed the packed table (§9) 3 0
DECODER 3 table, committed the family's decoded table (§10) 7 or 6 0
XOR8 4 table, virtual V[xor8_a], V[xor8_b], V[xor8_out]: y's low two bytes and their XOR 3 16

A virtual table is a closed form of the row index, never committed: the verifier evaluates its multilinear extension where the GKR pass ends (gkr_verify::virtual_at_point, gkr.md §2). Each is a weighted sum of the row's bits but V[xor8_out], Σ_{j<8} 2^j·(y_j + y_{j+8} − 2·y_j·y_{j+8}), which is exact because y ^ z = y + z − 2yz is multilinear. So XOR8 costs no commitment and nothing in the SRS digest.

constraints::lookup::table_vars is the fewest variables at which a table is complete: BITS for a range channel, 16 for XOR8, 0 for a committed table, a setup column at the circuit's own height. Below it a virtual table holds only part of its range, which costs completeness, not soundness. family_circuit returns None below the largest table_vars of a family's channels, so a key naming such a height fails to load (proof.md §7). On the height menu (program.md §7) a family carrying TIMESTAMP is at 2^20 or more, and one carrying RANGE16 or XOR8 at 2^16 or more. The packed table needs 2^18 rows (§9), and every family that reads it carries TIMESTAMP. Above table_vars a table repeats, which §7 makes harmless.

XOR8's tuple is three wide so that membership bounds each entry to [0, 256) on its own; a packed key x + 256·y would bound neither, (x, y) and (x + 256, y − 1) compressing alike. Every other bitwise operation on bytes is a linear form over its results (delegation-circuits.md §1).

4 Gated keys#

A lookup expression is evaluated on every row, so the selector sends a row whose key means nothing to a neutral tuple, which is a real table row:

gating channels gated position j neutral tuple
NoOffset TIMESTAMP, RANGE16, XOR8 s·e_j all zero
ZeroEntry GENERIC s·(e_0 + 1), then s·e_j the all-zero ZeroEntry row
MinusOne DECODER s·(e_j + 1) − 1 −1 in every column, a padding row

The + 1 keeps every real key of the packed table at 1 or above, so no real entry is the all-zero tuple a switched-off row looks up. A range table needs no offset, 0 being in range, and one would push 2^BITS − 1 out of it; XOR8's (0, 0, 0) is a true entry. A decoded table has no all-zero row, pc 0 being a valid pc, and its MINUS_ONE padding rows (program.md §5) are the neutral entry.

Each key a table channel looks up is bounded by the family that looks it up, because a channel proves membership and nothing more. A selected row whose key expression is −1 gates to the ZeroEntry, and an unbounded key reaches any sub-table of the packed table: an AND key a + AND_BASE with a unbounded lands on a U16GetSign row and proves a false AND. Families bound their keys with RANGE16 obligations or build them from bounded columns (shift-bitwise.md §3 and the other family pages); the decoder's key is §10's.

5 The denominator#

With s the selector, e_j = Σ_i c_{j,i}·x_{j,i} + k_j, and §4's offset o_j (1 or 0) and neutral value n_j (−1 or 0):

E + g  =  Σ_j β^j·(s·(e_j + o_j) + n_j)  +  g
       =  Σ_j β^j·s·e_j  +  Σ_j β^j·o_j·s  +  (g + Σ_j β^j·n_j)
T + g  =  Σ_j β^j·t_j  +  g

E + g is one Quadratic (constraints::lookup::row_denominator): each term of e_j the product (β^j·c)·s·x, each offset the linear term β^j·o_j·s, and the bracket the slot LOOKUP_G or, for the decoder, LOOKUP_DECODER_NEUTRAL. β^j·c is one coefficient only where β^0 = 1 makes it a literal or c = 1 makes it the slot, so position 0 takes any literal coefficients and constant and every later position weights its columns by 1 with no constant. T + g is one Linear over the table's columns (table_denominator).

6 The fraction tree#

A channel's leaf level is (num, den) pairs of gate-list-0 columns, P = (L + 1).next_power_of_two() of them for L lookups:

leaf num den
the table, first −mult T + g
each lookup, in artifact order 1 E_l + g
padding, up to P 0 1

Row-wise gate lists add sibling pairs, (n_a·d_b + n_b·d_a, d_a·d_b), until each row holds one pair; a tree shallower than the circuit's deepest copies itself up. Then trace_vars halving lists add the rows' pairs, TreeCross writing the numerator and TreeProduct the denominator (gkr.md §3). The circuit's outputs are the memory argument's read and write roots, then each channel's (num, den) in the order of its channel specs (crates/constraints/src/build.rs).

A channel costs 4P − 2 inner columns to reduce a row, 2 more per copy-up layer and 2 per halving list, and one committed column. P doubles each time L reaches a power of two.

The padding clause (gkr.md §4) asks a padding row to feed 1 into every product tree. A fraction tree is exempt: its identity is (0, 1), and a padding row is not idle in a channel but looks up the neutral tuple, which the multiplicity counts. checker::check_padding_identity exempts every column a TreeCross reads.

7 Multiplicities#

Each channel has one multiplicity column, a committed W column; a circuit's are its last W columns, in channel order. Row t counts the (row, lookup) pairs of the shard whose gated tuple is table row t's, switched-off rows included. A tuple at several table rows is credited to the lowest; every other copy holds 0 and contributes 0/(T + g). The count is over raw gated tuples, the column being committed before g and β exist (trace::build_multiplicities, which refuses a tuple no table row holds: the honest prover cannot balance it).

No gate or range check constrains the column, and soundness needs none. If a gated tuple v is in no table row, the left side of §1's identity, as a rational function of g, has a pole at −v whose residue is the number of lookups producing v: a positive integer below p, whatever the column holds.

8 The root check#

accept  iff  num = 0  and  den ≠ 0

on each channel's root pair, at step 9 of proof.md §6 (gkr_verify::channel_holds); a failure is VerifyError::Lookup { channel }. The GKR pass absorbs the pair before its first challenge and proves it (gkr.md §5). den is the product of every leaf denominator, and num = 0 means the sum vanishes only where den ≠ 0: one leaf (0, 0) — a table row whose T + g vanishes, counted 0 — makes the root (0, 0) whatever the other leaves hold. With g drawn after the columns that has probability at most fractions/|Fr|, and den ≠ 0 makes it a refusal.

9 The generic table#

One committed table of constants::generic_table::WIDTH = 3 columns, a key and two values, packing three sub-tables under disjoint key ranges (program::lookup_tables::generic_table):

row 0                      ZeroEntry    (0, 0, 0)
rows 1 ..= 2^16            AND          (AND_BASE + a + 1,    b,        a & b)        a, b < 2^8
rows 2^16+1 ..= 2^17       U16GetSign   (SIGN_BASE + h + 1,   h >> 15,  0)            h < 2^16
rows 2^17+1 ..= 2^17+32    ShiftPowers  (SHIFT_BASE + s + 1,  2^s,      2^(31 − s))   s < 32
rows above                 zero

AND_BASE = 0, SIGN_BASE = 256 and SHIFT_BASE = 65,792 put the keys at 1..=256, 257..=65,792 and 65,793..=65,824; a lookup's key expression is x + BASE, and the gating adds the 1. U16GetSign serves every sign an execution family computes, AND the bitwise operations of SHIFT_BITWISE and ATOMICS, ShiftPowers the shifts. The copower 2^(32 − s) is stored halved (SHIFT_COPOWER_BITS = 31), 2^32 not fitting a u32 column, and the two gates that read it carry the factor 2 (shift-bitwise.md §4). 131,105 rows in all (GENERIC_ROWS).

Its commitments are a constant of the ceremony. A Mercury commitment reads the evaluation table as coefficients (mercury.md §2) and the table is zero past its entries, so over 2^n rows it commits to the same three points for every n ≥ 18; generic_commitments(srs) computes them at 2^18 (GENERIC_LOG_HEIGHT). Every verifying key carries them once, as VerifyingKey::generic_table, whether or not a family reads the channel, and its SRS digest covers them (proof.md §3); program identity does not. A circuit that reads GENERIC names the table as its three setup columns after identity's (FamilyCircuit::reads_generic_table), and a shard's opening checks them against the key's points (proof.md §5).

10 The decoder channel#

The DECODER table is the family's decoded table, program::lookup_tuple(family)'s columns, as its first setup columns at its height, row i holding pc 2i (program.md §5); program identity commits them (program.md §8). Each execution family makes one lookup on it, imm absent for MUL_DIV and ATOMICS:

decode_row    selector m_pc    tuple (pc read value, next_pc, rs1, rs2, rd, [imm], extra_mask)

The key is the frame's own pc read (memory.md §2), so the cycle itself is bound to the program; the rest are the row's decoded columns, which the family's other gates read. The selector is the row's liveness, so a padding row looks up the MINUS_ONE tuple, which every decoded table holds, being taller than its last instruction.

The family's decoded_mask_bits gate ties the packed mask to boolean kind bits. That the bits are one-hot, and that a live row is an instruction at all, is the table's domain: its live rows hold one-hot masks and its padding rows −1, which no sum of kind bits reaches. Boolean columns looked up one by one would lose this: booleanity admits any subset of bits, the empty one included, and an all-zero mask makes every gate a kind selects vacuous.

11 Construction rules#

CircuitArtifact::validate enforces §1's form and widths, §2's selector rule and §5's coefficients wherever an artifact is built or loaded (gkr.md §4). When a circuit is assembled, constraints::lookup asserts that a channel has a lookup, that its multiplicity is a W column, that every lookup has its table's width, and that a range channel's table is the one its bound names (range_table) with BITS ≤ trace_vars; constraints::memory::frame_with_channels_artifact refuses an empty channel list, which would leave a frame's gap obligations discharged by nothing.

The discharge rule, constraints::lookup::check_discharge, at assembly and at every key load (VerifyingKey::check): every lookup is the denominator of exactly one gate-list-0 column, its numerator 1 directly before it; no column is two lookups'; each channel's (−mult, T + g) appears once. It matches by normalized expansion inside the cone below the channel's own root pair, so an obligation or table fraction in another channel's tree is refused, and the two range channels, which gate alike, are not confused. Which output pair is whose root, which columns are a table and which counts it is not in the artifact but in its ChannelSpecs, which a key carries in FamilyCircuit::channels and must hold as the registry's (circuits.md §1). checker enforces this rule and the lookup rules a second time, with code of its own (circuits.md §3).

The copower rule, constraints::lookup::check_copowers, run by every constructor that bounds a column through a copower. A bound x < p written as x·p′ < 2^32, p·p′ = 2^32, bounds nothing alone: p′ is a unit of Fr, so x = s·p′⁻¹ ranges over a coset of 2^32 values. Each such x therefore also carries a direct RANGE16 bound, as a halfword or as a high chunk and a remainder, under the same selector.

12 What it rests on#

  • Every gated tuple is a table row: §1's identity over challenges drawn after every column it reads, boolean selectors (§2), both root conditions (§8) and the GKR pass. The error is at most fractions/|Fr| for g, plus looked-up tuples × table rows × (width − 1)/|Fr| for a β collision: below 2^−190 at every menu height.
  • A lookup answers from its own sub-table: one width per channel (§11), disjoint key ranges and the + 1 (§9), and its family's bound on the key (§4).
  • A switched-off row costs nothing: its neutral tuple is a table row the multiplicity counts (§4).
  • The table is the intended one: the verifier's own closed form (§3), or a table bound by identity or by the SRS digest, as trustworthy as the channel the verifier took that from (program.md §8, srs.md §3).
  • Every declared obligation is discharged: the discharge rule over the registry's specs (§11).

The channel does not check the multiplicity column (§7), a key's bound (§4), or that a committed table holds its neutral row, a property of its values that no artifact states: a table without one stops the honest prover at trace::build_multiplicities.

Auditeurs/Système de preuve

La preuve

Spécification normativedocs/spec/proof.mdVoir en Markdown

Résumé

Comment les arguments se composent en un seul énoncé vérifié. La page définit PublicInputs et l’ordre de l’énoncé, le bloc et l’exactitude de son ensemble de shards, la transcription globale G1–G11, le condensé SRS, la transcription de shard S1–S6, l’ouverture groupée, les vérifications du vérificateur dans l’ordre avec la classe de chaque échec, la clé de vérification et ses règles de chargement, les fenêtres temporelles, et les formats de sérialisation, au niveau de l’octet, de chaque objet, y compris l’archive de preuve.

Le texte normatif ci-dessous est tenu à jour en anglais, langue canonique de la spécification.

What a verifier checks and the formats it reads, from the statement to the bytes. The memory argument, LogUp, GKR and Mercury are their own pages; this one is how they compose. crates/verifier-core (#![no_std]) implements everything here but step 12, the opening, which crates/verifier runs.

1 The statement#

A statement is a PublicInputs under a verifying key (§7): one execution of the key's program. It is proved by one ShardProof per statement shard, a (family, index) with index below the family's shard count, each verified against the same PublicInputs. A shard's proof establishes its own circuit and opening, and the reconciliation it joins reads the roots the statement claims for every other shard, which only their own proofs establish: a statement is verified when its proofs are exactly its shards and all pass, never by a subset.

Every entry point is (&VerifyingKey, proof, &PublicInputs): verifier::verify_shard, verifier::verify_block, verifier_core::reduce_shard. A verifier holds two values from a channel the prover does not control, the program identity and the SRS digest (§3), and compares them with the key's; the key itself may come from anyone (§7). The verifier CLI compares identity only (tools.md §6); host::verify(vk, block), which is verify_block(vk, block, block.statement()), compares neither and leaves its caller to check the statement's input, output and exit status too.

1.1 PublicInputs#

field
input: Vec<u8> the public input window's payload, at most PUBLIC_PAYLOAD_BYTES = 16,380 (public-values.md §3)
output: Vec<u8> the journal, the public output window's payload, as long
exit_status: u32 x10's final value
shard_counts: Vec<u32> one per family of the VmConfig, in its order, possibly 0
windows: Vec<u32> ZERO_WINDOWS' window ids, one per shard (memory.md §3.5)
boundary: BoundaryFinals the 64 register and pc boundary scalars (memory.md §4.1)
memory_commitments: Vec<Vec<[u8; 64]>> per statement shard, its M columns' commitments in layout order
memory_roots: Vec<[Fr; 2]> per statement shard, [read_root, write_root]

The first three are the claim; the rest is the execution's record, which the prover chooses. All of it but the roots and the exit status is absorbed before any challenge (§2).

1.2 Statement order#

verifier_core::statement_shards(config, counts):

(INIT_TEARDOWN, 0)
(ZERO_WINDOWS, 0) … (ZERO_WINDOWS, k − 1)
every other family of the VmConfig, ascending by id, shards 0 … count − 1 each

It orders memory_commitments, memory_roots, G8's groups and a block's proofs. The two leading families are ids 7 and 8, so the order is not ascending by id. A family with count 0 has no entry.

1.3 The block#

verifier_core::BlockProof { config, statement, shards } is one execution closed: the static VmConfig, the statement and one proof per statement shard, in statement order; it adds no evidence to the proofs'. The config and counts are public data of the proof, absorbed at G3 and G4, so the block carries both and check B1 holds them to the key's and the verifier's.

Shard-set exactness, BlockProof::shape, at decode and again in verify_block: one count per config family; the counts' total, summed in u64 before any list is built from them, equal to the numbers of proofs, commitment lists and root pairs; the proofs naming statement_shards in order. No (family, index) is missing, repeated or extra.

BlockProof::reconciliation is the cross-shard record set, a BlockReconciliation of one ShardRecord { family, shard_index, ts_window, memory_commitments, roots } per statement shard, assembled from the shard's proof (the window) and the statement (the rest).

2 The global transcript#

verifier_core::global_commit(vk, statement), run by the verifier in derive_global_phase and by the prover once every shard's M columns are committed (streaming.md §2): a fresh transcript, tag values in transcript.md §5.

# op tag message
G1 absorb PROTOCOL_SUITE [PROTOCOL_VERSION], 0
G2 absorb SRS_DIGEST [vk.srs_digest] (§3)
G3 absorb VM_CONFIG the config (program.md §7)
G4 absorb SHARD_COUNTS shard_counts
G5 absorb MEMORY_WINDOWS windows
G6 absorb PROGRAM_IDENTITY [vk.identity]
G7 absorb PUBLIC_INPUTS, bytes the 32 bytes of io_digest(input, output) (public-values.md §5)
G8 per family MEMORY_GROUP, COMMITMENT below
G9 absorb MEMORY_BOUNDARY the 64 boundary scalars
G10 squeeze ×4 MEMORY_CHALLENGE γ_M, α_addr, α_ts, α_val, challenge slots 1–4
G11 squeeze GLOBAL_STATE_DIGEST the global state digest

G3–G5 are verifier_core::absorb_statement_descriptor. G8 is one group per family of the config, in statement order, a family with count 0 included:

MEMORY_GROUP   [family, shard count]
COMMITMENT     per shard, ascending: its memory_commitments, one message of 4k limbs

A statement's or proof's points are absorbed as limbs (transcript.md §4) and decoded only at step 12.

Everything a memory tuple or the reconciliation reads precedes G10 (memory.md §6.1 says why for each). Two fields are not absorbed: memory_roots, which depend on the challenges and are bound by each shard's own GKR proof (step 10a), and exit_status, which step 10b holds to v_10, absorbed at G9. The digest seeds every shard (§4); a proof carries the digest it was seeded with (ShardProof::global_digest) and step 5 compares it with the replay, so a shard proof is for one statement under one key.

3 The SRS digest#

t ← Transcript::new()
t.append_bytes(SRS_VERIFIER, srs_verifier)           320 bytes, srs.md §5
append_g1_points(t, GENERIC_TABLE, generic_table)    the table's 3 points, one 12-limb message
srs_digest ← t.sample()                              one raw squeeze

verifier_core::srs_digest; GENERIC_TABLE is absorbed in this sponge and nowhere else, the points key column first. G2 absorbs the digest, so a proof is bound to the three points its pairings read and the table its GENERIC lookups read. Both are constants of the ceremony (lookup.md §9), so one digest serves every key. It does not cover the powers, which only a prover reads: an opening is checked against g2_tau whatever powers made the commitment.

A key's load recomputes the digest from the key's own points (§7), which shows they agree, not that they are the ceremony's, and identity binds neither (program.md §8). So the verifier compares vk.srs_digest with the ceremony's (srs.md §3). Without that comparison, whoever built the key chose τ, so can open anything, and chose the table every GENERIC lookup is held to.

4 The shard transcript#

Shard (family, index) runs a fresh sponge (verifier_core::shard_transcript), not a restored global one:

# op tag message
S1 absorb SHARD_SEED [global state digest, family, index]
S2 absorb SHARD_TS_WINDOW [ts_start, ts_end] (§8)
S3 absorb COMMITMENT the shard's W commitments, multiplicities included, one message
S4 squeeze ×2 LOOKUP_CHALLENGE g, then β (lookup.md §2)
S5 the GKR backward pass (gkr.md §5.2)
S6 the batch opening (§5): B1–B3 of mercury.md §5, then the sixteen steps of its §3

S4 is drawn for every shard, whether or not its circuit has a channel. Every challenge follows every commitment the circuit reads: M at G8, S through identity at G6 or the SRS digest at G2, W at S3, as GKR requires of its caller (gkr.md §5.1).

The circuit's external challenges (verifier_core::shard_challenges) are slots 1–4 from G10; for a window family, slot 5 at the window verifier_core::shard_window gives the shard (memory.md §3.3); then the lookup slots from g and β. Its outputs, the top layer, are the two memory roots and then each channel's (num, den) (lookup.md §6), 2 + 2c of them for c channels.

In the recursion format a shard commits M and W as stacks of 2^σ columns, and S6 opens with σ STACK_CHALLENGE squeezes extending the opening point (recursion.md §1.3). At σ = 0, the base format, there are none.

5 The opening#

After S5 every committed column has one claim, layer 0's, all at one point u (gkr.md §5.2). So there is nothing for a claim-merging sumcheck to merge, and S6 opens every column as one batch (mercury.md §5), one 704-byte Mercury proof a shard:

columns      the circuit's committed layout: M[0..], W[0..], S[0..]
commitments  M  PublicInputs.memory_commitments[the shard's position]
             W  ShardProof.witness_commitments
             S  VerifyingKey.setup_commitments[family], then VerifyingKey.generic_table
                when the circuit reads GENERIC (FamilyCircuit::reads_generic_table)
point        u, variable j at index j
values       layer 0's claims, ShardProof.gkr.layers[0].final_evals

Column i carries ρ^i, so this order is part of what is proved. Virtual columns are neither claimed nor opened: the verifier evaluates their closed forms. Taking S from the key is what makes the opening bind the columns identity commits, the decoded tables and the image column (memory.md §6.2), and the generic table the SRS digest covers.

reduce_shard ends at an OpeningClaim: these commitments, the point, the values and the live shard transcript. verify_shard spends it with pcs::batch_verify (step 12); a recursion node defers it (recursion.md §8.3).

6 Verification#

verifier::verify_shard(vk, proof, public) returns the first failure, in this order, as a VerifyError:

step class check
1 Statement one shard count per config family; the key's circuits are its config's families, in order, with one setup list each
2 Statement check_memory_windows (memory.md §3.5); input and output each at most PUBLIC_PAYLOAD_BYTES
3 Statement one root pair and one commitment list per statement shard, each list its family's M width; the total summed in u64 first
G1–G11 (§2)
4 Statement ts_start ≤ ts_end ≤ 2^38
5 Statement the replayed global state digest is proof.global_digest
6 Malformed (family, index) is a statement shard; the witness commitments and outputs have the circuit's counts
7 Constraint { layer } gkr_verify::verify over the shard transcript: LayerInconsistency { layer }; its ProofShape, OutputShape and MissingChallenge are Malformed
8 Constraint { layer: 0 } every base claim at one point
9 Lookup { channel } gkr_verify::channel_holds on each channel's root pair, in channel order (lookup.md §8)
10a MemoryArgument the proof's two roots are the statement's for its position
10c MemoryArgument a PUBLIC_INPUT or PUBLIC_OUTPUT shard's value column is the statement's string (public-values.md §5)
10b MemoryArgument every boundary timestamp below 2^38; v_10 = exit_status; gkr_verify::reconciles over every shard's roots and boundary_factors(challenges, vk.entry_pc, boundary) (memory.md §4.2)
11 — the opening claim (§5)
12 Opening the SrsVerifier, every commitment and the Mercury proof through their validating decoders, then pcs::batch_verify; any failure

Step 8 cannot fail on verify's output, whose base claims share layer 0's point; it states what step 11 relies on. Steps 1–3 hold the statement to the key before the replay indexes by it, so nothing a proof or statement carries makes the core panic, for a loaded key.

The split, by what each part reads (verifier_core):

function reads steps runs
derive_global_phase(vk, public) → GlobalChallenges key, statement 1–3, G1–G11 once a statement
verify_shard_local(vk, global, proof, public) → OpeningClaim and one ShardProof 4–10a, 10c, 11 once a shard
verify_global_memory(vk, global, public) key, statement, challenges 10b once a statement

GlobalChallenges is the four memory challenges and the digest. reduce_shard is the three in that order, verify_shard that and step 12; step 11 cannot fail, so 10b after it is 10b in place. Step 10b reads only vk.entry_pc, the boundary, the roots and the challenges, so a block runs it once; step 10a puts each shard into the product by holding the roots its GKR proof outputs to the statement's entry, and shard-set exactness makes every root there a verified shard's. verify_shard_local alone verifies no memory argument: without verify_global_memory it accepts shards, each valid, whose multiset does not close.

verifier::verify_block(vk, block, public):

class check
B1 Statement block.config is vk.config, and block.statement is public
B2 Statement derive_global_phase, once
B3 Statement BlockProof::shape (§1.3)
B4 Statement check_ts_windows over the records (§8)
B5 MemoryArgument verify_global_memory, once
B6 as verify_shard per shard, in statement order: verify_shard_local, then step 12

B1–B5 read no GKR proof or opening, so a statement that cannot reconcile is refused before any circuit runs, and the class can differ from verify_shard's: a change to anything G1–G9 absorb that B1–B4 admit moves the challenges, so the honest roots stop reconciling and verify_block answers MemoryArgument where verify_shard names the seed at step 5; a forgery that unbalances the multiset is MemoryArgument even where it also breaks a gate. A dropped shard fails B5: the truncated statement, re-proved honestly with its counts, lists and roots adjusted, passes B1–B4 and misses that shard's memory events on one side of the product.

In verify_shard's order the class names the fault: a tampered witness proved honestly, its multiplicities recounted, columns recommitted and statement rebuilt, fails at the gate (Constraint), table membership (Lookup) or multiset (MemoryArgument) it broke, which checker::TamperHarness asserts (circuits.md §3).

7 The verifying key#

7.1 Fields#

field
code_version: u32 constants::family::CODE_VERSION, 0
config: VmConfig the static shape (program.md §7)
entry_pc: u32 the image's entry pc
identity: ProgramIdentity program.md §8
setup_commitments: Vec<Vec<[u8; 64]>> identity's commitment lists, one per config family, in its order
srs_verifier: [u8; 320] the SrsVerifier (srs.md §5)
generic_table: [[u8; 64]; 3] the generic table's commitments, key column first, in every key (lookup.md §9)
srs_digest: Fr §3
circuits: Vec<FamilyCircuit> one per config family, in its order: the family, its CircuitArtifact and its ChannelSpecs (lookup.md §11)

A key carries every family's artifact, so its size is mostly its delegation families' (circuits.md §1).

7.2 Loading#

VerifyingKey::from_bytes decodes (§9), refuses bytes that are not the key's canonical encoding, and runs VerifyingKey::check, which refuses, in order:

  1. a VmConfig no derivation produces (VmConfig::from_bytes of its own bytes), or a code_version other than CODE_VERSION;
  2. a setup list count other than the config's family count;
  3. an identity that identity_digest(code_version, config, entry_pc, setup_commitments) does not reproduce;
  4. an srs_digest that srs_digest(srs_verifier, generic_table) does not reproduce;
  5. a circuit count other than the family count; then, family by family: a circuit for another family; a height the registry has no circuit for; a circuit, artifact or channel specs, other than config.circuit(family, trace_vars), the registry of the config's format (circuits.md §1); an artifact failing CircuitArtifact::validate, constraints::memory::check_memory or constraints::lookup::check_discharge; a setup list whose length, plus 3 if the circuit reads GENERIC, is not the artifact's S count; and GENERIC specs naming anything but the 3 setup columns after identity's, §5's order, which no registry circuit fails.

verifier::load_verifying_key then decodes every curve point: the SrsVerifier's three, each setup commitment and each generic-table commitment, through the validating readers. The circuits are held to the registry because nothing else binds them: identity binds the program, not the circuit that proves it.

A key from an untrusted source. Every field is recomputed from or compared with one of the verifier's two trusted values (§1), or fixed by the code: config, entry_pc and the setup lists through identity; srs_verifier and generic_table through the SRS digest; code_version and the circuits by the verifier's own registry. So a key may come from the prover, provided both comparisons are made.

Validation runs once, at load; verify_shard and verify_block assume a loaded key. On one edited in memory a changed config or circuit list is still refused as Statement, but an edit inside a circuit may go unnoticed.

prover::ProverSetup::new(program, srs) builds the key: each family's circuit from VmConfig::circuit and fill from prover::family_fill (ProverError::Unregistered if either is missing), identity's and the generic table's commitments over srs, then check (ProverError::Key). srs needs as many powers as the tallest family has rows, and 2^18 for the generic table (program::lookup_tables::GENERIC_LOG_HEIGHT); fewer panics.

8 Time windows#

Each shard claims [ts_start, ts_end) (ShardProof::ts_window): the slice of the clock (execution-trace.md §1) its rows write in, their reads reaching back before it. S2 absorbs it before the witness commitments, so a proof made under one window fails under another; step 4 holds it to ts_start ≤ ts_end ≤ 2^38 and nothing more.

verifier_core::check_ts_windows (B4), over the records in statement order: within each cycle-owning family (constants::family::CYCLE_OWNING, the execution families 0–6), every window is non-empty and no shard's ts_end exceeds the next shard's ts_start; a family's records are consecutive and ascending, so neighbours suffice. It is per family because families interleave — ADD_SUB_LUI_AUIPC may own cycles 1 and 3 and JUMP_BRANCH_SLT cycle 2 — and every other family is exempt: a window family's rows are words, a delegation family's invocations at their requesting cycles (delegation.md §8).

The prover reads a window off the shard's committed M[0] cycle column (ts_window, crates/prover/src/lib.rs): [4·c_0, 4·c_max + 4), c_0 row 0's cycle and c_max the largest, padding rows carrying 0, for cycle-owning and delegation families alike. Window families claim verifier_core::TRIVIAL_TS_WINDOW = [0, 2^38).

A window binds nothing. No gate ties it to the rows committed under it, so a prover may claim any windows the rule admits; cross-shard order, cycle uniqueness and pc continuity are the memory multiset's alone (memory.md §9). B4 checks the shape of the shard plan and adds nothing to soundness.

9 Wire forms and the proof archive#

verifier_core::wire: integers little-endian; an Fr its 32 canonical bytes (primitives.md §1), refused at or above p; a G1 its 64 bytes (primitives.md §3), opaque to the core; bytes a u32 length then the bytes; list<T> a u32 count then the items; T[k] exactly k items, no count. Every decoder is total: it refuses a count the remaining bytes cannot hold, so it reserves nothing an untrusted length asks for, and refuses trailing bytes.

PublicInputs   input bytes, output bytes, exit_status u32,
               shard_counts list<u32>, windows list<u32>,
               boundary Fr[64]                     memory.md §4.1's order and ranges
               memory_commitments list<list<G1>>, memory_roots list<Fr[2]>

ShardProof     family u32, shard_index u32, ts_start u64, ts_end u64, global_digest Fr,
               witness_commitments list<G1>, outputs list<Fr>,
               gkr list<(rounds list<Fr[4]>, final_evals list<Fr>)>       transition 0 first
               opening u8[704]                     pcs::MercuryProof, mercury.md §4

BlockProof     config bytes                        VmConfig, program.md §7
               statement bytes                     PublicInputs
               shards list<bytes>                  each a ShardProof; then BlockProof::shape

VerifyingKey   code_version u32, config bytes, entry_pc u32, identity Fr,
               setup_commitments list<list<G1>>, srs_verifier u8[320], generic_table G1[3],
               srs_digest Fr,
               circuits list<(family u32, artifact bytes,          CircuitArtifact, gkr.md §4.1
                              channels list<(channel u32, table list<Address>,
                                             multiplicity Address)>)>
Address        tag u8 (0 M, 1 W, 2 S, 3 V), index u32; a V's index is its gkr.md §2.1 kind tag

BlockReconciliation   list<(family u32, shard_index u32, ts_start u64, ts_end u64,
                            memory_commitments list<G1>, read_root Fr, write_root Fr)>

A ShardProof's lengths are fixed by its key and family, and steps 6–7 hold them: transition k carries n_{k+1} rounds and w_k claims, twice that if halving (gkr.md §5.5). For a base-format circuit at 2^n with W witness, C committed and I inner columns, O outputs, and R row-wise lists before its n halving ones, that is

772 + 64·W + 32·O + 8·(R + n) + 128·(R·n + n(n − 1)/2) + 32·(C + I + O·(n − 1))   bytes

which circuits.md §1 tabulates per family.

The proof archive. verifier::proof_archive::write_proof(dir, stem, vk, block), re-exported as host::proof_archive, writes four files, each a bare to_bytes with no header of its own:

<stem>.vk         VerifyingKey
<stem>.identity   the key's identity: its 32 bytes in order, 64 lowercase hex digits, a newline
<stem>.public     PublicInputs: the block's own statement
<stem>.block      BlockProof

read_proof(dir, stem) is the inverse, each file through its type's decoder and the key through load_verifying_key. .identity records what the run claimed, and read_proof returns it unchecked: a verifier's identity comes from its own channel (§1). .public repeats the statement .block carries, for the CLI, which takes it as a file (tools.md §6).

Auditeurs/Familles d'instructions

La famille ADD_SUB_LUI_AUIPC

Spécification normativedocs/spec/add-sub.mdVoir en Markdown

Résumé

Le circuit de la famille 0, colonne par colonne et porte par porte. Outre add, sub, addi, lui, auipc et fence, chaque ecall est une ligne de cette famille : son circuit prouve donc aussi la sortie du programme et le côté demande de chaque appel de délégation. La page énumère ses colonnes engagées, ses 63 portes de contrainte, ses 15 obligations de lookup, ce qui la rend solide, et ses limites.

Le texte normatif ci-dessous est tenu à jour en anglais, langue canonique de la spécification.

add, sub, addi, lui, auipc, ecall, ebreak and fence, compressed forms included, are family 0, one executed instruction a row; constraints::add_sub::artifact is its circuit. Every ecall is a row of it, so the circuit also proves the exit and the request side of every delegation call. This page specifies what it adds beside the memory frame (memory.md §2).

1 Columns#

The decoded tuple is pc next_pc rs1 rs2 rd imm extra_mask (program.md §5), the mask one-hot over the kinds system addi auipc add sub lui in bit order (constants::extra_mask::add_sub_lui_auipc). ecall, ebreak and fence share the system kind, with imm 0, 1 and 2 (constants::extra_mask::system_code); elsewhere imm is what the instruction adds — addi's sign-extended immediate, lui's and auipc's shifted left by 12, 0 for add and sub — and a register field the instruction lacks is 0.

M[0..26] and W[0..8] are the frame of the five queries pc rs1 rs2 rd deleg, and M[26], deleg_space, is the requested delegation type's anchor address space, 0 on a row requesting none (memory.md §2). The family adds these columns, and reads V[range19] and V[range16]:

column name
W[8..14] decoded_next_pc, decoded_rs1, decoded_rs2, decoded_rd, decoded_imm, decoded_mask the claimed decoded row
W[14..20] kind_system … kind_lui b_k, the mask's bits
W[20], W[21] is_ecall, is_fence the system kind, split by its code
W[22..28] is_deleg_<f>, f = 9, 10, 11, 15, 16, 17 d_t: a request of delegation type t, family f
W[28], W[29] wrap, rd_hi the sum's carry or the difference's borrow; sel's high halfword
W[30], W[31] pc_wrap, next_pc_hi next_pc's wrap and high halfword
W[32..35] mult_timestamp, mult_range16, mult_decoder one multiplicity a channel
S[0..7] table_pc … table_extra_mask the decoded table

Below, m_q, a_q, ts_q and v_q are query q's mask, address, read timestamp and read value; pc and next_pc the pc query's read and write values; sel is rd_selected (W[7]), the value the frame writes to a nonzero rd. N_t and tag_t are type t's ecall number and anchor space, the first six rows of constants::delegation::TYPES in order (delegation.md §3); is_exit = is_ecall − Σ_t d_t; 93 is constants::ecall::EXIT and HALT_PC is 1 (memory.md §5).

The family's fill (prover::family_fill, crates/prover/src/fill.rs) writes sel as the computed value even where rd = x0.

2 Gates#

63 enforcing gates, all in gate list 0, each of degree at most 2 and 0 on the all-zero row: the frame's eleven (memory.md §2) and these 52, in artifact order, each held to 0:

gate expression
kind_<k>_boolean, six b_k − b_k²
decoded_mask_bits Σ_k 2^k·b_k − decoded_mask
is_ecall_boolean, is_fence_boolean y − y²
system_split is_ecall + is_fence − b_system
ecall_code is_ecall·decoded_imm
fence_code is_fence·(decoded_imm − 2)
per type: is_deleg_<f>_boolean, deleg_<f>_is_an_ecall, deleg_<f>_number d_t − d_t²; d_t·(1 − is_ecall); d_t·(v_rs1 − N_t)
ecall_is_exit is_exit·(v_rs1 − 93)
rs1_mask_rule m_rs1 − m_pc·(b_add + b_sub + b_addi + is_ecall)
rs2_mask_rule m_rs2 − m_pc·(b_add + b_sub + is_ecall)
rd_mask_rule m_rd − m_pc·(b_add + b_sub + b_addi + b_auipc + b_lui + is_ecall)
deleg_mask_rule m_deleg − m_pc·Σ_t d_t
rs1_addr_rule m_rs1·(a_rs1 − decoded_rs1 − 17·is_ecall)
rs2_addr_rule, rd_addr_rule m_q·(a_q − decoded_q − 10·is_ecall)
rs1_value_masked, rs2_value_masked v_q − m_q·v_q
add_addi_auipc (b_add + b_addi + b_auipc)·(v_rs1 + v_rs2 + decoded_imm − sel − 2^32·wrap) + b_auipc·pc
sub b_sub·(v_rs1 − v_rs2 − sel + 2^32·wrap)
lui b_lui·(decoded_imm − sel)
exit_status is_exit·(v_rd − sel)
deleg_writes_no_register m_deleg·sel
deleg_read_ts_zero, deleg_read_value_zero m_deleg·ts_deleg; m_deleg·v_deleg
deleg_addr_rule m_deleg·(a_deleg − v_rs2)
deleg_space_rule deleg_space − Σ_t tag_t·d_t
wrap_boolean, pc_wrap_boolean y − y²
next_pc_rule next_pc + 2^32·pc_wrap − (1 − is_exit)·decoded_next_pc − is_exit·HALT_PC

N_t and tag_t are literals read from constants::delegation::TYPES, so the base circuit depends on the registry's first BASE_TYPES = 6 rows and on no row appended after them. The recursion format's circuit, add_sub::recursion_artifact, carries a selector and its three gates for each of the ten types, the columns after them shifted by four, and in place of deleg_writes_no_register deleg_a0_rule, Σ_{t<6} d_t·sel + Σ_{t≥6} d_t·(sel − v_rs2 − 4·words_t) with words_t the type's frame length: a recursion type's request leaves a0 past its frame (recursion.md §1.4).

3 Lookups#

15 obligations on three channels, none of them GENERIC, so the setup columns are the decoded table alone: the frame's ten TIMESTAMP gaps, two a query under its mask (memory.md §2), and five under m_pc:

lookup channel tuple
rd_hi_range, rd_lo_range RANGE16 rd_hi; sel − 2^16·rd_hi
next_pc_hi_range, next_pc_lo_range RANGE16 next_pc_hi; next_pc − 2^16·next_pc_hi
decode_row DECODER pc, decoded_next_pc, decoded_rs1, decoded_rs2, decoded_rd, decoded_imm, decoded_mask

The channels, in output order (add_sub::channels), are TIMESTAMP over V[range19], RANGE16 over V[range16] and DECODER over S[0..7].

4 Why it is sound#

On a live row (m_pc = 1) decode_row makes the claimed row the table's at pc, so exactly one b_k is 1 (lookup.md §10). The mask rules make each query present exactly where the row's kind or request makes it (execution-trace.md §4, §6). The address rules make a register query the decoded register, or on an ecall row, whose decoded registers are 0, a7 (17) for rs1 and a0 (10) for rs2 and rd. The _value_masked gates make an absent operand read 0, which lets one gate serve three sums: an addi or auipc row's v_rs2, and an auipc row's v_rs1, would otherwise be free addends, and add's imm is the table's 0.

Read values are words (memory-ops.md §5), sel is a word by its range pair and wrap is boolean, so each arithmetic gate is an identity over ℤ with one solution: the sum mod 2^32 and its carry, the difference mod 2^32 and its borrow, or imm. Without the pair, a sum at or above 2^32 would satisfy the gate with wrap = 0 and reach a register. The frame's x0 rule then writes sel or discards it.

next_pc is a word by its range pair and is decoded_next_pc — the table's fall-through, so a compressed instruction advances by 2 (program.md §5) — or HALT_PC on the exit row, less 2^32·pc_wrap. Both are far below 2^32, so pc_wrap = 0 on every live row.

On a system row system_split sets exactly one of is_ecall and is_fence, and the code gates make it the one imm names; ebreak's code 1 satisfies neither, so an ebreak row is unprovable. A fence row makes no query but the pc's and falls through. Off a system row both bits are 0, and so, by deleg_<f>_is_an_ecall, is every d_t.

A set d_t forces is_ecall = 1 and a7 = N_t. The numbers are pairwise distinct and none is 93, const assertions beside the circuit, so at most one d_t is set, is_exit is 0 or 1, and an ecall row is the exit, with a7 = 93, or a request of exactly one type; no other a7 passes.

  • The exit row writes back the a0 it read (exit_status), so x10's final value is the status the statement carries (proof.md §6, step 10b), and writes HALT_PC, after which no row runs (memory.md §5).
  • A request row falls through, writes 0 to a0, and makes the mirror query at the frame base it read from a0, in the space deleg_space names, reading timestamp 0 and value 0. Those three zeroings pair it one-to-one with an invocation of its type (delegation.md §5); the mirror's write value is free here, and what the call computed is the invoked family's circuit (delegation-circuits.md). deleg_space is an M column because a memory leaf may read no W column (memory.md §8); deleg_space_rule ties it to the selectors.

A row with m_pc = 0 is bound to no table row and its kind bits are free; the arithmetic gates are gated by those bits alone, m_pc times a bit being degree 3. That is harmless: the mask rules zero the row's other four masks and every lookup is off, so it adds no memory tuple. On a live row, wrap outside the four sums and sel on a fence row are free, and nothing reads them.

5 Limits#

  • An ecall whose a7 is neither 93 nor a type the format's circuit knows has no proof. The emulator answers an unassigned number -ENOSYS and continues (ecall-abi.md §5); the fill refuses that trace, naming the cycle.
  • An ebreak has no proof; it is fatal in the emulator (execution-trace.md §10).
  • No row touches RAM: an ecall row reads a7 and a0 and writes a0, and a request's operands travel in the invoked family's frame (delegation.md §4).

Auditeurs/Familles d'instructions

La famille JUMP_BRANCH_SLT

Spécification normativedocs/spec/jump-branch-slt.mdVoir en Markdown

Résumé

Le circuit de la famille 1 : les instructions set-less-than, les six branchements, jalr et jal. La page spécifie les colonnes, le gadget de test de nullité et le gadget de comparaison, dans lequel un seul écart vérifié par intervalle détermine l’ordre signé et non signé sans table de comparaison, les portes, dont l’unique règle next_pc avec sa vérification de parité, les lookups, et pourquoi le circuit admet exactement le comportement de l’ISA.

Le texte normatif ci-dessous est tenu à jour en anglais, langue canonique de la spécification.

The circuit of slti, sltiu, slt, sltu, the six branches, jalr and jal: what it adds beside the memory frame every execution family carries (memory.md §2), and the two gadgets other families reuse (§3). One comparison settles signed and unsigned order for the branches and the slt kinds alike. The circuit is constraints::jump_branch_slt::artifact (crates/constraints/src/jump_branch_slt.rs); prover::family_fill writes its witness.

1 What the circuit reads from the decoded table#

The tuple is pc next_pc rs1 rs2 rd imm extra_mask (program.md §5). next_pc is the fall-through, seq below; imm is the two's-complement word of the value the instruction uses: the sign-extended immediate of slti and sltiu (which sltiu compares unsigned), a branch's or jal's displacement, jalr's offset. extra_mask is one-hot over constants::extra_mask::jump_branch_slt:

bit    0     1      2    3     4    5    6    7    8     9     10    11
kind   slti  sltiu  slt  sltu  beq  bne  blt  bge  bltu  bgeu  jalr  jal

The legal masks are these twelve one-bit values, jump_branch_slt::LEGAL_MASKS; rd = x0 is the table's rd, not a mask. The circuit commits the twelve bits b_k, and every signal it needs is a linear form over them: the signed-comparison flag sc = b_slti + b_slt + b_blt + b_bge, the compared immediate (b_slti + b_sltiu)·imm, so that a branch's displacement never reaches the comparison, and the branch weights of taken_rule (§4).

2 Columns#

The frame is M[0..21] and W[0..7], over the queries pc rs1 rs2 rd at slots 0–3 (memory.md §2). Its W[6], rd_selected (sel below), holds the value the instruction computes, which the x0 rule masks into the write. The circuit adds:

column name value
W[7..13] decoded_next_pc … decoded_mask the claimed decoded row after pc
W[13..25] kind_slti … kind_jal the bits b_k, in §1's order
W[25] cmp_rhs the right operand, rs2 + (b_slti + b_sltiu)·imm
W[26], W[27] rs1_hi, rs1_sign rs1 >> 16, rs1 >> 31
W[28], W[29] cmp_rhs_hi, cmp_rhs_sign the same of cmp_rhs
W[30] lt rs1 < cmp_rhs, signed where sc = 1
W[31], W[32] cmp_gap, cmp_gap_hi (rs1 − cmp_rhs) mod 2^32, and its high halfword
W[33], W[34] eq, eq_inv [rs1 = cmp_rhs] on a live row; the difference's inverse
W[35] taken a taken branch
W[36] jalr_drop bit 0 of rs1 + imm on a jalr row
W[37] pc_wrap the carry out of whichever sum next_pc is
W[38], W[39] next_pc_hi, rd_hi next_pc >> 16, sel >> 16
W[40..44] mult_timestamp … mult_decoder one multiplicity per channel, in channel order
S[0..7] table_pc … table_extra_mask the decoded table, which program identity binds
S[7..10] generic_key … generic_result the packed table (lookup.md §9)
V[range19], V[range16] the TIMESTAMP and RANGE16 tables

21 M, 44 W and 10 S columns, 75 committed; 42 enforcing gates, the frame's 10 and §4's 32; 22 lookups: 8 TIMESTAMP, 11 RANGE16, 2 GENERIC, 1 DECODER, counts artifact asserts.

3 The gadgets#

constraints::gadgets returns gates and lookups as data. is_zero also builds the frame's x0 rule (memory.md §2) and MUL_DIV's zero tests (mul-div.md); the comparison also orders ATOMICS' minimum and maximum (memory-ops.md §6).

3.1 is_zero(x, inv, z, enable)#

x·inv + z − enable = 0          x = Σ c_i·x_i, a linear form
z·x = 0

With enable boolean, which the caller establishes, these force z = enable·[x = 0]: at x ≠ 0 the second gives z = 0 and the first inv = enable/x; at x = 0 the first gives z = enable. So z is boolean with no gate of its own, and enable = 0 gives z = 0, which keeps the all-zero row valid.

3.2 The comparison#

Comparison names one comparison lhs < rhs by its columns, its lookups' selector, and the kind bits signed whose sum is sc, which the caller holds to 0 or 1 on a selected row. comparison returns, for x each of lhs, rhs and gap:

name kind expression
<p>_order gate lhs − rhs − 2^32·sc·lhs_sign + 2^32·sc·rhs_sign + 2^32·lt − gap
<p>_lt_boolean gate lt − lt²
<p>_<x>_hi_range, <p>_<x>_lo_range RANGE16 x_hi; x − 2^16·x_hi
<p>_lhs_get_sign, <p>_rhs_get_sign GENERIC (x_hi + SIGN_BASE, x_sign, 0)

The range pairs make lhs, rhs and gap words and each x_hi the true high halfword (memory.md §7), which keeps each sign key inside U16GetSign's range (lookup.md §4), so each sign is its operand's bit 31. Let D = lhs − rhs − 2^32·sc·(lhs_sign − rhs_sign): both operands read in two's complement where sc = 1, so mixed signs are no case split, and D ∈ (−2^32, 2^32). The gate says gap = D + 2^32·lt, and only lt = [D < 0] puts gap in [0, 2^32): at D ≥ 0, lt = 1 puts it at 2^32 or above; at D < 0, lt = 0 makes it a negative field element. So the range check on gap carries the order, and no comparison table exists; the honest gap is (lhs − rhs) mod 2^32 whatever sc is. Both gates are ungated, since a selector would make the order gate degree 3, and every row satisfies them with the gap its own values give. comparison_equation(c, word_bits) builds the order gate at any width to 32, and the row suite evaluates it at 6 bits over every operand pair, signed and unsigned, finding exactly one (lt, gap), the ISA's.

4 Gates#

After the frame's ten in gate list 0, with m_q, a_q, v_q query q's mask, address and read value, and pc, next_pc the pc query's read and write:

gate polynomial
kind_<k>_boolean ×12 b_k − b_k²
decoded_mask_bits Σ_k 2^k·b_k − decoded_mask
rs1_mask_rule m_rs1 − m_pc·(Σ_k b_k − b_jal)
rs2_mask_rule m_rs2 − m_pc·(b_slt + b_sltu + the six branch bits)
rd_mask_rule m_rd − m_pc·(b_slti + b_sltiu + b_slt + b_sltu + b_jalr + b_jal)
<q>_addr_rule, for rs1, rs2, rd m_q·(a_q − decoded_q)
<q>_value_masked, for rs1, rs2 v_q − m_q·v_q
cmp_rhs_rule cmp_rhs − v_rs2 − (b_slti + b_sltiu)·imm
cmp_order, cmp_lt_boolean §3.2: lhs = v_rs1, rhs = cmp_rhs, signed the bits of sc
eq_inverse, eq_at_nonzero §3.1: x = v_rs1 − cmp_rhs, z = eq, enable = m_pc
taken_rule taken − w_1 − w_eq·eq − w_lt·lt
taken_boolean, jalr_drop_boolean, pc_wrap_boolean x − x²
next_pc_rule §5's equation
rd_value_rule sel − (b_jal + b_jalr)·seq − (b_slti + b_sltiu + b_slt + b_sltu)·lt

The branch weights are w_1 = b_bne + b_bge + b_bgeu, w_eq = b_beq − b_bne and w_lt = b_blt + b_bltu − b_bge − b_bgeu. Every gate has degree at most 2 and is 0 on the all-zero row, which artifact asserts.

4.1 Lookups#

After the frame's 8 TIMESTAMP obligations, all under m_pc:

lookup channel expression
cmp_<x>_hi_range, cmp_<x>_lo_range ×3 RANGE16 §3.2 over v_rs1, cmp_rhs, cmp_gap
cmp_lhs_get_sign, cmp_rhs_get_sign GENERIC §3.2
rd_hi_range, rd_lo_range RANGE16 rd_hi; sel − 2^16·rd_hi
next_pc_hi_range, next_pc_lo_range RANGE16 next_pc_hi; next_pc − 2^16·next_pc_hi
next_pc_even RANGE16 2^−1·next_pc − 2^15·next_pc_hi
decode_row DECODER pc and W[7..13] (lookup.md §10)

The channels, in output order, are TIMESTAMP on V[range19], RANGE16 on V[range16], GENERIC on S[7..10] and DECODER on S[0..7]. next_pc_even is the low halfword lo halved, (lo + p)/2 and far above 2^16 when lo is odd. Because it scales next_pc, the constructor runs lookup::check_copowers (lookup.md §11) over (next_pc, m_pc).

5 Why it is sound#

On a live row, m_pc = 1, the decoder lookup makes the claimed tuple the table's row at pc, so pc is even, seq is below 2^24 and exactly one b_k is 1 (lookup.md §10). The mask and address rules make the frame's queries the instruction's (execution-trace.md §4): jal reads nothing, and a branch has no rd query, so nothing it computes is written. An absent operand reads 0, so cmp_rhs is rs2 or the immediate, never their sum, and the comparison's pairs make both operands words. So lt is the ISA's order (§3.2), eq its equality (§3.1), and taken its branch decision: eq on beq, 1 − eq on bne, lt on blt and bltu, 1 − lt on bge and bgeu, and 0 off the branches, every term of taken_rule carrying a branch bit. taken is a committed bit because, inlined, taken·(pc + imm) would be degree 3.

next_pc is held by one gate:

next_pc + 2^32·pc_wrap = (1 − taken − b_jal − b_jalr)·seq
                       + (taken + b_jal)·(pc + imm)
                       + b_jalr·(v_rs1 + imm − jalr_drop)

At most one of taken, b_jal, b_jalr is 1, so one sum is selected, and one wrap bit outside the selectors serves all three: imm is a two's-complement word, so every backward branch and jump wraps, not only jalr. With next_pc an even word and pc_wrap, jalr_drop boolean:

  • the default arm is seq, below 2^24, so pc_wrap = 0;
  • pc + imm and v_rs1 + imm are below 2^33, so one wrap bit holds the carry, uniquely;
  • on jalr, v_rs1 + imm − jalr_drop − 2^32·pc_wrap is a unique even word, (rs1 + imm) mod 2^32 with bit 0 cleared; a false jalr_drop makes next_pc odd or negative.

A branch's or jal's target is even unchecked, pc and imm both being even. Evenness is what keeps the family off HALT_PC = 1 (memory.md §5): without next_pc_even, a jalr whose rs1 + imm ≡ 1 keeps bit 0 and writes HALT_PC with every other gate and lookup holding, and a program that would crash by jumping to address 0 is proven to exit cleanly.

The link is seq, a table value and not a sum, so it has no wrap bit; the rd pair range-checks it and lt like every register write, and the x0 rule masks both at x0. A target needs no check of its own: at an address holding no instruction, the next row's decoder lookup fails whatever family claims the row, no table holding a live row there (lookup.md §10).

On a padding row, m_pc = 0, the mask rules zero every query mask, eq is 0 and every lookup is off, so the row reaches no memory event whatever its free bits hold.

Auditeurs/Familles d'instructions

La famille SHIFT_BITWISE

Spécification normativedocs/spec/shift-bitwise.mdVoir en Markdown

Résumé

Le circuit de la famille 2 : les six décalages et les opérations bit à bit and, or et xor. Un décalage dans l’une ou l’autre direction est un seul produit avec une puissance de deux obtenue par lookup dans la table générique; AND correspond à quatre lookups d’octets, et OR et XOR sont des formes linéaires sur le résultat de AND. La page spécifie les colonnes, les tables, pourquoi chaque clé doit porter sa propre borne, la vérification de copuissance, les portes et les lookups, et l’argument de solidité.

Le texte normatif ci-dessous est tenu à jour en anglais, langue canonique de la spécification.

The circuit of the shifts sll, slli, srl, srli, sra, srai and the bitwise and, andi, or, ori, xor, xori, one family, beside the memory frame (memory.md §2). A shift either way is one product with a looked-up power of two; AND is four byte lookups, and OR and XOR are linear forms over it. The circuit is constraints::shift_bitwise::artifact (crates/constraints/src/shift_bitwise.rs); prover::family_fill writes its witness.

1 What the circuit reads from the decoded table#

The tuple is pc next_pc rs1 rs2 rd imm extra_mask (program.md §5), next_pc the fall-through, seq below. imm is the shamt of slli, srli and srai, below 32 because the decoder refuses shamt[5] on RV32; the sign-extended immediate, as a word, of andi, ori and xori; and 0 on a register form. extra_mask is one-hot over constants::extra_mask::shift_bitwise, the legal masks its twelve one-bit values (shift_bitwise::LEGAL_MASKS):

bit    0     1     2     3     4    5     6    7    8    9    10   11
kind   slli  xori  srli  srai  ori  andi  sll  xor  srl  sra  or   and

The second operand of all twelve is src2 = rs2 + imm: an immediate form has no rs2 query, so rs2 reads 0, and a register form's imm is 0. One addend is always zero, so the sum needs no wrap bit, and an immediate never enters the rs2 column the memory argument ties. The circuit commits the twelve bits b_k, and its flags are linear forms over them:

left    b_slli + b_sll
right   b_srli + b_srai + b_srl + b_sra
arith   b_srai + b_sra
t1      b_or + b_ori + b_xor + b_xori
t2      b_and + b_andi − b_or − b_ori − 2·(b_xor + b_xori)

Only the two halves' sums, f_shift and f_bitwise, are columns: each selects lookups, and a selector is a committed boolean (lookup.md §2).

2 Columns#

The frame is M[0..21] and W[0..7], over the queries pc rs1 rs2 rd at slots 0–3 (memory.md §2); its W[6], rd_selected (sel below), holds the value the instruction computes, which the x0 rule masks into the write. The circuit adds:

column name value
W[7..13] decoded_next_pc … decoded_mask the claimed decoded row after pc
W[13..25] kind_slli … kind_and the bits b_k, in §1's order
W[25], W[26] f_shift, f_bitwise the two halves
W[27], W[28] rs1_hi, rs1_sign rs1 >> 16, rs1 >> 31
W[29] src2_hi src2 >> 16
W[30] amount src2 & 31
W[31], W[32] pow, copow 2^amount, 2^(31 − amount) on a shift row
W[33], W[34] high, high_hi src2 >> 5, and its high halfword
W[35] se arith·rs1_sign
W[36], W[37] shift_in, shift_prod both directions' multiplicand, and shift_in·pow
W[38], W[39] ovf, ovf_hi a left shift's discarded high word, and its high halfword
W[40], W[41] residue, residue_hi a right shift's remainder, and its high halfword
W[42], W[43] scaled, scaled_hi residue·2^(32 − amount), and its high halfword
W[44..52] byte_a<j>, byte_b<j> the bytes of rs1, then of src2, low first
W[52..56] byte_and<j> their bytewise AND
W[56] rd_hi sel >> 16
W[57..61] mult_timestamp … mult_decoder one multiplicity per channel, in channel order
S[0..10], V[range19], V[range16] as in jump-branch-slt.md §2

21 M, 61 W and 10 S columns, 92 committed; 48 enforcing gates, the frame's 10 and §4's 38; 39 lookups: 8 TIMESTAMP, 24 RANGE16, 6 GENERIC, 1 DECODER, counts artifact asserts.

3 Tables, and the bound on every key#

3.1 ShiftPowers#

Row s of the packed table's top sub-table (lookup.md §9) is (SHIFT_BASE + s + 1, 2^s, 2^(31 − s)), one for each of the 32 shift amounts and for none other, so a key past its last row matches nothing. The second value is the copower a residue bound multiplies by, 2^(32 − s), stored halved (SHIFT_COPOWER_BITS = 31): at s = 0 it is 2^32, which the table's u32 columns cannot hold, so the two gates that read it carry the factor 2 (§4.2, §4.3).

3.2 The AND rows#

An AND row is (AND_BASE + a + 1, b, a & b) over bytes a and b, so a key inside their range matches a row that makes byte_b<j> a byte and byte_and<j> its AND with byte_a<j>: those two need no bound of their own.

3.3 Every key is bounded#

The channel proves membership of the packed table, not of a sub-table (lookup.md §4), so an out-of-range key lands on another sub-table's row. A bitwise row with byte_a0 = 65,823 gates to key 65,824, ShiftPowers' row (65,824, 2^31, 1); with rs1 = 65,823 and rs2 = 2^31 every gate holds, and and writes 1 where the answer is 0. So every key carries its own bound, as RANGE16 obligations under its lookup's selector:

key bound obligations selector
rs1_hi + SIGN_BASE rs1_hi < 2^16 rs1's 16+16 pair m_pc
amount + SHIFT_BASE amount < 2^5 amount; 2^11·amount f_shift
byte_a<j> + AND_BASE byte_a<j> < 2^8 byte_a<j>; 2^8·byte_a<j> f_bitwise

A bound below a halfword takes both obligations: the scaled one alone does not make the key an integer (lookup.md §11), and the direct one alone admits every halfword, byte_a0 = 256 landing on U16GetSign's row (257, 0, 0).

3.4 The copower check#

artifact runs lookup::check_copowers (lookup.md §11) over each column it bounds by scaling, which must carry its direct bound under its scaled obligation's own selector: residue, scaled by the looked-up copower (§4.3), under m_pc; amount under f_shift; each byte_a<j> under f_bitwise.

4 Gates#

Gate list 0 holds the frame's ten and these 38. m_q, a_q, v_q are query q's mask, address and read value, and a flag of §1 times (…) stands for each of its weighted bits times (…), so every term is of degree 2.

4.1 Presence and next_pc#

gate polynomial
kind_<k>_boolean ×12, decoded_mask_bits as in jump-branch-slt.md §4
f_shift_rule, f_bitwise_rule f − Σ its half's six bits
f_shift_boolean, f_bitwise_boolean f − f²
rs1_mask_rule, rd_mask_rule m_q − m_pc·Σ_k b_k
rs2_mask_rule m_rs2 − m_pc·(b_sll + b_srl + b_sra + b_and + b_or + b_xor)
<q>_addr_rule ×3, <q>_value_masked ×2 as in jump-branch-slt.md §4
next_pc_rule next_pc − seq

No kind computes a pc: next_pc is the decoder-bound fall-through, with no wrap bit and no bound of its own, and HALT_PC is beyond the family's reach (memory.md §5).

4.2 The shift amount#

amount_split    rs2 + imm − 32·high − amount
copower_rule    pow·copow − 2^31·f_shift

amount_split is ungated. copower_rule says pow·(2·copow) = 2^32 on a shift row, and pow·copow = 0 on a bitwise row.

4.3 The one product, both directions#

se_rule           se − arith·rs1_sign
rs1_sign_boolean  rs1_sign − rs1_sign²
se_boolean        se − se²
shift_in_rule     shift_in − left·v_rs1 − right·(sel − 2^32·se)
shift_prod_rule   shift_prod − shift_in·pow
shift_out_rule    left·(shift_prod − sel − 2^32·ovf)
                    + right·(shift_prod + residue − v_rs1 + 2^32·se)
scaled_rule       scaled − 2·residue·copow

shift_prod_rule, ungated, is the one multiplication by pow; shift_in_rule picks its multiplicand, which keeps shift_out_rule at degree 2 where left·(v_rs1·pow − …) would be 3, and se is committed for the same reason. A right shift is the floor division rs1 − 2^32·se = (sel − 2^32·se)·2^s + residue, which covers sra: the arithmetic shift of a negative word is the floor division of its signed value, and the result keeps the operand's sign. shift_in and shift_prod are the only columns that are not words: the multiplicand is negative where se = 1, and a left shift's product reaches 2^63.

4.4 The bitwise half#

rs1_bytes         v_rs1 − Σ_j 2^(8j)·byte_a<j>
src2_bytes        rs2 + imm − Σ_j 2^(8j)·byte_b<j>
bitwise_out_rule  f_bitwise·sel − t1·(v_rs1 + rs2 + imm) − t2·Σ_j 2^(8j)·byte_and<j>

Per byte, OR is a + b − (a & b) and XOR is a + b − 2·(a & b). Summed by weight through the two decompositions, sel is rs1 & src2 at (t1, t2) = (0, 1), their OR at (1, −1) and their XOR at (1, −2), exactly, no carry crossing a byte: there is no OR or XOR table, and the AND accumulator is a linear form, not a column. sel is gated by f_bitwise because t1 and t2 are 0 on a shift row, where a bare sel would force rd = 0. The decompositions are ungated: on a shift row the bytes carry no lookup, and a decomposition always exists.

4.5 Lookups#

After the frame's 8 TIMESTAMP obligations, in the channel order of jump-branch-slt.md §4.1:

RANGE16   <x>_hi_range, <x>_lo_range     under m_pc, x = rs1 src2 high ovf residue scaled rd
          amount_range, amount_scaled    under f_shift      §3.3
          byte_a<j>_range, _scaled ×4    under f_bitwise    §3.3
GENERIC   rs1_get_sign   (rs1_hi + SIGN_BASE, rs1_sign, 0)                 under m_pc
          shift_powers   (amount + SHIFT_BASE, pow, copow)                 under f_shift
          and_byte_<j>   (byte_a<j> + AND_BASE, byte_b<j>, byte_and<j>)    under f_bitwise
DECODER   decode_row     under m_pc (lookup.md §10)

5 Why it is sound#

On a live row the decoder lookup makes the claimed tuple the table's row at pc, so one kind bit is 1 and one of f_shift, f_bitwise (lookup.md §10); the mask and address rules make the queries the instruction's (execution-trace.md §4). rs1, src2 and sel are words by their pairs, rs1_hi is rs1's true high halfword and rs1_sign its bit 31. Every term of §4 that reads sel carries a shift bit or f_bitwise, so the inactive half never constrains it.

  • The amount is the ISA's. §3.3 bounds amount below 32 and high's pair bounds high below 2^32, so amount_split is an integer identity below 2^37, amount = src2 mod 32, and the ShiftPowers row it keys gives pow = 2^amount. Without high's pair, sll by rs2 = 4 can shift by 8, at high = −1/8.
  • A left shift: shift_prod = rs1·2^s < 2^63, and sel + 2^32·ovf, both words, is its unique split, so sel = (rs1·2^s) mod 2^32.
  • A right shift: se is rs1's bit 31 on sra and srai and 0 otherwise, so se_rule alone keeps an srai from carrying srli's answer. Every term of the floor division is below 2^64 in magnitude, so residue is the integer (rs1 − 2^32·se) − (sel − 2^32·se)·2^s, and scaled's pair puts it in [0, 2^s): sel − 2^32·se is the floor of (rs1 − 2^32·se)/2^s. residue's own pair, which check_copowers requires, bounds it without appeal to sel's, the scaled pair alone saying nothing of a non-integer: 2^−28 passes it at s = 3.
  • A bitwise result: each byte_a<j> is below 256, so its lookup matches an AND row (§3.2); with rs1 and src2 words, both decompositions are the unique byte splits and §4.4's identity holds.

copower_rule is implied by the bounded key and kept as the circuit's own reading of the table: a ShiftPowers row generated wrong stops the honest prover rather than license a residue bound that is not one. It also confines the key to ShiftPowers alone, no other row's two values having the product 2^31: an AND row's is at most 255·255, every other row's 0.

On a padding row, m_pc = 0, every query mask is 0 and every obligation under m_pc vacuous. f_shift and f_bitwise are free booleans there, so a padding row may look up ShiftPowers or the AND rows, which consumes a multiplicity and changes nothing.

Auditeurs/Familles d'instructions

La famille MUL_DIV

Spécification normativedocs/spec/mul-div.mdVoir en Markdown

Résumé

Le circuit de la famille 3, l’extension M. Une seule identité de produit sert aux quatre multiplications et à la division; une règle de signe et un écart vérifié par intervalle rendent la division tronquée plutôt qu’arrondie à l’entier inférieur; et une seule porte fixe la division par zéro. La page spécifie les colonnes, les ajustements de signe, les portes et l’argument de solidité, y compris le cas du dépassement signé et le remplissage honnête.

Le texte normatif ci-dessous est tenu à jour en anglais, langue canonique de la spécification.

The M extension — mul, mulh, mulhsu, mulhu, div, divu, rem, remu — as one circuit beside the memory frame every execution family carries (memory.md §2). One product identity serves the four multiplies and the division; a sign rule and a range-checked gap make the division truncated, and one gate pins division by zero. constraints::mul_div builds it: 21 M, 54 W and 9 S columns, 54 enforcing gates, 27 lookups.

1 What the circuit reads from the decoded table#

Every M instruction is R-type, so the decoded tuple has no imm: pc next_pc rs1 rs2 rd extra_mask, six columns (program.md §5). extra_mask is one-hot over constants::extra_mask::mul_div, bits 0–7 in the order above; the legal masks are its eight single bits (mul_div::LEGAL_MASKS), which the table's domain enforces (lookup.md §10). The circuit commits the bits b_k and reads every signal as a linear form over them:

signal form
reads rs1 signed b_mul + b_mulh + b_mulhsu + b_div + b_rem
reads rs2 signed b_mul + b_mulh + b_div + b_rem
a multiply, Σ_mul b_mul + b_mulh + b_mulhsu + b_mulhu
a division, f_div b_div + b_divu + b_rem + b_remu, a column: the is-zero gadgets' enable

mul is read signed × signed: its low word is the same either way, which lets one product identity serve all four multiplies. mulhsu's asymmetry is the two lists, not a case split.

2 Columns#

The frame is pc rs1 rs2 rd, M[0..21] and W[0..7] (memory.md §2.1); below, m_q, a_q and v_q are query q's mask, address and read value, and rs1, rs2 the operands' read values. The family adds:

W[7..12]   decoded_next_pc decoded_rs1 decoded_rs2 decoded_rd decoded_mask    (no imm)
W[12..20]  kind_mul … kind_remu
W[20]      f_div
W[21..27]  rs1_hi rs1_top rs2_hi rs2_top s1 s2          high halfwords, bit 31, §3
W[27..34]  mx my p_low p_low_hi p_high p_high_hi p_sign  the product
W[34..40]  q q_hi q_sign r r_hi r_sign                   quotient and remainder
W[40..45]  r_inv rz d1 d_inv dz                          is_zero(r), f_div·s1, is_zero(rs2)
W[45..50]  abs_r abs_d gap gap_hi rd_hi
W[50..54]  mult_timestamp mult_range16 mult_generic mult_decoder
S[0..6]    the decoded table, bound by identity
S[6..9]    the packed generic table (lookup.md §9)
V          range19 range16

The fill keeps mx, my (signed) and r_inv, d_inv (inverses) in Fr, every other column in u32.

3 The sign adjustments#

rs1_adj = rs1 − 2^32·s1      s1 = (b_mul + b_mulh + b_mulhsu + b_div + b_rem)·rs1_top
rs2_adj = rs2 − 2^32·s2      s2 = (b_mul + b_mulh + b_div + b_rem)·rs2_top
q_adj   = q − 2^32·q_sign    r_adj = r − 2^32·r_sign

rs1_top is the U16GetSign lookup of rs1_hi, which rs1's 16+16 pair makes its true high halfword, so the key lies in that sub-table's range and the answer is bit 31 (lookup.md §4); rs2_top likewise. An unsigned position forces its adjustment to 0 whatever the top bit, which keeps the selection degree 2. q_sign and r_sign are not sign lookups (§5.3). f_div, rs1_top, rs2_top, s1, s2, p_sign, q_sign and r_sign carry booleanity gates; rz and dz are boolean by the is-zero gadget (jump-branch-slt.md §3), d1 as a product of booleans.

4 Gates#

The frame's ten enforcing gates (memory.md §2.4) and the family's 44, all in gate list 0, each formula = 0. The plumbing:

kind_<k>_boolean          b_k − b_k²                       eight
decoded_mask_bits         Σ_k 2^k·b_k − decoded_mask
<q>_mask_rule             m_q − m_pc·Σ_k b_k               rs1, rs2, rd: every kind uses all three
<q>_addr_rule             m_q·(a_q − decoded_q)            rs1, rs2, rd
<q>_value_masked          v_q − m_q·v_q                    rs1, rs2
next_pc_rule              next_pc − decoded_next_pc        the fall-through (memory.md §5)

The arithmetic, mul_div::arithmetic_gates(32), written with §3's abbreviations:

f_div_rule, s1_rule, s2_rule   §1's and §3's forms, and eight booleanity gates (§3)
mx_rule                mx − Σ_mul b·rs1_adj − f_div·rs2_adj
my_rule                my − Σ_mul b·rs2_adj − f_div·q_adj
product_rule           mx·my − p_low − 2^32·p_high + 2^64·p_sign
division_rule          f_div·(p_low + 2^32·p_high − 2^64·p_sign + r_adj − rs1_adj)
rz_inverse             r·r_inv + rz − f_div          rz_at_nonzero   rz·r
dz_inverse             rs2·d_inv + dz − f_div        dz_at_nonzero   dz·rs2
d1_rule                d1 − f_div·s1
r_sign_rule            r_sign − d1 + d1·rz           so r_sign = f_div·s1·(1 − [r = 0])
abs_r_rule             abs_r − r − 2^32·r_sign + 2·r·r_sign       abs_r = |r_adj|
abs_d_rule             abs_d − rs2 − 2^32·s2 + 2·rs2·s2           abs_d = |rs2_adj|
gap_rule               gap − f_div·(abs_d − abs_r − 1) − 2^32·dz
zero_divisor_quotient  dz·(q − (2^32 − 1))
rd_value_rule          rd_selected − b_mul·p_low − (b_mulh + b_mulhsu + b_mulhu)·p_high
                         − (b_div + b_divu)·q − (b_rem + b_remu)·r

The lookups: the frame's eight TIMESTAMP gap chunks, each under its query's mask; and under m_pc, 16+16 RANGE16 pairs on rs1, rs2, p_low, p_high, q, r, gap and rd_selected, rs1_get_sign, (rs1_hi + SIGN_BASE, rs1_top, 0) on GENERIC, and rs2_get_sign, and decode_row on DECODER.

The width is a parameter of arithmetic_gates so the encoding can be checked whole: crates/checker/tests/mul_div.rs evaluates arithmetic_gates(4) through gkr::eval_gate over every (dividend, divisor) pair of a 4-bit word and each division kind, and exactly one (q, r) survives, RV32M's.

5 Why it is sound#

On a live row the decoder lookup makes exactly one kind bit 1 (lookup.md §10), and rs1, rs2 are words whose _top is bit 31, so rs1_adj, rs2_adj ∈ [−2^31, 2^32) are the operands as the kind reads them.

5.1 The product#

On a multiply row mx·my = rs1_adj·rs2_adj; on a division row it is rs2_adj·q_adj, q's pair and q_sign's booleanity putting q_adj in [−2^32, 2^32). Either way |mx·my| < 2^64, and two words and a boolean cover [−2^64, 2^64) once, so product_rule holds over the integers with one solution: p_low, p_high are the words of the 64-bit two's-complement product, RV32M's for each multiply. product_rule is ungated and the circuit's only product of two row values, which is what lets both readings share it at degree 2.

5.2 The division#

With rs2_adj ≠ 0, division_rule is rs2_adj·q_adj + r_adj = rs1_adj over the integers. Truncated division is its one solution with |r_adj| < |rs2_adj| and r_adj zero or of the dividend's sign, and two gates state exactly that:

  • The sign. r_sign = f_div·s1·(1 − [r = 0]) makes r_adj the word r on an unsigned row or a non-negative dividend, and r − 2^32 < 0 on a negative one unless r = 0. It is what separates truncated division from floored: without it DIV(−7, 2) admits q = −4, r = 1 as readily as q = −3, r = −1. As a definition, through d1, it is degree 2.
  • The magnitude. gap = abs_d − abs_r − 1 is range-checked, and neither magnitude reaches 2^32: abs_d ≤ 2^31 where s2 = 1, abs_r ≤ 2^32 − 1 where r_sign = 1, which needs r ≠ 0, and each is a word elsewhere. So the difference lies in [−2^32, 2^32), in range exactly when |r_adj| < |rs2_adj|. The comparison gadget would repeat bounds that hold and has no place for the zero divisor's term.

So q_adj and r_adj are RV32M's, and q_sign is pinned only by q's range: one value puts q_adj + 2^32·q_sign in [0, 2^32).

A zero divisor makes dz = 1 and rs2_adj = 0: the identity leaves r_adj = rs1_adj, so r is the dividend's word; zero_divisor_quotient, the one pin, makes q all ones; the 2^32·dz term lifts gap to 2^32 − 1 − abs_r, so the divisor imposes no bound. q_sign is free and harmless: mx = 0, and rd reads the word q.

The identity is gated. On a multiply row r_sign = 0 and r is a word, so an ungated identity would demand rs1_adj − rs1_adj·rs2_adj ∈ [0, 2^32), false for nearly every multiply: 7 × 3, a negative rs1 times x0.

5.3 The signed overflow, and why q_sign is free#

DIV(−2^31, −1) needs no pin: |r_adj| < 1 forces r = 0, the identity q_adj = 2^31, and q's range q_sign = 0, q = 0x80000000, RV32M's answer; REM gives 0. This row is why q_sign is a free boolean: tied to bit 31 of q, as s1 and s2 are to their operands', it would force q_adj = −2^31 and make the row unprovable. r_sign likewise follows the dividend's sign, not the remainder's word.

rd_selected's pair is implied by its four sources' and kept, every family bounding what it writes to rd (memory-ops.md §5). Every gate is zero on the all-zero padding row, which mul_div::artifact asserts with each channel's obligation count.

5.4 The fill#

prover::family_fill(MUL_DIV) (crates/prover/src/fill.rs) computes the witness with Rust's integers: the product in i128, the division by wrapping_div and wrapping_rem, which give RV32M's overflow answer, with the zero divisor an arm of its own, and q_sign from the sign of q_adj. It writes the computed value to rd_selected, which the frame's x0 rule masks, and panics, on rows the emulator cannot produce, if the identity does not divide, a product exceeds two words, or the trace's rd write or next_pc is not what the instruction computes.

Auditeurs/Familles d'instructions

Les familles d’opérations mémoire

Spécification normativedocs/spec/memory-ops.mdVoir en Markdown

Résumé

Les circuits des familles 4, 5 et 6 : les chargements et stockages de mots, les chargements et stockages d’octets et de demi-mots, et les opérations atomiques. La page spécifie leur adressage commun, les copies de MEM_WORD, l’insertion par MEM_SUBWORD d’un sous-mot dans son mot, l’induction côté écriture qui garantit que chaque valeur de registre et de RAM reste un mot de 32 bits, et ATOMICS, y compris l’unique écart par rapport à RV32IMAC : sc.w réussit toujours.

Le texte normatif ci-dessous est tenu à jour en anglais, langue canonique de la spécification.

MEM_WORD (lw, sw), MEM_SUBWORD (lb, lh, lbu, lhu, sb, sh) and ATOMICS (lr.w, sc.w, the nine AMOs): the execution families whose rows touch RAM, each a circuit beside the memory frame (memory.md §2), sharing §2's addressing. They are constraints::{mem_word, mem_subword, atomics}, filled by prover::family_fill (crates/prover/src/fill.rs), which computes each witness with Rust's integer operations.

family M W S gates, frame + own TIMESTAMP, RANGE16, GENERIC, DECODER
MEM_WORD 31 24 7 13 + 20 12, 5, 0, 1
MEM_SUBWORD 31 55 10 13 + 40 12, 22, 1, 1
ATOMICS 26 54 9 11 + 35 10, 19, 6, 1

Each artifact asserts its gate and obligation counts and that the all-zero padding row satisfies every gate. Below, m_q, a_q and v_q are query q's mask, address and read value, rs1 and rs2 the operands' read values (memory.md §2.1), and b_k (b_lw, b_lr, …) the committed kind bits.

1 What the circuits read from the decoded table#

MEM_WORD's and MEM_SUBWORD's tuple is pc next_pc rs1 rs2 rd imm extra_mask, imm the offset's two's-complement u32; ATOMICS' has no imm, its address being rs1 (program.md §5). The tuple is the first setup columns, and the packed generic table follows it where a family reads one: S[7..10] in MEM_SUBWORD, S[6..9] in ATOMICS (lookup.md §9). extra_mask is one-hot over constants::extra_mask, bit k the k-th mnemonic below, and each module's LEGAL_MASKS is those single bits:

mem_word      lw sw
mem_subword   lb lh lbu lhu sb sh
atomics       amoadd amoswap lr sc amoxor amoor amoand amomin amomax amominu amomaxu

The atomics order is ascending funct5; aq and rl order nothing on one hart and are not recorded. MEM_SUBWORD's modifiers are linear forms over its bits:

LOADK = b_lb + b_lh + b_lbu + b_lhu     BYTE = b_lb + b_lbu + b_sb     SIGNEXT = b_lb + b_lh
STORE = b_sb + b_sh                     HALF = b_lh + b_lhu + b_sh

All three carry the same plumbing, each formula = 0:

kind_<k>_boolean    b_k − b_k²
decoded_mask_bits   Σ_k 2^k·b_k − decoded_mask
<q>_mask_rule       m_q − m_pc·uses_q              every query but pc
<q>_addr_rule       m_q·(a_q − decoded_q)          rs1, rs2, rd
                    m_q·(a_q − 4·word_index)       load, ram (§2)
<q>_value_masked    v_q − m_q·v_q                  rs1, rs2
next_pc_rule        next_pc − decoded_next_pc      the fall-through (memory.md §5)
uses_q rs1 rs2 load ram rd
MEM_WORD b_lw + b_sw b_sw b_lw b_sw b_lw
MEM_SUBWORD LOADK + STORE STORE LOADK STORE LOADK
ATOMICS every bit every bit but b_lr no query every bit every bit

m_rs2 is keyed on b_lr, the one kind without an rs2 field, and not on rs2 = x0: an amoadd.w whose rs2 is x0 still reads it.

2 Addressing#

The effective address is rs1 + imm mod 2^32, or rs1 for an atomic. One degree-1 gate splits it, with wrap, bit0 and bit1 boolean:

MEM_WORD      addr_split   rs1 + imm − 2^32·wrap − 4·word_index
MEM_SUBWORD   addr_split   rs1 + imm − 2^32·wrap − 4·word_index − 2·bit1 − bit0
ATOMICS       addr_word    rs1 − 4·word_index

Over Fr that says nothing, 4 being a unit. Three RANGE16 obligations under m_pc, on word_index_hi, word_index − 2^16·word_index_hi and 4·word_index_hi (word_index_hi_range, word_index_lo_range, word_index_hi_scaled), cap word_index at 2^30 − 1, the top word's. With rs1 a word (§5) and imm a table value the split is then one of integers: wrap is the true carry, bit1 and bit0 the true low bits, and every RAM address is a 4-aligned address below 2^32. Having no offset bits, a misaligned MEM_WORD or ATOMICS access needs a word_index that is not an integer, which its pair refuses; the emulator refuses it first (execution-trace.md §10). addr_word derives rs1 < 2^32 rather than assuming it. half_aligned, HALF·bit0 = 0, refuses a halfword at an odd address and keeps w·p a divisor of 2^32 (§4.3).

Every RAM query's address is 4·word_index, so byte, halfword, word and atomic accesses to one word name one cell; the byte position lives only in MEM_SUBWORD's splice. Confining an access to initialized memory is the multiset's (memory.md §9): an out-of-window access fails the statement's memory argument, not a gate.

3 MEM_WORD#

A load copies the word into rd, a store copies rs2 into the word; there is no splice, no generic lookup, and the decoded table is the only setup.

W[9..15]   decoded_next_pc decoded_rs1 decoded_rs2 decoded_rd decoded_imm decoded_mask
W[15..21]  kind_lw kind_sw wrap word_index word_index_hi rd_hi
W[21..24]  mult_timestamp mult_range16 mult_decoder

wrap_boolean, addr_split (§2)
rd_value_rule       rd_selected − b_lw·load_read_value
store_value_rule    ram_write_value − m_ram·rs2

Its RANGE16 obligations are §2's three and the 16+16 pair on rd_selected, and the two copies are its whole semantics. rd_selected is range-checked although it copies a RAM word, because a RAM word need not be a word, advice's initial values being bound to nothing (public-values.md §6): the pair keeps every register value a word without reference to RAM (§5). No gate reads ram_read_value, the word a store overwrites; the memory argument alone pins it.

4 MEM_SUBWORD#

4.1 The splice#

A sub-word's position in its word lives only in

word = high·(w·p) + sub·p + low      p = 2^(8·offset), offset = 2·bit1 + bit0
                                     w, the access width: 2^8 if BYTE, 2^16 if HALF

p and its copower are degree-2 forms in the offset bits, written as gates rather than looked up:

p_rule        p − m_pc − 255·bit0 − 65535·bit1 − K·bit0·bit1      K = 2^24 − 2^16 − 2^8 + 1
pcopow_rule   p·pcopow − 2^31·m_pc                                 pcopow = 2^31/p
wph_rule      wph − 32768·p + 32640·BYTE·p                         wph = w·p/2
p_ram_rule    p_ram − m_ram·p

p_rule takes the four offsets to 1, 2^8, 2^16, 2^24, m_pc standing for the constant so the all-zero row satisfies it. The copower and w·p are stored halved so that 2^32 fits a u32 column, the gates reading them carrying the factor 2, as ShiftPowers' do (lookup.md §9). p_ram keeps store_rule degree 2. A table keyed by the offset would pin nothing addr_split does not, and add a key to bound (lookup.md §4).

4.2 Columns and gates#

W[9..21]   the decoded row; kind_lb … kind_sh
W[21..31]  wrap word_index word_index_hi bit0 bit1 p pcopow wph p_ram word
W[31..42]  high high_hi high_scaled high_scaled_hi sub sub_scaled sub_scaled_hi
           low low_hi low_scaled low_scaled_hi
W[42..51]  src_sub src_sub_scaled src_sub_scaled_hi src_high src_high_hi sign_in sign se rd_hi
W[51..55]  the four multiplicities

Its gates, beside the plumbing: wrap_boolean, bit0_boolean, bit1_boolean, addr_split, half_aligned, §4.1's four, and

word_rule            word − LOADK·load_read_value − STORE·ram_read_value
splice_rule          word − high_scaled − sub·p − low
high_scaled_rule     high_scaled − 2·high·wph                             = high·w·p
sub_scaled_rule      sub_scaled − 2^16·sub − (2^24 − 2^16)·BYTE·sub       = sub·2^32/w
low_scaled_rule      low_scaled − 2·low·pcopow                            = low·2^32/p
src_sub_rule         rs2 − src_sub − 2^16·src_high + 65280·BYTE·src_high
src_sub_scaled_rule  src_sub_scaled − 2^16·src_sub − (2^24 − 2^16)·BYTE·src_sub
store_rule           ram_write_value − m_ram·word − (src_sub − sub)·p_ram
sign_in_rule         sign_in − sub − 255·BYTE·sub                         = 2^8·sub or sub
se_rule              se − SIGNEXT·sign
rd_value_rule        rd_selected − LOADK·sub − (2^32 − 2^16)·se − 65280·BYTE·se

mem_subword::splice_gates(byte_bits) builds the twelve whose literals depend on the byte width — §4.1's first three and these but word_rule and se_rule — and the circuit takes it at BYTE_BITS = 8. Its RANGE16 obligations, all under m_pc, are §2's three, 16+16 pairs on high, high_scaled, sub_scaled, low, low_scaled, src_sub_scaled, src_high and rd_selected, and one obligation each on sub, src_sub and sign_in; its GENERIC lookup is sub_get_sign, (sign_in + SIGN_BASE, sign, 0).

4.3 Why it is sound#

§2 fixes the offset bits and half_aligned clears bit0 at halfword width, so p and w are the access's. Each part has a direct bound and a scaled one: high < 2^32 makes high·w·p an integer, sub_scaled < 2^32 is sub < w and low_scaled < 2^32 is low < p. So splice_rule holds over ℤ with one solution, the base-(p, w) digits of the word, and a word not below 2^32 has none. A scaled bound alone admits non-integers, its scale being a unit of Fr (lookup.md §11); constraints::lookup::check_copowers holds word_index_hi, high, sub, low and src_sub to their direct bounds, one obligation being exact for sub and src_sub, both below w ≤ 2^16. src_high's pair makes rs2 = src_sub + w·src_high integral, so src_sub is rs2 mod w: without it sb could store a byte unrelated to rs2.

A load writes rd = sub + (2^32 − w)·se: the sub-word, or at se = 1 its two's-complement extension (lb of 0x88 is 0xffffff88). sign_in is 2^8·sub for a byte and sub for a halfword, so its bit 15 is the sign at either width and one U16GetSign lookup serves both; its own obligation bounds the key into that sub-table (lookup.md §4). se is a one-hot sum times a table bit, boolean without a gate.

A store writes word + (src_sub − sub)·p = high_scaled + src_sub·p + low, a word with no appeal to memory: high_scaled is a multiple of w·p below 2^32 and w·p divides 2^32 (a halfword at offset 3 would make it 2^40; half_aligned excludes it), so high_scaled ≤ 2^32 − w·p and src_sub·p + low ≤ w·p − 1. That is why high_scaled keeps its own pair.

crates/checker/tests/mem_subword.rs checks the splice whole at a 4-bit word: for every word, admissible offset and width, splice_gates(1) and the bounds admit exactly one (high, sub, low).

5 The write-side induction#

A circuit may use a register operand as a word without bounding it. That rests on two facts:

  • Every register write is a word on its own row. Every execution family's rd_selected carries a 16+16 pair under m_pc, but ATOMICS', which is the old word or 0, the old word bounded by its comparison's pair under m_pc (§6). The frame writes (1 − z)·rd_selected (memory.md §2.4), registers start at 0 and a read returns the last write (memory.md §9), so every register read is a word, with no appeal to RAM.
  • Every RAM write of an execution family is a word: MEM_WORD writes rs2, a register value; MEM_SUBWORD bounds its merged word itself (§4.3); each ATOMICS arm is bounded (§6); a read-only query writes back what it read.

RAM's initial values are words — the image's, 0, the public input's — but advice's, which nothing bounds. No execution family relies on a RAM word being one: each bounds the value it uses, by MEM_WORD's rd pair, MEM_SUBWORD's splice or ATOMICS' comparison, so a row using a non-word is unprovable. The register half is what every carry needs: a + b − 2^32·wrap is a reduction only for words (memory.md §7), and addr_split's integer argument needs rs1 < 2^32.

6 ATOMICS#

One row is one read-modify-write: the ram query reads old and writes new at Δ = 3, beside rd (execution-trace.md §4), lr.w included, which writes its word back.

W[8..13]   decoded_next_pc decoded_rs1 decoded_rs2 decoded_rd decoded_mask    (no imm)
W[13..24]  kind_amoadd … kind_amomaxu
W[24..30]  word_index word_index_hi sum sum_hi add_wrap f_bitwise
W[30..42]  byte_a0..3 byte_b0..3 byte_and0..3       old's bytes, rs2's, their AND
W[42..50]  old_hi old_sign src_hi src_sign lt cmp_gap cmp_gap_hi lo
W[50..54]  the four multiplicities

With A = Σ_j 2^(8j)·byte_and_j inlined, its gates beside the plumbing are:

ram_value_rule    new − b_lr·old − (b_sc + b_amoswap)·rs2 − b_amoadd·sum − b_amoand·A
                    − b_amoor·(old + rs2 − A) − b_amoxor·(old + rs2 − 2A)
                    − (b_amomin + b_amominu)·lo − (b_amomax + b_amomaxu)·(old + rs2 − lo)
rd_value_rule     rd_selected − Σ_{k ≠ sc} b_k·old
add_rule          old + rs2 − sum − 2^32·add_wrap
f_bitwise_rule    f_bitwise − b_amoand − b_amoor − b_amoxor
old_bytes_rule    old − Σ_j 2^(8j)·byte_a_j          src_bytes_rule   rs2 − Σ_j 2^(8j)·byte_b_j
lo_rule           lo − rs2 − lt·(old − rs2)
addr_word (§2); add_wrap_boolean, f_bitwise_boolean; cmp_order, cmp_lt_boolean (below)

Each takes a kind's bit through its constants::extra_mask constant, from which the table's masks are built too, so a transposed arm would pass the decoder lookup. Under m_pc the family looks up the comparison's pairs on old, rs2 and cmp_gap and its two signs, §2's three and sum's pair; under f_bitwise, for each j, byte_a_j and 2^8·byte_a_j on RANGE16 and and_byte_j, (byte_a_j + AND_BASE, byte_b_j, byte_and_j), on GENERIC.

The comparison is constraints::gadgets::comparison (jump-branch-slt.md §3) with selector m_pc, lhs = old, rhs = rs2 and signed = [b_amomin, b_amomax]. The family's assemble asserts all four, nothing else in the artifact determining them: signed widened to amominu orders it signed, lhs and rhs swapped turn amomin into a max, and a selector narrowed to the min/max kinds drops old's bound on the other seven, and with it the bound on their rd write (§5). lo is the smaller under the ordering lt settles, and old + rs2 − lo the larger.

Why new is a word. old and rs2 are bounded by the comparison, sum by its own pair (add_rule is ungated: sum = (old + rs2) mod 2^32 on every live row), lo and the larger by being old and rs2. On a bitwise row byte_a_j's pair puts the key in the AND sub-table, whose row bounds byte_b_j and fixes byte_and_j = byte_a_j & byte_b_j; the byte rules are then the operands' decompositions, and A, old + rs2 − A, old + rs2 − 2A are AND, OR and XOR, carry-free byte by byte. Without its pair byte_a0 = 65,823 reads ShiftPowers' (65,824, 2^31, 1) (lookup.md §4); check_copowers takes the four keys under f_bitwise, which covers all three bitwise kinds: under b_amoand alone amoor and amoxor would read free byte_and.

sc.w always succeeds: it stores rs2 and writes 0 to rd, and the machine holds no reservation. The emulator does the same (execution-trace.md §10), and a row claiming failure, a nonzero rd or an unchanged word, is refused by rd_value_rule or ram_value_rule. This is a conformance deviation, not a soundness one: the proof is of what the program did on this machine. A guest may not rely on an sc.w failing where the ISA requires it to: with no valid reservation (no earlier lr.w, or one an earlier sc.w consumed) or at an address outside the reservation set. The lr.w/sc.w retry loop compiled code uses is unaffected, first-pass success being legal on any hart.

Auditeurs/Délégations

Délégation

Spécification normativedocs/spec/delegation.mdVoir en Markdown

Résumé

L’ABI de délégation : comment un programme invité remet un cadre de mots de RAM à un circuit au moyen d’un ecall, le registre des types de délégation, les règles du cadre, l’ancre qui apparie chaque demande à exactement une invocation par le multiensemble mémoire, le côté de l’exécuteur, la déclaration statique des familles qu’appelle un programme, les shards et les fenêtres temporelles, les hauteurs et leurs compromis, les appelants côté programme invité, et les limites de l’ensemble délégué.

Le texte normatif ci-dessous est tenu à jour en anglais, langue canonique de la spécification.

The delegation ABI: how a guest hands a frame of RAM words to a circuit with an ecall, how each request pairs with exactly one invocation, how a program declares the families it calls, and how each family is sized. Frame layouts and circuits are delegation-circuits.md's, the recursion format's four families recursion.md's.

1 What a delegation family is#

A delegation family proves a function of guest memory too costly to run as instructions. It is invoked, never decoded: its number is a run-time value of a7, so it claims no pc and has no decoded table. A row is one invocation, which rides the cycle that requested it and owns no cycle (execution-trace.md §1); its accesses join the one memory multiset; it is in a VmConfig exactly when the image declares it (§7). Otherwise it is an ordinary family, an arm in constraints::family_circuit and a fill in prover::family_fill. A call is one row, the anchor's two leaves being a row's (§5); an operation wider than a row is several calls on one frame, chained through RAM (delegation-circuits.md §1, RAM glue).

2 The calling convention#

A call is an ecall (ecall-abi.md §1): a7 the number, a0 the frame base. It writes 0 to a0 and falls through (execution-trace.md §6); a recursion-format type writes a0 + 4·words instead (recursion.md §1.4).

An executor without a family's circuit answers -ENOSYS, on which a base-format shim's caller computes the same function in software, so an executor may implement any subset of the families; any other nonzero answer is fatal (ecall-abi.md §7).

3 The registry#

constants::delegation::TYPES, also program::DELEGATIONS, is one table of (family, number, anchor space, frame words), ascending by family, which the emulator dispatches on and constraints::add_sub builds its request gates from. The first BASE_TYPES = 6 rows are the base format's (recursion.md §1.2). Why each family has its height is §9's.

family id number anchor space frame words
KECCAK_F 9 0x0507 4 51
POSEIDON2 10 0x0500 5 24
FR_ARITH 11 0x0502 6 25
MOD_MUL 15 0x0504 7 25
SHA256_COMP 16 0x0508 8 25
EC_ADD 17 0x0506 9 97
FR_OP 19 0x0509 11 4
P2_FIELD 20 0x050A 12 5
FIELD_IO 21 0x050B 13 3
FQ_OP 22 0x050C 14 4

constraints::add_sub asserts at compile time that every number is in the precompile range and not EXIT, and that numbers and spaces are pairwise distinct, so an ecall row is the exit or a request of one type; a type costs that circuit a selector is_deleg_<f>, three gates and a term in five shared ones (add-sub.md §2, §4). A type's anchor space is the type: only its requests and invocations touch it, so the anchor's address is the frame base alone. A reserved range of RAM would need an argument that no guest access reaches it.

4 The frame#

A frame is words 32-bit words at the base a0 names, word j at base + 4j, read and written in place. Its base is word-aligned and it lies in RAM, RAM_ORIGIN ≤ base and base + 4·words ≤ 2^31: the executor refuses any other (Misaligned, OutOfBounds, the sum taken in u64) and the circuit has no witness for one (delegation-circuits.md §1, frame chain). So no frame lies in a public window or in advice.

An invocation reads and writes every word, unchanged ones written back, each a RAM query of the requesting cycle at slot constants::delegation::FRAME_DELTA = 0, ahead of the request's own queries (execution-trace.md §4, §7).

5 The anchor#

Requests and invocations pair one to one through the memory multiset, in the requested type's anchor space s. Otherwise N requests could close against one invocation, N − 1 calls going unexecuted, or an unrequested invocation could rewrite a frame.

5.1 The two sides#

                       reads                               writes
request (deleg)        T(s, a0, 0, 0)                      T(s, a0, 4c + 3, v)
invocation (anchor)    T(s, base, 4c + 3, anchor_value)    T(s, base, 0, 0)

The request is the deleg query of an ADD_SUB_LUI_AUIPC ecall row at cycle c (memory.md §2.1): deleg_mask_rule makes its mask m_pc·Σ_t is_deleg_t and deleg_addr_rule its address the a0 the row read. One query serves every type, so its space is deleg_space, an M column deleg_space_rule pins to Σ_t tag_t·is_deleg_t: a memory leaf may read no W column, and the selectors are W (memory.md §8).

The invocation's two leaves are the anchor read (delegation-circuits.md §1): it writes the answer, stamped 0 with value 0, and reads back the request's write at 4c + 3, c its cycle column. v and anchor_value are free and cancel only when equal; an honest prover writes 0 on both. Each answer starts a path one request long (memory.md §9).

5.2 The three request-side zeroings#

gate, under the request's mask forces
deleg_writes_no_register 0 written to a0, so the result is not the prover's choice
deleg_read_ts_zero the mirror read stamped 0
deleg_read_value_zero the mirror read's value 0

With deleg_addr_rule the last two make the mirror read the answer tuple, so every request consumes an answer of its own; without the timestamp, requests at one base chain, each consuming the previous one's write. The gates are the request row's, the same for every family, so the pairing needs nothing from a family's frame, and a call that changes no memory value has nothing else to expose it. The recursion format's deleg_a0_rule replaces the first (recursion.md §1.4).

5.3 Why the pairing is one to one#

In s the only tuples are the requests' and the invocations': no instruction reaches it, no window initializes it, nothing chains there (trace::AddressSpace::chains).

  1. A live row's 4c + 3 is not 0: the request's pc write and the invocation's frame writes at 4c lie on memory paths, whose timestamps are integers below 2^105 (memory.md §4.2).
  2. So the tuples stamped 0 are the requests' reads and the invocations' answers: as many invocations as requests, with the same multiset of bases.
  3. The rest are the requests' writes and the invocations' reads. No two requests share a cycle (memory.md §9), so each invocation's read is exactly one request's write: every invocation sits at its request's base and cycle, its frame accesses at that point of each word's history.

The trace-level check credits each anchor-space query with its invocation's tuples and sees none of this (execution-trace.md §9).

6 The executor's side#

For a registered number, Machine::ecall and Machine::delegate (crates/emulator/src/lib.rs) read a7 and a0; on the tracing paths refuse a family the VmConfig lacks (§7); read the frame, refusing §4's rules; compute the function natively (emulator::keccak_round, transcript::poseidon2_permute, Fr's operators, schoolbook products with long division, emulator::sha256_call) and write the whole frame back, a recursion family leaving it unchanged and working on field cells; stage the mirror query, reading and writing 0; and write a0 (constants::delegation::a0_after).

EmuError::DelegationFrame refuses a frame the circuit has no witness for, which the arithmetic would answer — long division is right for an unreduced operand too — leaving a proof that fails inside the GKR pass with nothing named: a KECCAK_F round word above 23, a SHA256_COMP group word above 15, an FR_ARITH code other than 1, 2, 3 or operand at or above p in memory form, a MOD_MUL or EC_ADD selector naming nothing or operand its row reads at or above the modulus, a POSEIDON2 lane at or above p. The recursion families' refusals are recursion.md §3–§6's.

The tracer records each invocation in its family's trace::DelegationTrace (execution-trace.md §11), which a shard reads as a trace::FrameSlice, ⌈invocations / height⌉ shards a family. The fill (prover::family_fill) commits the recorded words and derives the circuit's intermediates from those read. It never recomputes a written word: what is committed is what the execution did, and the circuit says that is the function. The circuit's side — frame chain, anchor read, gap decomposition, RAM glue — is delegation-circuits.md §1's.

7 Static detachment#

The instruction sweep cannot see a call, so each shim declares its family with a declaration record (constants::delegation):

MARKER_MAGIC = "APOGDEL1" (8 bytes) ‖ ecall number (u32 LE)          MARKER_BYTES = 12

guest_sdk emits one per family, a static whose #[link_section] is its own allocated section, .rodata.apogee.delegations.<family>, which link.ld's *(.rodata*) absorbs.

  • Its own section, because the linker's garbage collection keeps or drops whole input sections: records sharing one would be kept together, and reaching one shim would declare all.
  • Kept by reachability, not #[used], which keeps every record in every guest. Only the family's shim references its record, reading its own number from it through core::hint::black_box: a linked shim has a record, calls the number it declares, and the optimizer cannot fold the read away.
  • Statically: a call linked but never executed declares its family, which proves zero shards.

program::declared_delegations scans the image's file-backed bytes at every byte offset, a static's address being the linker's; a duplicate is one declaration, and a number no family answers is ProgramError::UnknownDelegation. Identity binds a record through the image column (program.md §8).

A called number whose family the VmConfig lacks is the fatal DelegationFamilyAbsent on the tracing paths; emulator::run, having no VmConfig, executes it. No proof covers it: the statement has no shard of that family, so the mirror read has no answer to consume.

8 Shards, time windows and the block#

A delegation shard's window is proof.md §8's, taken over its invocations' requesting cycles, so it lies inside the span of the ADD_SUB_LUI_AUIPC windows that made the requests. A delegation family is not cycle-owning, so the block holds its windows to nothing beyond start ≤ end ≤ 2^38; the anchor, not the window, places an invocation in time (§5.3).

9 Heights and channels#

A height sets how many calls a shard holds and limits no program. It is a parameter (ProgramParams::heights, defaulting to constants::family::DEFAULT_HEIGHTS, circuits.md §1) in identity's VM_CONFIG: a program's, not an execution's (program.md §7).

  • Floor: constraints::family_circuit returns None below the most variables any of the family's channel tables needs (constraints::lookup::table_vars, lookup.md §3).
  • Trade: a shard costs its height, not its occupancy (streaming.md §1), but its proof grows with the height only by a sumcheck round a variable in each gate list, a height changing no gate, only the number of halving lists. For a family with many calls the fatter shard is the smaller proof.
family channels floor unit of work calls a unit units a shard
KECCAK_F RANGE16, XOR8 2^16 keccak-f[1600] 24 10,922
POSEIDON2 none none width-3 permutation 1 256
FR_ARITH none none Fr add, multiply or inverse 1 256
MOD_MUL RANGE16 2^16 a·b mod m 1 65,536
SHA256_COMP RANGE16, XOR8 2^16 compression 16 16,384
EC_ADD RANGE16 2^16 complete point addition 3 21,845
  • POSEIDON2 and FR_ARITH take 2^8, the menu's smallest shard, where no table fits: every bound is a boolean decomposition. MOD_MUL and EC_ADD take their floor.
  • KECCAK_F and SHA256_COMP take 2^18, two variables above it: four times the calls for 2% more proof (a KECCAK_F shard's is 381,100 bytes, against 373,276 at 2^16). The price is memory: two 2^18 KECCAK_F shards in flight set the measured block's peak (streaming.md §1).
  • No base family carries TIMESTAMP, whose table needs 2^19 rows. FR_OP, P2_FIELD and FIELD_IO carry RANGE16, and FQ_OP TIMESTAMP and RANGE16, flooring it at 2^20.

10 Guest-side callers#

delegation reached from
KECCAK_F guest_sdk::keccak256; in guests/revm-block every alloy-primitives keccak, through its native-keccak hook native_keccak256
SHA256_COMP guest_sdk::sha256; revm-precompile's Crypto::sha256, the 0x02 precompile and the stateless guest's SSZ hashing
POSEIDON2 transcript::poseidon2_permute; guest_sdk::poseidon2_permute
FR_ARITH field::Fr's addition, Montgomery multiplication (*, square, pow, the conversions in from_u64, from_bytes, to_bytes) and nonzero inverse
MOD_MUL k256's FieldElement10x26::{mul, square}, Scalar::mul; ark-ff's MontBackend::{mul_assign, square_in_place} for BN254's two fields, as the product and then ·R⁻¹
EC_ADD guest_sdk::{ec_add, ec_mul}; k256's ProjectivePoint::{add, add_mixed, double}; revm-precompile's Crypto::{bn254_g1_add, bn254_g1_mul}
  • The shims are guest_sdk::recursion's but KECCAK_F's, which only keccak256 reaches (ecall-abi.md §7). Their frame types are #[repr(C, align(4))], so §4's alignment is the type's and not where the code generator put a local.
  • A multi-call operation's order is the caller's, and nothing refuses a wrong one: it computes something else. So each is one SDK function, keccak256's permutation, guest_sdk::recursion::sha256_comp and guest_sdk::recursion::ec_add_complete.
  • The transparent backends: field and transcript call the shims under cfg(target_arch = "riscv32"), through a target dependency on guest-sdk that a host build never resolves, not a cargo feature. Cargo refusing the cycle, guest-sdk cannot name Fr, so the shims take frames of bytes. The software path is each crate's own code, one branch below the call. A guest declares what its library calls reach: Fr arithmetic FR_ARITH, poseidon2_permute both.
  • FR_ARITH's frame carries Fr's memory form (primitives.md §1): canonical values would cost a Montgomery multiplication per value, more than the one the call replaces. POSEIDON2's carries canonical values, six conversions against the permutation's 240 multiplications.
  • The vendored crates, k256 0.13.4, ark-ff 0.6.0 and revm-precompile 43.0.2, are what a guest compiles through guests/Cargo.toml's [patch.crates-io], each route under the same cfg with upstream's code as its software path; the root workspace is unpatched. A MOD_MUL or EC_ADD operand must be below its modulus, so k256 first reduces its lazily reduced field elements. Changed files: guests/vendor/README.md.

11 Limits#

  • The EVM's MULMOD and MODEXP, BLS12-381 and every primitive outside §10's table run as instructions. No signature or pairing is delegated: secp256k1 recovery is k256 code over MOD_MUL and EC_ADD, a BN254 pairing ark-bn254 code over MOD_MUL.
  • A delegation is an operation's core: padding, a sponge or block loop, a scalar multiplication's ladder and a multi-call operation's order are guest code, proven as instructions.
  • A call's result is bound to memory alone: the frame after it is the function of the frame before.
  • This executor implements every family, so no proof here runs a base shim's software path.
  • Retired numbers are ecall-abi.md §4's.

Auditeurs/Délégations

Les circuits de délégation

Spécification normativedocs/spec/delegation-circuits.mdVoir en Markdown

Résumé

Les six circuits de délégation au format de base. Après les constructions qu’ils partagent (la chaîne de cadres, la lecture de l’ancre, la décomposition de l’écart, la chaîne de canonicité, la règle du code unique, les opérations sur octets par XOR8, et le liant RAM pour les opérations en plusieurs appels), la page spécifie le cadre, les colonnes, les portes et les lookups de chaque circuit, pourquoi il admet sa fonction et aucune autre, ainsi que son coût et ses appelants : KECCAK_F, POSEIDON2, FR_ARITH, MOD_MUL, SHA256_COMP et EC_ADD.

Le texte normatif ci-dessous est tenu à jour en anglais, langue canonique de la spécification.

The circuits of the six delegation families the base format registers (recursion.md §1.2): KECCAK_F, POSEIDON2, FR_ARITH, MOD_MUL, SHA256_COMP, EC_ADD. A row is one invocation of a function of a frame of guest memory. For each circuit: its frame, columns, gates and lookups, and why it admits that function and no other. The call, the anchor's pairing, declaration and heights are delegation.md's.

1 Shared constructions#

Each circuit is constraints::delegation's frame over words frame words beside the family's function. None has a setup column; its only tables are its channels' virtual ones (lookup.md §3). live is the one mask, boolean by live_boolean and every lookup's selector. A padding row is all zero and satisfies every gate, a constant term riding live (gkr.md §4).

M[0..4]        cycle  live  base  anchor_value
M[4 + 4j ..]   word j: addr_j  read_ts_j  read_j  write_j        w{j}_addr … w{j}_write_value

Frame chain. Each word is read and written once at a pinned address, as two RAM leaves over memory.md §1's tuple T, from M columns because a leaf reads no W (memory.md §8):

read_w{j}        live·T(RAM, addr_j, read_ts_j, read_j) + 1 − live
write_w{j}       live·T(RAM, addr_j, 4·cycle, write_j) + 1 − live
addr_w{j}        live·(addr_j − base − 4j) = 0
base_aligned     live·(base − RAM_ORIGIN − 4·base_low) = 0          base_low  < 2^29
base_in_window   live·(2^31 − 4·words − base − base_room) = 0       base_room < 2^31

The bounds are delegation.md §4's frame rules, alignment a decomposition because 4 is a unit of Fr. A word the call leaves alone is held by writes_back_w{j}, write_j = read_j; every other written word is bounded below 2^32 by its circuit. A frame lies in RAM proper (delegation.md §4), which starts as the image's words or 0 and which every writer — an execution family (memory-ops.md §5), a frame, FIELD_IO's export (recursion.md §5) — leaves holding words, so a frame word a circuit reads is a word without a bound of its own.

Anchor read. Two leaves in the family's address space s (delegation.md §5) pair the row with its request: it writes the answer T(s, base, 0, 0) and reads T(s, base, 4·cycle + 3, anchor_value), what the request wrote back; anchor_value is free. That makes words + 1 leaves a side, padded with literal 1s to a power of two.

Gap decomposition. Each read precedes the row's write: gap_j = 4·cycle − 1 − read_ts_j is in [0, 2^38). TIMESTAMP would need a 2^20 shard (lookup.md §3), so the frame bounds its gaps, base_low and base_room itself, at the head of W:

  • bit form, at 2^8, where no table fits: 38 booleans a word, gap{j}_{i}, under gap_w{j}, live·(gap_j − Σ_i 2^i·g_i) = 0, and 29 and 31 for base_low and base_room: 38·words + 60 columns, each with its booleanity gate.
  • chunk form, at 2^16 and above, with no gate: a bound x ∈ [0, 2^{16q+r}), 0 < r < 16, is q committed chunks c_k of weight 2^{16(k+1)}, a RANGE16 obligation on each and on the remainder x − Σ_k 2^{16(k+1)}·c_k, and one on 2^{16−r}·c_top, which bounds only beside the chunk's direct one (lookup.md §11). A gap (r = 6) is gap{j}_c0 and gap{j}_c1; base_low and base_room (r = 13, 15) take base_low_hi and base_room_hi: 2·words + 4 columns and 4·words + 6 obligations.

The frame's gates are live_boolean, the addr_w{j}, base_aligned and base_in_window, words + 3, and in the bit form the gap_w{j} and each bit's booleanity besides.

Canonicity chain. A value X in limbs x_0 … x_7 < 2^32 is compared with a modulus m, limbs m_i < 2^32, through boolean borrows β_i and differences d_i ∈ [0, 2^32):

<v>_canonical{i}    x_i − m_i − β_{i−1} + 2^32·β_i − d_i = 0        i = 0 … 7, β_{−1} = 0

Every term is a small integer, so the eight sum over ℤ to X − m + 2^256·β_7 = D, 0 ≤ D < 2^256: β_7 = 1 exactly when X < m. Against Fr's p (§3, §4) the m_i are literals, x_i − p_i rides live and each d_i is 32 booleans; against a selected modulus (§5, §7) the m_i are columns, 0 on a padding row, and each d_i has a 32-bit bound (memory.md §7).

Gated conclusion. The chain's last gate, <v>_below_modulus, is live − β_7 = 0 where every live row reads X; where only rows with enable = 1 read it, it is the gated conclusion enable·(1 − β_7) = 0. β_7 = enable would demand X ≥ m wherever enable = 0, so a row holding a reduced X it does not read would have no witness.

One-code rule. A frame word naming one of k cases is decoded into boolean selectors s_c by word − Σ_c code_c·s_c = 0 and Σ_c s_c − live = 0. The second is not implied: a code 0 has no selector set and a code that is a sum of two has two (1 + 2 = 3), mixing cases. With both, the word and any column pinned to Σ_c lit_c·s_c are one entry of a table of literals, selected and bounded by a degree-1 gate.

Byte operations. Where the unit is the byte (§2, §6), each Boolean operation is one XOR8 obligation (e_0, e_1, e_2), e_2 = e_0 ^ e_1 with all three bytes (lookup.md §3): e_1 and e_2 columns, e_0 any literal-weighted form with a constant (lookup.md §5). The rest is linear in the results: a & b = (a + b − (a ^ b))/2, ¬a & b = (b − a + (a ^ b))/2; against a literal k, v & k = (v + k − (v ^ k))/2 splits a byte at any bit, so a rotation or shift of a word held as bytes is a literal-weighted form over its bytes and their masked copies; and (0, c, c) bounds c to a byte. On true bytes and true XORs each form is exact over ℤ, so its value is the integer it denotes.

RAM glue. An operation too wide for a row is several invocations on one frame, a frame word naming the step (§2, §6, §7). Each proves its step on the frame as it finds it: its reads lie on each word's one history (memory.md §9), so it reads the previous step's writes unless the guest wrote there between. No gate joins two rows, and a shard boundary may fall between them. That every step runs, in order, is the calling code's, which the execution families prove.

2 KECCAK_F#

One invocation is one round of keccak-f[1600]; a permutation is 24 on one frame, the sponge and padding being guest code. The circuit, constraints::keccak, is flat, every gate in gate list 0, and its unit is the byte (§1): no column is a bit but live and the 24 round selectors.

2.1 Frame and columns#

51 words (constants::keccak; M[0..208]), the state in SHA-3 byte order: lane A[x][y], i = 5y + x, at words 1 + 2i (low half) and 2 + 2i. A[i][b] is its byte b; lane coordinates are mod 5.

word read written
0 the round r ∈ [0, 24) yes unchanged
1–50 the state yes the round's output
W name
0..106 the frame's chunks (§1)
106..130 round_sel{r} s_r, one a round
130..134 rc_b{b} rc_t, byte b_t = 0, 1, 3, 7 of the round's constant
134..334 state_in_l{i}_b{b} A
334..494 parity_x{x}_b{b}_s{s} column x's lanes XORed in four steps, the last C[x]
494..574 c_mask_…, theta_d_… C ^ 0x80; D
574..774 theta_a_… A′ = A ^ D
774..950 rho_mask_… A′ ^ mask on the 22 lanes not rotated by whole bytes
950..1150 rho_out_… B, after ρ and π
1150..1550 chi_and_…, chi_out_… B1 ^ B2; χ's output
1550..1554 iota_out_b{b} lane 0's bytes b_t after ι
1554..1556 the multiplicities

2.2 Gates and obligations#

385 gates; O is chi_out, but iota_out at lane 0's bytes b_t; r_xy = ROTATIONS[y][x].

gate count expression
the frame's (§1) 54
round{r}_boolean 24 s_r − s_r²
round_rule 1 read_0 − Σ_r r·s_r
one_round_a_live_row 1 Σ_r s_r − live
rc{t}_rule 4 rc_t − Σ_r s_r·(byte b_t of ROUND_CONSTANTS[r])
writes_back_w0 1 write_0 − read_0
input_w{j}, j = 1 + 2i + h 50 read_j − Σ_{k<4} 2^{8k}·A[i][4h + k]
output_w{j} 50 write_j − Σ_{k<4} 2^{8k}·O[i][4h + k]
rho_pi_l{i}_b{j} 200 B[y][2x + 3y][j] − rot_j(A′[x][y], r_xy), its constant times live

A rotation by 8q + s is linear in a lane's bytes v and their copies μ = v ^ mask (§1), mask = 256 − 2^{8−s} being the top s bits; with u = j − q and w = u − 1 mod 8,

rot_j(v) = 2^{s−1}·(v_u + μ_u) + 2^{s−9}·(v_w − μ_w) + mask·(2^{s−9} − 2^{s−1})      s > 0
rot_j(v) = v_u                                                                    s = 0

v_u's low bits moved up and v_w's top bits down, (v + mask − μ)/2 being v & mask.

The obligations are the frame's 210 on RANGE16 (§1) and 1,020 on XOR8, one a byte:

step count obligation e_2 = e_0 ^ e_1
θ 160 parity_s = parity_{s−1} ^ A[x][s + 1], s < 4, parity_{−1} = A[x][0]
θ 40 c_mask = 0x80 ^ C[x]
θ 40 D[x] = rot(C[x + 1], 1) ^ C[x − 1], c_mask as μ
θ 200 A′[x][y] = D[x] ^ A[x][y]
ρ 176 rho_mask = mask ^ A′
χ 200 chi_and = B1 ^ B2, Bk = B[x + k][y]
χ 200 chi_out = ((B2 − B1 + chi_and)/2) ^ B[x][y]
ι 4 iota_out_t = rc_t ^ chi_out[0][b_t]

2.3 Why it is sound#

Every byte column is an entry of some obligation, so all are bytes, each obligation is the operation it names and each form the integer it denotes (§1): rot because μ is the true XOR, and (B2 − B1 + chi_and)/2 is ¬B1 & B2. The channel alone fixes parity, c_mask, theta_d and chi_and. B is committed, and pinned by rho_pi, because χ reads every lane at an entry only a column may fill.

input_w and output_w are each a word's byte decomposition and its 32-bit bound, so no state word has a range obligation; without output_w a row could write any state. Both are ungated and degree 1, a padding row's words and bytes being 0, which pins its state bytes to 0; a cell that only live-gated gates and obligations reach is free on a padding row, to no effect.

one_round_a_live_row is the one-code rule (§1) over codes 0 … 23: without it a live row could set no selector, claiming round 0, or two spelling a third, and ι would add no constant or a wrong one. The constant is a table of literals the selectors pick (rc{t}_rule), with no lookup or commitment. So a live row writes round read_0 of the state it read.

A permutation is RAM glue (§1) over guest_sdk::keccak256's loop, which stores r = 0 … 23 in word 0 before each call. crates/checker/tests/keccak.rs holds every gate and obligation over 24 such rows to a round written apart in u64 and, through emulator::keccak_round, to tiny-keccak.

2.4 Cost and callers#

1,764 committed columns and, at 2^18, 5,490 inner ones in 29 gate lists, 11 row-wise and 18 halving, all the two memory trees' and the two fraction trees'. The 1,020 obligations and the table's fraction fill 1,021 of the XOR8 tree's 1,024 leaves (lookup.md §6); four more would double it, 4,100 more inner columns. So ι is four obligations: a round constant is zero outside bytes 0, 1, 3 and 7 (constants::keccak::IOTA_BYTES_ARE_THE_ONLY_ONES, checked at compile time).

A 2^18 shard (delegation.md §9) holds 10,922 permutations; its proof is 381,100 bytes (proof.md §9), 34.9 a permutation, and its forward pass 45.2 GB of inner layers (streaming.md §1), which is what sets a block's peak. Caller: guest_sdk::keccak256 (delegation.md §10).

3 POSEIDON2#

One invocation is one transcript::poseidon2_permute (transcript.md §1). The circuit, constraints::poseidon2, is at 2^8 with no lookup, bounding in bits (§1), and is the one delegation circuit that computes above gate list 0.

3.1 Frame and columns#

24 words (constants::poseidon2):

words read written
8l … 8l + 7 lane l, l < 3 yes the permuted lane

A lane is its value's canonical encoding (Fr::to_bytes), not §4's Montgomery form, so the circuit is the permutation itself; the caller's six conversions are small beside the 240 S-box multiplications a call replaces.

M[0..100] and W[0..972] are the frame (§1). W[972..4092] holds 520 booleans for each of six values, the lanes read (in0 … in2) then written (out0 … out2): 256 word bits, then the canonicity chain's (§1) 256 difference bits and 8 borrows.

3.2 Gates#

Gate list 0 holds 4,245: the frame's 51 (§1), a booleanity gate on each W column, and 17 a value, over its read or written words: eight <v>_word{k}, word_k − Σ_t 2^t·bit_{k,t}, and its canonicity chain against p (§1), eight <v>_canonical{i} and <v>_below_modulus, live − β_7.

The permutation is computed, not witnessed: three gate lists a round r, S-boxing every lane of a full round and lane 0 of a partial one, whose other lanes the first two lists copy:

list 3r         q_i = (x_i + c_{r,i})²       t_i = x_i + c_{r,i}
list 3r + 1     q2_i = q_i²                  t_i copied
list 3r + 2     x′ = M_r·v                   v_i = q2_i·t_i, or x_i on a copied lane

M_r is E or I and the constants are literals of the gates; round 0's x is E applied to in_l = Σ_k 2^{32k}·read_{8l+k}. A committed column is read by gate list 0 only (gkr.md §2), so live and out_l = Σ_k 2^{32k}·write_{8l+k} are carried up to gate list 192, which holds the last three gates,

out_lane{l}     live·(x_l − out_l) = 0          x the state after round 63

gated because a padding row computes the permutation of the zero state, which is not zero.

3.3 Why it is sound#

A layer's column is forced by the gate that writes it, so x is the permutation of (in_0, in_1, in_2) as field elements. The word gates make each in_l and out_l the integer its words spell, and the chains put it below p: a lane at or above p has no witness, and out_lane fixes all 24 written words, where without the chains on out a row could write x_l + p. The forward pass accepts Plonky3's permutation vectors (crates/checker/tests/poseidon2.rs).

3.4 Cost and callers#

4,192 committed columns and 2,020 inner ones in 201 gate lists, 193 row-wise and 8 halving: 736 the rounds' (15 a full round, 11 a partial one), 768 the four carried columns', the rest the memory trees'. A 2^8 shard holds 256 permutations; its proof is 664,780 bytes, 2,597 a permutation. Caller: transcript::poseidon2_permute on the guest target (delegation.md §10).

4 FR_ARITH#

One invocation is one Fr addition, multiplication or inversion. The circuit, constraints::fr_arith, is flat, at 2^8 with no lookup, bounding in bits (§1).

4.1 Frame and encoding#

25 words (constants::fr_arith):

words read written
0 the code: 1 add, 2 multiply, 3 inverse (OPS) yes unchanged
1–8, 9–16 a, b yes unchanged
17–24 out yes, unconstrained the result

A value is Fr's in-memory form, Fr::to_memory_bytes: the canonical encoding of the Montgomery representative x·R, R = 2^256 mod p. The circuit computes what Fr's own operators compute on representatives,

add         out = a + b
multiply    out = a·b·R⁻¹
inverse     out = R²·a⁻¹, and 0 at a = 0

because a frame of values would cost the guest a Montgomery conversion per value, more than the multiplication a call replaces. Fr::inverse answers None at 0 itself and makes no call.

4.2 Columns and gates#

M[0..104] and W[0..1010] are the frame (§1); W[1010..2570] 520 booleans for each of a, b (read) and out (written), as §3.1; W[2570..2573] the selectors f_add, f_mul, f_inv (selector1 … selector3); W[2573..2576] the field columns prod, inv and z (is_zero). The 2,701 gates: the frame's 53 (§1); 2,573 booleanity gates, on every bit and selector; §3.2's 17 per value; writes_back_w{j} for j < 17; and, a, b and out being the forms Σ_k 2^{32k}·word_k,

gate expression
opcode_rule read_0 − f_add − 2·f_mul − 3·f_inv
one_op_a_live_row f_add + f_mul + f_inv − live
prod_rule prod − a·b
inv_is_an_inverse a·inv + z − f_inv
is_zero_at_nonzero a·z
inverse_of_zero_is_zero z·inv
out_rule out − f_add·(a + b) − R⁻¹·f_mul·prod − R²·f_inv·inv

R⁻¹ and R² are literals derived from constants::FR_R.

4.3 Why it is sound#

As in §3.3, each value is the integer below p its words spell, so out_rule fixes the eight written words. prod is committed, under an ungated gate, because a selector times a·b is degree 3. On an inverse row a ≠ 0 forces z = 0 and inv = a⁻¹, and a = 0 forces z = 1 and inv = 0; without is_zero_at_nonzero, z = 1 and inv = 0 pass at any a, and without inverse_of_zero_is_zero, inv is free at a = 0. one_op_a_live_row is the one-code rule (§1): 1 + 2 = 3, so opcode_rule alone lets f_add and f_mul answer an inversion with a + b + a·b·R⁻¹.

4.4 Cost and callers#

2,680 committed columns and 142 inner ones, all the memory trees', in 14 gate lists, 6 row-wise and 8 halving. A 2^8 shard holds 256 operations; its proof is 266,292 bytes, 1,040 an operation. Caller: field's addition, Montgomery multiplication and inverse on the guest target (delegation.md §10).

5 MOD_MUL#

One invocation is one multiplication out = a·b mod m of 256-bit integers, m one of four fixed primes a frame word selects. The circuit is constraints::mod_mul.

5.1 The frame and the columns#

25 words (constants::mod_mul). A value is a plain residue, not a Montgomery one, in eight 32-bit limbs, least significant first.

words
0 the selector: 1 secp256k1's base field p, 2 its order n, 3 BN254's base field q, 4 its scalar field r (CODES, MODULI) read, written back
1–8, 9–16 a, b, each below the selected modulus read, written back
17–24 out written; the value read is ignored

Codes start at 1, so a zero word names no field. The EVM's MULMOD, whose modulus is arbitrary, is not this call and runs as guest code.

M[0..104], W[0..54]   the frame (§1)
W[54..58]     selector1 … selector4        s_c, one a code
W[58..66]     m_limb{k}                    m_k, the selected modulus
W[66..162]    <v>{k}_hi, <v>_diff{i}, <v>_diff{i}_hi, <v>_borrow{i}     for v = a, b, out
W[162..178]   q_limb{k}, q_limb{k}_hi      the quotient and its halfwords
W[178..220]   carry{k}, carry{k}_c0, carry{k}_c1      c_k + 2^36 for k < 14, and two chunks
W[220]        range16_multiplicity

5.2 Gates and lookups#

read_j and write_j are word j's two values (§1), a_i and b_i read limbs, out_i written ones, and c_k = carry{k} − 2^36·live. Each expression is held to 0:

gate count expression
the frame's (§1) 28
writes_back_w{j}, j < 17 17 write_j − read_j
selector{c}_boolean; selector_rule; one_modulus_a_live_row 6 s_c − s_c²; read_0 − Σ_c c·s_c; Σ_c s_c − live
m_limb{k}_rule 8 m_k − Σ_c s_c·MODULI[c][k]
<v>_borrow{i}_boolean, <v>_canonical{i}, <v>_below_modulus 51 v's canonicity chain (§1) against the m_k columns, concluding live − β_7
limb{k}, k < 15 15 Σ_{i+j=k} (a_i·b_j − q_i·m_j) − out_k + c_{k−1} − 2^32·c_k; out_k past limb 7, c_{−1} and c_14 are 0

274 RANGE16 obligations, all under live: the frame's 106 (§1); a pair — the 32-bit bound of memory.md §7, two obligations over a committed high halfword — on every limb of a, b, out and q and on every diff_i (112); and each carry{k} in [0, 2^37), by two chunks and four obligations as a gap (§1) (56).

5.3 Why it is sound#

The field. By the one-code rule (§1), m is the modulus word 0 names. Codes add (1 + 3 = 4), so without one_modulus_a_live_row selectors 1 and 3 answer a request for r modulo p + q; with it each m_k is one literal, which is all that keeps m's limbs, bound by no obligation, below 2^32.

The product. Every limb of a, b, out, q and m being below 2^32, a position's products sum below 2^67 a side and the carries lie in [−2^36, 2^36), so no term nears Fr's modulus: the fifteen limb{k} equations hold over ℤ and, weighted by 2^{32k}, sum to a·b = q·m + out, position 14 having no carry out.

The reduction is out_below_modulus: without it (q − 1, out + m) satisfies every other relation wherever out + m fits eight limbs.

The operand bounds make the relation total, not out right: with a, b < m, q = (a·b − out)/m < m, so every frame the circuit admits has an eight-limb quotient. A caller holding a lazily reduced value therefore owes a reduction below m, not below 2^256. The emulator's mod_mul_frame refuses the frames no proof could cover, a selector that is no code and an operand at or above m (EmuError::DelegationFrame).

5.4 Cost and callers#

Shape: circuits.md §1. A 2^16 shard (delegation.md §9) is 65,536 multiplications at 2.1 proof bytes each; its forward pass, 2,180 row-wise inner columns × 2^16 rows × 32 bytes, is 4.6 GB.

guest_sdk::recursion::mod_mul makes the call over a ModMulFrame. The vendored k256 reaches it from its field and scalar multiplies (codes 1, 2), the vendored ark-ff from BN254's Montgomery multiply (codes 3, 4): delegation.md §10.

6 SHA256_COMP#

One invocation is four rounds of SHA-256's compression function and four words of its message schedule; a compression is sixteen invocations on one frame, joined by RAM glue (§1). Padding, the block loop and the final addition of the chaining value are the caller's. The circuit is constraints::sha256.

6.1 The frame#

25 words (constants::sha256):

words read written
0 the round group r < 16 unchanged
1–8 the working variables a … h a … h four rounds on
9–24 the schedule window W_{4r} … W_{4r+15} moved down four words, W_{4r+16} … W_{4r+19} last

Call 0 reads the chaining value as a … h and the block, decoded big-endian, as the window. Over a row the state is two sequences: A_0 … A_{−3} are a … d as read, A_4 … A_1 are a … d as written, and E_j is the same over e … h, so each of the sixteen is a frame column. For k < 4 and m < 4, every sum mod 2^32:

T1          = E_{k−3} + Σ1(E_k) + Ch(E_k, E_{k−1}, E_{k−2}) + K_{4r+k} + W_{4r+k}
A_{k+1}     = T1 + Σ0(A_k) + Maj(A_k, A_{k−1}, A_{k−2})
E_{k+1}     = A_{k−3} + T1
W_{4r+16+m} = σ1(W_{4r+14+m}) + W_{4r+9+m} + σ0(W_{4r+1+m}) + W_{4r+m}

Call r + 4's rounds read the words call r derives, so the guest computes no schedule; calls 12–15 derive words no round reads.

6.2 Bytes and their obligations#

No column is a bit but live and the group selectors g_r. A word that enters a Boolean operation has four byte columns, and each such operation is one XOR8 obligation (x, y, x ^ y) a byte (lookup.md §3), of which position 0 alone may be a literal-weighted form (lookup.md §5).

  • A rotation is linear. With μ = v ^ (2^s − 1) committed, a byte v splits into lo = (v + 2^s − 1 − μ)/2 and hi = (v − lo)/2^s. Byte j of ROTR_{8t+s}(V) is hi(v_{j+t}) + 2^{8−s}·lo(v_{j+t+1}), indices mod 4, and for s < 8 the word ROTR_s(V) is (V − lo(v_0))/2^s + 2^{32−s}·lo(v_0).
  • The big sigmas nest, Σ0(a) = ROTR2(a ^ ROTR11(a ^ ROTR9(a))) and Σ1(e) = ROTR6(e ^ ROTR5(e ^ ROTR14(e))), so each XOR has one rotated operand and the outer rotation is a word's: 17 obligations a sigma.
  • The small sigmas end in a shift, σ0(x) = ROTR7(x ^ ROTR11(x)) ^ SHR3(x) and σ1(x) = ROTR17(x ^ ROTR2(x)) ^ SHR10(x), so their outer XOR has two derived operands: the shifted bytes are committed and pinned by gates. 16 and 15 obligations, SHR10's top byte being 0.
  • Ch and Maj are linear in XORs, Ch(e, f, g) = (f + g − (e ^ f) + (e ^ g))/2 and Maj(a, b, c) = (a + b + c − (a ^ b ^ c))/2: 8 obligations each.
  • A carry c is a byte by (0, c, c).

That is 52 obligations a round and 32 a schedule word, 336 on XOR8. RANGE16 carries 114: the frame's 106 (§1) and a pair (§5.2) on each written word without bytes, A_4, E_4, W_{4r+18} and W_{4r+19}.

M[0..104], W[0..54]   the frame (§1)
W[54..70]     group{r}                     g_r, one a group
W[70..118]    a{j}_b{b}, e{j}_b{b}         bytes of A_{−2} … A_3 and E_{−2} … E_3 (j = m2 … 3)
W[118..150]   w{i}_b{b}, n{m}_b{b}         bytes of window words 1–4, 14, 15, derived words 0, 1
W[150..358]   r{k}_…                       52 a round: the big sigmas' masks and XORs (34),
                                           e^f, e^g, a^b, c^a^b (16), two carries
W[358..514]   s{m}_…                       39 a schedule word: the small sigmas' masks, XORs
                                           and shifted bytes (38), a carry
W[514..518]   w{j}_written_hi              high halfwords of A_4, E_4, W_{4r+18}, W_{4r+19}
W[518..520]   range16_multiplicity, xor8_multiplicity

6.3 Gates#

All of degree 1 but the frame's and the booleans:

gate count expression
the frame's (§1) 28
group{r}_boolean; group_rule; one_group_a_live_row 18 g_r − g_r²; read_0 − Σ_r r·g_r; Σ_r g_r − live
writes_back_w0 1 write_0 − read_0
a{j}_decode, a{j}_encode, e{j}_…, w{i}_decode, n{m}_encode 20 a word − Σ_b 2^{8b}·byte_b, for every word with bytes
w{i}_shift, i < 12 12 write_{9+i} − read_{13+i}
r{k}_a, r{k}_e 8 §6.1's A_{k+1} and E_{k+1}, as word + 2^32·carry − sum
s{m}_sum 4 §6.1's W_{4r+16+m}, likewise
s{m}_shr3_b{b}, s{m}_shr10_b{b} 28 a committed shifted byte − its form

K_{4r+k} is the form Σ_r K_{4r+k}·g_r.

6.4 Why it is sound#

A sum's operands are words: those with bytes by their obligations, and d, h, W_{4r} and W_{4r+9} … W_{4r+12}, which only sums read, because the frame lies in [RAM_ORIGIN, 2^31) (§1), below advice, where every initial value and every write is a word (memory-ops.md §5; §1 for these circuits). Its carry being a byte, a sum gate holds over ℤ, and its left word, bounded by its bytes or its pair, is the sum mod 2^32. Without the carry's range any word satisfies the gate; without the pair on A_4, a carry of 0 writes the unreduced sum. Every word a row writes is therefore a word: a copy, one with bytes, or one of the four with a pair.

Group 0's code being 0, group_rule alone admits a live row with no selector or with g_0 beside another; one_group_a_live_row refuses those and two selectors spelling a third group, each a round under a wrong constant. Sixteen rows are one compression by RAM glue (§1) and by guest_sdk::recursion::sha256_comp, which stores r = 0 … 15 in word 0 before each call; the emulator's sha256_frame refuses a group word of 16 or more. crates/checker/tests/sha256.rs evaluates every gate and obligation over sixteen chained rows built from FIPS 180-4 in u32 arithmetic and holds their output to the standard's abc digest.

6.5 Cost and callers#

Shape: circuits.md §1. A 2^18 shard (delegation.md §9) holds 16,384 compressions at 11.6 proof bytes each; its forward pass, 2,694 row-wise inner columns × 2^18 × 32 bytes, is 22.6 GB.

guest_sdk::sha256 pads, walks the blocks, and for each runs sha256_comp's sixteen calls and adds the result to the chaining value. The vendored revm-precompile routes Crypto::sha256 to it: precompile 0x02, and the stateless guest's SSZ hashing (delegation.md §10).

7 EC_ADD#

One invocation is a third of one complete point addition P1 + P2 on secp256k1 or BN254 G1, in homogeneous projective coordinates (x = X/Z, y = Y/Z). An addition is three invocations on one frame in group order, joined by RAM glue (§1); scalar multiplication is guest code over it. The circuit is constraints::ec_add.

7.1 The formula#

Renes–Costello–Batina 2015, Algorithm 7, for y² = x³ + b, with b3 = 3b: 21 and 9 (constants::ec_add::CURVE_B3).

group 0   xx = X1·X2            yy = Y1·Y2            zz = Z1·Z2
group 1   m4 = (X1+Y1)(X2+Y2)   m5 = (Y1+Z1)(Y2+Z2)   m6 = (X1+Z1)(X2+Z2)
group 2   X3 = xy·ym − byz3·xz  Y3 = yp·ym + bxx9·xz  Z3 = yz·yp + xx3·xy

xy = m4 − xx − yy   yz = m5 − yy − zz   xz = m6 − xx − zz   ym = yy − b3·zz
yp = yy + b3·zz     byz3 = b3·yz        xx3 = 3·xx          bxx9 = 3·b3·xx

Both groups have prime order, so the formula is complete: a doubling, P + (−P), the identity (0 : 1 : 0) and any Z take no special case, in the guest or in a row, and nothing is inverted. The formula is the caller's: the vendored k256's ProjectivePoint addition is this algorithm on these coordinates, so the delegated and the software path return the same representative.

The twelve multiplications are nine reductions, each of X3, Y3, Z3 being two products under one quotient. A row holds three, not nine, because a shard's memory grows with its row's width and its height cannot fall below 2^16 (§7.5).

7.2 The frame and the columns#

97 words (constants::ec_add), a value as in §5.1:

words read by group written by group
0 the selector, one of CODES: 1–3 secp256k1's groups 0–2, 4–6 BN254 G1's all none
1–24 X1, Y1, Z1 0, 1 2, as X3, Y3, Z3
25–48 X2, Y2, Z2 0, 1 none
49–72 xx, yy, zz 2 0
73–96 m4, m5, m6 2 1

A row has three slots, each one reduction of one shape:

A·B + C·D + 1024·m² = q·m + out,     out < m

Group 0's (A, B) are (X1, X2), (Y1, Y2), (Z1, Z2) and group 1's the three pairs of sums, both with C = D = 0. Group 2's (A, B, C, D) are (xy, ym, byz3, −xz), (yp, ym, bxx9, xz) and (yz, yp, xx3, xy): a product's sign rides its operand.

M[0..392], W[0..198]   the frame (§1)
W[198..295]   word{j}_hi                   the high halfword of every word's read value
W[295..301]   selector{c}                  s_c, one a code
W[301..310]   m_limb{k}, b3                the curve's modulus and 3b
W[310..334]   bzz3_{k}, byz3_{k}, bxx9_{k}     b3·zz_k, b3·(m5_k − yy_k − zz_k), 3·b3·xx_k
W[334..622]   <v>_diff{i}, <v>_diff{i}_hi, <v>_borrow{i}     chains of the twelve values x1 … m6
W[622..1027]  slot{r}_…, out{r}_…, q{r}_…, carry{r}_…    135 a slot: four operands (32), out and
              its halfwords (16), a nine-limb q and its halfwords (18), 15 carries c_k + 2^46
              with two chunks each (45), out's chain (24)
W[1027]       range16_multiplicity

7.3 Gates and lookups#

G_g is the sum of the two selectors naming group g, and c_k = carry − 2^46·live:

gate count expression
the frame's (§1) 100
selector{c}_boolean, selector_rule, one_code_a_live_row 8 §5.2's, over six codes
m_limb{k}_rule, b3_rule 9 the column − Σ_c s_c·(its literal for code c's curve)
bzz3_{k}_rule, byz3_{k}_rule, bxx9_{k}_rule 24 the column − its product above
<v>_borrow{i}_boolean, <v>_canonical{i} 240 canonicity chains (§1) of the twelve values and the three outs, against m_k
<v>_below_modulus 15 e·(1 − β_7): e is G_0 + G_1 for x1 … z2, G_2 for xx … m6, live for an out
operand{r}_{o}_{k}_rule 96 an operand limb − Σ_g G_g·(group g's expression at that limb)
slot{r}_limb{k}, k < 16 48 Σ_{i+j=k} (A_i·B_j + C_i·D_j + 1024·m_i·m_j − q_i·m_j) − out_k + c_{k−1} − 2^32·c_k, q having nine limbs
writes_back_w{j} 97 write_j − read_j − G_g·(out_k − read_j), for the group g and slot limb k that write word j, if any

1,110 RANGE16 obligations under live: the frame's 394 (§1); a pair (§5.2) on every word's read value (194), every chain difference (240), every out limb (48) and every q limb (54); and each carry in [0, 2^47), four apiece (180).

7.4 Why it is sound#

The curve and the group are §5.3's argument over six codes: codes add (1 + 3 = 4, 2 + 4 = 6), and the one-code rule is also all that keeps m's limbs and b3 literals.

The operands. An operand limb is its group's expression: a combination, with coefficients of at most 3, of frame limbs below 2^32 and of their products with b3. Its pin is therefore its bound, below 2^38 in magnitude, and it carries no obligation. It is a committed column because the expression depends on the group, and a selector times a product of limbs would be degree 3; b3 enters through the three helper columns for the same reason.

The identity. As in §5.3: a position stays below 2^78, the carries in [−2^46, 2^46) (CARRY_OFFSET_BITS), the sixteen equations hold over ℤ and close because position 15 has no carry out, and out < m makes out the residue of A·B + C·D. The 1024·m² (OFFSET_MULTIPLE) keeps the left side non-negative, a quotient's limbs being unsigned: it is lowest in group 2's Y3, at −673·m² by its operands' ceilings 22m, 22m, 63m and 3m. One literal serves every slot, a group-dependent offset being degree 3, and q < 1697·m fits nine limbs. ec_add::artifact checks both constants against the ceilings when it builds the circuit.

Canonicity. out < m is the reduction. A read value below m is what the ceilings assume, and so what gives every admitted frame a quotient; the emulator's ec_add_frame refuses a frame whose group reads a value at or above m, or whose selector is no code. Each such conclusion is gated (§1) on the groups that read the value: every lane is below m on every row a guest builds (EcAddFrame::of zeroes the intermediates), so β_7 = e would leave no row a witness.

What the guest owns. Each third is proved; their order is the guest's. guest_sdk::recursion::ec_add_complete writes the three codes in turn, and groups out of order are not refused but compute another point from stale lanes. Nor is a point held to its curve: what is proved is the formula's arithmetic.

7.5 Cost and callers#

Every bound is a RANGE16 obligation, so the family sits at 2^16, the channel's floor (lookup.md §3), and no other height is practical: as bits the 97 gaps alone would be 3,686 columns, and at 2^18 the forward pass below would be 73 GB. Shape: circuits.md §1. A shard holds 21,845 additions at 19.9 proof bytes each; its forward pass, 8,708 row-wise inner columns × 2^16 × 32 bytes, is 18.3 GB.

guest_sdk::ec_add makes the three calls over an EcAddFrame, and guest_sdk::ec_mul is double-and-add over it. The vendored k256 routes ProjectivePoint's addition, mixed addition and doubling here, and the vendored revm-precompile routes Crypto::bn254_g1_add and Crypto::bn254_g1_mul, precompiles 0x06 and 0x07: delegation.md §10.

Auditeurs/Chaîne de preuve

Le prouveur en flux

Spécification normativedocs/spec/streaming.mdVoir en Markdown

Résumé

Comment une exécution devient une preuve de bloc sans que sa trace soit jamais conservée. La page donne les coûts mesurés du prouveur, spécifie les deux passes et pourquoi l’engagement doit précéder la preuve, ce qui subsiste d’une exécution, le plan de shards et la façon dont les shards sont découpés pendant que l’exécuteur tourne, le pipeline de traitement parallèle et ses garanties, y compris le fait que la preuve ne dépend pas de l’ordonnancement, et le chemin archivé sur lequel s’appuie la suite de falsification.

Le texte normatif ci-dessous est tenu à jour en anglais, langue canonique de la spécification.

How one execution becomes a BlockProof: the guest runs twice, and a fixed number of workers commit, then prove, its shards as the executor fills them. The block is proof.md's, and its bytes do not depend on the schedule; this page fixes when each column exists, and so what a proof costs.

1 The prover, and what it costs#

prover::prove_block_streaming(setup, io, max_in_flight) proves every block: host::prove wraps it over the ProverSetup that host::setup builds from an ELF, bench prove drives it (tools.md §1), and recursion nodes are proved through it. Beside the block it returns a StreamingReport: each pass's wall clock and the executor's time inside it, the cycle and shard counts, and the most shards held at once.

Its memory follows the shards in flight, not the shard or cycle count: a partial buffer per family (§4), the last-access tables (§3), at most max_in_flight shards being worked and one filled shard's rows per family waiting (§5), and the output, 64 bytes a commitment and the ShardProofs. The executor's whole output, emulator::trace_run's buffers and event log at about 300 bytes a cycle, never exists; executing twice (§2) costs time instead.

A shard costs its height times its circuit's width (circuits.md §1), however few of its rows are live. gkr::forward holds every inner layer as field elements, 32·Σ_{k≥1} w_k·2^{n_k} bytes over layer k's width and variable count (42 GiB for a 2^18 KECCAK_F shard, 8.4 GiB for a 2^20 SHIFT_BITWISE one), and gkr::prove adds a copy of the layer it reduces and an eq table. The opening, after the forward pass is dropped, copies every committed column.

Measured on the base proof of recursion.md §10:

workload block 257,510 of glamsterdam-devnet-8, revm-block-stateless: 60 transactions, 101.5 Mgas, 198M cycles, 207 shards
machine 32 vCPUs, 247.7 GiB, --in-flight 12
pass 1 191 s; 25.7 vCPUs busy on average; one-thread fills 81% of its shard-seconds; sampled RSS at most 15.9 GiB
pass 2 2,290 s; on average 11.95 of 12 shards held and 30.4 vCPUs busy until the guest exits; then the exit's 460 s tail, whose longest stretches are the two KECCAK_F shards' one-thread fills, 200 s and 279 s
peak RSS 173.92 GiB: the two 2^18 KECCAK_F shards, together in the tail with nothing else in flight

Twelve shards in flight never reached that peak: a delegation family's height set it, and max_in_flight bounds only how many shards coincide.

2 The two passes#

pass 1                                       pass 2
  execute; for each shard as it fills:         execute again; for each shard as it fills:
    fill its M columns, commit them,             fill every column, prove the shard,
    keep the points, drop the columns            keep the proof, drop the rest
  at exit: the window families' shards         at exit: the window families' shards
  the statement, then G1–G11                   the statement's roots, the BlockProof

Each pass drives a fresh emulator::StreamingRun, which hands over a family's buffer as a ShardChunk the moment it reaches the family's height (§4); the workers of §5 take the chunks.

Pass 1 commits each shard's M columns, its family's fill with them moved out and no multiplicities, one commitment a column (in the recursion format one a stack of 2^σ, recursion.md §1.3). At exit it derives the window list, shard counts and boundary from the final state (§3), asserts the cut equals trace::plan_shards, commits the window families' shards, puts the commitments in statement order by (family, index) (proof.md §1) and runs G1–G11 (proof.md §2). A commitment reads no transcript, so only its place in the absorbed order matters, not when it was computed.

Pass 2 re-executes. The emulator is a pure function of (image, io), with no clock, randomness or threads, so it cuts the same shards; pass 2 asserts that its CycleProfile, Execution, window list and boundary are pass 1's. Each shard gets every committed column, multiplicities included, and prover::prove_shard_columns: shard transcript, GKR proof, opening (proof.md §4, §5). Proofs go to their statement positions, and prover::public_inputs copies each shard's two memory roots into the statement.

M is not recommitted: a shard's opening takes its M commitments from the statement, pass 1's, and its polynomials from pass 2's columns, so columns that differed would give an opening the verifier refuses.

3 What survives an execution#

A streaming run records no memory event. At exit StreamingRun::finish hands over each non-empty partial buffer as its family's last shard, and a StreamedExecution: the last-access tables (trace::MemoryState), the CycleProfile and the Execution (execution-trace.md §11). Beyond the shards' rows, the guest's inputs and its journal, everything the statement needs is a function of that final state: the boundary (trace::build_boundary_finals), ZERO_WINDOWS' list (trace::init_windows), the window families' teardowns (memory.md §3) and the field-window count. So a window family's shard exists only once the execution is over (§5).

A fill reads one shard through prover::ShardSource, its ShardRows a trace::RowSlice (a cycle-owning family), a trace::FrameSlice (a delegation family) or, for a window family alone, the final MemoryState. The streaming path builds it over a fresh chunk, ShardSource::archived over a slice of a TraceArchive (§6); nothing else differs. Memory columns come from a shard's rows alone (execution-trace.md §11), and checker::memory_columns_from_log rebuilds them from the event log, independently (circuits.md §3).

4 The shard plan#

A family's rows, in the order they are appended, are cut into shards of its VmConfig height h: shard i is rows [i·h, min((i + 1)·h, len)), the last padded to h with zero rows (memory.md §2). trace::plan_shards is ⌈rows/h⌉ per family over the CycleProfile, cycles for a cycle-owning family and invocations for a delegation family, so a family the execution never reached has no shard. A window family plans 0; its shards are windows (memory.md §3), counted by shard_counts in crates/prover/src/lib.rs: one INIT_TEARDOWN shard and one of each public window whatever the execution did, a ZERO_WINDOWS shard per entry of init_windows, one per advice window supplied (trace::advice_window_count), and field windows through the highest cell touched (MemoryState::field_windows). The counts are the statement's shard_counts (proof.md §1).

The flush. StreamingRun makes the cut as it runs. After a cycle is recorded, a buffer that has reached h rows is handed over as ShardChunk { family, index, rows }, index = rows/h − 1, and replaced by an empty one. A cycle appends at most one row to any buffer, the owning family's and, for a delegation request, one invocation to the delegation family's, so a buffer reaches h without passing it, a step fills at most two, and no chunk is split. At exit finish hands over the partial buffers. Chunks arrive in fill order, not statement order, and pass 1 asserts that each family's count is the plan's.

5 The pipeline#

pipeline (crates/prover/src/streaming.rs) runs both passes: max_in_flight workers under std::thread::scope and one std::sync::Mutex around a Source, which holds the executor, the filled shards no worker has claimed, and the counts. Under the lock a worker gives back its shard and claims the next: a waiting one, or else it steps the executor itself until a buffer fills (Source::claim, the only place the guest runs). Outside the lock it builds the shard's columns, works it and drops it. These are the prover's only threads and only lock; within a shard, parallelism is rayon over data.

held bound by
claimed shards, and all built from them max_in_flight one a worker; asserted in Source::claim
filled, unclaimed shards rows only, one per family the executor steps only for a claim with nothing waiting, a step fills at most two buffers and the exit one per family; asserted in Source::admit

The executor never runs ahead of demand, and there is no batch: a slow shard holds one worker. The workers are not rayon threads. A shard's MSMs, forward pass, sumcheck and opening run on rayon's global pool, so RAYON_NUM_THREADS sets the cores the shards share, and a worker blocked in that work cannot take a second shard as a rayon thread waiting in a nested join would. Fills run on the workers' own threads, one each, so up to RAYON_NUM_THREADS + max_in_flight threads are runnable. Fork-join cannot express this: below one shard per core, a batch waits for its slowest shard.

  • The block is independent of the schedule. A shard's proof is a function of the global state and its own columns, its transcript a fresh sponge seeded with the digest (proof.md §4); no proof depends on the thread count (gkr.md §5); proofs are placed by statement position. crates/prover/tests/streaming.rs compares the bytes at 1 and 8 in flight.
  • The failure returned is the earliest in fill order, at any worker count: claims follow fill order, a claimed shard is worked to its end, a failure stops later claims (Source::fail), and an executor failure ranks after every shard it filled.
  • No deadlock: the lock is never held while a shard is worked or taken twice by one worker, and nothing waits under it but the executor's step.
  • A panic stops the claims, through a drop guard (StopOnPanic) or, inside the executor, the poisoned lock; the shards in flight finish, and the panic is re-raised as itself.

The window families' shards follow the pipeline, built from the final state in rayon batches of at most max_in_flight, which are all that a ThreadPool::install around the call bounds.

The knob. max_in_flight, at least 1, is an argument because only the caller knows the machine; bench prove --in-flight defaults to 8. StreamingReport::peak_in_flight is the most shards claimed or batched at once in either pass.

6 The retained archived path#

emulator::trace_run keeps a whole execution, every buffer and the MemoryEventLog, and trace::TraceArchive::from_execution holds it (execution-trace.md §11). The per-shard component reads one through ShardSource::archived, with the same fills and shard proving: prover::statement_inputs (counts, windows, boundary, every shard's M columns), global_commit_phase, shard_columns, shard_memory_columns, prove_shard, prove_shard_columns and public_inputs. checker::TamperHarness is built on it (circuits.md §3): it writes changed cells into shards' columns, recommits changed M columns in a fresh global commit phase and re-proves, which needs an execution held still and read twice. Streaming has no such seam: pass 2 rebuilds, by re-executing, the columns pass 1 committed, so a cell changed in either pass would contradict the other.

prover::prove_block(setup, archive, plan), advance(setup, archive, until) and finish(archive) prove a block from an archive; nothing outside crates/prover/src/phases.rs calls them. prove_block refuses a plan that is not plan_shards of the archive's profile. advance fills the archive's four later phase sections in order, timing each, and decodes any it already holds, so an imported archive resumes; a stopped streaming run starts again. No column is stored: a phase rebuilds them from the archive. The sections, in proof.md §9's encodings, each refusing a byte too many or too few:

section content
PostCommit the statement's PublicInputs bytes, without roots; the global transcript after G11 as its 226-byte postcard snapshot (transcript.md §3); the four memory challenges; the digest
PostGkr per shard, in statement order: family u32, index u32, ts_start and ts_end u64, the witness commitments, the outputs, the GKR proof, the base claims' point, the shard transcript's snapshot after the GKR proof
PostOpening each shard's ShardProof bytes
Final the complete PublicInputs bytes, then the proofs

Auditeurs/Chaîne de preuve

Récursion

Spécification normativedocs/spec/recursion.mdVoir en Markdown

Résumé

Comment une preuve de base devient une seule preuve Groth16 que vérifie un contrat, sans modifier la preuve de base. La page spécifie le format de récursion et ses engagements empilés, la mémoire de corps et les quatre familles de coprocesseurs, les bandes qu’un nœud rejoue, les programmes feuilles et nœuds avec la transcription chaînée à travers l’arbre, le journal du nœud, la façon dont les ouvertures différées sont repliées en un seul accumulateur, l’ordonnanceur, le décideur Groth16 avec ses fils liés et sa cérémonie en deux phases, le contrat, et les coûts mesurés.

Le texte normatif ci-dessous est tenu à jour en anglais, langue canonique de la spécification.

How one base block proof becomes one Groth16 proof a contract checks. The section numbers are the ones the code cites. Where this page and the code disagree, the code is right.

base proof ──► leaves ──────► internal nodes ──► root ───► decider ───► contract
N shards,      each a run     each 2–4           covers    the root in  folds the root's
base format    of base shards children           0..N      Groth16      points; two pairings
  • A node is this VM proving a verifier program. It verifies shards and folds every Mercury check they defer into one accumulator (A, B), the claim e(A, [1]_2) = e(B, [x]_2). Nothing pairs before the contract.
  • Base proving is untouched. No base key, statement or proof moved a byte: a leaf verifies base shards as they are.
  • Nodes are proved in a recursion format (§1) over a field memory (§2) with four coprocessor families on it (§3–§6), and they replay tapes (§7) rather than run verifier-core on RV32, which measured 3.0B cycles for block 257,510's 207 shards: fifteen times the block itself.

1 Two formats, one code path#

1.1 The rule#

A statement is in the recursion format exactly when its VmConfig holds FIELD_WINDOWS (VmConfig::is_recursion), which is exactly when its program declares a field family. No wire form says which format applies.

1.2 The delegation registry#

constants::delegation::TYPES is one append-only table, and its first BASE_TYPES = 6 rows are all the base format knows. constraints::family_circuit is the base registry; constraints::recursion_circuit differs from it in two ways only: its ADD_SUB knows every row and carries §1.4's rule, and the five families of §2–§6 exist. VmConfig::circuit picks the registry, for a key's load rule and the prover alike.

1.3 Stacked commitments#

Every commitment a shard opens is a point its parent folds (§8.3). So a recursion shard commits each of its two phases — its M columns, and its W columns with the multiplicities — as stacks of 2^σ columns. At height 2^n, with k_M and k_W columns (VmConfig::stack_vars):

σ = min(24 − n, the smallest even σ with 2^σ ≥ max(k_M, k_W))
  • Column i is slot i mod 2^σ of stack ⌊i / 2^σ⌋. A stack is the (n + σ)-variate multilinear whose evaluations [j·2^n, (j + 1)·2^n) are slot j's column, and its commitment is that polynomial's Mercury commitment. 24 is the ceremony's size.
  • The GKR pass leaves each column's value v at u. Then σ challenges r are drawn (STACK_CHALLENGE), a stack's value is Σ_j eq(r, j)·v_j, and a setup column is a stack of one, eq(r, 0)·v, its commitment unchanged. The shard's one batch opening is at u ‖ r, over the M stacks, the W stacks, then the setup columns.

σ = 0 is the base format exactly.

1.4 A recursion request leaves a0 past its frame#

A base delegation request writes 0 into a0. A request of a type past BASE_TYPES writes a0 + 4·words (constants::delegation::a0_after), which the recursion ADD_SUB's deleg_a0_rule holds it to. So frames laid back to back replay as back-to-back ecalls, one RISC-V row a call.

2 The field memory#

2.1 The space#

address_space::FIELD = 10: cells addressed by a u32, each a whole Fr. Its tuples (FIELD, cell, ts, value) join RAM's in the one memory multiset. No instruction reaches it. Only §3–§6's rows do, each access at its row's requesting cycle c and its own slot, 4c + Δ, with a read's usual gap check; a read-only access writes back what it read. A field access is not a MemoryEventLog event, a value not being a u32: trace::MemoryState keeps each cell's last (ts, value), and a recursion execution has no TraceArchive form. It streams.

2.2 FIELD_WINDOWS#

Family 18, 2^20 rows: ZERO_WINDOWS' circuit at a stride of one cell a row. Window w is cells [h·w, h·(w + 1)), initialized to 0. The windows are consecutive from cell 0 — shard i is window i — so a statement lists none, and a cell outside them has no tuple to balance a read against.

The four families on it are invoked, by the delegation ABI: an ecall whose a0 is a frame of words in RAM. A frame's words name cells.

§ family id ecall anchor space height frame a row is
3 FR_OP 19 0x0509 11 2^20 [op, d, a, b] one field operation
4 P2_FIELD 20 0x050A 12 2^18 [n, s, x, y, d] one transcript duplex step
5 FIELD_IO 21 0x050B 13 2^18 [op, cell, ptr] eight RAM words to a cell, or back
6 FQ_OP 22 0x050C 14 2^20 [op, d, a, b] one BN254 base-field operation

3 FR_OP — one field operation a row#

op op
1 MUL d ← a·b 6 EQ a = b, or the row has no witness
2 ADD d ← a + b 7 IMM d ← word b, as an integer
3 SUB d ← a − b 8 SHL d ← a·2^32 + word b
4 MAC d ← d + a·b 9 DIGIT d ← a's low byte, b ← (a − d)/2^8
5 INV d ← a⁻¹, and 0 at a = 0

a, b and d are accessed at slots of their own, so any two may name one cell. EQ is how a tape asserts. IMM and SHL are how it builds a constant with no field arithmetic of the guest's. A scalar's 32 DIGITs ending at 0 represent it mod p, which is all a scalar multiplication needs.

4 P2_FIELD — one duplex step a row#

With the state at cells s..s+3, the row absorbs n ∈ {0, 1, 2} of the cells x, y — the lanes are (n ≥ 1 ? x : s₀, n = 2 ? y : n = 1 ? 0 : s₁, s₂ + n) — and writes poseidon2_permute of them to d..d+3. A state is never overwritten, so a challenge is a cell of the triple that made it. The circuit is flat, every S-box's u² and u⁴ committed, so a parent verifies it as one gate list.

5 FIELD_IO — between RAM and a cell#

Over the eight RAM words w_k at ptr:

  • IMPORT (1): the cell takes Σ_k w_k·2^{32k} mod p. A non-canonical encoding is harmless.
  • EXPORT (2): the words take limbs below 2^32 congruent to the cell. Congruence, not canonicity: a guest that needs the canonical value compares the words with p itself.

Addressability is the multiset's. A word no window initializes cannot balance.

6 FQ_OP — one base-field operation a row#

An element of BN254's Fq is four consecutive cells of 64-bit limbs, congruent to its value mod q and not necessarily below it. Only this family writes one. The op word is a code, three flags and a digit cell (word >> 6): a flagged operand's element is its word plus 8·digit, a bucket chosen by a digit, which is what lets an MSM be a static tape (§8.3).

op
1 MUL d ← a·b
2 ADD d ← a + b
3 SUB d ← a − b
4 MULEQ asserts a·b ≡ d
5 FROM128 d ← a₀ + 2^128·a₁ from two cells below 2^128: a coordinate from its transcript limbs

One integer identity serves all five, a·y + z = q·K + d′, checked over 128-bit groups of limbs with a range-checked quotient and carries. Some of those ranges go through TIMESTAMP, which is why the family is at 2^20. b's and d's four cells share one read timestamp, so an element is only ever written whole; tape::run refuses a tape that reads one written apart, before a fill would.

7 Tapes#

A shard's checks have a fixed shape for its family and height. So the host compiles them, once, into a tape (verifier_core::tape): a straight-line list of coprocessor calls over absolute cells, in which nothing branches on a value. A tape reads three kinds of cell:

  • constants, which it builds itself from IMM and SHL, so they are bound with it;
  • slots, which its caller fills: the statement's digest and memory challenges, and the shard's index, window, roots and commitments;
  • inputs, the proof, IMPORTed from a blob laid out in the tape's Input order (tape::shard_blob), which the tape's own checks are what bind.

tape::shard_tape is verify_shard_local's steps 7–11 and Mercury's field side (pcs_verify), call for call: the shard transcript, the GKR backward pass, the lookup and root checks, the stack challenges and values, and the opening's twelve scalars. Every check is an EQ. It leaves three things to its caller (§8): the shard's time window against its neighbours', step 10c on the two public shards, and everything on the curve — to a tape a point is four transcript limbs, and the batch's cm* is a hint.

tape::schedule reorders a tape into runs of one family's calls, tape::encode is the form a guest replays — the cells its imports fill, then runs of frames — and tape::run is the native reading, over a Memory that models each access's timestamp as well as each cell's value.

8 Nodes and the tree#

8.1 Two programs, one procedure#

guests/recursion is two binaries. The leaf verifies shards from..to of one statement of the base program. The node verifies two to four whole statements of the two recursion programs — its children's proofs — reads each child's journal out of the output window step 10c binds, holds the children to one another, and folds their accumulators beside their shards'.

Both run verifier_core::node::node through a Driver. The host runs it natively (host::recursion), so it refuses whatever a guest would, first and by name, and it writes the advice the guest reads. The guest runs it by coprocessor calls. A binary's image — every shard tape, the fold's templates, the constants — is built by build.rs with verifier-core itself and sits in .rodata, so a program's identity binds every tape it replays.

  • The base program's identity is a constant of the leaf's image. The SRS digest and the generic table are constants of both images.
  • A node takes the two recursion programs' identities as claims and journals them, for the top to check once.
  • A program's setup commitments are advice, held to its identity by recomputing it.

The global transcript is a chain across the tree (verifier_core::chain). The node with shard 0 runs the prefix, G1–G7. Every node absorbs its own shards' memory commitments, G8, from the state its predecessor left. The node with the last shard runs the suffix, G9–G11, which settles the digest and the memory challenges every node took as claims. A node that holds a whole statement makes its memory argument, Π reads · R_b = Π writes · W_b.

A node holds its children to: exit status 0; one base statement — its shape, digest, challenges, io_digest, exit status and shard count; adjacent shards; chain states that meet; time windows in order across the seam; and, of a node child, the two identities it requires itself.

8.2 The journal#

47 cells, each a 32-byte word (node::journal):

cells
0 a digest of the base statement's shape: its shard counts and windows
1–7 its global digest, four memory challenges, io_digest, exit status
8–10 its shard count, and the shards this node covers, from..to
11–18 the chain's state at from and at to: three lanes and a pending input each
19–22 the covered shards' read and write root products; the boundary factors where to is the count
23–28 the first and last covered shard's family and time window
29–44 A and B, each x then y in four 64-bit limbs
45–46 the leaf program's and the node program's identities this node requires; 0 for a leaf

The root covers 0..count: every base shard verified, the transcript run end to end, the memory argument made. What is left is one pairing check and two identities.

8.3 Folding#

After each shard's tape the node's own transcript absorbs the shard transcript's final state (FOLD_STATE) and draws w and w′ (FOLD_WEIGHT), so a shard's weights follow everything they weight. Then, as scalars of points:

  • entry i of the shard's Mercury check gets w·e_i, on its side (pcs_verify::ENTRY_POINTS);
  • the batch check cm* = Σ ρ^i·cm_i is folded beside it: cm* gets w′ more, and each cm_i gets −w′·ρ^i;
  • [1]_1 and the setup commitments, which every shard of a family shares, accumulate one scalar each and enter once;
  • a child's A and B enter under a weight drawn after its whole journal (FOLD_CHILD).

Each side is one MSM on FQ_OP (verifier_core::fold): Pippenger with 8-bit digits over GLV halves, 16 windows of 256 buckets, every step a static template. A point is held to the curve and its scalar's split to the scalar, then added to one bucket a window through an indirect operand. Inversions are host witnesses held by a MULEQ, and buckets start at offsets so that no addition degenerates. A point costs about 400 FQ_OP calls.

8.4 The scheduler#

bench recurse <dir>/<stem> --out <out>, over a base proof archive (tools/bench/src/recurse.rs):

  • Keys. It writes base.key and programs.key into <out> and builds the two binaries with APOGEE_RECURSION_KEYS=<out>, where their build.rs reads them. The node is built twice: once with no image, for the two programs' keys, and once over them.
  • Plan, fixed before anything is proved (host::recursion::Tree::plan, <out>/tree.txt): leaves of at most --leaf (64) consecutive base shards, then levels of internal nodes over two to --fan-in (4) children, a lone leftover carried up. A program has sixteen families and each costs at least a shard, so a node is sixteen shards before any work and leaves are cut large.
  • A node is a process, bench recurse-node: it verifies its inputs natively, builds its advice only then, proves, verifies, holds the proved journal to the native one and writes <out>/<id>.block. At most --in-flight nodes run, with --in-flight × --shards-in-flight shards in flight across them: a node takes its share of what is spare when it starts, so a root alone has the whole budget. A proof already in <out> is kept, so a stopped run resumes; a run whose plan or programs differ is refused.
  • At the root it checks what a verifier owes beside the root's own proof — the journal covers 0..count and is the archive's statement, it requires the two programs' identities, and (A, B) discharges — and then runs §9.

9 The decider#

The root is still a GKR proof and some hundreds of points, and a contract can check neither. host::decider splits its verification in two.

The circuit is §8.1's node procedure over one child, the root, through a Driver that writes rank-1 constraints: an FR_OP is one constraint in the common case and none where it only copies, a duplex is 255, advice is a free wire. It verifies the root as a node would, and holds its journal to from = 0 and to = count. But it folds nothing: every MSM template is skipped, and each point's four limbs and its scalar are bound wires instead, after the two identities, the base statement's exit status, and its public input and output, a wire a byte, whose digest the circuit holds to the journal's io_digest.

crates/groth16 is Groth16 over this repository's BN254. A circuit streams its constraints into a sink, so no matrix is held. Three things are not the textbook's:

  • Bound wires are values the verifier holds, too many to be public inputs. The proof carries their commitment D = Σ w_j·[(β·A_j + α·B_j + C_j)/η]_1 under a fifth trapdoor η. A challenge c is SHA-256 of D and the verifier's values, and the circuit ends with acc ← (acc + wire)·c over the bound wires. The public inputs are c and that result, both of which the verifier computes from its own values, and the check is e(A, B) = e(α, β)·e(IC, γ)·e(C, δ)·e(D, η), IC being the public wires' points under 1, c and the result. D is fixed before c, so wires that differ from the values agree with them at c with probability len/r.
  • No blinding. A proof hides nothing and is a function of its witness.
  • A Lagrange basis. A and B are sums over the constraints, Σ_j (A·w)_j·[L_j(τ)], not over the wires. So the one element a key holds a wire is [(β·A_i + α·B_i + C_i)/x]_1, x being γ, η or δ — and a powers-of-tau ceremony already publishes [L_j(τ)].

The key is a ceremony's, in two phases:

  • Phase 1 is ppot_0080_24.ptau, the ceremony the tree's own commitments are under (srs::Phase1): the Lagrange basis at the circuit's domain in both groups, and the powers a quotient takes. Everything of the key that depends on τ is a combination of those points, and nothing derives τ.

  • Phase 2 is the circuit's own (groth16::phase2, bench ceremony), and makes α, β, γ, δ and η from 1 by contributions: each multiplies a trapdoor by a factor only its contributor knew, so a trapdoor is unknown while one contributor to it was honest.

    step
    init every trapdoor 1: a wire's [A_i(τ)]_1, [B_i(τ)]_1, [C_i(τ)]_1, and [τ^k·Z(τ)]_1. Deterministic from the circuit and the file
    round 1, contribute to α and β: [β·A_i]_1 and [α·B_i]_1, kept apart
    seal a wire's three terms summed
    round 2, contribute to γ, δ and η: the sum over the wire's trapdoor, and [τ^k·Z(τ)/δ]_1
    key the last state verified and, if every trapdoor has a contribution, written as the key

    The order of the rounds is the soundness. A prover may hold a wire's three terms only summed, over δ or η: apart, it could give A, B and C three witnesses. A contribution to α or β scales the terms apart, so those are finished before anything is divided.

    A state carries each contribution's record — its factor in G1 with a Schnorr proof of knowing it, bound to the records before it, and the trapdoor in G2 afterwards. Verifying a state checks that chain, then its elements against init's under those trapdoors, one pairing equation over a random combination: against the circuit and the file alone, with no earlier state. Every step lists the records by their factors' points, so a contributor finds its own under the state the key is made of. bench decide reads the key key wrote, and nothing else writes one.

  • setup_dev, bench decide --dev-key, derives all six trapdoors from a public seed. It is for development and tests: anyone forges under it.

The contract (contracts/ApogeeVerifier.sol) is verify(input, output, exitStatus, proof, points), a point being x, y, scalar, side [1]_2's points and then side [x]_2's. It rebuilds the bound values — a point's limbs are its coordinates' halves, or four sentinels at infinity, which is what the root's transcript absorbed — recomputes c and the result, checks the Groth16 pairing, folds each side with ecMul and ecAdd, which is also what holds a point to the curve, and checks e(A, [1]_2) = e(B, [x]_2). Its Groth16 key, the ceremony's two G2 points and the two identities are set at deployment.

bench decide <out> proves under the ceremony's key, checks the proof natively, deploys and calls the contract in revm, and writes decision.constructor and decision.calldata — under --dev-key, development.*.

What a deployment still owes. A key is as trustworthy as its ceremony: one honest contributor a round, which a ceremony run on one machine is not. The circuit depends on the root's shape — its program, its shard counts, the public values' lengths — so a key, and its ceremony, is per shape. And the contract pays about 9k gas a point, because the circuit folds none.

10 Running it#

bench prove --stateless <fixture> --out <dir>             the base proof
bench recurse <dir>/<stem> --out <out> --in-flight 4      the tree
bench ceremony <out> init                                 the decider's key: once a root shape,
bench ceremony <out> contribute                           each contributor in turn, to alpha and beta
bench ceremony <out> seal
bench ceremony <out> contribute                           and to gamma, delta and eta
bench ceremony <out> key
bench decide <out>                                        the Groth16 proof, and the contract

It needs assets/ptau/ppot_0080_24.ptau. Measured on block 257,510 — the tree on a 32-CPU, 247 GiB machine, the ceremony and the decider on an 18-core laptop:

base proof 207 shards, 14.5 MB, 2,481 s
tree 4 leaves of at most 64 base shards and a root: 116 shards
leaves, four at once 21, 24, 23 and 27 shards; 2,157 s; 92 GiB peak
root, four shards in flight 21 shards, 460 s, 1.03 MB
decider's circuit 7,896,686 constraints, a domain of 2^23
ceremony init 65 s; a contribution 50–56 s; key 70 s, 12.7 GB; the key 2.65 GB
decider the key read in 1 s, the proof 18.5 s, 6.1 GB
contract 358 points; 3,620,026 gas; 34,980 bytes of calldata

Auditeurs/Chaîne de preuve

Blocs Ethereum

Spécification normativedocs/spec/ethereum.mdVoir en Markdown

Résumé

La charge de travail de référence : des blocs Ethereum exécutés sur revm à l’intérieur de la VM. La page spécifie la crate du programme invité et ses deux binaires, le format de témoin du mini-bloc et son engagement de sortie, l’entrée et la sortie du validateur sans état, les forks qu’il prend en charge, ce que prouve un résultat, la façon dont un bloc s’exécute sur revm, la récupération de signature, la conformité à la version de tests zkEVM, l’endroit où chaque règle de validation est vérifiée, et la façon dont un bloc est enregistré.

Le texte normatif ci-dessous est tenu à jour en anglais, langue canonique de la spécification.

guests/revm-block runs Ethereum blocks on revm inside the VM. This page specifies its two binaries: the mini-block binary's input, BlockWitness, and its output commitment; the stateless validator's input, result and rules; how each runs a block on revm; and how a block is recorded.

1 The guest crate#

One library, revm_block (src/lib.rs and its modules), and two binaries, each its own program identity, the same for every block because the block is advice (public-values.md §6):

binary advice journal exit status
revm-block (src/main.rs) a BlockWitness (§2) the output commitment (§3) 0; 61 not a canonical witness; 62 not executable
revm-block-stateless (src/stateless_main.rs) statelessInputBytes (§4) the 43-byte result always 0

The mini-block binary runs transactions, usually a block's first few, over a pre-state recorded from a node (§6); it proves an execution, not a block's validity (§3). Full blocks are proved with the stateless validator: the mini journal grows by a record a transaction and outgrows the public window (public-values.md §9). On the host the library is the oracle crates/emulator/tests/revm.rs holds the mini binary's journal to, over unpatched upstream crates.

Dependencies. revm is the workload being proven: it and what revm-precompile brings (arkworks, k256, p256, sha2, ripemd) are reachable from no prover, verifier or other guest. It is built without default features, so without blst, c-kzg or libsecp256k1. The pin is exact, =43.0.1, because an identity is a digest of the image (program.md §8), and the set is the reference stateless guest's (paradigmxyz/stateless's lock): twelve crates, held in both lockfiles by crates/host/tests/revm_lock.rs, its revm-handler 43.0.1 carrying the EIP-8037 system-call state-gas reservoir tests-zkevm@v21.0.1 expects.

Delegations. Both binaries declare KECCAK_F, SHA256_COMP, MOD_MUL and EC_ADD: keccak through alloy-primitives' native-keccak hook (revm_block::native_keccak256), SHA-256, secp256k1 and BN254 through vendored crates (delegation.md §10, guests/vendor/README.md). revm-precompile is patched in its Crypto trait's default bodies, not given a second implementation by install_crypto: two types behind crypto()'s OnceLock<Box<dyn Crypto>> stop LLVM devirtualizing its calls, keeping code it otherwise strips, 870,828 bytes of .text on the mini binary, past its tables' reach.

Code size. No ELF is committed: host::fixture::build_revm_guest builds either binary at --release, proved at host::fixture::revm_params — 2^20 for every family whose height is a choice (revm_block::TRACE_HEIGHT_RELEASE), each delegation family's default, bytecode_size_words = 2^21. A 2^20 table reaches 1.9375 MiB of .text (program.md §5); the stateless binary's is about 1.96 MB, 96.6% of it. The debug image needs 2^22 and is only ever run.

2 BlockWitness#

The mini binary's advice: postcard of revm_block::BlockWitness, a format of this repository's, written by host::recorder (§6). Fields in declaration order; a word is 32 big-endian bytes; a u8, an Option tag (0 or 1) and a fixed array are raw bytes; every other integer and every length is a varint.

BlockWitness          env BlockEnvWitness; accounts Vec<AccountWitness>, by address;
                      txs Vec<TxWitness>, in execution order
BlockEnvWitness       chain_id u64; spec_id u8 (revm's SpecId); number word; beneficiary [20];
                      timestamp word; gas_limit u64; basefee u64; difficulty word;
                      prevrandao Option<word>; excess_blob_gas Option<u64>;
                      blob_gasprice Option<u128>; slot_num u64;
                      block_hashes Vec<(u64, word)>, by number
AccountWitness        address [20]; nonce u64; balance word; code Vec<u8>;
                      slots Vec<(word, word)>, by key, zero values included
TxWitness             caller [20]; to Option<[20]>, None a creation; value word; data Vec<u8>;
                      gas_limit u64; gas_price u128, the max fee from type 2;
                      gas_priority_fee Option<u128>; nonce u64; chain_id Option<u64>;
                      access_list Vec<([20], Vec<word>)>; blob_hashes Vec<word>;
                      max_fee_per_blob_gas Option<u128>; authorizations Vec<AuthorizationWitness>
AuthorizationWitness  chain_id word; address [20]; nonce u64; authority Option<[20]>, recovered

2.1 One state, one encoding#

BlockWitness::decode refuses, with exit 61:

rule WitnessError
spec_id is a SpecId UnknownSpec
excess_blob_gas and blob_gasprice both present or both absent BlobPairing
accounts, each account's slots, block_hashes strictly ascending AccountsNotSorted, SlotsNotSorted, BlockHashesNotSorted
the bytes are exactly BlockWitness::encode's for the value Malformed

The last closes postcard's two second encodings: postcard::from_bytes ignores trailing bytes, and its varints accept non-minimal forms (81 00 reads as 1). A code hash is computed, not carried, and a transaction's type is derived from its fields (TxEnv::derive_tx_type).

2.2 Execution#

revm_block::WitnessDb answers revm from the witness and refuses every miss (DbError, exit 62): the witness is unbound advice, so a default would be a value the prover chose.

  • Absence is recorded: WitnessDb::basic answers None for an account recorded with nonce 0, balance 0 and no code. A zero slot is recorded like any other.
  • BLOCKHASH reads env.block_hashes. revm answers 0 without asking for any block but the 256 before the current one, and serves those from the database, not EIP-2935's contract: at most 256 entries.
  • Code is Bytecode::new_raw_checked's: bytes beginning 0xef01 that are not a 23-byte EIP-7702 delegation, which a few pre-EIP-3541 accounts hold, are DbError::MalformedCode, where Bytecode::new_raw would panic, an exit 101 that names nothing (ecall-abi.md §7).
  • The block gas limit is a running bound. revm checks each transaction against the block's limit and keeps no total; revm_block::run, the block executor, refuses transaction i unless gas_limit_i ≤ env.gas_limit − Σ_{j<i} gas_used_j, the Yellow Paper's intrinsic validity.
  • The blob gas price is recorded (§6). It derives from the excess through the fork's update fraction, 3,338,477 at Cancun, 5,007,716 at Prague, raised by each BPO fork (§4.2), and revm 43 knows only the first two. revm holds each type-3 transaction's max_fee_per_blob_gas to it.

Not in the witness: signatures, caller and each authority being the producer's recovery, unchecked; a parent header, and the header rules against it; a state root (§3); a slot number, which the recorder writes as 0, no JSON-RPC method serving EIP-7843's.

3 The output commitment#

The mini binary's journal, revm_block::run's return:

per transaction, in order   status u8 (0 halt, 1 revert, 2 success) ‖ gas_used u64 LE
                            ‖ output_len u32 LE ‖ output: the return data, empty on a halt
logs commitment       32    keccak256 of  count u32 LE ‖ per log, in emission order:
                            address 20 ‖ topic_count u8 ‖ topics, 32 each ‖ data_len u32 LE ‖ data
post-state summary    32    keccak256 of  count u32 LE ‖ per account, by address:
                            address 20 ‖ nonce u64 LE ‖ balance 32 BE ‖ code_hash 32
                            ‖ slot_count u32 LE ‖ per slot, by key: key 32 BE ‖ value 32 BE

The record count is the witness's. The summary covers the state revm's finalize returns: every account the block loaded, read-only and nonexistent ones included, with every slot it loaded.

What a proof states: some canonical BlockWitness makes revm_block::run return this journal. Nothing ties the witness to a chain; a reader holding one recomputes the journal natively. And the journal tells witnesses apart only as far as the execution reads them: a slot read and then overwritten unconditionally reaches nothing, while every loaded account's final balance and nonce are in the summary.

4 The stateless validator#

revm-block-stateless maps tests-zkevm@v21.0.1's statelessInputBytes to its statelessOutputBytes, byte for byte. The formats and rules are ethereum/execution-specs' verify_stateless_new_payload at the release's commit (host::zkevm::RELEASE_COMMIT); revm_block::stateless::run is the guest's whole computation, and §5 lists its rules.

4.1 Input and output#

input    schema_id u16 BE ‖ SSZ(StatelessInput)                          ssz::decode
           new_payload_request   the schema's fork's NewPayloadRequest
           witness               state: trie-node preimages; codes; headers: RLP, oldest
                                 first, the parent last, at most 256
           chain_id              u64
           public_keys           eth-act/ere-guests v0.17.1's layout only: 65 bytes a transaction
output   new_payload_request_root 32 ‖ successful_validation 1 ‖ chain_id u64 LE ‖ schema_id u16 LE

The layouts' fixed parts are 16 and 20 bytes, so no input is both; the second is the zkEVM benchmark's. The root is hash_tree_root under EIP-7916's and EIP-7495's progressive forms as of 2026-01-15 (ssz::request_root), whatever the layout. Decoding is as strict as the spec's: every offset against the bytes it bounds, every bounded list against its limit, nothing after the end.

The guest exits 0 on every input. One that does not decode, or names a schema §4.2 does not list, publishes the sentinel, 43 zero bytes (ssz::SENTINEL); any other publishes its request's root, its verdict, its chain id and its schema id. The empty input is the one a run cannot be given, a run without advice having no advice region.

4.2 Forks#

The schema id, fork_index << 8 | 0x01, names the fork; no activation schedule is compiled in (block::fork).

schema fork request revm SpecId blob target, max update fraction
0x1201 Osaka Electra/Fulu OSAKA 6, 9 5,007,716
0x1301 BPO1 Electra/Fulu OSAKA 10, 15 8,346,193
0x1401 BPO2 Electra/Fulu OSAKA 14, 21 11,684,671
0x1501 Amsterdam Gloas: a block access list, a slot number, EIP-8282's two request types AMSTERDAM 14, 21 11,684,671

4.3 What a result proves#

true says the request whose root is published is a valid block on chain chain_id under the fork schema_id names. The witness needs no binding: the root fixes the payload, and the witness is held to it by hashes — the parent header to the payload's parent_hash, each ancestor to its child's, the state trie to the parent's state_root and each node to its parent's reference, each code to its account's code hash. A node a read needs and the witness lacks is an error, never an absence (mpt::get). So a wrong witness cannot make an invalid payload valid; but false says only that this input did not validate, which a prover can arrange for any payload.

4.4 How a block runs on revm#

The pre-state is witness::WitnessDb behind revm's State: the state trie under the parent's root, each storage trie parsed on its first read, codes by hash, and BLOCKHASH numbering each ancestor by its position below the block. stateless::execute is the spec's apply_body: the EIP-4788 and EIP-2935 system calls; each transaction; the withdrawals; the requests, from deposit logs and the checked system calls of EIP-7002, EIP-7251 and, from Amsterdam, EIP-8282. The calls before the transactions are block access list index 0, each transaction has its own, and what follows them shares the last. A transaction must fit what is left, Amsterdam metering regular and state gas apart (EIP-8037):

before Amsterdam   tx.gas_limit ≤ gas_limit − Σ gas_used
Amsterdam          tx.gas_limit ≤ 2^32 − 1,  min(tx.gas_limit, 2^24) ≤ gas_limit − Σ regular,
                   tx.gas_limit ≤ gas_limit − Σ state;  the block uses max(Σ regular, Σ state)
both               2^17·blobs ≤ 2^17·max − Σ blob gas

Three rules make the result the spec's where following reth would not:

  • Code loads when revm asks (witness::WitnessDb::code_by_hash), never with its account: the witness carries only the code the spec's execution read, and a coinbase may be a contract nothing calls.
  • Every write precedes every deletion in the post-state replay (stateless::post_state_root), in each trie, as the spec's mpt_set_storage_slots orders them. A deletion that leaves a branch one child needs that child's node, on no changed key's path; the witness carries those the spec's order needs, and writing first needs a subset.
  • One commit per index (stateless::commit_index). revm 43's access-list builder records a value that differs from its commit's baseline, and revm re-bases a value at each call, so committing call by call records a slot one call toggles and the next restores. An index's calls are committed once, each baseline reset to the committed state.

Also the spec's: a checked system contract must have code, deposit events are parsed to the byte, withdrawals precede requests; the TxEnv is built field by field (build_fill would put a dummy authorization in an empty type-4 list); the blob price is a checked fake_exponential (block::blob_gas_price); 0xef01 code that is not a delegation runs as legacy. Declared lengths are added checked and trie parsing is depth-bounded, a panic publishing nothing.

4.5 Signatures#

Every sender and EIP-7702 authority is recovered in the guest (tx::recover_key) under EIP-2's rules, 0 < r < n, 0 < s ≤ n/2, a parity bit, as Q = r⁻¹(s·R − z·G) with k256's arithmetic, which the vendored k256 routes to MOD_MUL and EC_ADD. The verification upstream's recover_from_prehash ends with cannot fail once recovery succeeds and costs about as much again, so it is not done. An authorization that does not recover is skipped, as EIP-7702 says. A key in ere-guests' layout is checked, never used: one a transaction, 0x04 ‖ x ‖ y, naming the recovered sender.

4.6 Conformance#

All 67,251 pairs of tests-zkevm@v21.0.1 match natively (crates/host/tests/conformance.rs, by hand); CI holds the library to a committed subset of 34 — a case for each rule the release reaches, the smallest valid one, every undecodable one — in both layouts, and the binary runs the subset by hand. The release fills only Amsterdam: tools/stateless-ref holds the Electra/Fulu layout to eth-act/ere-guests v0.17.1 and crates/host/tests/canonical.rs the encodings and header rules to two mainnet blocks, but no Osaka-family input has an end-to-end oracle.

5 Where each rule is checked#

The mini binary's rules, then the validator's step by step. A validator refusal is a stateless::Invalid variant, which host::zkevm::verdict names and the guest publishes as false. Paths are revm_block's.

rule refusal code
mini: a canonical witness exit 61 BlockWitness::decode
mini: every read recorded, code well formed exit 62 WitnessDb
mini: each transaction fits the gas left and executes exit 62 run_against
the input decodes under a listed schema sentinel ssz::decode, block::fork
the ancestors decode and chain Ancestors stateless::ancestors
no empty transaction EmptyTransaction stateless::verify
the base fee fits a u64 Unrepresentable stateless::payload_header
the header the payload implies hashes to block_hash BlockHash stateless::{verify, payload_header}
each transaction decodes (EIP-2718, types 0–4) Transaction(i) tx::decode
keyed layout: a key a transaction PublicKeys stateless::verify
the versioned hashes are the request's VersionedHashes stateless::verify
EIP-7934's block size BlockSize block::block_rlp_len
the header against its parent, twelve rules Header(_) block::validate_header
the blob gas price fits a u128 Unrepresentable block::blob_gas_price
the parent's state root is in the witness Witness(_) witness::WitnessDb::new
chain id; signature; keyed layout: the key names the sender ChainId(i), Signature(i), PublicKeys stateless::execute, tx::sender
the transaction fits what is left Capacity(i) stateless::execute
revm executes it, every read in the witness Execution(i) stateless::execute
the system calls SystemCall stateless::{execute, commit_index}
the deposit events Deposits block::deposit_requests
gas used, receipts root, bloom, blob gas used, requests hash GasUsed, ReceiptsRoot, Bloom, BlobGasUsed, RequestsHash stateless::verify, block
Amsterdam: the access list's item count and hash AccessList stateless::verify, alloy_eip7928
the post-state root StateRoot, Witness(_) stateless::post_state_root, mpt

6 Recording a block#

host::recorder::record(rpc, block_number, range) makes a BlockWitness for a block's first n transactions or all of them (recorder::TxRange) by running them once against a node: recorder::WitnessRecorder is a revm::Database over the parent block's state that records each answer, and the transactions run through revm_block::run_against, the guest's own executor, so the record is what the guest will read. The result is put through BlockWitness::decode.

  • Reads. An account is eth_getProof with no keys, absent when nonce, balance, code hash and storage hash are all empty or both hashes are zero, Geth's answer; code eth_getCode, checked against the hash; a slot eth_getStorageAt; a header eth_getBlockByNumber; the blob gas price eth_feeHistory's baseFeePerBlobGas, a receipt's blobGasPrice existing only for type 3.
  • Choices. The hardfork is mainnet's by number (recorder::mainnet_spec): before the Merge is refused, after Osaka runs as Osaka. caller is the node's from; authorities are recovered on the host.
  • The client, host::rpc::Rpc, files each response under the SHA-256 of its canonical request in the fixture's rpc-cache/, so a second recording is byte-identical and offline; a miss without ETH_RPC_URL is an error. A request goes through curl, the endpoint and its key on the command line, retried on a transport failure, a 5xx or a 429.
  • On disk (host::fixture): <stem>.json, a Pin naming the block and the length and SHA-256 of <stem>-witness.bin and <stem>-journal.bin, native revm's journal, beside rpc-cache/. crates/host/tests/vectors/mini-block* is block 26,057,509's first two transactions, refreshed by kat-gen -- block (tools.md §7).

Nothing here produces a stateless input. eth_getProof returns the nodes on a key's path, and a deletion that collapses a branch needs its surviving sibling's node, which is on no changed key's path (mpt::MptError::BlindedCollapse is the validator's refusal without it), so the proofs of a block's keys are not a witness. Stateless inputs come from an external producer, a tests-zkevm release or the zkEVM benchmark's datasets; host::zkevm reads every JSON object carrying both statelessInputBytes and statelessOutputBytes, and bench prove --stateless proves one as it is (tools.md §1).

Référence

Glossaire

Spécification normativedocs/glossary.mdVoir en Markdown

Résumé

Le vocabulaire propre à Apogee VM, un terme par ligne, chacun lié à la section de la spécification qui le définit. Les termes que fixe la littérature, comme GKR, LogUp, KZG et RISC-V, n’y figurent pas.

Le texte normatif ci-dessous est tenu à jour en anglais, langue canonique de la spécification.

The project's own vocabulary, one line a term, each linked to the section that defines it. Terms the literature fixes (GKR, LogUp, KZG, RISC-V) are not listed.

term meaning defined in
accumulator, accumulator entry a deferred Mercury check as twelve (side, scalar, point) entries mercury.md §6
advice memory whose initial values the prover chose, bound by nothing public-values.md §6
anchor, anchor space tuples in a delegation type's own space pairing a request with one invocation delegation.md §5
archived path proving from a held TraceArchive; only the tamper suite (checker::TamperHarness) does streaming.md §6
artifact a circuit as data, CircuitArtifact; also an exported ProgramImage gkr.md §4, program.md §3
base claims each committed column's claimed value where the backward pass ends gkr.md §5
base format, recursion format recursion if a statement's VmConfig holds FIELD_WINDOWS, else base recursion.md §1
block BlockProof: config, statement and its shards' proofs proof.md §1
bound wire a decider value the verifier holds, committed instead of a public input recursion.md §9
boundary the registers' and pc's final timestamps and values; they have no rows memory.md §4
cached entry a sub-expression inlined into its list's gates, not a column gkr.md §3
canonical form an element as its value, 32 bytes little-endian, below the modulus primitives.md §1
challenge slot a gate coefficient's challenge: drawn, or derived by the verifier gkr.md §3, §5
channel one LogUp identity over a shard's lookups into one table lookup.md §1
copower x < p as x·2^32/p < 2^32, void without a direct bound lookup.md §11
cycle-owning the execution families 0–6, whose time windows are ordered proof.md §8
decider a Groth16 proof that the recursion root verifies, for the contract recursion.md §9
declaration record, static detachment 12 bytes a linked shim leaves in the image: how a delegation is declared delegation.md §7
decoded table an instruction family's setup columns: row i is pc 2i program.md §5
delegation a family proving a function of a RAM frame, invoked by ecall delegation.md §1
discharge spending an accumulator; the rule that each lookup is one leaf of its tree mercury.md §6, lookup.md §11
enforcing, producing a gate vanishing on every row; one writing the next layer gkr.md §1
extra mask, kind, kind bit family_extra_mask = 1 << kind, a kind being a mnemonic's index; b_k its bit program.md §6
family a circuit and the rows it proves: instructions (0–6), memory locations or invocations circuits.md §1
field memory address space FIELD: cells of one Fr, for the recursion families recursion.md §2
fold merging a node's deferred Mercury checks into one (A, B) recursion.md §8
frame an execution family's queries; a delegation's RAM words at a0 memory.md §2, delegation.md §4
gate list, row-wise, halving the gates from layer k to k + 1, keeping the height or halving it gkr.md §1
gated key, neutral tuple a lookup tuple under its selector; off, it reads a neutral table row lookup.md §4
generic table the committed table of ZeroEntry, AND, U16GetSign, ShiftPowers lookup.md §9
global transcript, global state digest G1–G11: the statement, M commitments, memory challenges; G11 seeds each shard proof.md §2
HALT_PC 1: the exit row's next_pc, the pc's final value memory.md §5
height a family's rows a shard: 2^8, 2^12, 2^16, 2^18, 2^20 or 2^22 program.md §7
identity, image column one Fr digest of the decoded tables, the image, the entry pc, VmConfig program.md §8
in flight shards worked at once, at most max_in_flight streaming.md §5
invocation, request a delegation's row doing one call; the ecall row asking for it delegation.md §1, §5
journal the public output: what the guest leaves in the output window public-values.md §1
laws Laws 1–4: locality, derived width, top layer, single source of truth gkr.md §4
layer layer 0 the committed columns, the top the outputs; L{k}[j] between gkr.md §1
leaf, node, root recursion programs: a leaf verifies base shards, a node 2–4 child proofs; the root, all recursion.md §8
live row, padding row m_pc = 1, or a zero row; in a decoded table, an instruction, or −1 throughout memory.md §2, program.md §5
M, W, S, V memory, witness and setup columns; virtual tables gkr.md §2
memory form an Fr's Montgomery limbs x·R; on the wire only in FR_ARITH's frame primitives.md §1
mini-block the revm-block binary: transactions over a recorded pre-state ethereum.md §1
multiplicity a channel's W column counting each table row's lookups lookup.md §7
padding contract padding.row makes row-local relations vanish and tree inputs 1 gkr.md §4
pairing side G2One or G2X: an entry's G2 argument, [1]_2 or [x]_2 mercury.md §6
pass 1, pass 2 executing to commit every shard's M columns; again to prove each streaming.md §2
phase 1, phase 2 the decider key's ceremonies: powers of tau, then the circuit's own recursion.md §9
public window windows 2 and 3 at 2^12: input at 0x8000, journal at 0xC000 public-values.md §2
query one read and one write at one address in one cycle execution-trace.md §3
RAM glue invocations chained through their frame's words in RAM delegation-circuits.md §1
reconciliation ∏ read roots · R_b = ∏ write roots · W_b, once a statement memory.md §4
registry family_circuit, recursion_circuit: each family's one circuit circuits.md §1
scratch scratch[i], a flat relation's intermediate, one per inner column gkr.md §2
shard h rows of one family, or one window, proved alone but for the memory argument streaming.md §4
slot Δ in a cycle's timestamps 4c + Δ; a ProgramImage halfword; a frame position execution-trace.md §1, program.md §2, memory.md §2
SRS digest a digest of the SrsVerifier and the generic table's commitments proof.md §3
stack 2^σ columns committed as one, in the recursion format recursion.md §1
statement PublicInputs: input, journal, exit status and the execution's record proof.md §1
statement shard, shard-set exactness a (family, index) below its count; a block proves each once, in order proof.md §1
tamper twin a forgery proved as an honest prover would, refused in its class circuits.md §3
tape straight-line coprocessor calls a node replays; checker tape's listing recursion.md §7, tools.md §4
time window a shard's claimed [ts_start, ts_end); it binds nothing proof.md §8
transcript form a G1 point as four 128-bit Fr limbs; infinity, four 2^128 transcript.md §4
tuple T(AS, ADDR, TS, VAL): a memory access as one field element memory.md §1
u1, u2 a Mercury opening point's halves, pairing with an index's low and high bits mercury.md §1
VmConfig a program's families, their heights, bytecode_size_words program.md §7
window h words from byte 4h·w, initialized and torn down by one shard memory.md §3
write-side induction an execution family writes only words, so operands need no bound memory-ops.md §5

Référence

Outils

Spécification normativedocs/tools.mdVoir en Markdown

Résumé

Chaque binaire autour du prouveur et du vérificateur, aucun ne se trouvant sur un chemin de preuve : bench, qui mesure et prouve; le profileur de cycles, avec sa classification et sa tarification des candidats à la délégation; le journal de débogage du prouveur et ses marqueurs; checker; artifact-dump; l’outil en ligne de commande verifier; kat-gen et les données de référence versionnées; et les deux oracles de référence hors de l’espace de travail.

Le texte normatif ci-dessous est tenu à jour en anglais, langue canonique de la spécification.

The binaries around the prover and verifier, none on a proof path: bench measures and proves (§1), profiler counts a guest's cycles (§2), a debug-info build logs a proving run (§3), checker validates circuits and the global transcript (§4), artifact-dump exports a guest's ProgramImage (§5), verifier checks a proof from files (§6), kat-gen regenerates the committed fixtures (§7), and two generators outside the workspace are reference oracles (§8).

1 bench#

cargo run --release -p bench [-- <routine>...]   every routine, or those named; --list lists them
cargo run --release -p bench -- prove <stem> | --stateless <file> [--case <name>]
    [--in-flight <n>] [--out <dir>] [--json <path>] [--hourly-usd <price>] [--toy-srs]

The routines time one component each, over their own data: fr-arith, poly-bind, msm, mercury, mercury-batch, zerocheck-prove, zerocheck-verify, gkr-prove. msm, mercury and mercury-batch run over ceremony bases, assets/ptau/ppot_0080_24.ptau, and return without them.

prove proves a block through host::prove (streaming.md) and verifies it (host::verify).

  • <stem> names a recorded block under crates/host/tests/vectors: its pin <stem>.json, to which <stem>-witness.bin and <stem>-journal.bin are held, names the guest that proves it; mini-block is committed (ethereum.md §6).
  • --stateless <file> is one input to revm-block-stateless. A .json EEST fixture gives its statelessInputBytes as the advice, unchanged, and its statelessOutputBytes as the journal the proof must bind, checked by revm_block::stateless::run first and on the proof after; --case picks one input by part of its name. Any other file is the raw input.
  • The guest is built at --release (host::fixture::build_revm_guest), decoded at host::fixture::revm_params and keyed over 2^22 ceremony powers or, with --toy-srs, over τ = 0xc0ffee, cached as apogee-bench-toy-22.srs in the temporary directory: the same timings, another identity, which the report names.
  • --in-flight is max_in_flight, 8 by default. The verb asserts that the guest exits 0 and the block verifies; --out then writes the proof archive (proof.md §9) under the stem's or the input file's name.

The printed BenchReport (--json writes it too) holds the block, identity, SRS, cycles per gas, shards per family, proof and statement bytes, clocks, peak RSS, cost and hardware. commit and gkr are pass 1's and pass 2's wall clocks; execution, the executor's time, runs inside them and is left out of their total; opening and final are 0; unattributed is the rest of the proving wall clock; setup and verify are apart. Peak RSS is Linux's VmHWM, absent elsewhere, where /usr/bin/time -l gives it. --hourly-usd adds the cost, price · proving_ms / 3,600,000, and the cost per Mgas. Any failure exits 1, a wrong journal or a failed --out after the report prints; a usage error exits 2.

The verbs recurse, recurse-node, ceremony and decide are recursion.md §8.4–§10's.

2 The cycle profiler#

cargo run --release -p profiler -- elf <file> [--advice <f>] [--input <f>] [<common>]
cargo run --release -p profiler -- block <stem> [<common>]
cargo run --release -p profiler -- record <number|latest> [--txs <n>] [--cache <dir>] [<common>]
    <common>: [--top <n>] [--json <path>]

elf runs any guest over the given input and advice, at the smallest menu height its code fits; block runs the revm guest over a recorded fixture; record records a block from ETH_RPC_URL (latest is the finalized one; every transaction unless --txs; cached in target/profiler-cache) and runs revm-block over it, its gas the transactions' limits capped at the block's. A run prints a table, the --top (30) functions in it, and with --json writes a ProfileReport; any error exits 2. Its numbers are counts of executed cycles, the same on any machine.

2.1 One histogram over pc#

profiler::profile adds 1 to one u64 per halfword slot of the image for each executed cycle, reading each chunk's pc column off emulator::StreamingRun and dropping the chunk, so it holds the histogram and one partial buffer per family. Delegation rows add nothing: their requesting cycle is the ecall row's. A function's cycles are the sum over its [st_value, st_value + st_size) (loader::function_symbols), its own and not its callees'; its calls are the count at its first instruction, which runs once a call, so code entered only past its entry shows cycles and no calls. A mnemonic's cycles are the sum over its slots, a category's over its functions', and the unattributed ones are at slots no symbol covers.

2.2 Classification#

tools/profiler/src/categories.rs puts each function in one of 14 categories by RULES, ordered substring rules where the first match wins, then FALLBACK_RULES, the generic runtime paths, each matched against the demangled path and the raw symbol (categories::classify). The order is the meaning: revm_interpreter::instructions::system::keccak256 is hashing because its rule comes before revm_interpreter::'s. Legacy mangling is decoded whole, v0 to its identifiers.

A function's cycles include what was inlined into it: ruint's 256-bit operations count in the EVM opcode handlers, each a symbol of its own, revm dispatching through a table of function pointers. The unattributed share and the mnemonic mix, which no symbol table can misattribute, are the checks on attribution.

2.3 Pricing a candidate#

removable = max(0, cycles − calls·(4 + 2·frame_words))

cycles is the category's, calls the entry counts of the candidate's named entry symbols, and 4 + 2·frame_words (categories::shim_cycles) the shim a delegation leaves: the frame's stores, the ecall, the results' loads. CANDIDATES prices secp256k1, 256-bit arithmetic, BN254, SHA-256/RIPEMD-160 and the keccak sponge; one without entry symbols is charged no shim and flagged. It is a ceiling: it charges nothing for the new family's shards (delegation.md §9) or for marshalling operands into a frame.

3 The proving debug log#

crates/prover/src/debug.rs and the prover's log lines exist only with its debug-info feature, the workspace's one cargo feature: off by default, enabling no dependency, changing no proof byte (crates/prover/tests/debug_info.rs proves one statement with the log off and at deep and compares the blocks). Without it dlog! and debug_only! expand to nothing, so no scan is compiled into a proving run. gkr::explain_self_check is compiled always.

cargo run --release -p bench --features prover/debug-info -- prove ...
cargo test --release -p prover --features debug-info --test <suite> -- --include-ignored
APOGEE_DEBUG=off | phase | detail | deep [:FAMILY,FAMILY]

APOGEE_DEBUG, read at each log site, picks the level, case ignored: unset or empty is phase, none and 0 also mean off, 1 to 3 the other levels. :FAMILY,… (names as the log prints them, or ids) keeps those families at the level and lowers the others one step; lines naming no family stay. A bad level falls back to phase, an unknown family is dropped, and either is reported once as apogee ERROR. Lines go to the raw io::stderr() handle, one locked write each: libtest shows captured eprintln! output only for a failed test, and an OOM kill, a hang or a SIGINT loses it.

level adds
phase identity and SRS digest in full, in to_bytes order as the verifier CLI takes them; each claim's take and its committed or proved; the global digest and memory challenges, on the apogee commit line; each shard's begin h= … gkr done and open begin … open done
detail each family's circuit inventory; each shard's time window, g, β, roots and opening commitments; gkr::self_check; the scans
deep each GKR layer's shape and bytes; the top layer's all-zero columns

Where a run died. A begin without its done names the shard that died (FAMILY#index, [k/N] its statement position); a take without committed or proved, one in flight. fill# is fill order, which picks the failure returned, and in_flight= below the bound mid-pass means the workers wait on the executor. fill_ms is the one-thread fill, ms a wall clock shared with the shards in flight. Every shard forks from the apogee commit line's values, so two runs that should agree diverge there or inside a shard.

self_check recomputes every gate on every row before the backward pass, a second forward pass (gkr.md §5). gkr::explain_self_check turns a failure into the row's first disagreeing gate and every operand's value, a committed column by its artifact name and an inner one by the relation that wrote it, where a verifier says only LayerInconsistency { layer }.

The scans read each base delegation shard's live rows: invocations against the height, cycle and frame-base ranges, timestamp gaps, selector and round histograms, and canonicity, a tally for POSEIDON2 and FR_ARITH, whose < p conclusions are gated to the rows that read a value, and a verdict for MOD_MUL's operands and the values each EC_ADD row's group reads (debug::ec_add_reads). On ADD_SUB_LUI_AUIPC they count requests per type, which sum to each delegation family's invocations, and exit rows, one in all. The log's verdicts:

marker
self_check FAILED a gate fails on the prover's own values
NOT CANONICAL a frame value at or above its modulus where a gate needs it below
UNBALANCED an EC_ADD curve whose three groups' counts differ
OVER the a timestamp gap beyond 38 bits
NAMES NO MODULUS a MOD_MUL selector naming no modulus
DISAGREES a SHA256_COMP frame its rounds do not produce: the fill's refusal, in every build
NOT LOOPING 24 TIMES KECCAK_F round counts more than 1 apart
ABORTED a nonzero exit status: the block proves a failed execution
OUTPUT-LAYOUT-BREAK outputs other than 2 + 2·channels: reduce_shard and channel_cones index channel roots from opposite ends
ALL ZERO a top-layer column all zero: a root of 0
DECLARED BUT NEVER INVOKED a delegation shard with no live row
console
$ APOGEE_DEBUG=detail <a debug-info run> 2>&1 | tee run.log
$ grep -c 'begin h=' run.log; grep -c 'gkr done' run.log    # unequal: a shard died
$ grep 'begin h=' run.log | tail -1
$ grep -E 'FAIL|NOT CANONICAL|UNBALANCED|OVER the|NAMES NO|DISAGREES|NOT LOOPING|ABORTED' run.log
$ grep -E 'LAYOUT-BREAK|ALL ZERO' run.log

At detail the self-check doubles each shard's forward work and the scans cost O(live rows × frame words); deep reads no layer's cells but the top's.

4 checker#

cargo run -p checker -- laws <artifact>       Laws 1–4, then the lookup rules (check_laws)
cargo run -p checker -- padding <artifact>    the padding contract (check_padding)
cargo run -p checker -- dump <artifact>       the circuit, readably (checker::dump)
cargo run -p checker -- tape <verifying-key> <public-inputs>

An artifact is a CircuitArtifact file, decoded for encoding only so that a lawless one reaches the checks, such as crates/constraints/tests/vectors/*.bin. The validators are circuits.md §3's, independent of constraints; padding omits the product-tree clause; dump prints any decodable artifact.

tape loads a key (verifier::load_verifying_key) and a PublicInputs file, an archive's .vk and .public, refuses a statement the key does not describe (verifier_core::derive_global_phase), and runs checker::check_global_tape. That renders the global commit phase's event log a line a message, absorb <TAG> <n> (n scalars, or a bytes message's 31-byte chunks) or squeeze <TAG>, and holds it to expected_global_tape: G1–G11 (proof.md §2) written from the statement's shape, sharing nothing with verifier_core::global_commit but statement_shards. It prints the tape or the first line out of order, and checks the script, not the values, which the log does not carry. checker exits 0 when a check holds or a listing prints, 1 naming the failure, 2 on a usage error.

5 artifact-dump#

cargo run -p artifact-dump -- <guest.elf> [--out <dir>]
cargo run --release -p artifact-dump -- tables <guest.elf> [--ptau <file>]

The first writes <name>.img, the ELF's ProgramImage in its postcard wire form with nothing around it (program.md §3), and <name>.img.txt, a report rendered from the image read back off those bytes, which must equal the loaded one or nothing is written: segments, the listing (address, length, encoding, expanded word), .symtab names marked as outside the artifact, and the artifact's and the ELF's SHA-256, which pin bytes and are not the program identity. <name> is the ELF's stem; --out defaults to the working directory.

tables prints the VmConfig and each instruction's pc, next_pc, family, mnemonic and decoded fields at ProgramParams::defaults(); with --ptau it reads 2^22 powers, the largest default height, and prints the program identity (program.md §8).

6 The verifier CLI#

cargo run --release -p verifier -- <verifying-key> <identity-hex> <public-inputs> <proof>...
cargo run --release -p verifier -- block <verifying-key> <identity-hex> <public-inputs> <block>

The key is loaded by verifier::load_verifying_key (proof.md §7) and its identity must equal <identity-hex>, 64 lowercase hex digits of its canonical bytes from a channel the prover does not control: never the key, the proof or an archive's .identity. The first form verifies each ShardProof file with verify_shard and requires the files to be the statement's shards, each once, in any order; the second verifies a BlockProof with verify_block, as an archive's .vk, .public and .block (proof.md §9). It takes no SRS digest, using the key file's (srs.md §3). Exit 0 when all verifies, 1 naming the first file refused or a wrong shard set, 2 on usage or a malformed identity.

7 kat-gen and the committed fixtures#

cargo run -p kat-gen regenerates the default groups, cargo run -p kat-gen -- <group> one. Each file written prints its SHA-256, which the tests reading it pin.

group writes from
field, poly, curve, tower, pairing, msm, srs, moduli arithmetic, ceremony-point, KZG and MOD_MUL modulus vectors arkworks; srs's points through its own .ptau reader
pcs G1 absorption limbs; Mercury proofs arkworks; pcs
loader, isa listings of the committed guest ELFs, synthetic ELFs; an RV32IMA corpus, words that must not decode the pinned toolchain's llvm-objdump, llvm-nm
program, tape program identities, the generic table's commitments; guests/shards' global tape program; checker
gkr, memory, lookup, family, delegation CircuitArtifact files: toy circuits; the four frames, the two RAM-window circuits and the seven execution circuits, at 2^22; each base delegation circuit's shape and SHA-256 constraints
revm a synthetic block's witness, output commitment and delegated keccak-f frames native revm, held to the guest
opt-in: block, zkevm, guests mini-block, over ETH_RPC_URL (ethereum.md §6); zkevm-subset.json, cut from the tests-zkevm release at APOGEE_ZKEVM_FIXTURES only if every pair matches; the guest ELFs, each built twice and compared

srs, and program's identities and table commitments, need assets/ptau/ppot_0080_24.ptau and are skipped without it. CI runs the default groups and both oracles (§8) and fails on any git diff in the vector directories. A guest ELF is not reproducible across machines, since rustc embeds absolute paths in the panic-location strings of core and of crates outside the guest workspace, which the guest build does not remap; two clean builds on one machine agree. So guests is run by hand on one machine, and CI regenerates only what derives from the ELFs.

8 Reference oracles#

cargo run --manifest-path tools/transcript-ref/Cargo.toml
cargo run --manifest-path tools/stateless-ref/Cargo.toml

tools/transcript-ref implements transcript.md from its text over Plonky3's Poseidon2 and HorizenLabs zkhash's round constants, pinned by revision, and writes crates/transcript/tests/vectors/: permutation vectors, transcript scripts and io_digest cases. tools/stateless-ref encodes stateless inputs with eth-act/ere-guests v0.17.1's stateless-validator-common over libssz 0.3.0 and writes stateless_ref.txt under crates/host/tests/vectors/: per input, its request's hash_tree_root or reject. Each is its own workspace root because its dependencies enable features, serde/std among them, that cargo's feature unification would carry into the workspace's no_std crates; the one repository crate either links is tools/test-support, a seeded RNG, SHA-256 and hex with no dependencies.

Référence

Carte du dépôt

Où se trouve chaque élément dans le dépôt Apogee VM, ce qu’est chaque crate, et la page de la spécification qui la définit.

Voir en Markdown

Le dépôt Apogee VM comprend deux espaces de travail Cargo : l’espace de travail racine pour tout ce qui s’exécute sur votre hôte, et guests/ pour tout ce qui s’exécute dans la VM.

Crates#

Chemin Ce que c’est Spécifié dans
crates/constants chaque constante, étiquette et identifiant du protocole; aucune logique la page qui utilise chacun d’eux
crates/field, curve, poly, sumcheck Fr; la tour Fq, G1, G2, le couplage, la MSM; les polynômes multilinéaires; le zerocheck Primitives
crates/transcript Poseidon2 et la transcription duplex Transcription
crates/srs l’ingestion de la cérémonie, l’archive SRS, KZG, la phase 1 de Groth16 SRS
crates/pcs, pcs-verify Mercury et la vérification différée; pcs-verify est le côté corps de la vérification Mercury
crates/loader, isa, program de l’ELF à ProgramImage; le décodeur; les tables décodées, VmConfig, l’identité du programme Programme et identité
crates/emulator, trace l’exécuteur et ses traceurs; les lignes, l’état de la mémoire, les constructeurs de colonnes Trace d’exécution
crates/constraints chaque circuit sous forme de données : cadres mémoire, canaux de lookup, circuits des familles, registres Moteur GKR, Mémoire, Lookups, Circuits et les pages des familles
crates/gkr-verify, gkr le vérificateur et le prouveur GKR Moteur GKR
crates/verifier-core l’énoncé, les transcriptions, la clé de vérification, toutes les vérifications d’un shard et d’un bloc sauf l’ouverture; les bandes, les nœuds et le repliement de la récursion La preuve, Récursion
crates/verifier verify_shard, verify_block, l’archive de preuve, la CLI verifier La preuve
crates/prover la construction des clés, le remplissage des colonnes, le prouveur en flux, le journal de débogage Prouveur en flux
crates/groth16 Groth16 avec fils liés et cérémonie en deux phases Récursion §9
crates/host le SDK hôte : mise en place, preuve, vérification; l’enregistreur de témoins de bloc; l’arbre de récursion et le décideur Blocs Ethereum, Récursion
crates/checker des validateurs indépendants des lois des circuits, des évaluateurs natifs des lookups et de la mémoire, la suite de falsification, la CLI checker Circuits §3
crates/guest-sdk l’environnement d’exécution des programmes invités : point d’entrée, script d’édition de liens, allocateur, régions mémoire, shims de délégation ABI du programme invité, ABI de délégation
guests/ les programmes invités de test et de charge de travail, dans leur propre espace de travail; vendor/ contient les crates amont corrigées Exemples de programmes invités
contracts/ ApogeeVerifier.sol Récursion §9
tools/ kat-gen, bench, profiler, artifact-dump, test-support; transcript-ref et stateless-ref, des oracles indépendants hors de l’espace de travail Outils et CLI
docs/ la vue d’ensemble de l’architecture, le glossaire, le manuel des programmes invités, la page des outils et spec/, une page par sujet ce site

Prérequis#

  • La chaîne d’outils, ses composants et la cible riscv32imac-unknown-none-elf sont fixés dans rust-toolchain.toml; rustup les installe à la première utilisation.
  • L’identité du programme, les vraies clés et la génération de preuves nécessitent le fichier de cérémonie assets/ptau/ppot_0080_24.ptau. Les tests de l’espace de travail, non.
  • La génération de preuves est limitée par la mémoire : un bloc complet a culminé à 174 GiB.

Commandes#

sh
# What CI runs
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
cargo run -p kat-gen && git diff --exit-code     # committed fixtures regenerate identically

# Guests: their own workspace and target
(cd guests && cargo clippy --bins -- -D warnings)
(cd guests/fib && cargo build --target riscv32imac-unknown-none-elf)   # --release for proving

# Prove and verify a block, then recurse and decide
cargo run --release -p bench -- prove mini-block --out <dir>
cargo run --release -p verifier -- block <stem>.vk <identity-hex> <stem>.public <stem>.block
cargo run --release -p bench -- recurse <dir>/<stem> --out <out>

Les suites qui prouvent de vrais shards sont marquées #[ignore] et la CI ne les exécute pas : chacune prouve sur sa propre SRS jouet et nécessite des dizaines de GiB.

sh
cargo test --release -p prover --test <suite> -- --include-ignored --test-threads=1
#   acceptance, control, alu, mem, fills, block, streaming, keccak, recursion, public_io, revm
cargo test --release -p host --test prove -- --include-ignored --test-threads=1     # a mainnet mini-block
cargo test --release -p checker --test tamper -- --include-ignored --test-threads=1 # every tamper twin

Référence

Notes de version

Apogee VM v1.0.0, la première version. Ce qu’elle prouve, ce qu’elle livre, comment elle a été mesurée et vérifiée, et ses limites connues.

Voir en Markdown

v1.0.0#

La première version d’Apogee VM : une zkVM RISC-V qui prouve des programmes RV32IMAC et règle leurs preuves sur Ethereum. La spécification que reproduit ce site est le répertoire docs/ du dépôt à la révision source 3571370.

Ce qu’elle prouve#

Qu’un programme, désigné par un condensé de son image, s’est exécuté sur une entrée publique jusqu’à un statut de sortie et a écrit un journal, le tout porté à travers un arbre de récursion jusqu’à une seule preuve Groth16 que vérifie ApogeeVerifier.sol. Les preuves sont succinctes, pas à divulgation nulle de connaissance.

Ce qui est livré#

  • La machine. RV32IMAC sur un seul hart; les 59 instructions de RV32IMA, les instructions compressées étant développées au chargement; un SDK des programmes invités avec trois régions mémoire pour l’entrée, les données auxiliaires (advice) et la sortie.
  • Le système de preuve. 23 familles de circuits sur le corps des scalaires de BN254, chacune un circuit GKR en couches : sept familles d’instructions, cinq familles de fenêtres mémoire, six délégations et cinq familles de récursion. Un seul multiensemble mémoire lecture/écriture sur toute l’exécution; des lookups LogUp sur cinq canaux.
  • Délégations. KECCAK_F, SHA256_COMP, POSEIDON2, FR_ARITH, MOD_MUL et EC_ADD, accessibles depuis le SDK et depuis des versions corrigées de k256, ark-ff et revm-precompile.
  • Engagements. Mercury sur KZG, sur les puissances de tau perpétuelles de PSE, une ouverture de 704 octets par shard; une vérification différée pour la récursion.
  • Le prouveur. Un prouveur en flux à deux passes dont la mémoire suit les shards en cours de traitement.
  • Règlement. Un arbre de récursion formé de programmes feuilles et nœuds, dans un format de récursion doté d’une mémoire de corps et de quatre coprocesseurs; un décideur Groth16 avec fils liés et cérémonie en deux phases; ApogeeVerifier.sol.
  • La charge de travail Ethereum. Un programme invité revm avec un binaire mini-bloc et un validateur sans état pour Osaka, BPO1, BPO2 et Amsterdam.
  • Outils. bench, le profileur de cycles, le journal de débogage du prouveur, checker, artifact-dump, la CLI verifier, kat-gen, et deux oracles de référence.
  • Aucune cryptographie externe. Corps, courbe, couplage, MSM, hachage, PCS, GKR et Groth16 sont implémentés dans le dépôt.

Mesures#

Bloc 257 510 de glamsterdam-devnet-8 (60 transactions, 101,5 Mgas, 198 millions de cycles) : une preuve de base de 207 shards en 2 481 s sur 32 vCPU avec un pic de 174 GiB; un arbre de récursion de 116 shards; une preuve du décideur en 18,5 s; une vérification sur la chaîne pour 3 620 026 gas. Les 67 251 paires de tests-zkevm v21.0.1 concordent toutes en exécution native. La page Performances donne chaque chiffre.

Limites connues#

Pas de divulgation nulle de connaissance; des données auxiliaires non liées, par conception; au plus 16 380 octets chacun pour l’entrée publique et le journal; sc.w réussit toujours; les déroutements (traps) ne sont pas prouvables; un ensemble fixe de six délégations de base; une mémoire du prouveur déterminée par les shards en cours de traitement; une clé du décideur propre à chaque forme de racine et digne de confiance seulement dans la mesure où sa cérémonie l’est. Le modèle de sécurité énumère chaque limite avec sa raison.

Documentation#

Ce site, en anglais, en français (Canada), en chinois simplifié et en allemand, avec la spécification normative en anglais dans toutes les langues. Le Compagnon IA et llms.txt servent les agents d’IA.

404

Cette page est hors orbite

Rien n’existe à cette adresse. Lancez une recherche, ou repartez du premier pas.