Rand Protocol
문서
예제 프로그램

익명 투표

Keccak Merkle 허용 목록의 구성원임을 어느 구성원인지 밝히지 않고 증명하고, 선택 사항과 두 번째 투표를 막는 nullifier를 공개하는 기밀 프로그램입니다. 인터페이스, Rust 게스트, 호스트 스크립트, 예산, 그리고 chain 18에서 실제로 운영 중인 프로그램을 다룹니다.

이 프로그램은 고정된 명단의 구성원이 자신이 누구인지 드러내지 않고 한 표를 행사할 수 있게 합니다. 명단은 Keccak-256 해시로 이루어진 Merkle 트리이며, Ethereum 에어드롭 명단을 만드는 방식과 같게 구성하므로 동일한 도구로 만들 수 있습니다. 프로그램은 chain 18에 배포되어 있으며, 마지막 절에서는 직접 사용할 수 있는 데모 구성원으로 이 프로그램을 호출합니다.

Example programs에는 네 개의 예제 페이지가 공통으로 쓰는 설정, 즉 circuits 체크아웃, 지갑, 소스를 구하는 방법이 있습니다.

1. 증명하는 내용

주최자에게는 구성원 명단이 있습니다. 각 구성원은 32바이트 비밀값을 가집니다. 주최자는 비밀값을 보지 못합니다. 각 구성원이 leaf, 즉 keccak256(secret ‖ tag)를 건네주면, 주최자는 leaf들을 바탕으로 Merkle 트리를 만듭니다.

투표는 한 번의 호출입니다. 이 호출은 다른 어떤 것도 드러내지 않고 다음을 증명합니다.

  1. 호출자가 leaf가 트리에 포함된 비밀값을 알고 있다.
  2. 공개된 선택지가 허용된 선택지 중 하나이다.
  3. 공개된 nullifier가 keccak256(secret ‖ tag ‖ poll id)이다.

nullifier는 하나의 비밀값이 한 투표(poll)에서 행사하는 모든 표에 대해 동일하고, 다른 투표에서는 달라집니다. 표를 집계하는 쪽은 각 nullifier를 한 번만 셉니다. nullifier는 leaf와 연결되지 않습니다. nullifier에서 leaf로 거슬러 가려면 비밀값이 필요합니다.

체인은 두 번째 표를 거부하지 않습니다. Rand의 프로그램에는 저장소(storage)가 없으므로 nullifier를 기억할 수 없습니다(Programs). 같은 비밀값으로 보낸 두 호출은 모두 검증을 통과하고 모두 영수증을 남깁니다. “nullifier 하나에 한 표”라는 규칙은 영수증을 대상으로 집계하는 쪽에서 적용합니다. 누구나 같은 집계를 실행할 수 있으며, 이것이 집계 결과를 검증할 수 있게 하는 이유입니다.

2. 인터페이스

트리, 투표, 선택지의 수는 모든 표에서 같으므로 배포 시점에 고정되는 public segment에 들어갑니다. 따라서 프로그램 id는 이 투표만을 가리키며 다른 투표는 가리키지 않습니다.

public 워드의미
0트리 깊이 d, 1에서 20
1poll id
2선택지의 수. 선택지는 0 .. choices − 1
3에서 10루트. 32바이트를 리틀 엔디언 워드 8개로 표현
private 워드의미
0에서 7비밀값
8구성원의 leaf 인덱스
9선택
10부터Merkle 경로: leaf에서 위로 올라가며 각각 8워드인 d개의 형제 노드
출력 슬롯의미
0상태: 1 투표 완료, 2 구성원 아님, 3 선택지 범위 초과, 4 잘못된 설정
1선택
2에서 6nullifier의 처음 20바이트, 리틀 엔디언 워드 5개
7사용하지 않음. Rust panic이 여기에 0xdeadbeef를 씁니다

32바이트 해시는 8워드이고, 출력 전체가 8워드입니다. 160비트를 공개하면 상태와 선택을 담을 자리가 남습니다. 두 구성원이 160비트 nullifier를 공유하는 경우는 해시 충돌뿐이며, 이를 찾으려면 약 2⁸⁰번의 시도가 필요합니다.

