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

オフチェーン台帳

プログラムが状態を持たない環境で状態を保持する機密プログラムです。オフチェーンに置かれた 256 アカウントの Merkle ツリーを用い、各バッチの送金をひとつのルートから次のルートへの遷移として証明し、レシートでつないでいきます。インターフェース、Rust のゲスト、ホストツール、サイクル数を半分にした方法、そして chain 18 上で稼働しているプログラムを扱います。

Rand のプログラムにはストレージがありません。呼び出しは入力を読み取り、8 ワードを公開し、レシートを残します(プログラム)。このページでは、それでも状態を保持するための標準的な方法を示します。状態はオフチェーンに Merkle ツリーとして置きます。プログラムは各変更を、あるルートから次のルートへの一歩として証明します。レシートがその各ステップをつなぎます。

例として、256 個のアカウントを持つ台帳を使います。1 回の呼び出しで最大 4 件の送金を適用し、すべての送信者がそのアカウントを所有していること、残高が足りていること、そして何も生成も消失もしていないことを証明します。チェーンから見えるのは、ステータス、件数、1 つのハッシュだけです。

4 つのサンプルページが共通で使うセットアップは、サンプルプログラムにあります。

1. 何を証明するか

台帳は、Poseidon2 上の深さ 8 の Merkle ツリーです。Poseidon2 は、このマシンが最も安く証明できるハッシュです。リーフは 1 つのアカウントです。

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

シークレットは 4 ワードで、アカウントの保有者が保管します。

呼び出しは、現在のルートと送金のバッチをプライベート入力として受け取ります。各送金について、次の点を証明します。

  1. 送信者のリーフが、その Merkle パスによってツリーに含まれていること。
  2. 呼び出し元が、送信者のオーナー ID に対応するシークレットを知っていること。
  3. 金額が送信者の残高以下であること。
  4. 引き落とし後のツリーに、受信者のリーフが含まれていること。
  5. 入金でオーバーフローが起きないこと。

その後、リンクを公開します。これは Poseidon2(old root, new root, count, "LINK") を 160 ビットに切り詰めたものです。

2 つのルートではなくリンクを使う理由。 ルートは 8 ワードで、出力スロットも 8 つです。両方のルートを公開するなら、それぞれを約 96 ビットに切り詰める必要があります。96 ビットの値は約 2⁴⁸ の計算量で衝突させられるため、証明者が 1 つのレシートのもとで 2 つの異なる台帳を示せてしまいます。両方のルートを 1 つの 160 ビットのハッシュにすれば、この問題は起きません。完全なルートは台帳データとともにオフチェーンで受け渡され、それを持つ人なら誰でもリンクを再計算できます。

台帳の追跡。 フォロワーは現在の状態を保持します。このプログラムのレシートがステータス 1 で現れると、フォロワーは呼び出しを行った相手から新しいリーフを受け取り、ルートを再構築します。そして、自分の古いルート、新しいルート、レシートの件数からリンクを計算します。それがレシートの出力と一致すれば、そのステップは有効であり、フォロワーは新しい状態を採用します。フォロワーの現在のルートからつながらないレシートは無視されます。競合もこれで決着します。同じルートの上に構築した 2 人の呼び出し元は、どちらもレシートを得ますが、すべてのフォロワーが受け入れるのは、チェーンが先に順序づけたほうです。

誰が呼び出せるか。 送信者のシークレットと現在のツリーを知っている人なら誰でも可能です。ルールの中にオペレーターはいません。保有者は自分のマシンで自分の送金を証明できるので、シークレットがそのマシンの外に出ることはありません。

2. インターフェース

公開セグメントはありません。台帳の同一性は、オフチェーンで合意されたジェネシスルートです。

プライベートワード意味
0送金の件数(最大 4)
1 から 8古いルート
以降、送金ごとに 146 ワード
+0, +1送信者と受信者のリーフインデックス(いずれも 256 未満)
+2, +3金額(u64)。下位ワード、上位ワードの順
+4 から +7送信者のシークレット
+8 から +10送信者の残高(下位、上位)とノンス
+11 から +74送信者の Merkle パス。リーフから上へ、8 ワードのシブリング 8 個
+75 から +78受信者のオーナー ID
+79 から +81受信者の残高(下位、上位)とノンス
+82 から +145受信者の Merkle パス。引き落とし後に残るツリーのもの
出力スロット意味
0ステータス。1 適用済み、2 送信者のリーフがツリーにない(またはシークレットが誤り)、3 受信者のリーフがツリーにない、4 残高不足、5 入金でオーバーフロー、6 送信者と受信者が同一、7 送金が 4 件を超える、8 インデックスが範囲外
1適用された送金数。成功時は件数、そうでなければ失敗した送金のインデックス
2 から 6成功時は、リンクの最初の 5 ワード
7未使用。Rust のパニックはここに 0xdeadbeef を書き込みます

