Rand Protocol
Документация
Примеры программ

Внеблокчейновый реестр

Конфиденциальная программа, хранящая состояние там, где у программ его нет: Merkle-дерево на 256 аккаунтов, существующее вне цепочки, причём каждый пакет переводов доказывается как переход от одного корня к следующему и связывается через квитанции. Интерфейс, guest-программа на Rust, утилита хоста, как число циклов сократили вдвое, и программа, работающая в сети chain 18.

A program on Rand has no storage: a call reads its inputs, publishes eight words and leaves a receipt. Translated:

У программы в Rand нет хранилища: вызов читает входные данные, публикует восемь слов и оставляет квитанцию (Programs). Эта страница показывает стандартный способ всё же хранить состояние. Состояние живёт вне цепочки в виде дерева Меркла, программа доказывает каждое изменение как шаг от одного корня к другому, а квитанции связывают шаги в цепочку.

Пример — реестр из 256 счетов. Один вызов применяет до четырёх переводов и доказывает, что каждый отправитель владел счётом, располагал средствами и что ничего не было создано или потеряно. Цепочка видит статус, счётчик и один хеш.

В Example programs описана подготовка, общая для всех четырёх страниц с примерами.

1. Что доказывается

Реестр — это дерево Меркла глубины 8 над Poseidon2, хешем, который машина доказывает дешевле всего. Лист — это один счёт:

owner = first 4 words of Poseidon2("OWNR", secret)
leaf  = Poseidon2("LEAF", owner, balance low, balance high, nonce)
node  = Poseidon2(left, right, "NODE")

Секрет — это четыре слова, которые хранит владелец счёта.

Вызов принимает текущий корень и пакет переводов как приватный вход. Для каждого перевода он доказывает, что:

  1. лист отправителя входит в дерево, что подтверждается его путём Меркла;
  2. вызывающему известен секрет, стоящий за идентификатором владельца отправителя;
  3. сумма не превышает баланс отправителя;
  4. дерево после списания содержит лист получателя;
  5. зачисление не вызывает переполнения.

Затем он публикует звено: Poseidon2(old root, new root, count, "LINK"), усечённое до 160 бит.

Почему звено, а не два корня. Корень занимает восемь слов, а выходных слотов восемь. Публикация обоих корней потребовала бы усечь каждый примерно до 96 бит, а 96-битное значение можно найти в коллизии примерно за 2⁴⁸ операций, и тогда prover смог бы показать два разных реестра под одной квитанцией. Один 160-битный хеш обоих корней такой проблемы не имеет. Полные корни передаются вне цепочки вместе с данными реестра, и любой, у кого они есть, пересчитывает звено.

Как следовать за реестром. Последователь хранит текущее состояние. Когда появляется квитанция программы со статусом 1, последователь получает новые листья от того, кто сделал вызов, перестраивает корень и вычисляет звено из собственного старого корня, нового корня и счётчика из квитанции. Если оно совпадает с выходом квитанции, шаг корректен, и последователь принимает новое состояние. Квитанция, которая не продолжает текущий корень последователя, игнорируется. Именно так разрешается и гонка: два вызывающих, строящих на одном и том же корне, оба получают квитанции, и каждый последователь принимает ту, которую цепочка упорядочила первой.

Кто может сделать вызов. Любой, кому известны секрет отправителя и текущее дерево. В правиле нет оператора: владелец может доказать собственный перевод на своей машине, так что секрет её не покидает.

2. Интерфейс

Публичного сегмента нет. Идентичностью реестра служит его генезисный корень, о котором договариваются вне цепочки.

