Rand Protocol
Documentation
Example programs

A private credit decision

A confidential program that runs a published scoring model over private applicant data and publishes only approve or decline, with a SHA-256 commitment that ties the data to an attester - the interface, the fixed-point arithmetic, the Rust guest, the host script, and the program live on chain 18.

A lender publishes its scoring model. An applicant runs it over their own data and hands the lender a receipt that says “approved” or “declined”. The lender never sees the data, and the applicant cannot change the model or feed it invented data.

This is the smallest useful case of proving a computation with a public program and public parameters over private inputs. The model is a two-layer neural network in integer arithmetic. The same shape fits any rule a regulator or a counterparty wants to be able to read: a pricing formula, an eligibility test, a risk limit.

Example programs has the setup all four example pages share.

1. What it proves

Three parties:

  • The lender deploys the program with the model as its public segment. The program id names that model. Anyone can read the weights back from the chain.
  • An attester (a credit bureau, a bank, an employer) knows the applicant’s data. It gives the applicant the feature values, a random 16-byte salt, and its signature over the commitment SHA-256(salt ‖ features).
  • The applicant calls the program with the salt and the features as private input.

The call proves:

  1. every feature is inside the range the model declares for it;
  2. the model’s score over those features is at or above the threshold (status 1) or below it (status 2);
  3. the published commitment is the SHA-256 of the salt and those same features.

The lender checks the receipt’s program id, its status, and that the commitment is one the attester signed. It learns one bit about the applicant.

Private inputs prove nothing about the world on their own. Without point 3 an applicant could type in any numbers. The commitment is what connects the numbers in the proof to numbers somebody vouched for. The signature itself is checked off chain by the lender: a Dilithium or ECDSA verification does not fit a call’s budget, and it does not need to be inside the proof, because the commitment is public.

2. The interface

Every public word is an i32, stored as its two’s complement u32.

public wordmeaning
0the number of features n, 1 to 16
1the number of hidden units h, 1 to 16
2the threshold
3the number of fractional bits, the shift s, 0 to 31
then 2n wordseach feature’s allowed min and max
then h × n wordsthe first layer’s weights, row by row
then h wordsthe first layer’s biases
then h wordsthe second layer’s weights
then 1 wordthe second layer’s bias
private wordmeaning
0 to 3the salt, 16 bytes as four big-endian words
4 onwardthe n features, each an i32
output slotmeaning
0status: 1 approved, 2 declined, 3 a feature is out of range, 4 bad model (a size out of bounds, or the arithmetic overflowed)
1unused
2 to 6the first 20 bytes of the commitment, as five big-endian words
7unused; a Rust panic writes 0xdeadbeef here

The commitment is published only with status 1 or 2. An out-of-range input halts before anything is hashed.

3. The arithmetic

The model is integers only. The machine has no floating point, and a verifier must be able to reproduce the score exactly, which floating point does not promise across machines.

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

Weights are fixed point with s fractional bits: with s = 8, a weight of 256 means 1.0. Sums are i64, and every multiply and add is checked, so a model with weights large enough to overflow gives status 4 instead of a wrong score. >> on a negative i64 rounds toward minus infinity in Rust, and Python’s >> does the same, which is what lets a ten-line Python function be the reference.

The demo model has eight features and four hidden units. Its weights were written by hand for this page. It is not a trained model and says nothing about credit.

{
  "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. The guest

my-guests/score/src/main.rs, complete:

#![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();
}

The model is data, not code. The weights are read with read_public, so one deployed image serves any model of up to 16 features and 16 hidden units. Changing the model is a new deploy of the same code with a new public segment, and a new program id. A lender cannot swap the weights under an applicant, and an applicant cannot pick a kinder model: the receipt names the program id, and the id binds the weights.

The ranges are part of the model. Without them an applicant could not lie about a value (the commitment stops that) but an attester could sign nonsense, such as an age of −5, that a model with a negative weight would reward. The check also bounds the arithmetic.

The salt does two jobs. It makes the commitment unguessable: eight small integers have little entropy, and without a salt anyone could hash candidate applicants and compare. And it passes 128 random bits through the computation. Before constraint set 7 blinded the proof’s bus totals, a call over a few small private words could be brute-forced from the proof itself, and the fullnode’s notes told such programs to read random words. Chain 18 runs constraint set 8, where that is fixed, but a salt costs four input words and removes the question.

5. The host side