바이트 순서. Keccak은 바이트 단위로 동작합니다. 머신의 KECCAK syscall은 상태를 워드로 받으며, 상태의 바이트 i는 워드 i / 4의 바이트 i mod 4에 놓입니다. 따라서 32바이트는 한 번에 4바이트씩 리틀 엔디언으로 읽어 8워드가 되고, public segment의 루트는 루트의 바이트를 그렇게 읽은 값일 뿐입니다.

3. 게스트

my-guests/allowlist/src/main.rs 전체:

#![no_std]
#![no_main]

use guest_sdk::{halt, keccak, read_input, read_public, write_output};

const MAX_DEPTH: u32 = 20;

const OK: u32 = 1;
const NOT_MEMBER: u32 = 2;
const BAD_CHOICE: u32 = 3;
const BAD_SETUP: u32 = 4;

/// The 32 bytes hashed after a secret to make its leaf: the ASCII tag, zero-padded.
const LEAF_TAG: [u32; 8] = tag_words(b"rand-allowlist-leaf-v1");
/// The first 28 bytes hashed after a secret to make its nullifier; the poll id is the last 4.
const NULL_TAG: [u32; 8] = tag_words(b"rand-allowlist-null-v1");

/// An ASCII tag as eight words: byte `i` of the tag is byte `i % 4` of word `i / 4`.
const fn tag_words(tag: &[u8]) -> [u32; 8] {
    let mut w = [0u32; 8];
    let mut i = 0;
    while i < tag.len() {
        w[i / 4] |= (tag[i] as u32) << (8 * (i % 4));
        i += 1;
    }
    w
}

/// Keccak-256 of a 64-byte message, the hash Ethereum calls `keccak256(abi.encodePacked(a, b))`.
/// The message is one rate block, so this is one permutation: the two halves go into the first
/// 16 words of the state, `0x01` follows them, and `0x80` closes the 136-byte rate.
///
/// `a_second` is 0 or 1. When it is 1 the halves trade places. The place is computed as an
/// offset, not chosen by a branch, so both orders execute the same instructions.
fn hash2(a: &[u32; 8], b: &[u32; 8], a_second: u32) -> [u32; 8] {
    let at = 8 * (a_second & 1) as usize;
    let mut st = [0u32; 50];
    st[at..at + 8].copy_from_slice(a);
    st[8 - at..16 - at].copy_from_slice(b);
    st[16] = 0x01;
    st[33] = 0x8000_0000;
    keccak(&mut st);
    let mut out = [0u32; 8];
    out.copy_from_slice(&st[..8]);
    out
}

fn read8(at: u32) -> [u32; 8] {
    let mut w = [0u32; 8];
    for (i, x) in w.iter_mut().enumerate() {
        *x = read_input(at + i as u32);
    }
    w
}

/// Public segment, fixed at deploy: [depth, poll id, number of choices, root (8 words)].
/// Private input: [secret (8 words), leaf index, choice, siblings (8 words per level)].
/// Outputs: [status, choice, nullifier (5 words), 0].
#[no_mangle]
pub extern "C" fn main() -> ! {
    let depth = read_public(0);
    let poll = read_public(1);
    let choices = read_public(2);
    if depth == 0 || depth > MAX_DEPTH {
        write_output(0, BAD_SETUP);
        halt();
    }

    let secret = read8(0);
    let index = read_input(8);
    let choice = read_input(9);

    // Walk from the leaf to the root. Bit `level` of the index says whether the running hash
    // is the right child at that level.
    let mut cur = hash2(&secret, &LEAF_TAG, 0);
    let mut level = 0;
    while level < depth {
        let sib = read8(10 + 8 * level);
        cur = hash2(&cur, &sib, index >> level);
        level += 1;
    }

    let mut diff = 0u32;
    for k in 0..8 {
        diff |= cur[k] ^ read_public(3 + k as u32);
    }

    let mut ctx = NULL_TAG;
    ctx[7] = poll;
    let nullifier = hash2(&secret, &ctx, 0);

    if diff != 0 {
        write_output(0, NOT_MEMBER);
    } else if choice >= choices {
        write_output(0, BAD_CHOICE);
    } else {
        write_output(0, OK);
        write_output(1, choice);
        for k in 0..5 {
            write_output(2 + k as u32, nullifier[k]);
        }
    }
    halt();
}