приватное словозначение
0число переводов, не более 4
1–8старый корень
далее для каждого перевода 146 слов
+0, +1индексы листьев отправителя и получателя, каждый меньше 256
+2, +3сумма, u64: сначала младшее слово, затем старшее
+4–+7секрет отправителя
+8–+10баланс отправителя (младшее, старшее) и nonce
+11–+74путь Меркла отправителя: 8 соседних узлов по 8 слов, от листа вверх
+75–+78идентификатор владельца получателя
+79–+81баланс получателя (младшее, старшее) и nonce
+82–+145путь Меркла получателя в дереве, которое остаётся после списания
выходной слотзначение
0статус: 1 применено, 2 листа отправителя нет в дереве (или неверный секрет), 3 листа получателя нет в дереве, 4 недостаточно средств, 5 зачисление вызывает переполнение, 6 отправитель совпадает с получателем, 7 больше 4 переводов, 8 индекс вне диапазона
1применено переводов: при успехе — их число, иначе — индекс неудавшегося перевода
2–6при успехе — первые пять слов звена
7не используется; паника Rust записывает сюда 0xdeadbeef

Переводы применяются по порядку, каждый — к дереву, которое оставил предыдущий. В демонстрационном пакете счёт 3 начинается с нуля, получает 125 в первом переводе и тратит 25 из них в третьем.

3. Гостевая программа

my-guests/ledger/src/main.rs, полностью:

#![no_std]
#![no_main]

use guest_sdk::{halt, poseidon2, read_input, write_output};

/// The ledger is a Merkle tree of 2^8 = 256 accounts.
const DEPTH: usize = 8;
const MAX_TRANSFERS: u32 = 4;
/// Private words per transfer: from, to, amount (2), secret (4), sender balance (2) and nonce,
/// sender path (64), receiver owner (4), balance (2) and nonce, receiver path (64).
const TRANSFER_WORDS: u32 = 146;

const OK: u32 = 1;
const BAD_SENDER: u32 = 2;
const BAD_RECEIVER: u32 = 3;
const INSUFFICIENT: u32 = 4;
const OVERFLOW: u32 = 5;
const SELF_TRANSFER: u32 = 6;
const TOO_MANY: u32 = 7;
const BAD_INDEX: u32 = 8;

const OWNER_TAG: u32 = 0x4f57_4e52; // "OWNR"
const LEAF_TAG: u32 = 0x4c45_4146; // "LEAF"
const NODE_TAG: u32 = 0x4e4f_4445; // "NODE"
const LINK_TAG: u32 = 0x4c49_4e4b; // "LINK"

type Digest = [u32; 8];

/// Poseidon2 over `buf[..n]`. The sponge does not pad, so every message this program hashes
/// has a fixed length and carries its own tag word.
fn hash(buf: &mut [u32], n: usize) -> Digest {
    assert!(n <= buf.len() && buf.len() >= 8);
    // SAFETY: the syscall reads `buf[..n]` and overwrites `buf[..8]`; the assert keeps both
    // inside `buf`, which `&mut` makes aligned, valid and unaliased for the call.
    unsafe { poseidon2(buf.as_mut_ptr(), n) };
    let mut d = [0u32; 8];
    d.copy_from_slice(&buf[..8]);
    d
}

/// An account's owner id: the first four words of Poseidon2("OWNR", secret).
fn owner_of(secret: &[u32; 4]) -> [u32; 4] {
    let mut buf = [OWNER_TAG, secret[0], secret[1], secret[2], secret[3], 0, 0, 0];
    let d = hash(&mut buf, 5);
    [d[0], d[1], d[2], d[3]]
}

fn leaf(owner: &[u32; 4], balance: u64, nonce: u32) -> Digest {
    let mut buf = [
        LEAF_TAG, owner[0], owner[1], owner[2], owner[3], balance as u32, (balance >> 32) as u32, nonce,
    ];
    hash(&mut buf, 8)
}

/// One level of a walk. `buf` holds a node's message, `left ‖ right ‖ "NODE"`, and arrives with
/// the running hash in words 0..8, where the last hash left its digest. `side` is 0 when the
/// running hash is the left child and 8 when it is the right one: it is moved to its half and
/// the sibling written to the other. The halves are picked by an offset, not a branch, so both
/// cases execute the same instructions (for a left child the move is a copy onto itself).
fn level(buf: &mut [u32; 17], sib: &Digest, side: usize) {
    let mut cur = [0u32; 8];
    cur.copy_from_slice(&buf[..8]);
    buf[side..side + 8].copy_from_slice(&cur);
    buf[8 - side..16 - side].copy_from_slice(sib);
    // SAFETY: the syscall reads the 17 words of `buf` and overwrites its first 8.
    unsafe { poseidon2(buf.as_mut_ptr(), 17) };
}