score.py uses only Python’s standard library:

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

The reference scorer and the commitment, in full:

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

The large numbers are the negative weights: 4294966784 is −512.

The guest was tested against this reference on 300 random applicants, comparing the decision and all five commitment words, and on applicants whose scores are 599 and 600. They agree on every one.

6. Build and run

$ 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)

An applicant aged 41 with an income of 85 thousand, 28% debt to income, 97% of payments on time, 22% utilisation, 12 years of history, one inquiry and no delinquencies:

$ 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 is 0x9430c381, the first four bytes of the commitment. The score, 1075, appears nowhere in the outputs.

A second applicant (23, 31, 55, 82, 91, 2, 6, 2, with another salt) scores 417 and gives out[0] = 2 with their own commitment, in the same 1,810 cycles. The first applicant with the age changed to 17 gives out[0] = 3 and no commitment.

7. The budget

program462 words
public segment61 words
private words12
cycles1,810
SHA-256 compressions1 (48 bytes of message fit one block)
tier12

This is the cheapest of the four programs. The forward pass is 36 multiplications. A model at the program’s limits, 16 features and 16 hidden units, is 272 multiplications and a 325-word public segment. It measures 7,900 cycles in the emulator, about half of tier 14.

The run does not depend on the data: 200 random applicants, approved and declined alike, each took exactly 1,810 cycles. The gas limit a call declares is public and is rounded from the run’s gas, so a program whose cycle count followed its inputs would leak a little of them. This one has nothing to leak that way.

Since chain 18 the wallet prints the run’s exact gas and the limit it declares:

gas: 2011 (declaring 2048, tier 12)

1,948 rows at 1 gas each, plus 63 more for the one compression.

8. Deploy

The model’s 61 words are paid for like code words: 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. Call

$ 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 and the commitment 9430c381 8f5966f8 dd33f78e 05a7d079 355191e6 in hex. h_pub is the digest of the model’s 61 words. The features, the salt and the score are not in the transaction.

The call took a little over three minutes on an Apple Silicon laptop: 31 s for the call proof, 7 s for the spend authorisation and 124 s for the bundle, with a peak of 6.2 GB. The proof is 1,838,256 bytes.

10. Check it yourself

The lender’s side, with nothing but the receipt and the attester’s signed commitment:

  1. The model. rand_getProgramPublic on the program id returns the 61 words. Compare them with the published model.json (score.py setup rebuilds them).
  2. The code. Rebuild my-guests/score at the same circuits commit and compare hc with the program’s code_hash.
  3. The receipt. program is the model’s id, output 0 is 1, slot 7 is not 3735928559.
  4. The commitment. Outputs 2 to 6, printed as hex, are the first 20 bytes of the commitment the attester signed:
$ python3 score.py commit 00112233445566778899aabbccddeeff 41 85 28 97 22 12 1 0
9430c3818f5966f8dd33f78e05a7d079355191e6

A decline is a receipt too. The second applicant’s call, 119e7834…1b3b on chain 18 (repeated on chain 20 as c8a60183…9f73, which also declines), was proved and committed the same way:

$ 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, and 51636806 is 0x0313ea46. The gas, the declared limit and the tier are the same as the approved call’s, so the two differ on chain in the status word and the commitment and nowhere else. An applicant has no reason to publish a decline, but the lender can ask for the receipt either way, and a declined applicant cannot produce an approved one.

What the proof does not give you

  • True data. The proof shows the model ran over the committed values. Whether the values are true is the attester’s word, and the lender has to trust the attester’s signature for that.
  • An unlinkable applicant. The commitment is a stable identifier: the attester can recognise it, and two receipts with the same commitment are the same applicant and the same data. A fresh salt from the attester for each application fixes the second, not the first.
  • A secret model. The weights are public. That is the point for a rule people must be able to read, and a drawback for a lender who treats its model as a trade secret. An applicant can also search for the inputs that would pass, but cannot use them without an attester’s signature over them.
  • Protection from probing by a dishonest attester. Whoever knows the features can run the public model themselves. The privacy here is the applicant’s against the lender and the public, not against the party that supplied the data.
  • A large model. Sixteen features and sixteen hidden units is a scorecard, not a deep network. A call is capped at tier 14, about sixteen thousand cycles.

Last verified against fullnode commit 48dc04b (chain 18) on 2026-09-30, and 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. Where the code and this page disagree, the code is right; tell us.