이 코드에서 그대로 가져다 쓸 만한 점이 세 가지 있습니다.

해시당 순열 한 번. guest_sdk::keccak256은 임의의 바이트열을 받지만, 그 대가로 바이트 단위 루프를 사용합니다. 여기서 모든 메시지는 정확히 64바이트로 136바이트 rate보다 짧으므로, 패딩은 두 개의 상수 워드이고 해시는 워드 복사로 채운 상태에 대한 KECCAK 호출 한 번입니다. Ethereum의 관점에서 hash2(a, b, 0)은 두 bytes32에 대한 keccak256(abi.encodePacked(a, b))입니다.

leaf와 nullifier는 트리 노드와 혼동될 수 없습니다. 노드는 32바이트 자식 해시 두 개를 해시합니다. leaf는 비밀값과 고정된 ASCII tag를 해시하고, nullifier는 비밀값, 또 다른 tag, poll id를 해시합니다. leaf가 노드로 통하려면 자식 중 하나가 tag와 같아야 하는데, 이는 Keccak 프리이미지를 찾아야 함을 뜻합니다.

인덱스에 따라 분기하지 않고 경로를 따라갑니다. 첫 번째 버전은 마스크를 이용한 swap으로 두 절반의 순서를 정했는데, 컴파일러가 마스크를 다시 분기로 바꿔 버렸습니다. 그 결과 leaf 인덱스의 1 비트 하나마다 실행에 9 사이클이 더 걸렸습니다. 사이클 수 자체는 공개되지 않지만, 호출이 선언하는 가스 한도는 이 값을 올림하여 정해지므로(6절), 인덱스에 따라 달라지는 실행은 인덱스를 조금 노출할 수 있습니다. 각 절반을 계산된 오프셋에 쓰도록 바꾸어 문제를 해결했고, 이제 모든 leaf 인덱스가 똑같이 3,208 사이클로 실행됩니다.

4. 호스트 측

주최자와 투표자에게는 머신이 계산하는 것과 동일한 Keccak이 필요합니다. allowlist.py는 자체 Keccak-f[1600]을 갖춘, 의존성 없는 Python 스크립트입니다(Python의 hashlib.sha3_256은 SHA-3이며 패딩 방식이 달라 다른 다이제스트를 냅니다). 이 스크립트는 두 가지 일을 합니다.

allowlist.py setup  <depth> <poll id> <choices> <secrets file>   writes public.txt, prints the root
allowlist.py witness <depth> <poll id> <secrets file> <member> <choice>
                                                   prints the private words and the nullifier

스크립트의 용어로 나타낸 트리 전체는 다음과 같습니다.

LEAF_TAG = b"rand-allowlist-leaf-v1".ljust(32, b"\0")
NULL_TAG = b"rand-allowlist-null-v1".ljust(28, b"\0")
EMPTY = bytes(32)  # an unused leaf: no secret hashes to it

def levels(depth, secrets):
    row = [keccak256(s + LEAF_TAG) for s in secrets]
    row += [EMPTY] * ((1 << depth) - len(row))
    out = [row]
    while len(row) > 1:
        row = [keccak256(row[i] + row[i + 1]) for i in range(0, len(row), 2)]
        out.append(row)
    return out

nullifier = keccak256(secret + NULL_TAG + poll.to_bytes(4, "little"))

실제 운영에서는 주최자가 비밀값이 아니라 leaf를 보유하며, 각 투표자는 공개된 leaf로부터 자신의 witness를 직접 만듭니다. 데모가 두 역할을 모두 수행하기 때문에 스크립트는 비밀값 파일을 받습니다.

데모 투표는 깊이 10(leaf 1,024개), poll id 1, 선택지 3개입니다. 구성원 여덟 명의 비밀값은 demo-secrets.txt에 한 줄에 64자리 16진수 비밀값 하나씩 들어 있으며, 누구나 프로그램을 호출할 수 있도록 일부러 공개되어 있습니다.