/// Walks two leaves up the same path at once and returns the root each one reaches. The path
/// is the eight siblings whose words start at private input `at`; bit `d` of `index` says
/// whether the running hash is the right child at level `d`. An update needs both walks (the
/// leaf before and the leaf after), and doing them together reads each sibling once.
fn roots_from(before: Digest, after: Digest, index: u32, at: u32) -> (Digest, Digest) {
    let (mut a, mut b) = ([0u32; 17], [0u32; 17]);
    a[..8].copy_from_slice(&before);
    b[..8].copy_from_slice(&after);
    a[16] = NODE_TAG;
    b[16] = NODE_TAG;
    for d in 0..DEPTH {
        let side = 8 * ((index >> d) & 1) as usize;
        let sib: Digest = core::array::from_fn(|k| read_input(at + (8 * d + k) as u32));
        level(&mut a, &sib, side);
        level(&mut b, &sib, side);
    }
    let (mut ra, mut rb) = ([0u32; 8], [0u32; 8]);
    ra.copy_from_slice(&a[..8]);
    rb.copy_from_slice(&b[..8]);
    (ra, rb)
}

fn read_u64(at: u32) -> u64 {
    read_input(at) as u64 | (read_input(at + 1) as u64) << 32
}

/// Applies the transfer whose words start at `at` to the tree with root `root`.
fn apply(root: Digest, at: u32) -> Result<Digest, u32> {
    let from = read_input(at);
    let to = read_input(at + 1);
    let amount = read_u64(at + 2);
    let secret = [read_input(at + 4), read_input(at + 5), read_input(at + 6), read_input(at + 7)];
    let sender_balance = read_u64(at + 8);
    let sender_nonce = read_input(at + 10);
    let receiver = [read_input(at + 75), read_input(at + 76), read_input(at + 77), read_input(at + 78)];
    let receiver_balance = read_u64(at + 79);
    let receiver_nonce = read_input(at + 81);

    if from >= 1 << DEPTH || to >= 1 << DEPTH {
        return Err(BAD_INDEX);
    }
    if from == to {
        return Err(SELF_TRANSFER);
    }

    // The sender's leaf is in the tree, and the caller knows the secret behind its owner id.
    // The second walk carries the debited leaf up the same path: the root after the debit.
    let sender = owner_of(&secret);
    let (seen, debited) = roots_from(
        leaf(&sender, sender_balance, sender_nonce),
        leaf(&sender, sender_balance.wrapping_sub(amount), sender_nonce.wrapping_add(1)),
        from,
        at + 11,
    );
    if seen != root {
        return Err(BAD_SENDER);
    }
    if amount > sender_balance {
        return Err(INSUFFICIENT);
    }

    // The receiver's leaf is in the tree the debit left behind; the second walk credits it.
    let (seen, credited) = roots_from(
        leaf(&receiver, receiver_balance, receiver_nonce),
        leaf(&receiver, receiver_balance.wrapping_add(amount), receiver_nonce),
        to,
        at + 82,
    );
    if seen != debited {
        return Err(BAD_RECEIVER);
    }
    if receiver_balance.checked_add(amount).is_none() {
        return Err(OVERFLOW);
    }
    Ok(credited)
}

