Un prêteur publie son modèle de scoring. Un demandeur l’exécute sur ses propres données et remet au prêteur un reçu qui indique « approuvé » ou « refusé ». Le prêteur ne voit jamais les données, et le demandeur ne peut ni modifier le modèle ni lui fournir des données inventées.
C’est le cas utile le plus simple de preuve d’un calcul avec un programme public et des paramètres publics sur des entrées privées. Le modèle est un réseau de neurones à deux couches en arithmétique entière. La même forme convient à toute règle qu’un régulateur ou une contrepartie veut pouvoir lire : une formule de tarification, un test d’éligibilité, une limite de risque.
Programmes d’exemple contient la configuration commune aux quatre pages d’exemple.
1. Ce que la preuve établit
Trois parties :
- Le prêteur déploie le programme avec le modèle comme segment public. L’identifiant du programme désigne ce modèle. Chacun peut relire les poids depuis la chaîne.
- Un attestataire (une agence de notation de crédit, une banque, un employeur) connaît les données du demandeur. Il remet au demandeur les valeurs des variables, un sel aléatoire de 16 octets et sa signature de l’engagement
SHA-256(salt ‖ features). - Le demandeur appelle le programme avec le sel et les variables comme entrée privée.
L’appel prouve que :
- chaque variable se trouve dans la plage que le modèle déclare pour elle ;
- le score du modèle sur ces variables est supérieur ou égal au seuil (statut
1) ou inférieur à celui-ci (statut2) ; - l’engagement publié est le SHA-256 du sel et de ces mêmes variables.
Le prêteur vérifie l’identifiant de programme du reçu, son statut, et que l’engagement est bien un engagement signé par l’attestataire. Il n’apprend qu’un seul bit sur le demandeur.
Des entrées privées ne prouvent rien sur le monde par elles-mêmes. Sans le point 3, un demandeur pourrait saisir n’importe quels nombres. L’engagement est ce qui relie les nombres de la preuve à des nombres dont quelqu’un se porte garant. La signature elle-même est vérifiée hors chaîne par le prêteur : une vérification Dilithium ou ECDSA ne tient pas dans le budget d’un appel, et elle n’a pas besoin d’être dans la preuve, puisque l’engagement est public.
2. L’interface
Chaque mot public est un i32, stocké sous la forme de son complément à deux u32.
| mot public | signification |
|---|---|
| 0 | le nombre de variables n, de 1 à 16 |
| 1 | le nombre d’unités cachées h, de 1 à 16 |
| 2 | le seuil |
| 3 | le nombre de bits fractionnaires, le décalage s, de 0 à 31 |
puis 2n mots | le min et le max autorisés pour chaque variable |
puis h × n mots | les poids de la première couche, ligne par ligne |
puis h mots | les biais de la première couche |
puis h mots | les poids de la seconde couche |
| puis 1 mot | le biais de la seconde couche |
| mot privé | signification |
|---|---|
| 0 à 3 | le sel, 16 octets sous forme de quatre mots big-endian |
| 4 et suivants | les n variables, chacune un i32 |
| emplacement de sortie | signification |
|---|---|
| 0 | statut : 1 approuvé, 2 refusé, 3 une variable est hors plage, 4 modèle invalide (une taille hors limites, ou l’arithmétique a débordé) |
| 1 | inutilisé |
| 2 à 6 | les 20 premiers octets de l’engagement, sous forme de cinq mots big-endian |
| 7 | inutilisé ; une panique Rust y écrit 0xdeadbeef |
L’engagement n’est publié qu’avec le statut 1 ou 2. Une entrée hors plage interrompt l’exécution avant que quoi que ce soit ne soit haché.
3. L’arithmétique
Le modèle ne manipule que des entiers. La machine n’a pas de virgule flottante, et un vérificateur doit pouvoir reproduire exactement le score, ce que la virgule flottante ne garantit pas d’une machine à l’autre.
hidden[j] = max( (b1[j] + Σ w1[j][i] · x[i]) >> s , 0 ) for each hidden unit j
score = (b2 + Σ w2[j] · hidden[j]) >> s
approved = score >= threshold
Les poids sont en virgule fixe avec s bits fractionnaires : avec s = 8, un poids de 256 vaut 1,0. Les sommes sont des i64, et chaque multiplication et chaque addition est vérifiée, de sorte qu’un modèle dont les poids sont assez grands pour provoquer un débordement donne le statut 4 au lieu d’un score erroné. En Rust, >> sur un i64 négatif arrondit vers moins l’infini, et le >> de Python fait de même, ce qui permet à une fonction Python de dix lignes de servir de référence.
Le modèle de démonstration compte huit variables et quatre unités cachées. Ses poids ont été écrits à la main pour cette page. Ce n’est pas un modèle entraîné et il ne dit rien du crédit.
{
"shift": 8,
"threshold": 600,
"features": [
{"name": "age_years", "min": 18, "max": 120},
{"name": "income_thousands", "min": 0, "max": 100000},
{"name": "debt_to_income_pct", "min": 0, "max": 1000},
{"name": "on_time_payments_pct", "min": 0, "max": 100},
{"name": "utilisation_pct", "min": 0, "max": 100},
{"name": "history_years", "min": 0, "max": 100},
{"name": "inquiries_12m", "min": 0, "max": 100},
{"name": "delinquencies", "min": 0, "max": 100}
],
"w1": [
[ 64, 96, -512, 1536, -640, 1024, -2048, -8192],
[ 0, 256, -1024, 0, 0, 0, 0, 0],
[ 0, 0, 0, 2560, -1280, 0, -4096, -16384],
[ 128, 0, 0, 0, 0, 2048, 0, 0]
],
"b1": [25600, 12800, -128000, 0],
"w2": [160, 96, 224, 128],
"b2": 76800
}
4. Le guest
my-guests/score/src/main.rs, en entier :
#![no_std]
#![no_main]
use guest_sdk::{halt, read_input, read_public, sha256_compress, write_output};
const MAX_FEATURES: usize = 16;
const MAX_HIDDEN: usize = 16;
const APPROVED: u32 = 1;
const DECLINED: u32 = 2;
const OUT_OF_RANGE: u32 = 3;
const BAD_MODEL: u32 = 4;
/// SHA-256's initial state (FIPS 180-4 §5.3.3).
const IV: [u32; 8] = [
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
];
/// SHA-256 of `words`, each word taken as four big-endian bytes. The message is whole words,
/// so the padding is whole words too: `0x80000000`, zeros, then the bit length in the last
/// word of a block (the length's high word is always zero here).
fn sha256_words(words: &[u32]) -> [u32; 8] {
let mut buf = [0u32; 24];
buf[16..].copy_from_slice(&IV);
let mut fill = 0;
for &w in words {
buf[fill] = w;
fill += 1;
if fill == 16 {
sha256_compress(&mut buf);
fill = 0;
}
}
buf[fill] = 0x8000_0000;
fill += 1;
if fill > 14 {
buf[fill..16].fill(0);
sha256_compress(&mut buf);
fill = 0;
}
buf[fill..15].fill(0);
buf[15] = 32 * words.len() as u32;
sha256_compress(&mut buf);
let mut out = [0u32; 8];
out.copy_from_slice(&buf[16..]);
out
}
/// `weight · x`, added to `acc`; `None` when it does not fit an i64.
fn mul_add(acc: i64, weight: u32, x: i64) -> Option<i64> {
acc.checked_add((weight as i32 as i64).checked_mul(x)?)
}
/// Runs the model in the public segment over the private features.
fn score(features: &[i32], hidden: usize, shift: u32) -> Option<i64> {
let n = features.len();
// The public segment after the header and the feature ranges:
// hidden × n first-layer weights, hidden biases, hidden second-layer weights, one bias.
let w1 = 4 + 2 * n;
let b1 = w1 + hidden * n;
let w2 = b1 + hidden;
let b2 = w2 + hidden;
let mut out = read_public(b2 as u32) as i32 as i64;
for j in 0..hidden {
let mut acc = read_public((b1 + j) as u32) as i32 as i64;
for (i, &x) in features.iter().enumerate() {
acc = mul_add(acc, read_public((w1 + j * n + i) as u32), x as i64)?;
}
// Fixed point: drop the fractional bits, then ReLU.
let h = (acc >> shift).max(0);
out = mul_add(out, read_public((w2 + j) as u32), h)?;
}
Some(out >> shift)
}
/// Public segment, fixed at deploy (every value an i32):
/// [features n, hidden units, threshold, fractional bits,
/// n × (min, max), hidden × n weights, hidden biases, hidden weights, bias]
/// Private input: [salt (4 words), n features].
/// Outputs: [status, 0, commitment (first 5 words), 0], where
/// commitment = SHA-256(salt ‖ features), every word big-endian.
#[no_mangle]
pub extern "C" fn main() -> ! {
let n = read_public(0) as usize;
let hidden = read_public(1) as usize;
let threshold = read_public(2) as i32 as i64;
let shift = read_public(3);
if n == 0 || n > MAX_FEATURES || hidden == 0 || hidden > MAX_HIDDEN || shift > 31 {
write_output(0, BAD_MODEL);
halt();
}
// The committed message: the salt, then the features, in input order.
let mut message = [0u32; 4 + MAX_FEATURES];
for (i, w) in message.iter_mut().enumerate().take(4 + n) {
*w = read_input(i as u32);
}
let mut features = [0i32; MAX_FEATURES];
let mut in_range = true;
for i in 0..n {
let x = message[4 + i] as i32;
let min = read_public(4 + 2 * i as u32) as i32;
let max = read_public(5 + 2 * i as u32) as i32;
in_range &= min <= x && x <= max;
features[i] = x;
}
if !in_range {
write_output(0, OUT_OF_RANGE);
halt();
}
let status = match score(&features[..n], hidden, shift) {
Some(s) if s >= threshold => APPROVED,
Some(_) => DECLINED,
None => BAD_MODEL,
};
let commitment = sha256_words(&message[..4 + n]);
write_output(0, status);
for k in 0..5 {
write_output(2 + k as u32, commitment[k]);
}
halt();
}
Le modèle est une donnée, pas du code. Les poids sont lus avec read_public, de sorte qu’une seule image déployée sert n’importe quel modèle comptant jusqu’à 16 variables et 16 unités cachées. Changer le modèle revient à déployer à nouveau le même code avec un nouveau segment public, et donc un nouvel identifiant de programme. Un prêteur ne peut pas échanger les poids sous les pieds d’un demandeur, et un demandeur ne peut pas choisir un modèle plus clément : le reçu désigne l’identifiant du programme, et l’identifiant lie les poids.
Les plages font partie du modèle. Sans elles, un demandeur ne pourrait pas mentir sur une valeur (l’engagement l’en empêche), mais un attestataire pourrait signer des valeurs absurdes, comme un âge de −5, qu’un modèle à poids négatif récompenserait. Ce contrôle borne aussi l’arithmétique.
Le sel remplit deux fonctions. Il rend l’engagement impossible à deviner : huit petits entiers ont peu d’entropie, et sans sel, n’importe qui pourrait hacher des demandeurs candidats et comparer. Et il fait passer 128 bits aléatoires dans le calcul. Avant que le jeu de contraintes 7 ne masque les totaux de bus de la preuve, un appel portant sur quelques petits mots privés pouvait être retrouvé par force brute à partir de la preuve elle-même, et les notes du fullnode demandaient à de tels programmes de lire des mots aléatoires. La chaîne 18 exécute le jeu de contraintes 8, où ce défaut est corrigé, mais un sel coûte quatre mots d’entrée et écarte la question.
5. Côté hôte
score.py n’utilise que la bibliothèque standard de Python :
score.py setup <model.json> writes public.txt
score.py witness <model.json> <salt: 32 hex> <x0> <x1> … prints the private words;
stderr: the score, the decision, the commitment
score.py commit <salt: 32 hex> <x0> <x1> … prints the commitment alone
Le scoreur de référence et l’engagement, en entier :
def score(m, x):
"""The guest's arithmetic exactly: integers, a floor shift after each layer, ReLU between."""
out = m["b2"]
for row, b, w2 in zip(m["w1"], m["b1"], m["w2"]):
h = max((b + sum(w * xi for w, xi in zip(row, x))) >> m["shift"], 0)
out += w2 * h
return out >> m["shift"]
def commitment(salt: bytes, x):
return hashlib.sha256(salt + b"".join(struct.pack(">i", xi) for xi in x)).digest()
$ cd my-guests/score
$ python3 score.py setup model.json
wrote public.txt (61 words)
$ cat public.txt
8 4 600 8 18 120 0 100000 0 1000 0 100 0 100 0 100 0 100 0 100 64 96 4294966784 1536 4294966656 1024 4294965248 4294959104 0 256 4294966272 0 0 0 0 0 0 0 0 2560 4294966016 0 4294963200 4294950912 128 0 0 0 0 2048 0 0 25600 12800 4294839296 0 160 96 224 128 76800
Les grands nombres sont les poids négatifs : 4294966784 vaut −512.
Le guest a été testé face à cette référence sur 300 demandeurs aléatoires, en comparant la décision et les cinq mots de l’engagement, ainsi que sur des demandeurs dont les scores sont 599 et 600. Tous concordent.
6. Compiler et exécuter
$ rand-guest build my-guests/score --max-words 65535
462 words against a cap of 65535 (fits); 0 ecall(s) with a non-static a7
OK
wrote my-guests/score/image.bin and its .sha256 (462 words, hc c63d7b15a165a11d007ce48bc37563b71f77fcc4991ffe0182c8cf4c77b6e7b5, program id ce8d234faf737c24bdd2826933ac843973c140c53e76fbb2cccaef3073da9818)
Un demandeur de 41 ans, avec un revenu de 85 mille, un taux d’endettement de 28 %, 97 % de paiements à l’heure, 22 % d’utilisation, 12 ans d’historique, une demande de renseignements et aucun impayé :
$ python3 score.py witness model.json 00112233445566778899aabbccddeeff 41 85 28 97 22 12 1 0 > witness.txt
score 1075 approved
commitment 9430c3818f5966f8dd33f78e05a7d079355191e6
$ cat witness.txt
1122867 1146447479 2291772091 3437096703 41 85 28 97 22 12 1 0
$ rand-guest run image.bin --public $(cat public.txt) --input $(cat witness.txt) --tier 14
out[0] = 1
out[1] = 0
out[2] = 2486223745
out[3] = 2405000952
out[4] = 3711170446
out[5] = 94883961
out[6] = 894538214
out[7] = 0
cycles 1810
tier 12
tier 14: fits (cycles 1946 of 16383, Poseidon2 permutations 136 of 2048)
2486223745 est 0x9430c381, les quatre premiers octets de l’engagement. Le score, 1075, n’apparaît nulle part dans les sorties.
Un second demandeur (23, 31, 55, 82, 91, 2, 6, 2, avec un autre sel) obtient 417 et donne out[0] = 2 avec son propre engagement, en 1 810 cycles également. Le premier demandeur, avec l’âge changé en 17, donne out[0] = 3 et aucun engagement.
7. Le budget
| programme | 462 mots |
| segment public | 61 mots |
| mots privés | 12 |
| cycles | 1 810 |
| compressions SHA-256 | 1 (48 octets de message tiennent dans un bloc) |
| palier | 12 |
C’est le moins coûteux des quatre programmes. La passe avant compte 36 multiplications. Un modèle aux limites du programme, 16 variables et 16 unités cachées, compte 272 multiplications et un segment public de 325 mots. Il mesure 7 900 cycles dans l’émulateur, environ la moitié du palier 14.
L’exécution ne dépend pas des données : 200 demandeurs aléatoires, approuvés comme refusés, ont tous pris exactement 1 810 cycles. La limite de gas qu’un appel déclare est publique et arrondie à partir du gas de l’exécution ; un programme dont le nombre de cycles suivrait ses entrées en divulguerait donc un peu. Celui-ci n’a rien à divulguer de cette façon.
Depuis la chaîne 18, le wallet affiche le gas exact de l’exécution et la limite qu’il déclare :
gas: 2011 (declaring 2048, tier 12)
1 948 lignes à 1 gas chacune, plus 63 pour l’unique compression.
8. Déploiement
Les 61 mots du modèle se paient comme des mots de code : 0.001 + 0.0001 × (462 + 61) = 0,0533 RAND.
$ rand fee deploy 462 --public-words 61
0.0533 RAND
$ rand program deploy image.bin --public public.txt
program id: 7bbec14289f4446231b3a9a5ac18cb0613a373ca8a14fe4f046f3fde7ae6c73c (462 words, hc 157b3dc61da165a18be47c00b76375c3c4fc771f01fe1f994ccfc882b5e7b677, public input 61 words, digest a08a7c691c45babeb39e953930d4d7174662b7b8adcc2d0f4e6c193d362d6668)
proving the spend authorisation on this machine (tier 10; seconds)…
authorisation proved in 7.4s: tier 10, 1364940 bytes
proving the bundle (tier 14; about a minute and a half on a laptop)…
proved in 123.5s: tier 14, 1497319 bytes
submitted deploy d47a9323ea53bfafc3faa36d004416d374bf3c2a48971d87e346be426b0caf14
0 RAND out, 59.7229 RAND change, fee 0.0533 RAND, anchored at height 98062, proved in 123.5s (bundle) + 7.4s (auth)
9. Appel
$ rand call 7bbec14289f4446231b3a9a5ac18cb0613a373ca8a14fe4f046f3fde7ae6c73c \
$(sed 's/[0-9][0-9]*/--input &/g' witness.txt)
proving the call locally (12 inputs stay private, over the program's 61-word public input)…
gas: 2011 (declaring 2048, tier 12)
fee 0.005673037 RAND (incl. two price steps of headroom)
proved in 30.6s: tier 12, 1838256 bytes, outputs [1, 0, 2486223745, 2405000952, 3711170446, 94883961, 894538214, 0]
proving the spend authorisation on this machine (tier 10; seconds)…
authorisation proved in 6.8s: tier 10, 1369611 bytes
proving the bundle (tier 14; about a minute and a half on a laptop)…
proved in 124.3s: tier 14, 1499112 bytes
submitted call 454c1b9eb1e159e682d4ff26b71f77d8a950b813533d91a89293dd432fdcf6f7
0 RAND out, 59.696058627 RAND change, fee 0.005673037 RAND, anchored at height 98832, proved in 124.3s (bundle) + 6.8s (auth)
{
"h_in": "ef80c744a8cdea62b56e334baaf7372e4a2e6c8282d37aa8ae96b4f039ec1a22",
"h_pub": "a08a7c691c45babeb39e953930d4d7174662b7b8adcc2d0f4e6c193d362d6668",
"height": 98993,
"index": 0,
"outputs": [
1,
0,
2486223745,
2405000952,
3711170446,
94883961,
894538214,
0
],
"program": "7bbec14289f4446231b3a9a5ac18cb0613a373ca8a14fe4f046f3fde7ae6c73c",
"tier": 12,
"tx": "454c1b9eb1e159e682d4ff26b71f77d8a950b813533d91a89293dd432fdcf6f7"
}
input transcript published; open it with `rand open-call 454c1b9eb1e159e682d4ff26b71f77d8a950b813533d91a89293dd432fdcf6f7`
Statut 1 et l’engagement 9430c381 8f5966f8 dd33f78e 05a7d079 355191e6 en hexadécimal. h_pub est le condensat des 61 mots du modèle. Les variables, le sel et le score ne figurent pas dans la transaction.
L’appel a pris un peu plus de trois minutes sur un portable Apple Silicon : 31 s pour la preuve de l’appel, 7 s pour l’autorisation de dépense et 124 s pour le bundle, avec un pic de 6,2 GB. La preuve fait 1 838 256 octets.
10. Vérifiez par vous-même
Le côté du prêteur, avec pour seuls éléments le reçu et l’engagement signé par l’attestataire :
- Le modèle.
rand_getProgramPublicsur l’identifiant du programme renvoie les 61 mots. Comparez-les aumodel.jsonpublié (score.py setuples reconstruit). - Le code. Recompilez
my-guests/scoreau même commit des circuits et comparezhcaucode_hashdu programme. - Le reçu.
programest l’identifiant du modèle, la sortie 0 vaut1, l’emplacement 7 n’est pas3735928559. - L’engagement. Les sorties 2 à 6, affichées en hexadécimal, sont les 20 premiers octets de l’engagement que l’attestataire a signé :
$ python3 score.py commit 00112233445566778899aabbccddeeff 41 85 28 97 22 12 1 0
9430c3818f5966f8dd33f78e05a7d079355191e6
Un refus est aussi un reçu. L’appel du second demandeur, 119e7834…1b3b sur la chain 18 (répété sur la chain 20 sous c8a60183…9f73, qui refuse lui aussi), a été prouvé et enregistré de la même manière :
$ python3 score.py witness model.json 0f1e2d3c4b5a69788796a5b4c3d2e1f0 23 31 55 82 91 2 6 2 > witness.txt
score 417 declined
commitment 0313ea462178574c49a10fe7ea1d84207652e1a3
$ rand call 7bbec14289f4446231b3a9a5ac18cb0613a373ca8a14fe4f046f3fde7ae6c73c \
$(sed 's/[0-9][0-9]*/--input &/g' witness.txt)
…
gas: 2011 (declaring 2048, tier 12)
proved in 26.1s: tier 12, 1836624 bytes, outputs [2, 0, 51636806, 561534796, 1235292135, 3927802912, 1985143203, 0]
…
submitted call 119e7834033edd19bba8e10c1a764e8bdbc0b1c8415a42f041faa392c6411b3b
Statut 2, et 51636806 vaut 0x0313ea46. Le gaz, la limite déclarée et le palier sont les mêmes que pour l’appel approuvé : les deux appels ne diffèrent donc on-chain que par le mot de statut et par l’engagement, et par rien d’autre. Un demandeur n’a aucune raison de publier un refus, mais le prêteur peut réclamer le reçu dans les deux cas, et un demandeur refusé ne peut pas en produire un approuvé.
Ce que la preuve ne vous apporte pas
- Des données exactes. La preuve montre que le modèle a été exécuté sur les valeurs engagées. Que ces valeurs soient exactes relève de la parole de l’attesteur, et le prêteur doit pour cela se fier à la signature de l’attesteur.
- Un demandeur non liable. L’engagement est un identifiant stable : l’attesteur peut le reconnaître, et deux reçus portant le même engagement concernent le même demandeur et les mêmes données. Un sel neuf fourni par l’attesteur pour chaque demande règle le second point, pas le premier.
- Un modèle secret. Les poids sont publics. C’est le but pour une règle que chacun doit pouvoir lire, et un inconvénient pour un prêteur qui considère son modèle comme un secret commercial. Un demandeur peut aussi rechercher les entrées qui passeraient, mais il ne peut pas s’en servir sans une signature de l’attesteur sur celles-ci.
- Une protection contre le sondage par un attesteur malhonnête. Quiconque connaît les variables peut exécuter lui-même le modèle public. La confidentialité dont il est question ici est celle du demandeur face au prêteur et au public, pas face à la partie qui a fourni les données.
- Un grand modèle. Seize variables et seize unités cachées font une grille de score, pas un réseau profond. Un appel est plafonné au palier 14, soit environ seize mille cycles.