Rand Protocol
문서
예제 프로그램

오프체인 원장

프로그램에 상태가 없는 환경에서 상태를 유지하는 기밀 프로그램입니다. 256개 계정으로 이루어진 Merkle 트리를 오프체인에 두고, 각 전송 배치를 한 루트에서 다음 루트로의 전이로 증명하여 영수증으로 연결합니다. 인터페이스, Rust 게스트, 호스트 도구, 사이클 수를 절반으로 줄인 방법, 그리고 chain 18에 배포된 프로그램을 다룹니다.

Rand의 프로그램에는 스토리지가 없습니다. 호출은 입력을 읽고, 여덟 개의 워드를 게시하며, 영수증을 남깁니다(Programs). 이 페이지는 그럼에도 상태를 유지하는 표준적인 방법을 보여 줍니다. 상태는 Merkle 트리로 체인 밖에 존재하고, 프로그램은 각 변경을 한 루트에서 다음 루트로 가는 단계로 증명하며, 영수증이 그 단계들을 연결합니다.

예시는 256개 계정으로 이루어진 원장입니다. 한 번의 호출은 최대 네 건의 이체를 적용하고, 모든 송신자가 해당 계정을 소유했는지, 잔액이 충분했는지, 그리고 아무것도 생성되거나 소실되지 않았는지를 증명합니다. 체인이 보는 것은 상태 값 하나, 건수 하나, 해시 하나입니다.

Example programs에는 네 개의 예시 페이지가 모두 공유하는 설정이 있습니다.

1. 증명하는 내용

원장은 Poseidon2 위의 깊이 8 Merkle 트리입니다. Poseidon2는 머신이 가장 저렴하게 증명하는 해시입니다. 리프 하나가 계정 하나입니다.

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

시크릿은 계정 보유자가 보관하는 네 개의 워드입니다.

호출은 현재 루트와 이체 묶음을 비공개 입력으로 받습니다. 각 이체에 대해 다음을 증명합니다.

  1. 송신자의 리프가 Merkle 경로에 따라 트리에 포함되어 있습니다.
  2. 호출자가 송신자의 소유자 id에 대응하는 시크릿을 알고 있습니다.
  3. 금액이 송신자의 잔액 이하입니다.
  4. 차감 후의 트리에 수신자의 리프가 포함되어 있습니다.
  5. 입금 시 오버플로가 발생하지 않습니다.

그런 다음 링크를 게시합니다. 링크는 Poseidon2(old root, new root, count, "LINK")를 160비트로 잘라 낸 값입니다.

두 루트가 아니라 링크를 쓰는 이유. 루트 하나는 여덟 워드이고 출력 슬롯도 여덟 개입니다. 두 루트를 모두 게시하려면 각각을 약 96비트로 잘라야 하는데, 96비트 값은 약 2⁴⁸의 작업량으로 충돌을 만들 수 있어 증명자가 하나의 영수증 아래 서로 다른 두 원장을 보일 수 있게 됩니다. 두 루트에 대한 160비트 해시 하나에는 이 문제가 없습니다. 전체 루트는 원장 데이터와 함께 체인 밖으로 전달되며, 이를 가진 사람은 누구나 링크를 다시 계산할 수 있습니다.

원장 따라가기. 팔로워는 현재 상태를 보관합니다. 해당 프로그램의 영수증이 상태 1로 나타나면, 팔로워는 호출을 수행한 쪽에서 새 리프를 받아 루트를 다시 구성하고, 자신의 이전 루트와 새 루트, 영수증의 건수로 링크를 계산합니다. 이것이 영수증의 출력과 같으면 그 단계는 유효하며 팔로워는 새 상태를 채택합니다. 팔로워의 현재 루트에서 이어지지 않는 영수증은 무시됩니다. 경합도 이것으로 해결됩니다. 같은 루트를 기반으로 만든 두 호출자는 모두 영수증을 받지만, 체인이 먼저 정렬한 쪽을 모든 팔로워가 받아들입니다.

누가 호출할 수 있는가. 송신자의 시크릿과 현재 트리를 아는 사람은 누구나 가능합니다. 규칙에 운영자는 없습니다. 보유자는 자신의 머신에서 자신의 이체를 직접 증명할 수 있으므로 시크릿은 그 머신을 떠나지 않습니다.