/// Private input: [count, old root (8 words), then `count` transfers of 146 words each].
/// Outputs: [status, transfers applied, link (first 5 words), 0], where
/// link = Poseidon2(old root, new root, count, "LINK").
#[no_mangle]
pub extern "C" fn main() -> ! {
    let count = read_input(0);
    if count > MAX_TRANSFERS {
        write_output(0, TOO_MANY);
        halt();
    }
    let mut old = [0u32; 8];
    for (k, w) in old.iter_mut().enumerate() {
        *w = read_input(1 + k as u32);
    }

    let mut root = old;
    let mut done = 0;
    while done < count {
        match apply(root, 9 + TRANSFER_WORDS * done) {
            Ok(next) => root = next,
            Err(status) => {
                write_output(0, status);
                write_output(1, done);
                halt();
            }
        }
        done += 1;
    }

    let mut buf = [0u32; 18];
    buf[..8].copy_from_slice(&old);
    buf[8..16].copy_from_slice(&root);
    buf[16] = count;
    buf[17] = LINK_TAG;
    let link = hash(&mut buf, 18);

    write_output(0, OK);
    write_output(1, count);
    for k in 0..5 {
        write_output(2 + k as u32, link[k]);
    }
    halt();
}

Старый корень — приватный вход, и это корректно. Ничто не заставляет вызывающего начинать с настоящего корня. Вызов, начатый с выдуманного корня, доказывает корректный шаг из реестра, которого нет больше ни у кого, и его звено не совпадает ни с чьим состоянием последователя. Связывающим элементом служит звено: это публичное обязательство на оба корня, и последователи сравнивают его со своим.

Сохранение суммы обеспечено построением. Списанная и зачисленная суммы — одна и та же переменная. Отдельной суммы для проверки нет.

Списание и зачисление намеренно используют арифметику wrapping_. Программа строит лист «после», ещё не зная, подлинен ли лист «до», потому что оба обходят путь вместе. Если сумма превышает баланс, вычисляется «обёрнутый» лист, а затем отбрасывается, когда проверка двумя строками ниже возвращает статус 4.

Nonce увеличивается при каждом списании, поэтому лист отправителя меняется, даже когда баланс возвращается к прежнему значению. Последователи видят, сколько раз счёт тратил средства.

4. Два факта о toolchain, с которыми столкнулась эта гостевая программа

rand-guest check отвергает Poseidon2 с привязкой к длине. В SDK есть два вызова Poseidon2. poseidon2 (syscall 3) не дополняет сообщение: сообщение до четырёх слов хешируется так же, как оно же с добавленными нулевыми словами. poseidon2_len (syscall 7) закладывает длину в губку и является более безопасным выбором по умолчанию для данных переменной длины. Машина реализует оба, но в circuits aeacf31 список syscalls в проверяющем инструменте заканчивается на 6:

$ rand-guest build my-guests/ledger --max-words 65535
…
0x00001114  0x00000073  Syscall: ecall with a7 = 7, which is no syscall the machine implements
…
Error: the image is rejected by the checker (it was still written)

Поэтому эта гостевая программа использует poseidon2 и следует правилу, делающему безопасной губку без дополнения: у каждого сообщения одна фиксированная длина, и у каждого вида сообщений есть собственное слово-тег.

Обнуление буфера в цикле — самая дорогая строка, которую вы не заметите. Первая версия обхода Меркла была очевидной: функция создавала новый [0u32; 17] для каждого узла, копировала двух потомков, хешировала и возвращала дайджест. Четыре перевода заняли 26 124 цикла и не уместились в tier 14, наивысший tier, доступный вызову. Три изменения, ни одно из которых не затрагивает то, что доказывается:

изменениециклов на четыре перевода
новый обнулённый буфер и массив пути на каждый узел26 124
один буфер на обход, обнулённый один раз; без массива пути, заполненного нулями15 897
обходы «до» и «после» выполняются вместе, каждый соседний узел читается один раз15 445
текущий хеш хранится в буфере хеширования, где его оставляет syscall12 393

[0u32; 17] внутри цикла компилируется в вызов memset на каждой итерации. Syscall записывает дайджест поверх первых восьми слов своего буфера, поэтому размещение сообщения как left ‖ right ‖ tag означает, что дайджест уже лежит там, куда должен попасть левый потомок следующего уровня. llvm-objdump -d для ELF в target/riscv32im-unknown-none-elf/release/ показывает, где расходуются циклы.