送金は順番に適用され、それぞれが直前の送金が残したツリーに対して行われます。デモのバッチでは、アカウント 3 は 0 から始まり、1 件目の送金で 125 を受け取り、3 件目でそのうち 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_ 演算を使っています。 プログラムは、「前」のリーフが本物だと分かる前に「後」のリーフを構築します。2 つが同じパスを一緒にたどるためです。金額が残高を超える場合、ラップしたリーフが計算されますが、2 行後のチェックがステータス 4 を返すときに破棄されます。

ノンスは引き落としのたびに増えるため、残高が以前の値に戻っても送信者のリーフは変わります。フォロワーは、アカウントが何回支出したかを確認できます。

4. このゲストが直面した 2 つのツールチェーンの事実

rand-guest check は、長さを束縛する Poseidon2 を拒否します。 SDK には Poseidon2 の呼び出しが 2 つあります。poseidon2(syscall 3)はパディングしません。4 ワード以下のメッセージは、末尾にゼロのワードを足したメッセージと同じハッシュになります。poseidon2_len(syscall 7)は長さをスポンジにシードし、可変長データにはこちらのほうが安全な既定です。マシンは両方を実装していますが、circuits aeacf31 の時点では、チェッカーの syscall のリストは 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 を使い、パディングなしのスポンジを安全にするルールに従います。すべてのメッセージは固定長 1 種類で、メッセージの種類ごとに専用のタグワードを持ちます。

ループの中でバッファをゼロ埋めするのは、気づかれにくい最もコストの高い 1 行です。 Merkle ウォークの最初の版は素直なものでした。ノードごとに新しい [0u32; 17] を作り、2 つの子をコピーし、ハッシュして、ダイジェストを返す関数です。4 件の送金で 26,124 サイクルかかり、呼び出しが使える最上位のティアである tier 14 に収まりませんでした。証明される内容に影響しない 3 つの変更を加えました。

変更4 件の送金のサイクル数
ノードごとに新しいゼロ埋めバッファとパス配列26,124
ウォークごとに 1 つのバッファ(ゼロ埋めは 1 回)、ゼロ埋めのパス配列なし15,897
前と後のウォークを一緒に実行し、各シブリングを 1 回だけ読む15,445
実行中のハッシュを、syscall が結果を残すハッシュバッファに保持する12,393

ループ内の [0u32; 17] は、反復ごとに memset 呼び出しにコンパイルされます。syscall はダイジェストをバッファの最初の 8 ワードに上書きして書き込むので、メッセージを left ‖ right ‖ tag として配置すれば、ダイジェストはすでに次のレベルの左の子が入る位置にあります。サイクルがどこで消費されているかは、target/riscv32im-unknown-none-elf/release/ 以下の ELF に llvm-objdump -d をかければ分かります。

5. ホスト側

パスを構築するには、マシンの外で Poseidon2 が必要です。circuits のチェックアウトにそれがあります。rand_zkvm::hash::sponge_hash は、POSEIDON2 syscall が計算するスポンジのホスト側の関数です。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

状態ファイルは、owner balance nonce の 256 行です。デモの台帳には資金のあるアカウントが 6 つあります。そのシークレットは 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

送金ファイルは、1 行に 1 件の 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

6 つの残高の合計は、変わらず 1,737.5 です。ウィットネスは 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)

5 つのリンクワードは、ホストツールのものと一致します。拒否されるケースは、いずれも state-0.txt に対して 1 件の送金のバッチをエミュレーターで実行したものです。

バッチout[0], out[1]
アカウント 2 が保有額を超えて送る4, 0
アカウント 2 の送金にアカウント 5 のシークレットを使う2, 0
有効な送金のあとに、アカウント 5 が自分自身に支払う6, 1
state-1.txt の上に構築した有効な送金で、古いルートの 1 ワードを変更したもの2, 0
受信者の残高を偽った有効な送金3, 0
件数が 57, 0

7. 予算

1件の送金4件の送金
プライベートワード155593
サイクル3,22112,394
Poseidon2 順列(ダイジェストを含む)2,048 のうち 1,030
ティア1214

1件の送金は37回のハッシュで構成されます。32個のノード(8個ずつの2本のパスで、それぞれ送金前と送金後のリーフのために辿ります)、4個のリーフ、そして1個のオーナー id です。4件の送金を行っても、ティア14の予算のうち約3,600サイクルが使われずに残ります。上限を5に引き上げたビルドでは、5件の送金が 16,383 サイクルのうち 15,832 サイクルで実行でき、収まりはしますが余裕はわずかです。そのため、デプロイ済みのプログラムは4件で止まります。