$ python3 allowlist.py setup 10 1 3 demo-secrets.txt
root ce8d6ef78a2ccaab688afa50ff969b0f89b23e7f32d8c3746848b46f773f4750
wrote public.txt
$ cat public.txt
10 1 3 4151217614 2882153610 1358596712 261854975 2134815369 1958991922 1874085992 1346846583

첫 번째 루트 워드 4151217614는 0xf76e8dce입니다. 루트의 처음 네 바이트 ce 8d 6e f7을 리틀 엔디언으로 읽은 값입니다.

5. 빌드와 실행

$ rand-guest build my-guests/allowlist --max-words 65535
427 words against a cap of 65535 (fits); 0 ecall(s) with a non-static a7
OK
wrote my-guests/allowlist/image.bin and its .sha256 (427 words, hc 420876739f0987ae7f0fbd23a98312ed4452b7e77cf6153e424ed02aca7b5afe, program id 93c780859b60516408099dc45247a21aead612f0c65c667248253533dd932bb3)

5번 구성원이 선택지 2에 투표합니다. witness는 90워드입니다. 비밀값 8워드, 인덱스, 선택, 그리고 경로 80워드입니다.

$ cd my-guests/allowlist
$ python3 allowlist.py witness 10 1 demo-secrets.txt 5 2 > witness.txt
nullifier b5516c17d7f51034998f837217777deb8bc90886
nullifier words 392974773 873526743 1921224601 3950868247 2248722827
$ rand-guest run image.bin --public $(cat public.txt) --input $(cat witness.txt) --tier 14
out[0] = 1
out[1] = 2
out[2] = 392974773
out[3] = 873526743
out[4] = 1921224601
out[5] = 3950868247
out[6] = 2248722827
out[7] = 0
cycles 3208
tier 12
tier 14: fits (cycles 3342 of 16383, Poseidon2 permutations 134 of 2048)

다섯 개의 nullifier 워드는 스크립트가 머신 바깥에서 계산한 값과 같습니다. 실패한 경우에도 상태와 함께 정상적으로 halt하므로 증명할 수 있습니다. 같은 witness에서 비밀값의 첫 워드만 바꾸면 out[0] = 2가 되고 다른 값은 없으며, 3개 중 선택지 3을 요청하는 구성원은 out[0] = 3을 얻습니다.

6. 예산

프로그램427워드
사이클깊이 10 트리에서 3,208. 다이제스트 행을 포함하면 3,342
Keccak 순열12: leaf 하나, 노드 열, nullifier 하나. 호출당 상한은 128
Poseidon2 순열2,048 중 134, 모두 프로그램, 입력, public 다이제스트에 쓰임
티어12

깊이가 한 단계 늘어날 때마다 순열이 하나, 사이클이 약 270 늘어납니다. 깊이 20 트리(구성원 100만 명)는 5,866 사이클과 순열 22개로 측정되며, 이는 호출이 사용할 수 있는 가장 높은 티어인 티어 14에 해당합니다.

chain 18부터 호출은 가스를 지불하며, Keccak 순열은 비용이 큰 명령입니다. 일반 사이클이 1인 데 비해 192 가스입니다. 지갑은 실행의 정확한 가스와 그에 대해 선언하는 한도를 출력합니다.

gas: 5636 (declaring 6144, tier 12)

실행의 모든 행은 1 가스이고 Keccak 행은 191이 더 듭니다. 3,344행에 12 × 191을 더하면 5,636이며, 그중 5분의 2는 열두 번의 해시에서 나옵니다. 지갑은 rand-guest run보다 두 행을 더 셉니다. chain 18에서 호출 증명은 해당 트랜잭션에 바인딩되고, 이 바인딩은 public segment의 추가 워드 8개로 전달되어 다이제스트 행이 두 개 늘어납니다. 선언된 한도 6,144는 정확한 값을 1,024의 배수로 올림한 것입니다. 한도는 공개되고 정확한 값은 공개되지 않습니다.