5. Сторона хоста

Для построения путей нужен Poseidon2 вне машины. В репозитории circuits он есть: rand_zkvm::hash::sponge_hash — хост-функция для губки, которую вычисляет syscall POSEIDON2. ledger-host — небольшой бинарный файл на Rust, зависящий от неё по пути:

[dependencies]
rand_zkvm = { path = "../../research" }
ledger-host owner   <secret: 32 hex>                   the owner id of a secret
ledger-host genesis <accounts file> <state file>       build a tree, write its state
ledger-host root    <state file>                       the state's root
ledger-host step    <state file> <transfers file> <new state file>
                                                       stdout: the private words
                                                       stderr: old root, new root, link
ledger-host link    <old state file> <new state file> <count>
                                                       the link a receipt must show

Файл состояния — это 256 строк вида owner balance nonce. В демонстрационном реестре шесть funded-счетов. Их секреты лежат в demo-accounts.txt и, как во всех демонстрациях на этих страницах, намеренно публичны. Балансы указаны в единицах 10⁻⁸.

$ cd my-guests/ledger-host && cargo +1.98.1 build --release && cd ../ledger
$ alias ledger-host=../ledger-host/target/release/ledger-host
$ cat demo-accounts.txt
de0f9e0173fe8358df1de251d81b4bec 50000000000
350826bc703c63ee3497f907373397f0 12000000000
8d5684dd764886843e4660d2c1ef12cc 7550000000
89442144b6673a3f93a5aa2359ddc6cf 0
99e9f82eede0b8b9feca124c0e1808be 100000000000
f29afdfbf0b821aa6c52f21cedc4c14a 4200000000
$ ledger-host genesis demo-accounts.txt state-0.txt
root bd1e27e17c0a0fe6e102f4c2197ed8721f22fccc99657860f035d1f5a4d1a568

В файле переводов одна строка from to amount secret на перевод:

$ cat batch-1.txt
0 3 12500000000 de0f9e0173fe8358df1de251d81b4bec
4 1 99900000000 99e9f82eede0b8b9feca124c0e1808be
3 2 2500000000 89442144b6673a3f93a5aa2359ddc6cf
1 5 1500000000 350826bc703c63ee3497f907373397f0
$ ledger-host step state-0.txt batch-1.txt state-1.txt > witness-1.txt
old root bd1e27e17c0a0fe6e102f4c2197ed8721f22fccc99657860f035d1f5a4d1a568
new root c021ecd07d7ca8a04ee6dd7fce48052f2a88cf2b38040387066849b8e2e87e91
link bcf87def900bb2978b6c24bfaaf6c9d357d58ba9
link words 3170401775 2416685719 2339120319 2868300243 1473612713
$ head -6 state-1.txt
ff2a2fadae3eb422e7f8810b701d45c6 37500000000 1
d6f41168e6db1d63402588a7d50dda9c 110400000000 1
d240d9f11db1ed16b2902e4533c17015 10050000000 0
b357d759652ee04004d9c5c21a026d17 10000000000 1
7a179726cd16187bb760c0272c011cab 100000000 1
c393d03f75fcadfd1f1d7102040fc47b 5700000000 0

Шесть балансов по-прежнему в сумме дают 1 737,5. Witness состоит из 593 слов: счётчик, старый корень и по 146 на каждый перевод.

6. Сборка и запуск

$ rand-guest build my-guests/ledger --max-words 65535
773 words against a cap of 65535 (fits); 0 ecall(s) with a non-static a7
OK
wrote my-guests/ledger/image.bin and its .sha256 (773 words, hc 6bb2eccbe70a562774053da4631777d84f2bc68361f30f9be5c4aa16f0153814, program id c4ddd388f2d94299610c7c3409dfdfb4dcb4ae3e0bc9ec78d0c45f51d8231cab)
$ rand-guest run my-guests/ledger/image.bin --input $(cat witness-1.txt) --tier 14
out[0] = 1
out[1] = 4
out[2] = 3170401775
out[3] = 2416685719
out[4] = 2339120319
out[5] = 2868300243
out[6] = 1473612713
out[7] = 0
cycles 12394
tier 14
tier 14: fits (cycles 12739 of 16383, Poseidon2 permutations 1030 of 2048)

