Usa questa pagina per costruire o verificare qualsiasi componente che legga il pool: un wallet che cerca le proprie note, un indexer che segue commitment e nullifier, oppure uno strumento che controlla ciò che una transazione pubblica. Il valore su questa chain è un insieme di note; la chain conserva i loro commitment, mai il loro contenuto.
Una nota
Una nota è { pk, from, amount, asset, time, r }: 28 parole da 32 bit, 112 byte, disposte come pk(8) from(8) amount_lo amount_hi asset time r(8). pk è il valore pubblico di nota del proprietario (derivato dalla chiave di visualizzazione), from quello del mittente, amount un u64 in unità (1 RAND = 10⁹ unità), asset un u32 (0 per RAND, altrimenti l’indice denso del registro dei token per un token RPL, bridged o nativo; vedi token RPL), time un’altezza di blocco a cui il mittente ha puntato, e r otto parole casuali di blinding.
Il testo in chiaro non compare mai on chain. Ciò che compare è:
- il suo commitment
cm = H(CM, words), un hash Poseidon2 delle 28 parole con tag di dominio 4, aggiunto come foglia dell’albero; - la sua busta (envelope), il testo in chiaro sigillato all’indirizzo del proprietario in modo che solo il proprietario, o il mittente tramite la propria chiave di visualizzazione in uscita, possa leggerlo. Dalla chain 18 (chain 20 inclusa) la busta contiene anche un memo cifrato. Alcune note che la chain calcola da campi pubblici non hanno alcuna busta (più avanti).
Spendere la nota pubblica il suo nullifier H(NF, nk, cm), un hash Poseidon2 con tag di dominio 3 della chiave di visualizzazione del proprietario e del commitment. Senza nk un nullifier non può essere collegato al suo commitment. L’insieme dei nullifier è ciò che impedisce la doppia spesa.
La busta
Envelope { kem_ct, to_receiver, to_sender, body }
| parte | byte | cos’è |
|---|---|---|
kem_ct | 1,088 | un’incapsulazione ML-KEM-768 verso l’indirizzo del destinatario |
to_receiver | 60 | la chiave per transazione TxKey avvolta con il segreto incapsulato |
to_sender | 60 | la stessa TxKey avvolta con la chiave di visualizzazione in uscita del mittente ovk |
body | 652 | la nota da 112 byte e un campo memo da 512 byte sotto TxKey (140 byte, la sola nota, fino alla chain 17) |
Ogni parte usa ChaCha20-Poly1305 con un nonce casuale di 12 byte anteposto e un tag di 16 byte, e i dati associati sono un tag (rand-envelope-receiver, rand-envelope-sender, rand-envelope-body) seguito dal commitment della nota, così una chiave non può aprire una busta che non ha sigillato e una nota aperta è legata alla foglia sotto cui si trova. Sono 1,860 byte di testo cifrato; i prefissi di lunghezza di bincode portano il totale a 1,892 sul wire. Il genesis della chain 20 imposta envelope_bytes: 1860, e il suo ledger rifiuta una busta di nota di qualsiasi altra lunghezza (envelope is 1348 bytes, this chain requires exactly 1860); non ne controlla nient’altro. Fino alla chain 17 la busta non aveva il campo memo e misurava 1,348 byte (1,380 sul wire), e il ledger ne limitava solo la dimensione a 2,048 byte, che è ancora la regola su una chain il cui genesis non ha envelope_bytes.
Il campo memo è len (u16 LE) ‖ testo UTF-8 ‖ padding di zeri, 512 byte, quindi al massimo 510 byte di testo. Ogni busta di nota sulla chain 20 lo contiene, che ci sia un memo o no: un output di resto, un output dummy e un pagamento senza memo vengono riempiti allo stesso modo, e nulla on chain distingue un memo, la sua lunghezza o la sua assenza. Il memo non fa parte della nota, del commitment né di alcuna prova. È leggibile esattamente da chi può già leggere la nota: il proprietario, il mittente tramite ovk, o chiunque riceva la TxKey di quell’output. Un campo memo malformato (lunghezza errata, UTF-8 non valido, padding diverso da zero) si apre come assenza di memo e non costa mai al proprietario la nota. Un memo è un testo di chiunque, dato che chiunque può inviare una nota di polvere (dust) a un indirizzo pubblico: mostralo come non attendibile, con i caratteri di controllo e di formato Unicode sostituiti, come fa il wallet rand. rand send --memo ne sigilla uno nell’output del pagamento; rand notes --memo e rand history --memo lo mostrano.
L’albero, le ancore e le finestre
I commitment vengono aggiunti a un unico albero Poseidon2 di profondità 32, dal più vecchio al più recente, e non vengono mai rimossi. La foglia i è la i-esima nota mai creata dalla chain; le note di deposito del genesis sono le prime foglie. Un bundle aggiunge quattro foglie e quattro nullifier qualunque cosa sposti, dummy inclusi. La foglia vuota è tutta zeri e un nodo è H(NODE, left, right) (tag di dominio 7). Un nodo conserva solo la frontiera, quindi lo stato di consenso non contiene mai le foglie; rand_getWitness ricostruisce l’albero completo dalle foglie memorizzate su richiesta, ed è per questo la lettura più costosa che un nodo serva.
Un bundle dimostra l’appartenenza dei suoi input rispetto a un’ancora (anchor), una radice dell’albero. Due regole mantengono onesta un’ancora:
| costante | valore | regola |
|---|---|---|
ANCHOR_WINDOW | 256 blocchi; 1,024 sulla chain 20 | l’ancora deve essere una delle ultime window radici di fine blocco; una radice che l’albero attraversa a metà blocco non è mai un’ancora |
TIME_WINDOW | 256 blocchi; 1,024 sulla chain 20 | il time del bundle deve essere compreso in [height − window, height] |
Entrambe le finestre sono di 256 blocchi, a meno che il genesis non imposti proof_window_blocks (v0.6.8, da 256 a 4,096), che le sostituisce entrambe insieme. La chain 20 imposta 1,024; le chain da 14 a 19 non avevano il campo.
Le finestre esistono perché generare una prova richiede un tempo reale: la prova di un bundle di tier-14 misura circa 100 secondi su un laptop (e dalla chain 17 il wallet produce una seconda prova di autorizzazione di tier-10, circa 7 secondi). 256 blocchi corrispondevano a circa cinque minuti con i blocchi da 1.17 s della chain 18, e una prova delegata su un prover lento è stata rifiutata a 294 s; i 1,024 blocchi della chain 20 sono circa 23 minuti a 1.34 s, il valore riportato dall’explorer. Un wallet che perde la gara riceve anchor is not one of the last 1024 roots e rigenera la prova. rand_getAnchor senza parametri restituisce la radice di fine blocco della testa della chain, l’unica ancora su cui un prover dovrebbe costruire.
Il bundle
Ogni transazione che sposta valore contiene esattamente un bundle a 4 input e 4 output con asset nascosto (dalla chain 14; fino alla chain 13 era 2-in-2-out), dimostrato dal guest zkVM ad asset nascosto il cui digest hc_bundle è fissato nel file genesis (60af094acfe65d85fdb18fb3d06cf9085dcf28c96e59e87f1ee527226e6e3fce, guest del bundle v3, sulle chain da 17 a 20; era 83d3a3704a1fcdb9bae7136c0a947ffa34bd53f055388395ed705fe8cacd0ef8, guest v1, sulle chain 14 e 15):
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
Gli slot 0–1, in ingresso e in uscita, portano un asset privato A; gli slot 2–3 portano sempre RAND. In un pagamento solo RAND gli slot 0–1 sono dummy e il valore e la commissione si muovono attraverso gli slot 2–3. In un trasferimento di token, il token si muove attraverso gli slot 0–1 e la commissione in RAND è pagata dagli slot 2–3 della stessa prova. A è una parola di witness della prova e non compare in alcun campo pubblico, quindi il trasferimento di qualsiasi token RPL è Action::None con burn_a = burn_r = burn_asset = 0, campo per campo della stessa forma di un pagamento in RAND.
La prova dichiara che ogni input reale è una foglia sotto anchor e porta l’asset del proprio slot, che i quattro nullifier sono quelli pubblicati, che ogni gruppo è in equilibrio (in0 + in1 = out0 + out1 + burn_a nell’asset privato, in2 + in3 = out2 + out3 + fee + burn_r in RAND), e che chi spende possiede le chiavi — senza rivelare quali foglie, quali importi, quale asset, né chi. I tre campi burn sono l’unica dichiarazione pubblica del bundle sul valore che esce dal pool: burn_a e burn_asset sono diversi da zero solo in un TokenBurn o in un BridgeBurn, dove coincidono con l’importo e l’asset dell’azione; burn_r è RAND bruciato, diverso da zero solo in un Bond (lo stake) e in un RegisterAggregator (la garanzia). La commissione è sempre in RAND.
Dalla chain 17 l’autorità di spesa è divisa su due prove (autorizzazione divisa). Il guest del bundle v3 prende la chiave di visualizzazione nk e un salt là dove i guest precedenti prendevano la chiave di spesa: deriva pk_self = H(PK, nk), richiede che ogni input sia di sua proprietà e inserisce c = H(AUTH, nk ‖ salt) nel digest che pubblica. Un secondo guest, il guest di autorizzazione (genesis hc_auth, 1e4e347f44cf86750b30a9a4bdf9ec9256efe353d4ff8017451eca7d195639c1), prende la chiave di spesa sk e lo stesso salt, deriva nk = H(NK, sk) e pubblica lo stesso c. Il bundle porta c come auth_commit e la prova di tier-10 del guest di autorizzazione come auth_proof, e il ledger richiede che entrambe le prove si verifichino rispetto allo stesso binding di transazione e che i due valori di c coincidano. Un prover che possiede nk e il salt può quindi produrre la prova del bundle ma non quella di autorizzazione, ed è su questo che si fonda la prova delegata. Il salt è di 256 bit casuali freschi per ogni bundle, quindi auth_commit è pubblico e non collega nulla. Un wallet che dimostra per conto proprio produce anch’esso entrambe le prove, così una transazione dimostrata in proprio e una delegata appaiono uguali on chain.
La forma è fissa, quindi gli slot sono spesso fittizi: un input fittizio è una nota di valore zero appartenente a chi spende (il suo nullifier viene comunque pubblicato), un output fittizio è una nota di importo zero, e l’output di resto è presente anche quando il resto è zero. Una transazione che avesse un aspetto diverso in assenza di resto rivelerebbe che il resto non c’era. Una conseguenza per i wallet: ciascuno dei due gruppi di un bundle spende al massimo due note. Un pagamento in RAND, un bond, un deploy o una chiamata spende al massimo due note RAND; un trasferimento o un burn di token spende al massimo due note del token e al massimo due note RAND per la commissione. Il valore distribuito su tre o più note in un solo gruppo va prima consolidato, con un invio al proprio indirizzo.
Dalla chain 14 la prova è inoltre legata alla propria transazione: viene prodotta e verificata su un hash dell’intera transazione con le prove azzerate, perciò non può essere copiata su un’altra azione, su altre envelope o su un’altra chain; sulla chain 20 l’hash copre anche il genesis hash, quindi nemmeno su una chain che condivide lo stesso chain id (Transazioni).
Che cosa è pubblico e che cosa è nascosto
| pubblico sulla chain | nascosto | |
|---|---|---|
| un trasferimento, di RAND o di qualsiasi token RPL | anchor, tutti e quattro i nullifier, tutti e quattro i commitment, commissione, burn_a = burn_r = burn_asset = 0, time, quattro testi cifrati delle envelope, la prova, auth_commit e la prova di autorizzazione | chi lo ha inviato, chi viene pagato, l’importo, il memo, quale asset si è mosso, il resto, quali foglie sono state spese, quali slot erano fittizi |
| un burn di token o un burn di bridge | tutto ciò che pubblica un trasferimento, con burn_asset e burn_a pari all’asset e all’importo dell’azione (e, per un burn di bridge, la commissione del relayer, il token di backing e la destinazione) | quali note sono state bruciate e chi le ha bruciate |
| un mint dal faucet | il commitment, l’envelope, l’importo, il validator che ha eseguito il mint e, dalla chain 14, l’apertura della nota (pk, time, r), da cui l’ammissione ricalcola il commitment | ogni spesa successiva della nota |
| un deposito genesis | { cm, envelope, amount, opening: { pk, time, r } } nel file genesis, con asset nell’apertura per una nota di un token in elenco (il genesis della chain 20 contiene due note zUSD): il pk del proprietario, l’importo e l’asset sono pubblici per scelta progettuale | quando e in che cosa viene poi spesa |
| un withdraw, un deposito di bridge, un mint di token o il mint iniziale di una registrazione | l’importo, il destinatario, il time della nota e il blinding r (il commitment lo calcola la chain stessa) | ogni spesa successiva della nota |
| il payout di un invoke o il mint di un programma (chain 20) | l’importo, l’asset, il destinatario e r; il time della nota è quello del bundle | ogni spesa successiva della nota |
| una nota di commissione del bridge (chain 20) | l’importo, l’asset e il time, un blinding derivato, la treasury genesis come proprietario e nessuna envelope | ogni spesa successiva della nota |
Il valore entra nel pool con un importo pubblico (un mint dal faucet, un deposito genesis, il withdraw di un validator, un’attestazione del bridge e la sua nota di commissione, un mint di token, il payout di un vault di programma) ed esce come commissione o burn pubblici. Tutto ciò che sta in mezzo è una nota.
La tabella descrive ciò che le prove della chain 20 nascondono, e non è sempre stato così. Sulle chain 14 e 15 la prova del bundle stessa rivelava di più (la revisione della zkVM del 2026-09-27, INT-2 / GV-1): il totale parziale LogUp di ogni tabella veniva pubblicato senza blinding, e il guest v1 saltava i controlli di Merkle e dell’asset per un input fittizio e si ramificava su ogni bit del percorso di Merkle, così chiunque poteva leggere da una prova di bundle quali dei quattro slot di input fossero reali, e quindi se muovesse un token o solo RAND, e il numero di bit a 1 di ciascun indice di foglia speso. Due modifiche hanno chiuso la falla. La chain 16 è passata al constraint set 7, che applica il blinding a ogni terminale LogUp e porta ogni tabella dichiarata ad almeno 2^7 righe, e al guest di bundle v2 privo di ramificazioni, in cui ogni slot, fittizio o reale, esegue il controllo completo di appartenenza e di asset con le stesse istruzioni. Il guest v3, eseguito dalle chain dalla 17 alla 20, è la relazione del v2 con nk e il salt al posto della chiave di spesa, ed è anch’esso privo di ramificazioni. I bundle sottoposti a commit sulle chain 14 e 15 conservano ciò che hanno rivelato. Ciò che una prova mostra ancora sulla chain 20 è la sua intestazione: per un bundle è un’unica forma fissa (tier 14, un gas limit dichiarato di 20,479 su ogni bundle), e per una Call o un Invoke sono il tier, il gas limit dichiarato e se, e all’incirca quante volte, la chiamata ha usato il precompilato keccak o sha256 (Prove).
Note senza envelope
La maggior parte delle note si trova aprendo la propria envelope. Alcune sono calcolate dalla chain a partire da campi pubblici, e per queste l’envelope è assente oppure non è affidabile.
- Note di commissione del bridge (chain 20). Con
bridge.feesla chain accoda una nota zUSD alla treasury genesis a ogni deposito e a ogni burn (token RPL). Non ha envelope: lo storage la indicizza con una envelope vuota, sigillata per nessuno, erand_getCompactBlockserand_getCommitmentsla mostrano così, quella di un deposito dopo la nota di deposito e quella di un burn dopo le quattro del bundle. Ogni parola della nota è pubblica: l’importo dalla transazione e dal tasso genesis, l’asset e iltimedalla transazione,from = 0, il blindingblake3("rand-bridge-fee-r-1" ‖ mu)per un deposito oblake3("rand-bridge-burn-fee-r-1" ‖ txid)per un burn, e come proprietario ilrecipientgenesis. - Note la cui envelope non vincola nulla. Un deposito di bridge, un mint di token, il mint iniziale di una registrazione e, sulla chain 20, i payout e i mint di programma di un invoke portano una envelope sigillata da chi ha inviato la transazione, ma la chain calcola la nota dai campi pubblici dell’azione e non controlla mai l’envelope rispetto a essa. Un mittente negligente od ostile può sigillare dati senza senso, perciò un destinatario non deve farvi affidamento.
Il wallet rand quindi ricostruisce da sé queste note (wallet::rebuilt_notes_with), in un passaggio pubblico accanto alla decifratura per tentativi: per ogni transazione di questo tipo che nomina il proprio pk, e per le note di commissione quando il suo indirizzo è la treasury, calcola la nota e la registra nella foglia il cui hash corrisponde al suo commitment. Una nota ricostruita è solo una candidata: un nodo che mente su una transazione, sul tasso di commissione o su un hash fa sì che una nota non venga trovata, non che ne venga accreditata una. Dalla v0.6.8 le intestazioni restituite da rand_getBlocks portano le transazioni di deposito, mint e registrazione in public_notes, e su una chain con commissioni di bridge anche i burn, con le prove rimosse, sotto il loro id reale, così il passaggio legge solo pagine di intestazioni. Gli invoke non sono in public_notes nella v0.6.8, quindi contro un nodo v0.6.8 il wallet trova il payout di un invoke solo attraverso la sua envelope, che sigilla chi invoca e che nulla verifica. Il main del fullnode porta lì anche gli invoke, con le prove rimosse, dal commit d0bb73fb; è una modifica solo del nodo, non ancora distribuita sulla flotta della chain 20, e il wallet non richiede alcuna modifica.
Scansione: come un wallet trova le proprie note
Un nodo risponde “ecco l’intero albero”, mai “ecco il tuo saldo”. Un wallet:
- scorre le foglie a pagine con
rand_getCommitments(from_index, limit)(al massimo 1,000 per chiamata) oppure con il flusso per bloccorand_getCompactBlocks(from_height, to_height)(al massimo 128 blocchi per chiamata); ogni transazione che trasporta un bundle elenca lì quattro note e quattro nullifier, e nessun lettore può distinguere uno slot fittizio da uno reale; - decifra per tentativi ogni envelope: come destinatario tramite la propria chiave di decapsulazione ML-KEM, come mittente tramite
ovk; conserva le note il cuipkè il proprio; e ricostruisce dalle loro transazioni le note con importo pubblico (sopra); - calcola il nullifier di ciascuna nota conservata e contrassegna quelle che compaiono in
rand_getNullifiers(from_height, limit); - somma le restanti.
rand_getTreeInfo indica fin dove occorre scansionare: { "next_index", "root", "nullifiers" }. Per una spesa, il wallet ha bisogno del percorso di Merkle a 32 livelli di ogni input reale. Il wallet rand mantiene una propria copia dell’albero dei commitment, costruita durante la scansione dalle stesse pagine di rand_getCommitments, e calcola da sé ogni percorso, così l’unica domanda sull’albero che un invio pone al nodo è rand_getAnchor, che non nomina alcuna foglia. rand_getWitness(index) resta sul nodo per altri client. Dice al nodo quale foglia il chiamante sta per spendere, ed era la più grande fuga di informazioni del pool prima dell’albero locale: un client che deve usarlo dovrebbe eseguire un proprio nodo, oppure richiedere anche testimoni di cui non ha bisogno insieme a quelli che gli servono.
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
Una nota è pending tra rand send --no-wait e il successivo sync che vede il suo nullifier oppure blocchi oltre time + window (1.024 sulla chain 20, letto da rand_getLimits.proof_window_blocks; 256 in sua assenza); trascorso tale limite il bundle non potrà più essere ammesso e la nota torna spendibile.