Keccak 테이블을 포함하는 증명은 큽니다. 이 증명은 3,346,865바이트이며, chain 18에서 20까지의 상한은 4,194,304바이트(4 MiB)입니다. 위의 깊이 20 수치는 에뮬레이터 측정값이며, 이 페이지를 위해 그 깊이에서 증명을 만들지는 않았습니다.

7. 배포

배포에는 public segment가 포함되므로 수수료는 그 11워드를 427개의 코드 워드와 함께 계산합니다. 0.001 + 0.0001 × 438 = 0.0448 RAND입니다.

$ rand fee deploy 427 --public-words 11
0.0448 RAND
$ rand program deploy image.bin --public public.txt
program id: ad2f719f12d01c9f3c0962092b9898b75b0914bbb817031d51be13e54c05b970 (427 words, hc 73760842ae87099f23bd0f7fed1283a9e7b752443e15f67c2ad04e42fe5a7bca, public input 11 words, digest 2296cfca8db9d38bd0b7579056c9dea3c8d8005df7b42e2b1e954a75fed67510)
proving the spend authorisation on this machine (tier 10; seconds)…
authorisation proved in 22.9s: tier 10, 1370827 bytes
proving the bundle (tier 14; about a minute and a half on a laptop)…
proved in 190.2s: tier 14, 1497544 bytes
submitted deploy 05f8bfcbf9fccedbd53c3ba41bcc32dc8070ed3433364fc591b4f00699438de6
  0 RAND out, 59.9552 RAND change, fee 0.0448 RAND, anchored at height 97632, proved in 190.2s (bundle) + 22.9s (auth)

deploy가 출력한 id ad2f719f…b970은 rand-guest build가 출력한 것과 다릅니다. public segment가 있으면 id는 blake3("rand-program-2", base_pc ‖ len ‖ words ‖ len ‖ public)이므로 루트, poll id, 선택지의 수에 바인딩됩니다. 같은 구성원에 대한 두 번째 투표는 같은 코드를 다른 poll id로 다시 배포하는 것이며, 다른 프로그램 id와 다른 nullifier를 갖게 됩니다.

hc도 표기가 다릅니다. rand-guest는 여덟 워드를 각각 빅 엔디언으로 출력하고, 지갑은 각 워드의 바이트를 리틀 엔디언으로 출력하므로 42087673이 73760842가 되었습니다. 같은 다이제스트입니다.

$ rand program show ad2f719f12d01c9f3c0962092b9898b75b0914bbb817031d51be13e54c05b970
{
  "base_pc": 4096,
  "code_hash": "73760842ae87099f23bd0f7fed1283a9e7b752443e15f67c2ad04e42fe5a7bca",
  "deployed_at": 97821,
  "id": "ad2f719f12d01c9f3c0962092b9898b75b0914bbb817031d51be13e54c05b970",
  "public_digest": "2296cfca8db9d38bd0b7579056c9dea3c8d8005df7b42e2b1e954a75fed67510",
  "public_words_len": 11,
  "words_len": 427
}

누구나 public segment를 다시 읽어 볼 수 있습니다. rand_getProgramPublic은 워드를 리틀 엔디언 바이트로 반환하므로, 루트는 출력된 모습 그대로 나타납니다.

$ curl -s https://rpc.randprotocol.org -H 'content-type: application/json' \
    -d '{"jsonrpc":"2.0","id":1,"method":"rand_getProgramPublic","params":["ad2f719f12d01c9f3c0962092b9898b75b0914bbb817031d51be13e54c05b970"]}'
{"id":1,"jsonrpc":"2.0","result":"0a0000000100000003000000ce8d6ef78a2ccaab688afa50ff969b0f89b23e7f32d8c3746848b46f773f4750"}

8. 투표

rand call은 비공개 워드마다 --input을 하나씩, 십진수로 받습니다. sed가 증인(witness) 파일을 그 플래그로 바꿔 줍니다.

$ rand call ad2f719f12d01c9f3c0962092b9898b75b0914bbb817031d51be13e54c05b970 \
    $(sed 's/[0-9][0-9]*/--input &/g' witness.txt)