Пять слов звена совпадают со словами хост-инструмента. Отказы, каждый запущен в эмуляторе с пакетом из одного перевода против state-0.txt:

пакетout[0], out[1]
счёт 2 отправляет больше, чем у него есть4, 0
перевод со счёта 2 с секретом счёта 52, 0
корректный перевод, затем счёт 5 платит самому себе6, 1
корректный перевод, построенный на state-1.txt с изменённым одним словом старого корня2, 0
корректный перевод с неверно указанным балансом получателя3, 0
счётчик, равный 57, 0

7. Бюджет

один переводчетыре перевода
приватные слова155593
циклы3 22112 394
перестановки Poseidon2, включая дайджесты1 030 из 2 048
уровень1214

Один перевод — это 37 хешей: 32 узла (два пути по восемь, каждый проходится для листа до и после), четыре листа и один идентификатор владельца. Четыре перевода оставляют неиспользованными около 3 600 циклов из бюджета уровня 14. Сборка с лимитом, поднятым до 5, выполнила пять переводов за 15 832 из 16 383 циклов, то есть умещается с небольшим запасом, поэтому развёрнутая программа останавливается на 4.

Poseidon2 здесь — дешёвая хеш-функция. Каждый обычный цикл стоит 1 gas, а каждая строка absorb Poseidon2 — 3, против 64 за сжатие SHA-256 и 192 за перестановку Keccak. 149 хешей Poseidon2 в этом вызове в сумме добавляют около 2 500 gas, примерно столько же стоят 13 перестановок Keccak. Анонимное голосование делает 12:

gas: 14110 (declaring 16384, tier 14)

12 740 строк по 1 gas, плюс ещё 2 за каждую из 685 строк absorb, итого 14 110.

8. Развёртывание

У программы нет публичного сегмента, поэтому её идентификатор — тот, что вывел rand-guest build.

$ rand fee deploy 773
0.0783 RAND
$ rand program deploy my-guests/ledger/image.bin
program id: c4ddd388f2d94299610c7c3409dfdfb4dcb4ae3e0bc9ec78d0c45f51d8231cab (773 words, hc cbecb26b27560ae7a43d0574d877176383c62b4f9b0ff36116aac4e5143815f0)
proving the spend authorisation on this machine (tier 10; seconds)…
authorisation proved in 10.8s: tier 10, 1370188 bytes
proving the bundle (tier 14; about a minute and a half on a laptop)…
proved in 113.1s: tier 14, 1494406 bytes
submitted deploy 40a8e53d7e916c90db77504cf6601c443d7da0756e04c52745f350969659588f
  0 RAND out, 59.7762 RAND change, fee 0.0783 RAND, anchored at height 97945, proved in 113.1s (bundle) + 10.8s (auth)

Одна развёрнутая программа обслуживает любое число реестров. Два реестра различаются корнем генезиса, и их квитанции никогда не связываются друг с другом.

9. Вызов

$ rand call c4ddd388f2d94299610c7c3409dfdfb4dcb4ae3e0bc9ec78d0c45f51d8231cab \
    $(sed 's/[0-9][0-9]*/--input &/g' witness-1.txt)
