Rand Protocol
Dokumentation
Beispielprogramme

Eine anonyme Abstimmung

Ein vertrauliches Programm, das die Mitgliedschaft in einer Keccak-Merkle-Allowlist nachweist, ohne zu verraten, welches Mitglied, und eine Auswahl sowie einen Nullifier veröffentlicht, der eine zweite Stimmabgabe verhindert – die Schnittstelle, der Rust-Guest, das Host-Skript, das Budget und das Programm live auf chain 18.

Dieses Programm lässt ein Mitglied einer festen Liste eine Stimme abgeben, ohne zu zeigen, welches Mitglied es ist. Die Liste ist ein Merkle-Baum aus Keccak-256-Hashes, aufgebaut wie die Liste eines Ethereum-Airdrops, sodass dieselben Werkzeuge ihn erzeugen. Das Programm ist auf Chain 18 deployt, und der letzte Abschnitt ruft es mit Demo-Mitgliedern auf, die Sie selbst verwenden können.

Beispielprogramme enthält das Setup, das alle vier Beispielseiten gemeinsam haben: den Checkout der Circuits, das Wallet und die Bezugsquellen der Sourcen.

1. Was bewiesen wird

Ein Organisator hat eine Liste von Mitgliedern. Jedes Mitglied besitzt ein 32-Byte-Geheimnis. Der Organisator sieht die Geheimnisse nie: Jedes Mitglied übergibt ein Blatt (Leaf), keccak256(secret ‖ tag), und der Organisator baut einen Merkle-Baum über die Blätter.

Eine Stimmabgabe ist ein Aufruf. Er beweist, ohne sonst etwas offenzulegen:

  1. Der Aufrufer kennt ein Geheimnis, dessen Blatt im Baum liegt;
  2. die veröffentlichte Wahl gehört zu den zulässigen Wahlmöglichkeiten;
  3. der veröffentlichte Nullifier ist keccak256(secret ‖ tag ‖ poll id).

Der Nullifier ist für jede Stimme, die ein Geheimnis in einer Abstimmung abgibt, derselbe und in jeder anderen Abstimmung ein anderer. Wer die Stimmen zählt, zählt jeden Nullifier einmal. Er lässt sich nicht mit dem Blatt verknüpfen: Um von einem Nullifier zurück zu einem Blatt zu gelangen, braucht man das Geheimnis.

Die Chain lehnt eine zweite Stimme nicht ab. Ein Programm auf Rand hat keinen Speicher (Storage) und kann sich daher keinen Nullifier merken (Programme). Zwei Aufrufe mit demselben Geheimnis werden beide verifiziert und hinterlassen beide eine Quittung. Die Regel „ein Nullifier, eine Stimme“ wendet der Zähler auf die Quittungen an. Jeder kann dieselbe Zählung durchführen, und genau das macht das Ergebnis überprüfbar.

2. Die Schnittstelle

Der Baum, die Abstimmung und die Zahl der Wahlmöglichkeiten sind für jede Stimme gleich, daher stehen sie im öffentlichen Segment, das beim Deploy festgelegt wird. Die Programm-ID benennt dann diese Abstimmung und keine andere.

öffentliches WortBedeutung
0Baumtiefe d, 1 bis 20
1Poll-ID
2Anzahl der Wahlmöglichkeiten; eine Wahl ist 0 .. choices − 1
3 bis 10die Wurzel, 32 Bytes als acht Little-Endian-Wörter
privates WortBedeutung
0 bis 7das Geheimnis
8der Blattindex des Mitglieds
9die Wahl
ab 10der Merkle-Pfad: d Geschwister (Siblings) zu je 8 Wörtern, vom Blatt aufwärts
AusgabeslotBedeutung
0Status: 1 abgestimmt, 2 kein Mitglied, 3 Wahl außerhalb des Bereichs, 4 fehlerhaftes Setup
1die Wahl
2 bis 6die ersten 20 Bytes des Nullifiers, als fünf Little-Endian-Wörter
7unbenutzt; ein Rust-Panic schreibt hier 0xdeadbeef

Ein 32-Byte-Hash sind acht Wörter, und acht Wörter ist alles, was an Ausgabe zur Verfügung steht. Die Veröffentlichung von 160 Bit lässt Platz für den Status und die Wahl. Zwei Mitglieder würden einen 160-Bit-Nullifier nur durch eine Hash-Kollision teilen, deren Auffinden etwa 2⁸⁰ Versuche kostet.