ここで Poseidon2 は安価なハッシュです。通常のサイクルはどれも 1 ガス、Poseidon2 の absorb 行は 3 ガスです。これに対し、SHA-256 の圧縮は 64、Keccak の順列は 192 です。この呼び出しにある149回の Poseidon2 ハッシュが加える分は合わせて約 2,500 ガスで、Keccak の順列約13回分の費用にあたります。匿名投票では12回です。

gas: 14110 (declaring 16384, tier 14)

12,740 行が各 1 ガス、これに 685 行の absorb 行それぞれにさらに 2 を加えると 14,110 になります。

8. デプロイ

このプログラムには公開セグメントがないため、その id は 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)

デプロイしたプログラムが1つあれば、任意の数のレジャーに使えます。2つのレジャーはジェネシスルートで区別され、それぞれのレシートが互いにリンクすることはありません。

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、送金は4件、リンクは16進で bcf87def 900bb297 8b6c24bf aaf6c9d3 57d58ba9 です。このプログラムには公開セグメントがないため、h_pub は null です。チェーンには、アカウントも金額もルートも記録されません。

証明のサイズは 1,498,589 バイトで、ハッシュテーブルを持たない呼び出し証明のサイズです。Poseidon2 はすでにあらゆる証明に含まれており、Keccak と SHA-256 はそれぞれ専用のテーブルを持ち込みます。呼び出しには Apple Silicon のノート PC で5分強かかり(呼び出し証明に 115 秒、支出承認に 20 秒、バンドルに 134 秒)、ピークメモリは 6.1 GB でした。

10. 自分で追跡する

state-0.txt を持つフォロワーが state-1.txt を渡されると、1つのコマンドでレシートを検証できます。

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

この5つのワードはレシートの出力2から6にあたり、4 は出力1です。件数が異なる場合や、どちらかの状態が1単位でも変わった場合は、異なるリンクになります。

2つ目のステップ。 このページのために、レジャーはもう一度進めました。batch-2.txt は state-1.txt に対する2件の送金です。

$ 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では、同じ2つのステップが同じルートで、9c9998bb…66eb と dd756a89…ffc2 です。デモ用レジャーの現在の状態は、demo-accounts.txt からこの2つのバッチが生み出す状態であり、誰でもそれを再構築して3つ目のステップに進めます。

呼び出しを開く。 ウォレットは、各呼び出しの非公開入力を、呼び出し元のビューイングキーでしか開けないエンベロープに封印しました。呼び出し元は後からそれを開くことができ、ウォレットは見つかった内容でプログラムを再実行します。

$ 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…>)は、あるステップのデータを、指名された当事者がいつでも入手できる場所に置く方法の1つです。v0.6.7 では、open-call はこのプログラムでは動作しますが、他の3つのプログラムでは失敗します。公開セグメントを持つプログラムでは、入力を開いたあと re-running the program on these inputs failed: PublicIndex(0) で停止します。再実行がプログラムの公開入力なしで行われるためです。

証明が保証しないこと

  • 可用性。 チェーンが証明するのは、各ステップが有効であることです。レジャーそのものは保持しません。ステップを行った者が新しいリーフを公開しなければ、フォロワーは次のステップに進めません。ステップを証明しながらデータを出さない呼び出し元は、そのレシートを受け入れるすべての人にとって、その時点でレジャーを凍結させてしまいます。そのため、実際のデプロイにはこれに対する規則が必要です。データについて署名する委員会を置くか、変更されたリーフを公開の場所に掲載するか、といったものです。
  • 参加者間のプライバシー。 チェーンが目にするのは件数とハッシュだけです。しかし、支払者は受取人のリーフを提供しなければならないため、支払者は受取人の残高を知ります。また、状態ファイルを持つ者は全員の残高を持ちます。Rand 自身のシールドプールは、アカウントの代わりにノートを使うことでこれを避けています(ノートとコミットメントツリー)。この例は小さく保つために、アカウントを採用しています。
  • 順序。 同じルートから出発する2つの有効なステップは衝突し、チェーン上の順序で先のものだけが有効になります。後の呼び出し元は、誰にも受け入れられないレシートの費用を払ったことになり、新しいルートの上で作り直さなければなりません。
  • RAND やトークンとの結び付き。 残高はツリーの中の数値にすぎません。それをシールドプールへ出し入れするものは何もありません。プログラムの出力はデータであり、チェーンはそのどれにも作用しません。
  • 保有者が署名し、別の者が証明する送金。 アカウントを所有するとは、ハッシュの原像を知っていることを意味し、その原像は非公開入力であるため、証明者は保有者本人でなければなりません。委任するには、プログラム内に署名の検証が必要になります。Dilithium や ECDSA の検証は、ティア14の呼び出しには収まりません。

fullnode のコミット 48dc04b(チェーン 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。 コードとこのページが食い違う場合は、コードが正です。お知らせください。