Rand Protocol
ドキュメント
サンプルプログラム

プライベートな与信判断

公開されたスコアリングモデルを非公開の申込者データに適用し、承認か否認のみを公開する機密プログラムです。データと証明者(attester)を結び付ける SHA-256 コミットメントを併せて公開します。インターフェース、固定小数点演算、Rust のゲスト、ホストスクリプト、そして chain 18 上で稼働するプログラムを解説します。

レンダーは、公開されたスコアリングモデルを貸し手が公開します。申込者は自分のデータに対してそのモデルを実行し、「承認」か「否認」を示すレシートを貸し手に渡します。貸し手がデータを見ることはなく、申込者がモデルを変更することも、でっち上げたデータを入力することもできません。

これは、公開プログラムと公開パラメータを、非公開入力に対して実行した計算を証明する、実用的な最小のケースです。モデルは整数演算による2層のニューラルネットワークです。規制当局や取引相手が読めるようにしたいルールであれば、同じ形で表せます。価格設定式、適格性判定、リスク限度などです。

4つのサンプルページに共通するセットアップは、Example programsにあります。

1. 証明する内容

登場するのは3者です。

  • 貸し手は、モデルを公開セグメントとしてプログラムをデプロイします。プログラム id がそのモデルを指します。重みは誰でもチェーンから読み戻せます。
  • アテスター(信用情報機関、銀行、雇用主)は、申込者のデータを把握しています。申込者に、特徴量の値、ランダムな16バイトのソルト、そしてコミットメント SHA-256(salt ‖ features) への署名を渡します。
  • 申込者は、ソルトと特徴量を非公開入力として、プログラムを呼び出します。

この呼び出しは、次の点を証明します。

  1. すべての特徴量が、モデルがその特徴量について宣言した範囲内にあること。
  2. それらの特徴量に対するモデルのスコアが、しきい値以上であること(ステータス 1)、またはしきい値未満であること(ステータス 2)。
  3. 公開されたコミットメントが、ソルトとそれらと同じ特徴量の SHA-256 であること。

貸し手は、レシートのプログラム id とステータス、そしてコミットメントがアテスターの署名したものであることを確認します。貸し手が申込者について知るのは1ビットだけです。

非公開入力だけでは、現実世界について何も証明されません。 3番目の点がなければ、申込者は好きな数値を入力できてしまいます。コミットメントが、証明の中の数値と、誰かが保証した数値とを結びつけます。署名自体は、貸し手がオフチェーンで検証します。Dilithium や ECDSA の検証は呼び出しの予算に収まりませんが、コミットメントが公開されているため、証明の内部で行う必要もありません。

2. インターフェース

公開ワードはすべて i32 で、2の補数表現の u32 として格納されます。

公開ワード意味
0特徴量の数 n(1〜16)
1隠れユニットの数 h(1〜16)
2しきい値
3小数部のビット数、すなわちシフト s(0〜31)
続く 2n ワード各特徴量の許容 min と max
続く h × n ワード第1層の重み(行ごと)
続く h ワード第1層のバイアス
続く h ワード第2層の重み
続く 1 ワード第2層のバイアス
非公開ワード意味
0〜3ソルト。16バイトを4つのビッグエンディアンのワードとして格納
4以降n 個の特徴量(それぞれ i32)
出力スロット意味
0ステータス。1 は承認、2 は否認、3 は特徴量が範囲外、4 は不正なモデル(サイズが範囲外、または演算がオーバーフロー)
1未使用
2〜6コミットメントの先頭20バイト。5つのビッグエンディアンのワードとして格納
7未使用。Rust の panic はここに 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 の >> も同じです。このおかげで、10行の Python 関数をリファレンスにできます。

デモ用モデルは、8つの特徴量と4つの隠れユニットを持ちます。その重みは、このページのために手で書いたものです。学習済みのモデルではなく、与信については何も語りません。

