Ta strona służy do budowania i audytowania wszystkiego, co odczytuje pulę: portfela skanującego w poszukiwaniu swoich not, indeksera śledzącego zobowiązania i nullifiery albo narzędzia sprawdzającego, co publikuje transakcja. Wartość w tym łańcuchu jest zbiorem not; łańcuch przechowuje ich zobowiązania, nigdy ich zawartość.
Nota
Nota to { pk, from, amount, asset, time, r }: 28 słów po 32 bity, 112 bajtów, w układzie pk(8) from(8) amount_lo amount_hi asset time r(8). pk to publiczna wartość noty właściciela (wyprowadzona z klucza podglądu), from to wartość nadawcy, amount to u64 w jednostkach (1 RAND = 10⁹ jednostek), asset to u32 (0 dla RAND, w pozostałych przypadkach gęsty indeks rejestru tokenów dla tokena RPL, pomostowanego lub natywnego; zob. tokeny RPL), time to wysokość bloku, którą wskazał nadawca, a r to osiem losowych słów zaślepienia.
Treść jawna nigdy nie pojawia się w łańcuchu. Pojawia się natomiast:
- jej zobowiązanie
cm = H(CM, words), skrót Poseidon2 z 28 słów pod znacznikiem domeny 4, dołączany jako liść drzewa; - jej koperta, treść jawna zapieczętowana na adres właściciela tak, by mógł ją odczytać tylko właściciel albo nadawca za pomocą swojego wychodzącego klucza podglądu. Od łańcucha 18 (łącznie z łańcuchem 20) koperta zawiera także zaszyfrowany memoriał. Kilka not, które łańcuch oblicza z pól publicznych, nie ma koperty w ogóle (poniżej).
Wydanie noty publikuje jej nullifier H(NF, nk, cm), skrót Poseidon2 pod znacznikiem domeny 3 z klucza podglądu właściciela i zobowiązania. Bez nk nie można powiązać nullifiera z jego zobowiązaniem. Zbiór nullifierów zapobiega podwójnemu wydaniu.
Koperta
Envelope { kem_ct, to_receiver, to_sender, body }
| część | bajty | czym jest |
|---|---|---|
kem_ct | 1088 | enkapsulacja ML-KEM-768 do adresu odbiorcy |
to_receiver | 60 | klucz transakcji TxKey opakowany pod zenkapsulowanym sekretem |
to_sender | 60 | ten sam TxKey opakowany pod wychodzącym kluczem podglądu nadawcy ovk |
body | 652 | 112-bajtowa nota i 512-bajtowe pole memoriału pod TxKey (140 bajtów, sama nota, do łańcucha 17 włącznie) |
Każda część jest szyfrowana ChaCha20-Poly1305 z losowym 12-bajtowym nonce na początku i 16-bajtowym tagiem, a dane powiązane to znacznik (rand-envelope-receiver, rand-envelope-sender, rand-envelope-body), po którym następuje zobowiązanie noty. Dzięki temu klucz nie otworzy koperty, której nie zapieczętował, a otwarta nota jest związana z liściem, pod którym leży. Daje to 1860 bajtów szyfrogramu; z prefiksami długości bincode jest to 1892 na łączu. Genesis łańcucha 20 ustawia envelope_bytes: 1860, a jego księga odrzuca kopertę noty o jakiejkolwiek innej długości (envelope is 1348 bytes, this chain requires exactly 1860); niczego innego w niej nie sprawdza. Do łańcucha 17 koperta nie miała pola memoriału i miała 1348 bajtów (1380 na łączu), a księga jedynie ograniczała ją do 2048 bajtów, co nadal obowiązuje w łańcuchu, którego genesis nie ma envelope_bytes.
Pole memoriału ma postać len (u16 LE) ‖ tekst UTF-8 ‖ dopełnienie zerami, 512 bajtów, czyli co najwyżej 510 bajtów tekstu. Każda koperta noty w łańcuchu 20 je zawiera, niezależnie od tego, czy memoriał istnieje: reszta, wyjście-atrapa i płatność bez memoriału są dopełniane tak samo, a nic w łańcuchu nie odróżnia memoriału, jego długości ani jego braku. Memoriał nie należy do noty, zobowiązania ani żadnego dowodu. Może go odczytać dokładnie ten, kto już może odczytać notę: właściciel, nadawca przez ovk albo każdy, komu przekazano TxKey tego wyjścia. Źle sformatowane pole memoriału (zła długość, nieprawidłowy UTF-8, niezerowe dopełnienie) otwiera się jako brak memoriału i nigdy nie kosztuje właściciela noty. Memoriał to tekst dowolnej osoby, ponieważ każdy może wysłać notę-pył na publiczny adres: wyświetlaj go jako niezaufany, z zastąpionymi znakami sterującymi i znakami formatu Unicode, jak robi to portfel rand. rand send --memo pieczętuje go w wyjściu płatności; rand notes --memo i rand history --memo go pokazują.
Drzewo, kotwice i okna
Zobowiązania są dołączane do jednego drzewa Poseidon2 o głębokości 32, od najstarszego, i nigdy nie są usuwane. Liść i to i-ta nota, jaką łańcuch kiedykolwiek utworzył; noty depozytów genesis są pierwszymi liśćmi. Pakiet dołącza cztery liście i cztery nullifiery bez względu na to, co przenosi, wraz z atrapami. Pusty liść to same zera, a węzeł to H(NODE, left, right) (znacznik domeny 7). Węzeł przechowuje tylko krawędź drzewa, więc stan konsensusu nigdy nie zawiera liści; rand_getWitness odbudowuje na żądanie całe drzewo z zapisanych liści, dlatego jest to najdroższy odczyt, jaki obsługuje węzeł.
Pakiet dowodzi przynależności swoich wejść względem kotwicy, czyli korzenia drzewa. Dwie reguły utrzymują uczciwość kotwicy:
| stała | wartość | reguła |
|---|---|---|
ANCHOR_WINDOW | 256 bloków; 1024 w łańcuchu 20 | kotwica musi być jednym z ostatnich window korzeni końca bloku; korzeń, przez który drzewo przechodzi w środku bloku, nigdy nie jest kotwicą |
TIME_WINDOW | 256 bloków; 1024 w łańcuchu 20 | time pakietu musi mieścić się w przedziale [height − window, height] |
Oba okna mają po 256 bloków, chyba że genesis ustawia proof_window_blocks (v0.6.8, od 256 do 4096), które zastępuje oba naraz. Łańcuch 20 ustawia 1024; łańcuchy od 14 do 19 nie miały tego pola.
Okna istnieją, ponieważ dowodzenie zajmuje realny czas: dowód pakietu warstwy 14 trwa około 100 sekund na laptopie (a od łańcucha 17 portfel generuje drugi dowód autoryzacji warstwy 10, około 7 sekund). 256 bloków to było około pięciu minut przy blokach 1,17 s w łańcuchu 18, a dowód delegowany na wolnym proverze został odrzucony po 294 s; 1024 bloki łańcucha 20 to około 23 minut przy 1,34 s, które zgłaszał tam eksplorator. Portfel, który przegra ten wyścig, dostaje anchor is not one of the last 1024 roots i dowodzi od nowa. rand_getAnchor bez parametru zwraca korzeń końca bloku głowy łańcucha, jedyną kotwicę, względem której prover powinien budować dowód.
Pakiet
Każda transakcja przenosząca wartość zawiera dokładnie jeden stały pakiet 4-wejścia-4-wyjścia z ukrytym aktywem (od łańcucha 14; do łańcucha 13 było to 2-na-2), udowodniony przez gościa zkVM ukrytego aktywa, którego skrót hc_bundle jest przypięty w pliku genesis (60af094acfe65d85fdb18fb3d06cf9085dcf28c96e59e87f1ee527226e6e3fce, gość pakietu v3, w łańcuchach od 17 do 20; w łańcuchach 14 i 15 był to 83d3a3704a1fcdb9bae7136c0a947ffa34bd53f055388395ed705fe8cacd0ef8, gość v1):
Bundle { anchor, nullifiers[4], commitments[4], fee, burn_a, burn_r, burn_asset, time, envelopes[4], proof,
auth_commit, auth_proof } // the last two since chain 17
Sloty 0–1, wejściowe i wyjściowe, niosą aktywo prywatne A; sloty 2–3 zawsze niosą RAND. W płatności wyłącznie w RAND sloty 0–1 są atrapami, a wartość i opłata przechodzą przez sloty 2–3. W transferze tokena token przechodzi przez sloty 0–1, a opłata w RAND jest pobierana ze slotów 2–3 tego samego dowodu. A jest słowem świadka dowodu i nie występuje w żadnym polu publicznym, więc transfer dowolnego tokena RPL to Action::None z burn_a = burn_r = burn_asset = 0, pole w pole w kształcie płatności w RAND.
Dowód stwierdza, że każde rzeczywiste wejście jest liściem pod anchor i niesie aktywo swojego slotu, że cztery nullifiery są tymi opublikowanymi, że każda grupa się bilansuje (in0 + in1 = out0 + out1 + burn_a w aktywie prywatnym, in2 + in3 = out2 + out3 + fee + burn_r w RAND) i że wydający posiada klucze — nie ujawniając, które liście, jakie kwoty, które aktywo ani kto. Trzy pola burn są jedynym publicznym oświadczeniem pakietu o wartości opuszczającej pulę: burn_a i burn_asset są niezerowe tylko przy TokenBurn lub BridgeBurn, gdzie równają się kwocie i aktywu akcji; burn_r to spalony RAND, niezerowy tylko przy Bond (stawka) i RegisterAggregator (kaucja). Opłata jest zawsze w RAND.
Od łańcucha 17 uprawnienie do wydawania jest rozdzielone na dwa dowody (podzielona autoryzacja). Gość pakietu v3 przyjmuje klucz podglądu nk i sól tam, gdzie wcześniejsi goście przyjmowali klucz wydawania: wyprowadza pk_self = H(PK, nk), wymaga, by każde wejście należało do niego, i wkłada c = H(AUTH, nk ‖ salt) do skrótu, który publikuje. Drugi gość, gość autoryzacji (genesis hc_auth, 1e4e347f44cf86750b30a9a4bdf9ec9256efe353d4ff8017451eca7d195639c1), przyjmuje klucz wydawania sk i tę samą sól, wyprowadza nk = H(NK, sk) i publikuje to samo c. Pakiet niesie c jako auth_commit, a dowód warstwy 10 gościa autoryzacji jako auth_proof, i księga wymaga, aby oba dowody weryfikowały się względem tego samego wiązania transakcji, a obie wartości c były zgodne. Prover posiadający nk i sól może więc wykonać dowód pakietu, ale nie dowód autoryzacji, na czym opiera się dowodzenie delegowane. Sól to 256 świeżych losowych bitów na pakiet, więc auth_commit jest publiczny i niczego nie łączy. Portfel, który dowodzi sam dla siebie, wykonuje też oba dowody, więc transakcja udowodniona samodzielnie i delegowana wyglądają w łańcuchu tak samo.
Kształt jest stały, więc sloty bywają fikcyjne: fikcyjne wejście to nota o zerowej wartości, należąca do wydającego (jej nullifier jest mimo to publikowany), fikcyjne wyjście to nota o zerowej kwocie, a wyjście reszty występuje nawet wtedy, gdy reszta wynosi zero. Transakcja wyglądająca inaczej, gdy reszty nie ma, zdradzałaby, że jej nie było. Konsekwencja dla portfeli: każda z dwóch grup pakietu wydaje co najwyżej dwie noty. Płatność w RAND, obligacja, wdrożenie lub wywołanie wydaje co najwyżej dwie noty RAND; transfer lub spalenie tokena wydaje co najwyżej dwie noty tokena i co najwyżej dwie noty RAND na opłatę. Wartość rozłożoną na trzy lub więcej not w jednej grupie trzeba najpierw skonsolidować, wysyłając ją na własny adres.
Od chain 14 dowód jest także powiązany ze swoją transakcją: tworzy się go i weryfikuje względem skrótu całej transakcji z wyzerowanymi dowodami, więc nie można go skopiować na inną akcję, inne koperty ani inny łańcuch; na chain 20 skrót obejmuje również hash genesis, więc nie można go skopiować także na łańcuch o tym samym identyfikatorze łańcucha (Transakcje).
Co jest jawne, a co ukryte
| jawne w łańcuchu | ukryte | |
|---|---|---|
| transfer, w RAND lub w dowolnym tokenie RPL | anchor, wszystkie cztery nullifiery, wszystkie cztery zobowiązania, opłata, burn_a = burn_r = burn_asset = 0, time, cztery szyfrogramy kopert, dowód, auth_commit i dowód autoryzacji | kto wysłał, kto otrzymał, kwota, memo, który składnik majątku został przesłany, reszta, które liście zostały wydane, które sloty były fikcyjne |
| spalenie tokena lub spalenie mostu | wszystko, co publikuje transfer, z burn_asset i burn_a równymi składnikowi majątku i kwocie akcji (a dla spalenia mostu także opłata przekaźnika, token zabezpieczający i miejsce docelowe) | które noty zostały spalone i kto je spalił |
| mint z faucetu | zobowiązanie, koperta, kwota, walidator, który wykonał mint, a od chain 14 także otwarcie noty (pk, time, r), z którego przy przyjęciu przeliczane jest zobowiązanie | każde późniejsze wydanie noty |
| depozyt genesis | { cm, envelope, amount, opening: { pk, time, r } } w pliku genesis, z asset w otwarciu dla noty tokena z listy (genesis chain 20 zawiera dwie noty zUSD): pk właściciela, kwota i składnik majątku są jawne z założenia | kiedy i na co zostaje później wydana |
| withdraw, depozyt mostu, mint tokena lub początkowy mint rejestracji | kwota, odbiorca, time noty i czynnik zaślepiający r (zobowiązanie łańcuch oblicza sam) | każde późniejsze wydanie noty |
| wypłata invoke lub mint programu (chain 20) | kwota, składnik majątku, odbiorca i r; time noty pochodzi z pakietu | każde późniejsze wydanie noty |
| nota opłaty mostu (chain 20) | kwota, składnik majątku i time, wyprowadzony czynnik zaślepiający, skarbiec genesis jako właściciel i brak koperty | każde późniejsze wydanie noty |
Wartość wchodzi do puli z jawną kwotą — mint z faucetu, depozyt genesis, withdraw walidatora, atestacja mostu i jego nota opłaty, mint tokena, wypłata z sejfu programu — a opuszcza ją jako jawna opłata lub spalenie. Wszystko pomiędzy jest notą.
Tabela pokazuje, co ukrywają dowody chain 20, i nie zawsze tak było. Na chain 14 i 15 sam dowód pakietu ujawniał więcej (przegląd zkVM z 2026-09-27, INT-2 / GV-1): bieżąca suma LogUp każdej tabeli była publikowana bez zaślepienia, a guest v1 pomijał dla fikcyjnego wejścia sprawdzenia Merkle i składnika majątku oraz rozgałęział się na każdym bicie ścieżki Merkle, więc każdy mógł odczytać z dowodu pakietu, które z czterech slotów wejściowych były prawdziwe, a zatem czy przenosił token, czy tylko RAND, oraz liczbę bitów równych 1 w indeksie każdego wydanego liścia. Zamknęły to dwie zmiany. Chain 16 przeszedł na zestaw ograniczeń 7, który zaślepia każdy terminal LogUp i ustala dla każdej zadeklarowanej tabeli dolną granicę 2^7 wierszy, oraz na guesta pakietu v2 bez rozgałęzień, w którym każdy slot, fikcyjny czy prawdziwy, wykonuje pełne sprawdzenie przynależności i składnika majątku tymi samymi instrukcjami. Guest v3, na którym działają chain 17–20, to relacja v2 z nk i solą w miejscu klucza wydawania, również bez rozgałęzień. Pakiety zatwierdzone na chain 14 i 15 zachowują to, co ujawniły. To, co dowód nadal pokazuje na chain 20, to jego nagłówek: dla pakietu jest to jeden stały kształt (tier 14, zadeklarowany limit gazu 20,479 w każdym pakiecie), a dla Call lub Invoke jest to tier, zadeklarowany limit gazu oraz to, czy i mniej więcej jak często wywołanie korzystało z prekompilacji keccak lub sha256 (Dowody).
Noty bez koperty
Większość not odnajduje się, otwierając ich koperty. Niektóre łańcuch oblicza z pól jawnych, a dla nich koperta jest albo nieobecna, albo niegodna zaufania.
- Noty opłat mostu (chain 20). W
bridge.feesłańcuch dołącza notę zUSD do skarbca genesis przy każdym depozycie i każdym spaleniu (Tokeny RPL). Nie ma ona koperty: magazyn indeksuje ją z pustą, zaadresowaną do nikogo, arand_getCompactBlocksirand_getCommitmentspokazują ją w ten sposób, przy depozycie po nocie depozytu, a przy spaleniu po czterech notach pakietu. Każde słowo noty jest jawne: kwota wynika z transakcji i stawki z genesis, składnik majątku itimez transakcji,from = 0, czynnik zaślepiający toblake3("rand-bridge-fee-r-1" ‖ mu)dla depozytu lubblake3("rand-bridge-burn-fee-r-1" ‖ txid)dla spalenia, a właścicielem jestrecipientz genesis. - Noty, których koperta niczego nie wiąże. Depozyt mostu, mint tokena, początkowy mint rejestracji oraz, na chain 20, wypłaty invoke i minty programów mają kopertę zapieczętowaną przez tego, kto złożył transakcję, ale łańcuch oblicza notę z jawnych pól akcji i nigdy nie sprawdza koperty względem niej. Nieostrożny lub wrogi nadawca może zapieczętować śmieci, więc odbiorca nie może na niej polegać.
Portfel rand odbudowuje więc te noty samodzielnie (wallet::rebuilt_notes_with), w jawnym przebiegu obok próbnego odszyfrowywania: dla każdej takiej transakcji wskazującej jego pk, a dla not opłat, gdy jego adres jest skarbcem, oblicza notę i zapisuje ją w liściu, z którego zobowiązaniem się zgadza. Odbudowana nota jest tylko kandydatem: węzeł, który kłamie o transakcji, stawce opłaty lub haszu, sprawia, że nota pozostaje nieodnaleziona, ale nigdy nie zalicza fałszywej. Od v0.6.8 nagłówki zwracane przez rand_getBlocks niosą transakcje depozytów, mintów i rejestracji w public_notes, a w łańcuchu z opłatami mostu także spalenia, z usuniętymi dowodami, pod ich prawdziwym identyfikatorem, więc przebieg czyta wyłącznie strony nagłówków. Invoke nie ma w public_notes w v0.6.8, więc wobec węzła v0.6.8 portfel znajduje wypłatę invoke tylko przez jego kopertę, którą pieczętuje wywołujący i której nic nie sprawdza. Gałąź main fullnode niesie tam także invoke, z usuniętymi dowodami, od d0bb73fb; jest to zmiana tylko po stronie węzła, jeszcze nie wdrożona na flotę chain 20, a portfel nie wymaga w związku z nią żadnej zmiany.
Skanowanie: jak portfel znajduje swoje noty
Węzeł odpowiada „oto całe drzewo”, nigdy „oto twoje saldo”. Portfel:
- pobiera liście stronami przez
rand_getCommitments(from_index, limit)(co najwyżej 1,000 na wywołanie) lub strumień blok po blokurand_getCompactBlocks(from_height, to_height)(co najwyżej 128 bloków na wywołanie); każda transakcja niosąca pakiet wymienia tam cztery noty i cztery nullifiery, a żaden czytelnik nie odróżni slotu fikcyjnego od prawdziwego; - próbnie odszyfrowuje każdą kopertę: jako odbiorca przez swój klucz dekapsulacji ML-KEM, jako nadawca przez
ovk; zachowuje noty, którychpkjest jego własnym; i odbudowuje noty z jawną kwotą na podstawie ich transakcji (wyżej); - oblicza nullifier każdej zachowanej noty i oznacza te, które występują w
rand_getNullifiers(from_height, limit); - sumuje pozostałe.
rand_getTreeInfo mówi, jak daleko sięga skanowanie: { "next_index", "root", "nullifiers" }. Do wydania portfel potrzebuje 32-poziomowej ścieżki Merkle każdego prawdziwego wejścia. Portfel rand utrzymuje własną kopię drzewa zobowiązań, budowaną podczas skanowania z tych samych stron rand_getCommitments, i sam oblicza każdą ścieżkę, więc jedyne pytanie o drzewo, jakie wysyłanie zadaje węzłowi, to rand_getAnchor, które nie wskazuje żadnego liścia. rand_getWitness(index) pozostaje w węźle dla innych klientów. Mówi ono węzłowi, który liść wywołujący zamierza wydać, co przed lokalnym drzewem było największym wyciekiem puli: klient, który musi go użyć, powinien uruchomić własny węzeł albo pytać o świadectwa, których nie potrzebuje, obok tych, których potrzebuje.
rand sync # scanned 41 leaves and 37 blocks; 1 notes, 1 unspent
rand notes # index, asset, amount, height, spent, pending, memo
rand history # notes this wallet created for others, opened through ovk
Notatka ma stan pending od rand send --no-wait do następnego sync, który zobaczy jej nullifier albo bloki po time + window (1,024 w chain 20, odczytywane z rand_getLimits.proof_window_blocks; 256 bez tego pola). Po tym czasie paczka nie może już zostać przyjęta, a notatka znów jest możliwa do wydania.