2. 인터페이스

공개 세그먼트는 없습니다. 원장의 정체성은 체인 밖에서 합의된 제네시스 루트입니다.

비공개 워드의미
0이체 건수, 최대 4
1 to 8이전 루트
이후 이체마다 146개 워드
+0, +1송신자와 수신자의 리프 인덱스, 각각 256 미만
+2, +3금액, u64, 하위 워드 다음 상위 워드
+4 to +7송신자의 시크릿
+8 to +10송신자의 잔액(하위, 상위)과 nonce
+11 to +74송신자의 Merkle 경로: 8워드짜리 형제 노드 8개, 리프부터 위로
+75 to +78수신자의 소유자 id
+79 to +81수신자의 잔액(하위, 상위)과 nonce
+82 to +145수신자의 Merkle 경로, 차감이 남긴 트리 기준
출력 슬롯의미
0상태: 1 적용됨, 2 송신자의 리프가 트리에 없음(또는 잘못된 시크릿), 3 수신자의 리프가 트리에 없음, 4 잔액 부족, 5 입금 시 오버플로, 6 송신자와 수신자가 동일, 7 이체가 4건 초과, 8 인덱스 범위 초과
1적용된 이체 수: 성공 시에는 건수, 그렇지 않으면 실패한 이체의 인덱스
2 to 6성공 시 링크의 처음 다섯 워드
7미사용. Rust 패닉은 여기에 0xdeadbeef를 기록합니다

이체는 순서대로 적용되며, 각 이체는 직전 이체가 남긴 트리에 적용됩니다. 데모 묶음에서 계정 3은 0에서 시작하여 첫 번째 이체에서 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. 이 게스트가 맞닥뜨린 툴체인 관련 사실 두 가지

rand-guest check는 길이가 결합된 Poseidon2를 거부합니다. SDK에는 Poseidon2 호출이 두 가지 있습니다. poseidon2(syscall 3)는 패딩하지 않습니다. 네 워드 이하의 메시지는 그 메시지 뒤에 0 워드를 덧붙인 것과 같은 해시가 됩니다. poseidon2_len(syscall 7)은 길이를 스펀지에 시딩하며, 가변 길이 데이터에는 더 안전한 기본값입니다. 머신은 둘 다 구현하지만, 회로 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를 사용하며, 패딩 없는 스펀지를 안전하게 만드는 규칙을 따릅니다. 모든 메시지는 하나의 고정된 길이를 가지며, 메시지 종류마다 고유한 태그 워드를 가집니다.

루프 안에서 버퍼를 0으로 채우는 것이 눈에 띄지 않는 가장 비싼 줄입니다. Merkle 순회의 첫 버전은 가장 단순한 형태였습니다. 노드마다 새 [0u32; 17]을 만들고, 두 자식을 복사해 넣고, 해시한 뒤 다이제스트를 반환하는 함수였습니다. 네 건의 이체에 26,124 사이클이 들었고, 호출이 사용할 수 있는 가장 높은 티어인 티어 14에 들어맞지 않았습니다. 증명되는 내용에는 영향을 주지 않는 세 가지 변경을 했습니다.

변경이체 네 건의 사이클
노드마다 새로 0으로 채운 버퍼와 경로 배열26,124
순회당 버퍼 하나, 한 번만 0으로 채움. 0으로 채운 경로 배열 없음15,897
이전 순회와 이후 순회를 함께 수행하여 각 형제를 한 번만 읽음15,445
실행 중인 해시를 syscall이 남겨 두는 위치인 해시 버퍼에 유지12,393

루프 안의 [0u32; 17]은 반복마다 memset 호출로 컴파일됩니다. syscall은 다이제스트를 버퍼의 처음 여덟 워드에 덮어쓰므로, 메시지를 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줄입니다. 데모 원장에는 자금이 있는 계정이 여섯 개 있습니다. 이들의 시크릿은 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입니다. 위트니스는 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
계정 5의 시크릿으로 만든 계정 2의 이체2, 0
유효한 이체 후 계정 5가 자기 자신에게 지급6, 1
이전 루트의 한 워드를 바꾼 채 state-1.txt 기반으로 만든 유효한 이체2, 0
수신자의 잔액을 잘못 기재한 유효한 이체3, 0
건수 57, 0