proving the call locally (593 inputs stay private)…
gas: 14110 (declaring 16384, tier 14)
fee 0.007489462 RAND (incl. two price steps of headroom)
proved in 114.8s: tier 14, 1498589 bytes, outputs [1, 4, 3170401775, 2416685719, 2339120319, 2868300243, 1473612713, 0]
proving the spend authorisation on this machine (tier 10; seconds)…
authorisation proved in 19.9s: tier 10, 1374567 bytes
proving the bundle (tier 14; about a minute and a half on a laptop)…
proved in 134.0s: tier 14, 1495974 bytes
submitted call 0ec66eed3bdeefc4a95741d26ad84f0a2d8198c4af3f7b1ec778e0525fa56a99
  0 RAND out, 59.701731664 RAND change, fee 0.007489462 RAND, anchored at height 98577, proved in 134.0s (bundle) + 19.9s (auth)
{
  "h_in": "68ea34ef767a30987c155a5a3cf0e69e832166b54611510ce0abc3430b8ff44c",
  "h_pub": null,
  "height": 98831,
  "index": 0,
  "outputs": [
    1,
    4,
    3170401775,
    2416685719,
    2339120319,
    2868300243,
    1473612713,
    0
  ],
  "program": "c4ddd388f2d94299610c7c3409dfdfb4dcb4ae3e0bc9ec78d0c45f51d8231cab",
  "tier": 14,
  "tx": "0ec66eed3bdeefc4a95741d26ad84f0a2d8198c4af3f7b1ec778e0525fa56a99"
}
input transcript published; open it with `rand open-call 0ec66eed3bdeefc4a95741d26ad84f0a2d8198c4af3f7b1ec778e0525fa56a99`

Статус 1, четыре перевода и связка bcf87def 900bb297 8b6c24bf aaf6c9d3 57d58ba9 в шестнадцатеричном виде. h_pub равен null, потому что у программы нет публичного сегмента. В цепочке нет ни счёта, ни суммы, ни корня.

Доказательство занимает 1 498 589 байт — это размер доказательства вызова без хеш-таблицы: Poseidon2 уже входит в каждое доказательство, тогда как Keccak и SHA-256 приносят каждый свою таблицу. Вызов занял чуть более пяти минут на ноутбуке с Apple Silicon (115 s на доказательство вызова, 20 s на авторизацию траты, 134 s на пакет) и достиг пика в 6,1 GB.

10. Проверьте сами

Наблюдатель, у которого есть state-0.txt и которому передан state-1.txt, проверяет квитанцию одной командой:

$ ledger-host link state-0.txt state-1.txt 4
link bcf87def900bb2978b6c24bfaaf6c9d357d58ba9
link words 3170401775 2416685719 2339120319 2868300243 1473612713

Эти пять слов — выходы квитанции со 2-го по 6-й, а 4 — её выход 1. Другое число переводов или любое из состояний, изменённое на одну единицу, даёт другую связку.

Второй шаг. Ради этой страницы реестр продвинулся ещё раз. batch-2.txt — это два перевода относительно state-1.txt:

$ cat batch-2.txt
2 0 1050000000 8d5684dd764886843e4660d2c1ef12cc
5 4 700000000 f29afdfbf0b821aa6c52f21cedc4c14a
$ ledger-host step state-1.txt batch-2.txt state-2.txt > witness-2.txt
old root c021ecd07d7ca8a04ee6dd7fce48052f2a88cf2b38040387066849b8e2e87e91
new root a1f79db3379bc2a06c6da4326f122bb7bec04689329b79ba76f931cbb6ef7b47
link 2e2738c49dc6edcfd1349a6932145c12b23d366b
link words 774322372 2647059919 3509885545 840195090 2990356075
$ rand call c4ddd388f2d94299610c7c3409dfdfb4dcb4ae3e0bc9ec78d0c45f51d8231cab \
    $(sed 's/[0-9][0-9]*/--input &/g' witness-2.txt)
…
gas: 7240 (declaring 8192, tier 14)
fee 0.00645165 RAND (incl. two price steps of headroom)
proved in 100.9s: tier 14, 1516155 bytes, outputs [1, 2, 774322372, 2647059919, 3509885545, 840195090, 2990356075, 0]
…
submitted call 590f9814a9de00e6d667ac17587b3ea9742b745b6b43657876c42bc095ce8a99