proving the call locally (90 inputs stay private, over the program's 11-word public input)…
gas: 5636 (declaring 6144, tier 12)
fee 0.006191437 RAND (incl. two price steps of headroom)
proved in 29.2s: tier 12, 3346865 bytes, outputs [1, 2, 392974773, 873526743, 1921224601, 3950868247, 2248722827, 0]
proving the spend authorisation on this machine (tier 10; seconds)…
authorisation proved in 8.2s: tier 10, 1367303 bytes
proving the bundle (tier 14; about a minute and a half on a laptop)…
proved in 117.2s: tier 14, 1504260 bytes
submitted call 8aa13d1f76d28f63a46c30bb09ef44b1ba35083ca80ba7301997f98e5f783666
  0 RAND out, 59.716708563 RAND change, fee 0.006191437 RAND, anchored at height 98185, proved in 117.2s (bundle) + 8.2s (auth)
{
  "h_in": "ea71e7701e62d49cac37b239bef132178f481835027d62b34da45fd6cf2667c6",
  "h_pub": "2296cfca8db9d38bd0b7579056c9dea3c8d8005df7b42e2b1e954a75fed67510",
  "height": 98335,
  "index": 0,
  "outputs": [
    1,
    2,
    392974773,
    873526743,
    1921224601,
    3950868247,
    2248722827,
    0
  ],
  "program": "ad2f719f12d01c9f3c0962092b9898b75b0914bbb817031d51be13e54c05b970",
  "tier": 12,
  "tx": "8aa13d1f76d28f63a46c30bb09ef44b1ba35083ca80ba7301997f98e5f783666"
}
input transcript published; open it with `rand open-call 8aa13d1f76d28f63a46c30bb09ef44b1ba35083ca80ba7301997f98e5f783666`

영수증이 체인에 남는 기록입니다. 상태 1, 선택지 2, 그리고 다섯 개의 널리파이어 워드가 투표 프로그램 id 아래에 저장됩니다. h_pub는 투표의 공개 세그먼트에 대한 다이제스트이며, program show가 출력한 값과 같습니다. h_in은 90개의 비공개 워드에 대한 솔트가 적용된 커밋먼트이며, 솔트 없이는 아무것도 열 수 없습니다.

호출에는 Apple Silicon 노트북에서 3분이 걸렸고 최대 메모리 사용량은 6.1 GB였습니다. 호출 증명에 29초, 지출 승인에 8초, 수수료를 지불하는 번들에 117초가 들었습니다. 수수료 0.0062 RAND는 대부분 바이트에 대한 비용입니다. 계산 과정은 Four programs on chain 20에 있습니다.

트랜스크립트 행. 기본적으로 지갑은 호출자의 viewing key로만 열 수 있는 봉투에 비공개 입력을 봉인하여 호출과 함께 게시합니다. 이는 호출자를 위한 기록일 뿐 공개가 아닙니다. 다른 누구도 열 수 없습니다. 기록을 전혀 남기고 싶지 않은 투표자는 --no-envelope를 전달합니다. 그렇게 하면 투표자 본인을 포함하여 누구도 입력이 무엇이었는지 증명할 수 없게 됩니다.

9. 표 집계하기

집계하는 쪽에 필요한 것은 체인뿐입니다.

  1. 투표. 프로그램의 공개 세그먼트를 가져와 루트를 공개된 리프 목록과 대조합니다(allowlist.py의 levels로 트리를 다시 구성합니다). 가드를 소스에서 다시 빌드하여 hc를 프로그램의 code_hash와 비교합니다.
  2. 영수증. program이 투표의 id이고 출력 0이 1인 영수증을 모두 가져옵니다. 상태가 다르거나 슬롯 7에 3735928559가 있는 영수증은 투표가 아닙니다.
  3. 집계. 출력 2~6을 기준으로 묶습니다. 각 널리파이어는 체인 순서상 첫 번째 영수증만 한 번 집계하고, 그 출력 1을 합계에 더합니다.

집계자에게 할 일을 주기 위해 세 번의 호출을 더 수행했습니다. 5번 구성원이 이번에는 선택지 0으로 두 번째 투표를 했습니다. 2번 구성원은 선택지 1에 투표했습니다. 3번 구성원은 공개 RPC 엔드포인트만 사용하는 지갑에서 선택지 0에 투표했습니다. 다음은 5번 구성원의 두 번째 투표입니다.

$ python3 allowlist.py witness 10 1 demo-secrets.txt 5 0 > witness.txt
nullifier b5516c17d7f51034998f837217777deb8bc90886
nullifier words 392974773 873526743 1921224601 3950868247 2248722827
$ rand call ad2f719f12d01c9f3c0962092b9898b75b0914bbb817031d51be13e54c05b970 \
    $(sed 's/[0-9][0-9]*/--input &/g' witness.txt)
…
proved in 31.0s: tier 12, 3345265 bytes, outputs [1, 0, 392974773, 873526743, 1921224601, 3950868247, 2248722827, 0]
…
submitted call 0f596b87f5137a165d6abc691dbe04f7533db619911d81449fea9ede602c40d0

체인은 두 번째 투표를 받아들였습니다. 증명이 유효하므로 당연한 결과입니다. 이 영수증은 첫 번째 영수증과 동일한 다섯 개의 널리파이어 워드를 담고 있습니다.

tally.py는 세 가지 규칙을 적용합니다. 프로그램의 호출은 익스플로러에서, 각 영수증은 공개 RPC에서 읽으므로 지갑도 노드도 필요하지 않습니다.

$ python3 tally.py ad2f719f12d01c9f3c0962092b9898b75b0914bbb817031d51be13e54c05b970
8aa13d1f…  height 98335  choice 2  nullifier b5516c17d7f51034…  counted
0f596b87…  height 99194  choice 0  nullifier b5516c17d7f51034…  repeat, dropped
2db517df…  height 99339  choice 1  nullifier 52f2455918bbc811…  counted
fdbcc960…  height 101833  choice 0  nullifier cd86c5454b809914…  counted
tally: choice 0: 1, choice 1: 1, choice 2: 1

5번 구성원의 번복은 집계되지 않았습니다. 네 개의 영수증 가운데 두 개가 목록의 같은 구성원에게서 나왔다는 사실은 널리파이어 외에는 어디에도 나타나지 않습니다. 투표는 열려 있으니, 여덟 명의 데모 구성원 중 누구로든 투표한 다음 집계를 다시 실행해 보십시오.

증명이 보장하지 않는 것

  • 사람당 한 표가 아니라 비밀값당 한 표입니다. 누구에게 리프를 줄지는 주최자가 정합니다. 리프를 두 개 등록한 구성원은 두 표를 갖습니다.
  • 투표자의 비밀값은 그 무작위성만큼만 안전합니다. 추측 가능한 문자열에서 만든 비밀값은 추측값을 해시하여 공개된 리프와 비교하는 방식으로 찾아낼 수 있습니다. 32바이트 무작위 값을 사용하십시오.
  • 선택지는 공개됩니다. 영수증은 어떤 널리파이어가 어느 선택지에 투표했는지 보여 줍니다. 그 널리파이어가 어느 구성원인지는 아무도 알 수 없지만, 투표자가 적은 투표에서 투표율까지 공개되면 그것은 암호학이 아니라 소수 표본 통계입니다.
  • 타이밍과 수수료는 메타데이터입니다. 수수료 번들은 shielded 처리되어 누구의 이름도 드러내지 않지만, 지갑에 자금을 넣고 한산한 시간대에 1분 뒤 투표하는 투표자는 관찰자에게 단서를 준 셈입니다.
  • 집계는 체인 밖에서 이루어집니다. 체인은 영수증을 저장할 뿐입니다. 영수증을 세고 두 번째 널리파이어를 거부하는 일은 그것을 읽는 쪽이 합니다.

fullnode 커밋 48dc04b(chain 18) 기준으로 2026-09-30에 마지막으로 검증되었습니다, 그리고 circuits `aeacf31` (`guest-sdk/src/lib.rs`, `rand-guest`), the commit fullnode v0.6.7 vendors; every `rand-guest` and `rand` output on this page was produced on 2026-09-30, the deploy and the call on chain 18. 코드와 이 페이지의 내용이 다를 경우 코드가 옳습니다. 알려 주시기 바랍니다.