{
  "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 で読み込まれるため、デプロイされた1つのイメージが、特徴量16個・隠れユニット16個までのどのモデルにも対応します。モデルを変更するには、同じコードを新しい公開セグメントで再デプロイします。その場合、プログラム id も新しくなります。貸し手が申込者の知らないうちに重みを差し替えることはできず、申込者がより甘いモデルを選ぶこともできません。レシートがプログラム id を指し、その id が重みを束縛しているからです。

範囲はモデルの一部です。 範囲がなければ、申込者は値について嘘をつけない(コミットメントがそれを防ぎます)一方で、アテスターが無意味な値に署名できてしまいます。たとえば年齢 −5 のような値は、負の重みを持つモデルでは有利に働きかねません。この検査は、演算の大きさにも上限を与えます。

ソルトは2つの役割を果たします。 1つ目は、コミットメントを推測不能にすることです。8個の小さな整数はエントロピーが小さく、ソルトがなければ、誰でも候補となる申込者をハッシュして比較できてしまいます。2つ目は、128ビットのランダムな値を計算に通すことです。制約セット 7 が証明のバス合計にブラインドをかける以前は、少数の小さな非公開ワードに対する呼び出しを、証明そのものから総当たりで解読できました。そのためフルノードのメモは、そのようなプログラムにランダムなワードを読み込むよう指示していました。Chain 18 は制約セット 8 で動作しており、この問題は修正されていますが、ソルトは入力ワードを4つ使うだけで、この懸念を解消できます。

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人の申込者で決定と5つのコミットメントワードをすべて比較し、さらにスコアが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歳、収入8万5千、負債比率28%、期日どおりの支払い97%、利用率22%、取引履歴12年、照会1件、延滞なしの申込者の場合です。

$ 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 で、コミットメントの先頭4バイトです。スコアの 1075 は、出力のどこにも現れません。

2人目の申込者(23、31、55、82、91、2、6、2、別のソルト)のスコアは417で、out[0] = 2 とその申込者自身のコミットメントが得られます。サイクル数は同じ 1,810 です。1人目の申込者の年齢を17に変えると、out[0] = 3 となり、コミットメントは出力されません。

7. 予算

プログラム462 ワード
公開セグメント61 ワード
非公開ワード12
サイクル1,810
SHA-256 圧縮1(48バイトのメッセージが1ブロックに収まります)
ティア12

4つのプログラムの中で、これが最も安価です。順伝播は36回の乗算です。このプログラムの上限である特徴量16個・隠れユニット16個のモデルでは、272回の乗算と325ワードの公開セグメントになります。エミュレータでの測定値は 7,900 サイクルで、ティア14のおよそ半分です。

実行はデータに依存しません。承認と否認を含む200人のランダムな申込者は、いずれもちょうど 1,810 サイクルでした。呼び出しが宣言するガス上限は公開されており、実行のガスから丸めて決まります。そのため、サイクル数が入力に左右されるプログラムでは、入力の情報が少し漏れることになります。このプログラムには、その形で漏れるものがありません。

Chain 18 以降、ウォレットは実行の正確なガスと、宣言する上限を表示します。

gas: 2011 (declaring 2048, tier 12)

1,948 行が1ガスずつ、これに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 で、コミットメントは16進で 9430c381 8f5966f8 dd33f78e 05a7d079 355191e6 です。h_pub は、モデルの61ワードのダイジェストです。特徴量、ソルト、スコアはトランザクションに含まれません。

呼び出しには、Apple Silicon のノート PC で3分強かかりました。呼び出しの証明に31秒、支払い承認に7秒、バンドルに124秒で、ピークは 6.2 GB でした。証明は 1,838,256 バイトです。

10. 自分で確認する

貸し手側の確認手順です。必要なのは、レシートとアテスターが署名したコミットメントだけです。

  1. モデル。 プログラム id に対する rand_getProgramPublic が61ワードを返します。公開されている model.json と比較してください(score.py setup で再構築できます)。
  2. コード。 同じ circuits コミットで my-guests/score を再ビルドし、hc をプログラムの code_hash と比較します。
  3. レシート。 program がモデルの id であり、出力0が 1 で、スロット7が 3735928559 ではないことを確認します。
  4. コミットメント。 出力2〜6を16進で表示したものが、アテスターが署名したコミットメントの先頭20バイトです。
$ python3 score.py commit 00112233445566778899aabbccddeeff 41 85 28 97 22 12 1 0
9430c3818f5966f8dd33f78e05a7d079355191e6

否認もまた受領証です。2人目の申請者の呼び出し 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 です。ガス、宣言された上限、ティアは承認された呼び出しと同じであるため、両者がチェーン上で異なるのはステータスワードとコミットメントだけです。申請者が否認を公開する理由はありませんが、貸し手はどちらの場合でも受領証を求めることができ、否認された申請者は承認の受領証を作ることができません。

証明が保証しないこと

  • データの真実性。 証明が示すのは、コミットされた値に対してモデルが実行されたことです。その値が真実かどうかは attester の言明によるもので、貸し手はその点について attester の署名を信頼しなければなりません。
  • 紐付け不可能な申請者。 コミットメントは固定の識別子です。attester はそれを識別でき、同じコミットメントを持つ2つの受領証は、同じ申請者かつ同じデータを示します。申請ごとに attester が新しいソルトを発行すれば後者は防げますが、前者は防げません。
  • 秘密のモデル。 重みは公開されています。人が読めるべきルールにとってはそれが要点ですが、モデルを営業秘密として扱う貸し手にとっては欠点です。申請者は、通過する入力を探索することもできますが、attester の署名がなければそれらを使うことはできません。
  • 不誠実な attester による探索からの保護。 特徴量を知る者なら誰でも、公開モデルを自分で実行できます。ここでのプライバシーは、貸し手と公衆に対する申請者のものであり、データを提供した当事者に対するものではありません。
  • 大規模なモデル。 16個の特徴量と16個の隠れユニットは、スコアカードであって、深層ネットワークではありません。1回の呼び出しはティア14、約1万6千サイクルに制限されています。

fullnode のコミット 48dc04b(チェーン 18)に対して 2026-09-30 に最終確認しました。また、circuits `aeacf31` (`guest-sdk/src/lib.rs`, `rand-guest`), the commit fullnode v0.6.7 vendors; every `rand-guest`, `python3` and `rand` output on this page was produced on 2026-09-30, the deploy and the call on chain 18。 コードとこのページが食い違う場合は、コードが正です。お知らせください。