Byte-Reihenfolge. Keccak arbeitet auf Bytes. Der KECCAK-Syscall der Maschine nimmt den Zustand als Wörter entgegen, wobei Byte i des Zustands in Byte i mod 4 von Wort i / 4 liegt. So werden aus 32 Bytes acht Wörter, indem man jeweils vier Bytes Little-Endian liest, und die Wurzel im öffentlichen Segment sind einfach die so gelesenen Bytes der Wurzel.

3. Der Guest

my-guests/allowlist/src/main.rs, vollständig:

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

Drei Dinge darin sind es wert, übernommen zu werden.

Eine Permutation pro Hash. guest_sdk::keccak256 nimmt eine beliebige Bytefolge entgegen und bezahlt dafür mit Byte-Schleifen. Jede Nachricht hat hier genau 64 Bytes, weniger als die Rate von 136 Bytes, sodass das Padding aus zwei konstanten Wörtern besteht und der Hash ein einziger KECCAK-Aufruf auf einem durch Wortkopien gefüllten Zustand ist. In Ethereum-Begriffen ist hash2(a, b, 0) gleich keccak256(abi.encodePacked(a, b)) für zwei bytes32.

Blatt und Nullifier lassen sich nicht mit einem Baumknoten verwechseln. Ein Knoten hasht zwei 32-Byte-Kind-Hashes. Ein Blatt hasht ein Geheimnis und ein festes ASCII-Tag, und ein Nullifier hasht ein Geheimnis, ein weiteres Tag und die Poll-ID. Damit ein Blatt als Knoten durchgeht, müsste eines seiner Kinder dem Tag entsprechen, was das Finden eines Keccak-Urbilds bedeutet.

Der Pfad wird durchlaufen, ohne auf den Index zu verzweigen. Die erste Version wählte die Reihenfolge der beiden Hälften mit einem maskierten Tausch, und der Compiler machte aus der Maske wieder eine Verzweigung: Der Lauf brauchte für jedes 1-Bit im Blattindex 9 Zyklen mehr. Die Zyklenzahl selbst wird nicht veröffentlicht, aber das Gas-Limit, das ein Aufruf deklariert, wird aus ihr gerundet (Abschnitt 6), sodass ein vom Index abhängiger Lauf ein wenig vom Index preisgeben kann. Das Schreiben jeder Hälfte an einen berechneten Offset behob das: Jeder Blattindex läuft nun dieselben 3.208 Zyklen.

4. Die Host-Seite

Der Organisator und die Wähler brauchen dasselbe Keccak, das die Maschine berechnet. allowlist.py ist ein abhängigkeitsfreies Python-Skript mit eigenem Keccak-f[1600] (Pythons hashlib.sha3_256 ist SHA-3, das anders auffüllt und andere Digests liefert). Es erledigt zwei Aufgaben:

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

Der ganze Baum, in den Begriffen des Skripts:

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

In der Produktion hält der Organisator Blätter, keine Geheimnisse, und jeder Wähler baut sein eigenes Witness aus den veröffentlichten Blättern. Das Skript nimmt eine Datei mit Geheimnissen entgegen, weil die Demo beide Rollen spielt.

Die Demo-Abstimmung hat Tiefe 10 (1.024 Blätter), Poll-ID 1 und drei Wahlmöglichkeiten. Die Geheimnisse ihrer acht Mitglieder stehen in demo-secrets.txt, ein 64-stelliges Hex-Geheimnis pro Zeile, und sie sind absichtlich öffentlich, damit jeder das Programm aufrufen kann:

$ 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

Das erste Wurzelwort, 4151217614, ist 0xf76e8dce: die ersten vier Bytes der Wurzel ce 8d 6e f7, Little-Endian gelesen.

5. Bauen und ausführen

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

Mitglied 5 stimmt für Wahl 2. Das Witness besteht aus 90 Wörtern: 8 für das Geheimnis, den Index, die Wahl und 80 für den Pfad.

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

Die fünf Nullifier-Wörter sind die, die das Skript außerhalb der Maschine berechnet hat. Die Fehlerfälle halten sauber mit einem Status an, sodass auch sie bewiesen werden können. Dasselbe Witness mit verändertem erstem Wort des Geheimnisses ergibt out[0] = 2 und sonst nichts, und ein Mitglied, das Wahl 3 von 3 verlangt, ergibt out[0] = 3.

