Un prestamista publica su modelo de puntuación. Un solicitante lo ejecuta sobre sus propios datos y entrega al prestamista un recibo que dice «aprobado» o «rechazado». El prestamista nunca ve los datos, y el solicitante no puede cambiar el modelo ni alimentarlo con datos inventados.
Este es el caso útil más pequeño de demostrar un cálculo con un programa público y parámetros públicos sobre entradas privadas. El modelo es una red neuronal de dos capas en aritmética entera. La misma forma sirve para cualquier regla que un regulador o una contraparte quiera poder leer: una fórmula de precios, una prueba de elegibilidad, un límite de riesgo.
Programas de ejemplo contiene la configuración que comparten las cuatro páginas de ejemplo.
1. Qué demuestra
Tres partes:
- El prestamista despliega el programa con el modelo como su segmento público. El id del programa nombra ese modelo. Cualquiera puede leer los pesos desde la cadena.
- Un atestador (una central de riesgo, un banco, un empleador) conoce los datos del solicitante. Entrega al solicitante los valores de las características, una sal aleatoria de 16 bytes y su firma sobre el compromiso
SHA-256(salt ‖ features). - El solicitante llama al programa con la sal y las características como entrada privada.
La llamada demuestra que:
- cada característica está dentro del rango que el modelo declara para ella;
- la puntuación del modelo sobre esas características es igual o superior al umbral (estado
1) o inferior a él (estado2); - el compromiso publicado es el SHA-256 de la sal y de esas mismas características.
El prestamista comprueba el id del programa del recibo, su estado y que el compromiso sea uno que el atestador firmó. Aprende un solo bit sobre el solicitante.
Las entradas privadas, por sí solas, no demuestran nada sobre el mundo. Sin el punto 3, un solicitante podría escribir cualesquiera números. El compromiso es lo que conecta los números de la prueba con números que alguien avaló. La firma en sí la verifica el prestamista fuera de la cadena: una verificación Dilithium o ECDSA no cabe en el presupuesto de una llamada, y no necesita estar dentro de la prueba, porque el compromiso es público.
2. La interfaz
Cada palabra pública es un i32, almacenado como su complemento a dos u32.
| palabra pública | significado |
|---|---|
| 0 | el número de características n, de 1 a 16 |
| 1 | el número de unidades ocultas h, de 1 a 16 |
| 2 | el umbral |
| 3 | el número de bits fraccionarios, el desplazamiento s, de 0 a 31 |
luego 2n palabras | el min y el max permitidos de cada característica |
luego h × n palabras | los pesos de la primera capa, fila por fila |
luego h palabras | los sesgos de la primera capa |
luego h palabras | los pesos de la segunda capa |
| luego 1 palabra | el sesgo de la segunda capa |
| palabra privada | significado |
|---|---|
| 0 a 3 | la sal, 16 bytes como cuatro palabras big-endian |
| 4 en adelante | las n características, cada una un i32 |
| ranura de salida | significado |
|---|---|
| 0 | estado: 1 aprobado, 2 rechazado, 3 una característica fuera de rango, 4 modelo no válido (un tamaño fuera de límites, o la aritmética se desbordó) |
| 1 | sin uso |
| 2 a 6 | los primeros 20 bytes del compromiso, como cinco palabras big-endian |
| 7 | sin uso; un pánico de Rust escribe aquí 0xdeadbeef |
El compromiso se publica solo con el estado 1 o 2. Una entrada fuera de rango detiene la ejecución antes de calcular ningún hash.
3. La aritmética
El modelo usa solo enteros. La máquina no tiene coma flotante, y un verificador debe poder reproducir la puntuación con exactitud, algo que la coma flotante no garantiza entre máquinas distintas.
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
Los pesos son de punto fijo con s bits fraccionarios: con s = 8, un peso de 256 equivale a 1,0. Las sumas son i64, y cada multiplicación y suma está comprobada, de modo que un modelo con pesos lo bastante grandes como para desbordarse da el estado 4 en lugar de una puntuación errónea. >> sobre un i64 negativo redondea hacia menos infinito en Rust, y el >> de Python hace lo mismo, lo que permite que una función de Python de diez líneas sirva de referencia.
El modelo de demostración tiene ocho características y cuatro unidades ocultas. Sus pesos se escribieron a mano para esta página. No es un modelo entrenado y no dice nada sobre el crédito.
{
"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. El guest
my-guests/score/src/main.rs, completo:
#![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();
}
El modelo son datos, no código. Los pesos se leen con read_public, de modo que una sola imagen desplegada sirve para cualquier modelo de hasta 16 características y 16 unidades ocultas. Cambiar el modelo es un nuevo despliegue del mismo código con un nuevo segmento público, y un nuevo id de programa. Un prestamista no puede cambiar los pesos a espaldas de un solicitante, y un solicitante no puede elegir un modelo más benévolo: el recibo nombra el id del programa, y el id vincula los pesos.
Los rangos forman parte del modelo. Sin ellos, un solicitante no podría mentir sobre un valor (el compromiso lo impide), pero un atestador podría firmar disparates, como una edad de −5, que un modelo con un peso negativo recompensaría. La comprobación también acota la aritmética.
La sal cumple dos funciones. Hace que el compromiso sea imposible de adivinar: ocho enteros pequeños tienen poca entropía, y sin sal cualquiera podría calcular el hash de solicitantes candidatos y compararlos. Y aporta 128 bits aleatorios al cálculo. Antes de que el conjunto de restricciones 7 ocultara los totales de bus de la prueba, una llamada con unas pocas palabras privadas pequeñas podía descifrarse por fuerza bruta a partir de la propia prueba, y las notas del fullnode indicaban a tales programas que leyeran palabras aleatorias. La cadena 18 ejecuta el conjunto de restricciones 8, donde esto está corregido, pero una sal cuesta cuatro palabras de entrada y elimina la duda.
5. El lado del anfitrión
score.py usa solo la biblioteca estándar 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
El evaluador de referencia y el compromiso, completos:
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
Los números grandes son los pesos negativos: 4294966784 es −512.
El guest se probó contra esta referencia con 300 solicitantes aleatorios, comparando la decisión y las cinco palabras del compromiso, y con solicitantes cuyas puntuaciones son 599 y 600. Coinciden en todos los casos.
6. Compilar y ejecutar
$ 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 solicitante de 41 años con unos ingresos de 85 mil, una relación deuda-ingresos del 28 %, el 97 % de los pagos a tiempo, una utilización del 22 %, 12 años de historial, una consulta y ningún impago:
$ 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 es 0x9430c381, los primeros cuatro bytes del compromiso. La puntuación, 1075, no aparece en ninguna de las salidas.
Un segundo solicitante (23, 31, 55, 82, 91, 2, 6, 2, con otra sal) obtiene 417 y da out[0] = 2 con su propio compromiso, en los mismos 1.810 ciclos. El primer solicitante con la edad cambiada a 17 da out[0] = 3 y ningún compromiso.
7. El presupuesto
| programa | 462 palabras |
| segmento público | 61 palabras |
| palabras privadas | 12 |
| ciclos | 1.810 |
| compresiones SHA-256 | 1 (48 bytes de mensaje caben en un bloque) |
| nivel | 12 |
Este es el más barato de los cuatro programas. El paso hacia adelante son 36 multiplicaciones. Un modelo en los límites del programa, con 16 características y 16 unidades ocultas, son 272 multiplicaciones y un segmento público de 325 palabras. Mide 7.900 ciclos en el emulador, aproximadamente la mitad del nivel 14.
La ejecución no depende de los datos: 200 solicitantes aleatorios, aprobados y rechazados por igual, tardaron exactamente 1.810 ciclos cada uno. El límite de gas que declara una llamada es público y se redondea a partir del gas de la ejecución, de modo que un programa cuyo número de ciclos siguiera a sus entradas revelaría una pequeña parte de ellas. Este no tiene nada que revelar de ese modo.
Desde la cadena 18, la wallet imprime el gas exacto de la ejecución y el límite que declara:
gas: 2011 (declaring 2048, tier 12)
1.948 filas a 1 de gas cada una, más 63 por la única compresión.
8. Despliegue
Las 61 palabras del modelo se pagan como las palabras de código: 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. Llamada
$ 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`
Estado 1 y el compromiso 9430c381 8f5966f8 dd33f78e 05a7d079 355191e6 en hexadecimal. h_pub es el resumen de las 61 palabras del modelo. Las características, la sal y la puntuación no están en la transacción.
La llamada tardó algo más de tres minutos en un portátil Apple Silicon: 31 s para la prueba de la llamada, 7 s para la autorización de gasto y 124 s para el paquete, con un pico de 6,2 GB. La prueba pesa 1.838.256 bytes.
10. Compruébelo usted mismo
El lado del prestamista, con nada más que el recibo y el compromiso firmado por el atestador:
- El modelo.
rand_getProgramPublicsobre el id del programa devuelve las 61 palabras. Compárelas con elmodel.jsonpublicado (score.py setuplas reconstruye). - El código. Recompile
my-guests/scoreen el mismo commit de circuits y comparehccon elcode_hashdel programa. - El recibo.
programes el id del modelo, la salida 0 es1, la ranura 7 no es3735928559. - El compromiso. Las salidas 2 a 6, impresas en hexadecimal, son los primeros 20 bytes del compromiso que firmó el atestador:
$ python3 score.py commit 00112233445566778899aabbccddeeff 41 85 28 97 22 12 1 0
9430c3818f5966f8dd33f78e05a7d079355191e6
Un rechazo también es un recibo. La llamada del segundo solicitante, 119e7834…1b3b en la chain 18 (repetida en la chain 20 como c8a60183…9f73, que también rechaza), fue demostrada y confirmada de la misma manera:
$ 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
Estado 2, y 51636806 es 0x0313ea46. El gas, el límite declarado y el nivel son los mismos que los de la llamada aprobada, de modo que las dos solo difieren en la cadena en la palabra de estado y en el compromiso, y en nada más. Un solicitante no tiene motivo para publicar un rechazo, pero el prestamista puede pedir el recibo en cualquier caso, y un solicitante rechazado no puede producir uno aprobado.
Lo que la prueba no te da
- Datos verídicos. La prueba muestra que el modelo se ejecutó sobre los valores comprometidos. Que los valores sean verdaderos depende de la palabra del atestador, y el prestamista tiene que confiar en la firma del atestador para ello.
- Un solicitante imposible de vincular. El compromiso es un identificador estable: el atestador puede reconocerlo, y dos recibos con el mismo compromiso corresponden al mismo solicitante y a los mismos datos. Una sal nueva del atestador para cada solicitud resuelve lo segundo, pero no lo primero.
- Un modelo secreto. Los pesos son públicos. Ese es el objetivo en una regla que las personas deben poder leer, y un inconveniente para un prestamista que trata su modelo como secreto comercial. Un solicitante también puede buscar las entradas que lo harían pasar, pero no puede usarlas sin una firma de un atestador sobre ellas.
- Protección frente al sondeo de un atestador deshonesto. Quien conoce las características puede ejecutar el modelo público por su cuenta. La privacidad aquí es la del solicitante frente al prestamista y al público, no frente a la parte que aportó los datos.
- Un modelo grande. Dieciséis características y dieciséis unidades ocultas son una tarjeta de puntuación, no una red profunda. Una llamada está limitada al nivel 14, unos dieciséis mil ciclos.