Его старый корень — это новый корень первого шага, c021ecd0…7e91. Это и есть цепочка преемственности: в цепочке 18 квитанция 0ec66eed…6a99 связала состояние генезиса с state-1, а квитанция 590f9814…8a99 связала state-1 с state-2. В цепочке 20 те же два шага с теми же корнями — 9c9998bb…66eb и dd756a89…ffc2. Текущее состояние демонстрационного реестра — то, которое эти две партии дают из demo-accounts.txt, так что любой может восстановить его и сделать третий шаг.

Открытие вызова. Кошелёк запечатал приватные входные данные каждого вызова в конверт, который открывается только ключом просмотра вызывающего. Вызывающий может открыть его позже, и кошелёк заново запускает программу на найденных данных:

$ rand open-call 0ec66eed3bdeefc4a95741d26ad84f0a2d8198c4af3f7b1ec778e0525fa56a99
opened with the caller's viewing key: 593 input word(s)
inputs: [4, 3172870113, 2081034214, 3775067330, 427743346, 522386636, 2573564000, 4030058997, 2765202792, 0, 3, …]
emulator outputs: [1, 4, 3170401775, 2416685719, 2339120319, 2868300243, 1473612713, 0]
receipt outputs:  [1,4,3170401775,2416685719,2339120319,2868300243,1473612713,0]
verdict: faithful — these are the words the proof was made over, and they reproduce its outputs

Эти входные данные содержат старый корень и каждый изменённый лист, поэтому конверт, запечатанный для аудитора (rand call --auditor <rand1…>), — один из способов положить данные шага туда, где названная сторона всегда сможет их получить. В v0.6.7 open-call работает для этой программы и не работает для трёх остальных: для программы с публичным сегментом он открывает входные данные, а затем останавливается с re-running the program on these inputs failed: PublicIndex(0), потому что повторный запуск выполняется без публичного входа программы.

Чего доказательство не даёт

  • Доступность данных. Цепочка доказывает, что каждый шаг корректен. Она не хранит реестр. Тот, кто делает шаг, обязан опубликовать новые листья, иначе наблюдатели не смогут сделать следующий. Вызывающий, который доказал шаг и утаил данные, заморозил реестр в этой точке для всех, кто принимает квитанцию, поэтому реальному развёртыванию нужно правило на этот случай: комитет, подписывающий за наличие данных, или публикация изменённых листьев в общедоступном месте.
  • Приватность среди участников. Цепочка не видит ничего, кроме числа и хеша. Но плательщик должен предоставить лист получателя, поэтому плательщик знает баланс получателя, а любой, у кого есть файл состояния, знает все балансы. Собственный защищённый пул Rand избегает этого, используя ноты вместо счетов (Ноты и дерево обязательств). Этот пример оставляет счета, чтобы оставаться небольшим.
  • Порядок. Два корректных шага от одного корня конфликтуют, и засчитывается только первый в порядке цепочки. Второй вызывающий заплатил за квитанцию, которую никто не примет, и должен перестроить шаг на новом корне.
  • Связь с RAND или с токеном. Балансы — это числа в дереве. Ничто не переводит их в защищённый пул или из него: выходы программы — это данные, и цепочка не действует ни по одному из них.
  • Переводы, подписанные владельцем и доказанные кем-то другим. Владеть счётом значит знать прообраз хеша, а прообраз — приватный вход, поэтому доказывающим должен быть сам владелец. Делегирование потребовало бы проверки подписи внутри программы. Проверка Dilithium или ECDSA не умещается в вызов уровня 14.

Последняя проверка по коммиту fullnode 48dc04b (chain 18) от 2026-09-30, а также circuits `aeacf31` (`guest-sdk/src/lib.rs`, `research/src/hash.rs`, `rand-guest`), the commit fullnode v0.6.7 vendors; every `rand-guest`, `ledger-host` and `rand` output on this page was produced on 2026-09-30, the deploy and the call on chain 18. Если код и эта страница расходятся, прав код; сообщите нам.