6. Das Budget

Programm427 Wörter
Zyklen3.208 für einen Baum der Tiefe 10; 3.342 mit den Digest-Zeilen
Keccak-Permutationen12: ein Blatt, zehn Knoten, ein Nullifier. Die Obergrenze für einen Aufruf ist 128
Poseidon2-Permutationen134 von 2.048, alle für das Programm, die Eingabe und die öffentlichen Digests
Tier12

Jede zusätzliche Ebene Tiefe kostet eine Permutation und etwa 270 Zyklen. Ein Baum der Tiefe 20 (eine Million Mitglieder) misst 5.866 Zyklen und 22 Permutationen, das ist Tier 14, das höchste Tier, das ein Aufruf verwenden darf.

Seit Chain 18 zahlt ein Aufruf für Gas, und eine Keccak-Permutation ist die teure Instruktion: 192 Gas gegenüber 1 für einen gewöhnlichen Zyklus. Das Wallet gibt das exakte Gas eines Laufs und das dafür deklarierte Limit aus:

gas: 5636 (declaring 6144, tier 12)

Jede Zeile des Laufs kostet 1 Gas, und eine Keccak-Zeile kostet 191 mehr: 3.344 Zeilen plus 12 × 191 ergibt 5.636, und zwei Fünftel davon sind die zwölf Hashes. Das Wallet zählt zwei Zeilen mehr als rand-guest run. Auf Chain 18 ist ein Aufrufbeweis an seine Transaktion gebunden, und die Bindung wird als acht zusätzliche Wörter des öffentlichen Segments übertragen, was zwei Digest-Zeilen hinzufügt. Das deklarierte Limit, 6.144, ist die exakte Zahl, aufgerundet auf ein Vielfaches von 1.024. Das Limit ist öffentlich, die exakte Zahl nicht.

Ein Beweis, der die Keccak-Tabelle enthält, ist groß. Dieser hat 3.346.865 Bytes, bei einer Obergrenze von 4.194.304 (4 MiB) auf den Chains 18 bis 20. Die obigen Zahlen für Tiefe 20 sind Emulatormessungen, und für diese Seite wurde bei dieser Tiefe kein Beweis erzeugt.

7. Deploy

Der Deploy trägt das öffentliche Segment, sodass die Gebühr dessen 11 Wörter zusammen mit den 427 Code-Wörtern zählt: 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)

Die ID, die deploy ausgibt, ad2f719f…b970, ist nicht die, die rand-guest build ausgegeben hat. Mit einem öffentlichen Segment ist die ID blake3("rand-program-2", base_pc ‖ len ‖ words ‖ len ‖ public), sie bindet also die Wurzel, die Poll-ID und die Zahl der Wahlmöglichkeiten. Eine zweite Abstimmung über dieselben Mitglieder ist ein zweiter Deploy desselben Codes mit einer anderen Poll-ID und erhält eine andere Programm-ID und andere Nullifier.

hc wird ebenfalls anders geschrieben: rand-guest gibt jedes seiner acht Wörter Big-Endian aus, und das Wallet gibt die Bytes jedes Wortes Little-Endian aus, sodass aus 42087673 73760842 wurde. Es ist derselbe Digest.

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

Jeder kann das öffentliche Segment zurücklesen. rand_getProgramPublic liefert die Wörter als Little-Endian-Bytes, sodass die Wurzel so erscheint, wie sie ausgegeben wurde:

$ 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. Stimme abgeben

rand call nimmt pro privatem Wort ein --input in Dezimalschreibweise entgegen. Das sed macht aus der Witness-Datei diese Flags:

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

Die Quittung ist das, was die Chain speichert: Status 1, Wahl 2 und die fünf Nullifier-Wörter, unter der Programm-ID der Abstimmung. h_pub ist der Digest des öffentlichen Segments der Abstimmung, derselbe Wert, den program show ausgegeben hat. h_in ist eine gesalzene Commitment auf die 90 privaten Wörter und öffnet ohne das Salt nichts.

Der Aufruf dauerte auf einem Apple-Silicon-Laptop drei Minuten und erreichte eine Spitze von 6,1 GB: 29 s für den Aufrufbeweis, 8 s für die Ausgabeautorisierung und 117 s für das Bündel, das die Gebühr bezahlt. Die Gebühr von 0,0062 RAND besteht größtenteils aus Bytes. Four programs on chain 20 enthält die Rechnung.

