Um credor publica o seu modelo de pontuação. Um solicitante o executa sobre os próprios dados e entrega ao credor um comprovante que diz “aprovado” ou “recusado”. O credor nunca vê os dados, e o solicitante não pode alterar o modelo nem alimentá-lo com dados inventados.
Este é o caso útil mais simples de provar uma computação com um programa público e parâmetros públicos sobre entradas privadas. O modelo é uma rede neural de duas camadas em aritmética de inteiros. O mesmo formato serve para qualquer regra que um regulador ou uma contraparte queira poder ler: uma fórmula de precificação, um teste de elegibilidade, um limite de risco.
Example programs traz a configuração que as quatro páginas de exemplo compartilham.
1. O que ela prova
Três partes:
- O credor implanta o programa com o modelo como seu segmento público. O id do programa nomeia esse modelo. Qualquer pessoa pode ler os pesos de volta a partir da chain.
- Um atestador (um bureau de crédito, um banco, um empregador) conhece os dados do solicitante. Ele entrega ao solicitante os valores das características, um salt aleatório de 16 bytes e a sua assinatura sobre o compromisso
SHA-256(salt ‖ features). - O solicitante chama o programa com o salt e as características como entrada privada.
A chamada prova que:
- cada característica está dentro do intervalo que o modelo declara para ela;
- a pontuação do modelo sobre essas características é igual ou superior ao limiar (status
1) ou inferior a ele (status2); - o compromisso publicado é o SHA-256 do salt e dessas mesmas características.
O credor verifica o id do programa do comprovante, o seu status e se o compromisso é um dos que o atestador assinou. Ele fica sabendo um único bit sobre o solicitante.
Entradas privadas, por si sós, não provam nada sobre o mundo. Sem o ponto 3, um solicitante poderia digitar quaisquer números. O compromisso é o que liga os números da prova a números pelos quais alguém se responsabilizou. A própria assinatura é verificada fora da chain pelo credor: uma verificação Dilithium ou ECDSA não cabe no orçamento de uma chamada, e não precisa estar dentro da prova, porque o compromisso é público.
2. A interface
Toda palavra pública é um i32, armazenado como o seu u32 em complemento de dois.
| palavra pública | significado |
|---|---|
| 0 | o número de características n, de 1 a 16 |
| 1 | o número de unidades ocultas h, de 1 a 16 |
| 2 | o limiar |
| 3 | o número de bits fracionários, o deslocamento s, de 0 a 31 |
depois 2n palavras | o min e o max permitidos de cada característica |
depois h × n palavras | os pesos da primeira camada, linha por linha |
depois h palavras | os vieses da primeira camada |
depois h palavras | os pesos da segunda camada |
| depois 1 palavra | o viés da segunda camada |
| palavra privada | significado |
|---|---|
| 0 a 3 | o salt, 16 bytes como quatro palavras big-endian |
| 4 em diante | as n características, cada uma um i32 |
| slot de saída | significado |
|---|---|
| 0 | status: 1 aprovado, 2 recusado, 3 uma característica fora do intervalo, 4 modelo inválido (um tamanho fora dos limites, ou a aritmética estourou) |
| 1 | não usado |
| 2 a 6 | os primeiros 20 bytes do compromisso, como cinco palavras big-endian |
| 7 | não usado; um panic do Rust escreve 0xdeadbeef aqui |
O compromisso só é publicado com status 1 ou 2. Uma entrada fora do intervalo interrompe a execução antes que qualquer coisa seja submetida a hash.
3. A aritmética
O modelo usa apenas inteiros. A máquina não tem ponto flutuante, e um verificador precisa ser capaz de reproduzir a pontuação exatamente, o que o ponto flutuante não garante entre máquinas.
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
Os pesos são de ponto fixo com s bits fracionários: com s = 8, um peso de 256 significa 1,0. As somas são i64, e cada multiplicação e cada adição é verificada, de modo que um modelo com pesos grandes o bastante para estourar resulta em status 4 em vez de uma pontuação errada. >> sobre um i64 negativo arredonda em direção a menos infinito em Rust, e o >> do Python faz o mesmo, o que permite que uma função Python de dez linhas sirva de referência.
O modelo de demonstração tem oito características e quatro unidades ocultas. Os seus pesos foram escritos à mão para esta página. Não é um modelo treinado e não diz nada sobre 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. O 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();
}
O modelo é dado, não código. Os pesos são lidos com read_public, de modo que uma única imagem implantada serve a qualquer modelo de até 16 características e 16 unidades ocultas. Mudar o modelo é um novo deploy do mesmo código com um novo segmento público, e um novo id de programa. Um credor não pode trocar os pesos por baixo de um solicitante, e um solicitante não pode escolher um modelo mais benevolente: o comprovante nomeia o id do programa, e o id vincula os pesos.
Os intervalos fazem parte do modelo. Sem eles, um solicitante não poderia mentir sobre um valor (o compromisso impede isso), mas um atestador poderia assinar um valor sem sentido, como uma idade de −5, que um modelo com peso negativo recompensaria. A verificação também limita a aritmética.
O salt cumpre duas funções. Ele torna o compromisso impossível de adivinhar: oito inteiros pequenos têm pouca entropia, e sem um salt qualquer pessoa poderia calcular o hash de solicitantes candidatos e comparar. E ele faz passar 128 bits aleatórios pela computação. Antes de o conjunto de restrições 7 mascarar os totais de barramento da prova, uma chamada sobre algumas poucas palavras privadas pequenas podia ser decifrada por força bruta a partir da própria prova, e as notas do fullnode mandavam que tais programas lessem palavras aleatórias. A chain 18 executa o conjunto de restrições 8, no qual isso está corrigido, mas um salt custa quatro palavras de entrada e elimina a dúvida.
5. O lado do host
score.py usa apenas a biblioteca padrão do 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
O avaliador de referência e o compromisso, na íntegra:
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
Os números grandes são os pesos negativos: 4294966784 é −512.
O guest foi testado contra esta referência com 300 solicitantes aleatórios, comparando a decisão e as cinco palavras do compromisso, e com solicitantes cujas pontuações são 599 e 600. Os resultados coincidem em todos os casos.
6. Compilar e executar
$ 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)
Um solicitante de 41 anos, com renda de 85 mil, 28% de endividamento sobre a renda, 97% dos pagamentos em dia, 22% de utilização, 12 anos de histórico, uma consulta e nenhuma inadimplência:
$ 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 é 0x9430c381, os primeiros quatro bytes do compromisso. A pontuação, 1075, não aparece em nenhuma das saídas.
Um segundo solicitante (23, 31, 55, 82, 91, 2, 6, 2, com outro salt) obtém pontuação 417 e produz out[0] = 2 com o seu próprio compromisso, nos mesmos 1,810 ciclos. O primeiro solicitante com a idade alterada para 17 produz out[0] = 3 e nenhum compromisso.
7. O orçamento
| programa | 462 palavras |
| segmento público | 61 palavras |
| palavras privadas | 12 |
| ciclos | 1,810 |
| compressões SHA-256 | 1 (48 bytes de mensagem cabem em um bloco) |
| tier | 12 |
Este é o mais barato dos quatro programas. A passagem direta (forward pass) são 36 multiplicações. Um modelo nos limites do programa, 16 características e 16 unidades ocultas, são 272 multiplicações e um segmento público de 325 palavras. Ele mede 7,900 ciclos no emulador, cerca de metade do tier 14.
A execução não depende dos dados: 200 solicitantes aleatórios, aprovados e recusados por igual, levaram exatamente 1,810 ciclos cada. O limite de gas que uma chamada declara é público e é arredondado a partir do gas da execução, de modo que um programa cuja contagem de ciclos acompanhasse as suas entradas vazaria um pouco delas. Este não tem nada que vazar dessa forma.
Desde a chain 18 a carteira imprime o gas exato da execução e o limite que ela declara:
gas: 2011 (declaring 2048, tier 12)
1,948 linhas a 1 de gas cada, mais 63 pelo uma compressão.
8. Deploy
As 61 palavras do modelo são pagas como as palavras 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. Chamada
$ 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`
Status 1 e o compromisso 9430c381 8f5966f8 dd33f78e 05a7d079 355191e6 em hexadecimal. h_pub é o digest das 61 palavras do modelo. As características, o salt e a pontuação não estão na transação.
A chamada levou pouco mais de três minutos em um laptop Apple Silicon: 31 s para a prova da chamada, 7 s para a autorização de gasto e 124 s para o bundle, com um pico de 6.2 GB. A prova tem 1,838,256 bytes.
10. Verifique você mesmo
O lado do credor, com nada além do comprovante e do compromisso assinado pelo atestador:
- O modelo.
rand_getProgramPublicsobre o id do programa devolve as 61 palavras. Compare-as com omodel.jsonpublicado (score.py setupas reconstrói). - O código. Recompile
my-guests/scoreno mesmo commit dos circuits e comparehccom ocode_hashdo programa. - O comprovante.
programé o id do modelo, a saída 0 é1, o slot 7 não é3735928559. - O compromisso. As saídas 2 a 6, impressas em hexadecimal, são os primeiros 20 bytes do compromisso que o atestador assinou:
$ python3 score.py commit 00112233445566778899aabbccddeeff 41 85 28 97 22 12 1 0
9430c3818f5966f8dd33f78e05a7d079355191e6
Uma recusa também é um recibo. A chamada do segundo solicitante, 119e7834…1b3b na chain 18 (repetida na chain 20 como c8a60183…9f73, que também recusa), foi provada e confirmada da mesma forma:
$ 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
Status 2, e 51636806 é 0x0313ea46. O gas, o limite declarado e o tier são os mesmos da chamada aprovada, de modo que as duas diferem on-chain na palavra de status e no compromisso, e em mais nada. Um solicitante não tem motivo para publicar uma recusa, mas o credor pode pedir o recibo de qualquer maneira, e um solicitante recusado não consegue produzir um recibo de aprovação.
O que a prova não oferece
- Dados verdadeiros. A prova mostra que o modelo foi executado sobre os valores comprometidos. Se os valores são verdadeiros, isso depende da palavra do attester, e o credor precisa confiar na assinatura do attester quanto a isso.
- Um solicitante não vinculável. O compromisso é um identificador estável: o attester pode reconhecê-lo, e dois recibos com o mesmo compromisso são o mesmo solicitante e os mesmos dados. Um salt novo do attester para cada solicitação resolve o segundo ponto, mas não o primeiro.
- Um modelo secreto. Os pesos são públicos. Esse é o objetivo de uma regra que as pessoas precisam poder ler, e uma desvantagem para um credor que trata seu modelo como segredo comercial. Um solicitante também pode procurar as entradas que seriam aprovadas, mas não pode usá-las sem a assinatura de um attester sobre elas.
- Proteção contra sondagem por um attester desonesto. Quem conhece as features pode executar o modelo público por conta própria. A privacidade aqui é a do solicitante diante do credor e do público, não diante da parte que forneceu os dados.
- Um modelo grande. Dezesseis features e dezesseis unidades ocultas formam um scorecard, não uma rede profunda. Uma chamada é limitada ao tier 14, cerca de dezesseis mil ciclos.