7. 예산

전송 1건전송 4건
비공개 워드155593
사이클3,22112,394
Poseidon2 순열, 다이제스트 포함2,048 중 1,030
티어1214

전송 1건은 37개의 해시로 이루어집니다. 노드 32개(여덟 개 노드로 된 경로 두 개를 각각 전후 리프를 위해 한 번씩 따라갑니다), 리프 4개, 소유자 id 1개입니다. 전송 4건을 처리하고 나면 티어 14 예산에서 약 3,600 사이클이 남습니다. 상한을 5로 올린 빌드는 전송 5건을 16,383 사이클 중 15,832 사이클에 실행했으며, 여유가 거의 없는 수준에서 들어맞습니다. 그래서 배포된 프로그램은 4건에서 멈춥니다.

여기서는 Poseidon2가 저렴한 해시입니다. 일반 사이클은 모두 1 gas이고 Poseidon2 absorb 행은 3 gas인 반면, SHA-256 압축은 64 gas, Keccak 순열은 192 gas입니다. 이 호출의 Poseidon2 해시 149개는 합쳐서 약 2,500 gas가 추가되며, 이는 Keccak 순열 13회의 비용과 거의 같습니다. 익명 투표는 12회를 사용합니다.

gas: 14110 (declaring 16384, tier 14)

12,740개 행에 각 1 gas를 적용하고, absorb 행 685개에 각각 2 gas를 더하면 14,110 gas입니다.

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)

배포된 프로그램 하나로 원장을 몇 개든 운영할 수 있습니다. 두 원장은 제네시스 루트에서 서로 다르며, 두 원장의 영수증은 서로 연결되지 않습니다.

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 노트북에서 5분 남짓 걸렸으며(호출 증명 115초, 지출 승인 20초, 번들 134초), 메모리 사용량은 최대 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입니다. 이것이 보관 이력의 연속성입니다. chain 18에서는 영수증 0ec66eed…6a99가 제네시스 상태를 state-1에 연결했고, 영수증 590f9814…8a99가 state-1을 state-2에 연결했습니다. chain 20에서는 같은 루트로 이루어진 동일한 두 단계가 9c9998bb…66eb와 dd756a89…ffc2입니다. 데모 원장의 현재 상태는 demo-accounts.txt에서 이 두 배치가 만들어 내는 상태이므로, 누구나 이를 다시 구축하고 세 번째 단계를 진행할 수 있습니다.

호출 열기. 지갑은 각 호출의 비공개 입력을 호출자의 viewing key로만 열 수 있는 봉투에 담아 봉인했습니다. 호출자는 나중에 이를 열 수 있고, 지갑은 찾아낸 입력으로 프로그램을 다시 실행합니다.

$ 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 자체의 shielded pool은 계정 대신 노트를 사용해 이 문제를 피합니다(노트와 커밋먼트 트리). 이 예제는 규모를 작게 유지하기 위해 계정을 사용합니다.
  • 순서. 같은 루트에서 출발한 유효한 두 단계는 충돌하며, 체인 순서상 먼저 온 단계만 인정됩니다. 두 번째 호출자는 아무도 받아들이지 않는 영수증에 비용을 지불한 셈이며, 새 루트를 기준으로 다시 구축해야 합니다.
  • RAND나 토큰과의 연결. 잔액은 트리 안의 숫자일 뿐입니다. 잔액을 shielded pool 안팎으로 옮기는 것은 아무것도 없습니다. 프로그램의 출력은 데이터이며, 체인은 그중 어느 것에도 작동하지 않습니다.
  • 보유자가 서명하고 다른 사람이 증명하는 전송. 계정을 소유한다는 것은 해시 프리이미지를 안다는 뜻이고, 프리이미지는 비공개 입력이므로 증명자가 곧 보유자여야 합니다. 위임하려면 프로그램 안에서 서명 검증을 해야 합니다. 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. 코드와 이 페이지의 내용이 다를 경우 코드가 옳습니다. 알려 주시기 바랍니다.