Die Transkriptzeile. Standardmäßig versiegelt die Wallet die privaten Eingaben in einem Umschlag, den nur der Viewing Key des Aufrufers öffnet, und veröffentlicht ihn mit dem Aufruf. Das ist ein Protokoll für den Aufrufer, keine Offenlegung: Niemand sonst kann ihn öffnen. Wer abstimmt und überhaupt kein Protokoll wünscht, übergibt --no-envelope. Danach kann niemand, auch der Wähler nicht, jemals zeigen, was die Eingaben waren.

9. Die Stimmen auszählen

Ein Zähler braucht nichts außer der Chain:

  1. Die Abstimmung. Rufen Sie das öffentliche Segment des Programms ab und prüfen Sie die Wurzel anhand der veröffentlichten Liste der Blätter (bauen Sie den Baum mit levels aus allowlist.py neu auf). Bauen Sie den Guest aus dem Quellcode neu und vergleichen Sie hc mit dem code_hash des Programms.
  2. Die Quittungen. Nehmen Sie jede Quittung, deren program die ID der Abstimmung ist und deren Ausgabe 0 den Wert 1 hat. Eine Quittung mit einem anderen Status oder mit 3735928559 in Slot 7 ist keine Stimme.
  3. Die Auszählung. Gruppieren Sie nach den Ausgaben 2 bis 6. Zählen Sie jeden Nullifier einmal, nämlich seine erste Quittung in Chain-Reihenfolge, und addieren Sie dessen Ausgabe 1 zur Summe.

Drei weitere Aufrufe wurden gemacht, damit der Zähler etwas zu tun hat. Mitglied 5 stimmte ein zweites Mal ab, diesmal für Wahl 0. Mitglied 2 stimmte für Wahl 1. Mitglied 3 stimmte für Wahl 0, von einer Wallet, die nur den öffentlichen RPC-Endpunkt nutzte. Dies ist die zweite Stimme von Mitglied 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

Die Chain nahm die zweite Stimme an, wie sie muss: Der Beweis ist gültig. Ihre Quittung trägt dieselben fünf Nullifier-Wörter wie die erste.

tally.py wendet die drei Regeln an. Es liest die Aufrufe des Programms aus dem Explorer und jede Quittung aus dem öffentlichen RPC, braucht also weder eine Wallet noch einen Node:

$ 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

Die Meinungsänderung von Mitglied 5 zählte nicht, und nichts in den vier Quittungen verrät, dass zwei davon vom selben Mitglied der Liste stammen, außer dem Nullifier selbst. Die Abstimmung ist offen: Stimmen Sie als eines der acht Demo-Mitglieder ab und führen Sie die Auszählung erneut aus.

Was der Beweis nicht leistet

  • Eine Stimme pro Secret, nicht eine Stimme pro Person. Der Organisator entscheidet, wer ein Blatt erhält. Ein Mitglied, das zwei Blätter registriert, hat zwei Stimmen.
  • Das Secret des Wählers ist nur so gut wie seine Zufälligkeit. Ein Secret, das aus einer erratbaren Zeichenfolge abgeleitet wird, lässt sich finden, indem man Vermutungen hasht und mit den veröffentlichten Blättern vergleicht. Verwenden Sie 32 zufällige Bytes.
  • Die Wahl ist öffentlich. Eine Quittung zeigt, für welche Wahl ein Nullifier gestimmt hat. Niemand kann erkennen, welches Mitglied der Nullifier ist, aber eine Abstimmung mit wenigen Wählern und veröffentlichter Beteiligung ist Statistik kleiner Zahlen, keine Kryptografie.
  • Zeitpunkt und Gebühren sind Metadaten. Das Gebührenbündel ist shielded und nennt niemanden, aber ein Wähler, der seine Wallet auflädt und eine Minute später in einer ruhigen Stunde abstimmt, hat einem Beobachter etwas verraten.
  • Die Auszählung findet off chain statt. Die Chain speichert Quittungen. Sie zu zählen und einen zweiten Nullifier abzulehnen, übernimmt, wer sie liest.

Zuletzt geprüft gegen den fullnode-Commit 48dc04b (Chain 18) am 2026-09-30 und 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. Wo Code und diese Seite voneinander abweichen, hat der Code recht; sagen Sie uns Bescheid.