Лендер публикует свою скоринговую модель. Заявитель прогоняет её на собственных данных и передаёт лендеру квитанцию с вердиктом «одобрено» или «отклонено». Лендер не видит данных, а заявитель не может ни изменить модель, ни подать на вход выдуманные данные.
Это простейший полезный случай доказательства вычисления с публичной программой и публичными параметрами над приватными входными данными. Модель представляет собой двухслойную нейронную сеть в целочисленной арифметике. Та же схема подходит для любого правила, которое регулятор или контрагент хочет иметь возможность прочитать: формулы ценообразования, проверки допустимости, лимита риска.
На странице Example programs описана подготовка, общая для всех четырёх примеров.
1. Что доказывается
Три стороны:
- Лендер развёртывает программу, задавая модель как её публичный сегмент. Идентификатор программы указывает на эту модель. Любой может прочитать веса из чейна.
- Аттестатор (кредитное бюро, банк, работодатель) знает данные заявителя. Он передаёт заявителю значения признаков, случайную 16-байтовую соль и свою подпись под обязательством
SHA-256(salt ‖ features). - Заявитель вызывает программу, передавая соль и признаки как приватные входные данные.
Вызов доказывает, что:
- каждый признак лежит в диапазоне, который модель объявляет для него;
- оценка модели по этим признакам не меньше порога (статус
1) либо меньше него (статус2); - опубликованное обязательство есть SHA-256 от соли и именно этих признаков.
Лендер проверяет идентификатор программы в квитанции, её статус и то, что обязательство подписано аттестатором. Об заявителе он узнаёт один бит.
Сами по себе приватные входные данные ничего не доказывают о реальном мире. Без пункта 3 заявитель мог бы подставить любые числа. Именно обязательство связывает числа в доказательстве с числами, за которые кто-то поручился. Саму подпись лендер проверяет вне чейна: проверка Dilithium или ECDSA не укладывается в бюджет вызова, и ей не нужно быть внутри доказательства, поскольку обязательство публично.
2. Интерфейс
Каждое публичное слово — это i32, хранящийся как его дополнение до двух в виде u32.
| публичное слово | значение |
|---|---|
| 0 | число признаков n, от 1 до 16 |
| 1 | число скрытых нейронов h, от 1 до 16 |
| 2 | порог |
| 3 | число дробных бит, сдвиг s, от 0 до 31 |
далее 2n слов | допустимые min и max каждого признака |
далее h × n слов | веса первого слоя, строка за строкой |
далее h слов | смещения первого слоя |
далее h слов | веса второго слоя |
| далее 1 слово | смещение второго слоя |
| приватное слово | значение |
|---|---|
| от 0 до 3 | соль, 16 байт в виде четырёх слов в порядке big-endian |
| с 4-го | n признаков, каждый — i32 |
| выходной слот | значение |
|---|---|
| 0 | статус: 1 — одобрено, 2 — отклонено, 3 — признак вне диапазона, 4 — неверная модель (размер вне границ или переполнение при вычислениях) |
| 1 | не используется |
| 2–6 | первые 20 байт обязательства в виде пяти слов в порядке big-endian |
| 7 | не используется; паника Rust записывает сюда 0xdeadbeef |
Обязательство публикуется только со статусом 1 или 2. Входное значение вне диапазона останавливает выполнение до того, как что-либо хешируется.
3. Арифметика
Модель целочисленная. У машины нет чисел с плавающей запятой, а верификатор должен уметь воспроизвести оценку точно, чего арифметика с плавающей запятой на разных машинах не гарантирует.
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
Веса заданы в формате с фиксированной запятой с s дробными битами: при s = 8 вес 256 означает 1,0. Суммы имеют тип i64, а каждое умножение и сложение проверяется, поэтому модель с весами, достаточно большими для переполнения, даёт статус 4, а не неверную оценку. Операция >> над отрицательным i64 в Rust округляет в сторону минус бесконечности, и >> в Python делает то же самое, благодаря чему эталоном может служить десятистрочная функция на Python.
В демонстрационной модели восемь признаков и четыре скрытых нейрона. Её веса написаны вручную для этой страницы. Это не обученная модель, и она ничего не говорит о кредитовании.
{
"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. Гостевая программа
my-guests/score/src/main.rs, полностью:
#![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();
}
Модель — это данные, а не код. Веса читаются через read_public, поэтому один развёрнутый образ обслуживает любую модель вплоть до 16 признаков и 16 скрытых нейронов. Смена модели — это новое развёртывание того же кода с новым публичным сегментом и новым идентификатором программы. Лендер не может подменить веса под заявителем, а заявитель не может выбрать более мягкую модель: квитанция называет идентификатор программы, а идентификатор связывает веса.
Диапазоны — часть модели. Без них заявитель не смог бы солгать о значении (этому мешает обязательство), но аттестатор мог бы подписать бессмыслицу, например возраст −5, который модель с отрицательным весом вознаградила бы. Эта проверка также ограничивает арифметику.
У соли две задачи. Во-первых, она делает обязательство неугадываемым: у восьми малых целых чисел мало энтропии, и без соли кто угодно мог бы хешировать возможных заявителей и сравнивать. Во-вторых, она пропускает через вычисление 128 случайных бит. До того как набор ограничений 7 начал маскировать суммы шины в доказательстве, вызов с несколькими малыми приватными словами можно было подобрать перебором по самому доказательству, и заметки fullnode предписывали таким программам читать случайные слова. В chain 18 работает набор ограничений 8, где это исправлено, но соль стоит четыре входных слова и снимает сам вопрос.
5. Сторона хоста
score.py использует только стандартную библиотеку 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
Эталонная функция оценки и обязательство, полностью:
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
Большие числа — это отрицательные веса: 4294966784 — это −512.
Гостевую программу сверили с этим эталоном на 300 случайных заявителях, сравнивая решение и все пять слов обязательства, а также на заявителях, чьи оценки равны 599 и 600. Они совпадают во всех случаях.
6. Сборка и запуск
$ 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)
Заявитель 41 года с доходом 85 тысяч, долговой нагрузкой 28 %, 97 % платежей в срок, использованием лимита 22 %, кредитной историей 12 лет, одним запросом и без просрочек:
$ 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, первые четыре байта обязательства. Оценка, 1075, нигде в выходных данных не появляется.
Второй заявитель (23, 31, 55, 82, 91, 2, 6, 2, с другой солью) получает оценку 417 и даёт out[0] = 2 со своим обязательством, за те же 1 810 циклов. Первый заявитель с возрастом, изменённым на 17, даёт out[0] = 3 и не даёт обязательства.
7. Бюджет
| программа | 462 слова |
| публичный сегмент | 61 слово |
| приватные слова | 12 |
| циклы | 1 810 |
| сжатия SHA-256 | 1 (48 байт сообщения умещаются в один блок) |
| уровень | 12 |
Это самая дешёвая из четырёх программ. Прямой проход — это 36 умножений. Модель на пределе возможностей программы, с 16 признаками и 16 скрытыми нейронами, — это 272 умножения и публичный сегмент в 325 слов. В эмуляторе она занимает 7 900 циклов, примерно половину уровня 14.
Выполнение не зависит от данных: 200 случайных заявителей, одобренных и отклонённых, потратили ровно по 1 810 циклов. Лимит газа, который объявляет вызов, публичен и округляется от газа выполнения, поэтому программа, число циклов которой зависело бы от входных данных, немного раскрывала бы их. Здесь такой утечки нет.
Начиная с chain 18 кошелёк печатает точный газ выполнения и объявляемый лимит:
gas: 2011 (declaring 2048, tier 12)
1 948 строк по 1 газу каждая, плюс 63 за единственное сжатие.
8. Развёртывание
Стоимость 61 слова модели оплачивается так же, как слов кода: 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. Вызов
$ 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`
Статус 1 и обязательство 9430c381 8f5966f8 dd33f78e 05a7d079 355191e6 в шестнадцатеричном виде. h_pub — это дайджест 61 слова модели. Признаков, соли и оценки в транзакции нет.
Вызов занял чуть более трёх минут на ноутбуке с Apple Silicon: 31 с на доказательство вызова, 7 с на авторизацию траты и 124 с на пакет, с пиком в 6,2 GB. Размер доказательства — 1 838 256 байт.
10. Проверьте сами
Сторона лендера, у которого есть только квитанция и подписанное аттестатором обязательство:
- Модель.
rand_getProgramPublicпо идентификатору программы возвращает 61 слово. Сравните их с опубликованнымmodel.json(score.py setupпересобирает их). - Код. Пересоберите
my-guests/scoreна том же коммите circuits и сравнитеhcсcode_hashпрограммы. - Квитанция.
program— идентификатор модели, выход 0 равен1, слот 7 не равен3735928559. - Обязательство. Выходы с 2 по 6, выведенные в шестнадцатеричном виде, — это первые 20 байт обязательства, которое подписал аттестатор:
$ python3 score.py commit 00112233445566778899aabbccddeeff 41 85 28 97 22 12 1 0
9430c3818f5966f8dd33f78e05a7d079355191e6
Отказ — тоже квитанция. Вызов второго заявителя, 119e7834…1b3b в chain 18 (повторён в chain 20 как c8a60183…9f73, который также отклоняет заявку), был доказан и зафиксирован тем же способом:
$ 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
Статус 2, а 51636806 — это 0x0313ea46. Газ, объявленный лимит и уровень те же, что у одобренного вызова, поэтому в сети эти два вызова различаются только словом статуса и обязательством, больше ничем. У заявителя нет причин публиковать отказ, но кредитор может запросить квитанцию в любом случае, а заявитель, получивший отказ, не может предъявить одобрительную.
Чего доказательство не даёт
- Достоверности данных. Доказательство показывает, что модель была выполнена над зафиксированными значениями. Достоверны ли сами значения, зависит от слова аттестатора, и кредитору приходится полагаться на подпись аттестатора.
- Неотслеживаемости заявителя. Обязательство — это постоянный идентификатор: аттестатор может его распознать, а две квитанции с одним и тем же обязательством относятся к одному заявителю и к одним и тем же данным. Новая соль от аттестатора для каждой заявки решает вторую проблему, но не первую.
- Секретной модели. Веса открыты. Для правила, которое люди должны иметь возможность прочитать, в этом и состоит смысл, а для кредитора, считающего свою модель коммерческой тайной, это недостаток. Заявитель также может искать входные данные, которые пройдут проверку, но воспользоваться ими без подписи аттестатора под ними не может.
- Защиты от зондирования со стороны недобросовестного аттестатора. Любой, кто знает признаки, может сам запустить открытую модель. Приватность здесь — это приватность заявителя перед кредитором и публикой, а не перед стороной, которая предоставила данные.
- Большой модели. Шестнадцать признаков и шестнадцать скрытых нейронов — это скоринговая карта, а не глубокая сеть. Вызов ограничен уровнем 14, примерно шестнадцатью тысячами циклов.