Rand Protocol
Documentazione
Concetti fondamentali

Mempool

Che cosa fa un nodo con una transazione tra il momento in cui la riceve e quello in cui la inserisce in un blocco: l'ordine di ammissione, il tracciamento dei conflitti al posto dei nonce, i quattro worker di verifica, il contatore dei peer e la cache dei verdetti, come un proposer sceglie un blocco e perché un double-spend perdente scompare da solo.

Usa questa pagina per capire che cosa accade a una transazione dopo che rand_sendTransaction l’ha accettata e prima che un blocco la includa: perché è stata rifiutata, se lo è stata, perché può sparire dal pool senza mai fallire, in quale ordine si riempie un blocco e che cosa può e non può fare a un validator un flusso massiccio di proof non valide. Il mempool è crates/randprotocol-node/src/mempool.rs; le protezioni che lo precedono sono in admission.rs.

Nessun mittente, quindi nessun nonce

In una catena basata su account, un mempool ordina le transazioni di ciascun mittente per nonce. Una catena shielded non ha mittenti: un bundle non contiene alcun indirizzo né alcuna firma, e due bundle sono in relazione solo quando toccano la stessa nota. Non resta quindi alcun ordinamento per mittente da effettuare. Al posto della contabilità dei nonce subentra il tracciamento dei conflitti: il pool indicizza i nullifier e gli impegni (commitment) di ogni transazione in attesa e rifiuta un nuovo arrivo che ne rivendichi anche uno solo. Lo stesso vale per i pochi contatori per i quali il ledger accetta esattamente una transazione per ciascun valore: il nonce di registro di un validator (un Unbond o un Withdraw), il mint_nonce di un token (condiviso da TokenMint e SetAuthority), l’indice di registro a cui è vincolato un RegisterToken, e i pause_nonce e list_nonce del bridge. Ciascuno è uno slot che al massimo una transazione nel pool può occupare. Di due transazioni di questo tipo al massimo una potrebbe mai essere inclusa, e trasportare l’altra costerebbe al proposer spazio nel blocco e a ogni nodo una verifica di proof a ogni ciclo di gossip.

Il pool contiene al massimo 10.000 transazioni (Mempool::new(10_000) in node.rs) e, a partire dalla v0.6.8, al massimo i byte di otto blocchi: MAX_POOL_BLOCKS = 8 volte il max_block_bytes della catena, ossia 160 MiB sulla chain 20 (audit v6, CH-7). In precedenza era limitato solo il numero, mentre una singola transazione con proof pesa da circa 1,3 MB a oltre 12 MB. Ciascuna viene validata rispetto al ledger alla punta dell’albero del consenso, non all’ultimo blocco finalizzato: lo stato su cui un proposer costruirebbe davvero.

Ammissione: prima i controlli economici, poi quelli costosi

Una transazione che arriva via RPC o gossip attraversa questi passaggi in ordine, e il primo rifiuto è l’errore che si vede:

  1. Duplicato. L’hash è già nel pool: already in mempool.
  2. Conflitto. Un nullifier, un impegno o uno degli slot di nonce e indice descritti sopra è già rivendicato da una transazione in attesa: conflicts with a pending transaction over <hex>. Per uno slot di token l’esadecimale è una chiave di visualizzazione: una parola di etichetta (tokn per un mint_nonce, tidx per un indice di registrazione), poi l’indice e il nonce. Un’attestazione del bridge su cui due relayer sono entrati in competizione si distingue dal suo digest: conflicts with a pending transaction over bridge attestation <hash>.
  3. Pieno. mempool full. Il controllo di capacità è l’ultimo dei tre. Al limite di byte, un nuovo arrivo può prendere il posto di voci già nel pool che si collocano strettamente al di sotto di esso — per commissione sopra la soglia corrente di ciascuna per KiB, poi per commissione — espellendo prima le più economiche finché non c’è spazio; se non bastano, o se pareggia soltanto, a essere rifiutato è lui. Le azioni di governance sono fuori da entrambi i limiti e non vengono mai espulse: le quattro azioni di governance del bridge PauseMints, UnpauseMints, RegisterBridgedToken, ListBacking e, dalla v0.6.8, RotatePqGuardians, RotatePauseKey, le rispettive forme …V2, CancelRotation, AdmitValidator e SlashEquivocation. I loro byte e le loro voci contano comunque per i limiti. PauseMints è il freno in caso di compromissione della chiave di un guardian: senza bundle, senza commissione, e l’unica transazione che deve raggiungere un blocco mentre un attaccante sta coniando, quindi un pool pieno non deve tenerla fuori. L’esenzione è limitata dal passaggio dei conflitti: pause_nonce e list_nonce ammettono ciascuno una sola transazione nel pool alla volta, per cui le quattro azioni del bridge possono aggiungere al massimo due voci oltre il limite; le altre consumano ciascuna un nonce o un quorum proprio.
  4. La parte economica di Ledger::validate. Tutto ciò che può diventare obsoleto tra l’ammissione e l’inclusione e non richiede alcuna proof: l’anchor è una delle ultime 1.024 radici sulla chain 20 (proof_window_blocks del genesis; 256 in sua assenza), time rientra nella finestra, i nullifier non sono spesi, gli impegni sono assenti, il digest di un’attestazione non è rivendicato, il nonce di registro di un’azione di staking è quello corrente, il mint_nonce o l’indice di registrazione di un’azione su token è quello a cui è giunto il registro (wrong mint nonce, wrong token index una volta che la catena è andata oltre), il nonce di una pausa o di una quotazione è quello corrente del bridge. Anche gli impegni che una transazione crea vengono calcolati qui, compresa la nota che il ledger stesso deriva per un Withdraw, un BridgeAttest o un pagamento a un aggregator, che non è presente sulla rete ma non deve nemmeno collidere. I limiti di dimensione vengono controllati prima di decodificare qualsiasi cosa: una proof oltre il max_proof_bytes della catena (2 MiB di default, 4 MiB sulla chain 20), un’attestazione oltre 16 KiB o una transazione più grande di un blocco (max_block_bytes: 4 MiB di default, 20 MiB sulla chain 20) viene rifiutata subito, così un blob sovradimensionato non ottiene alcun lavoro. Una proof di bundle nella forma con marcatore sigillato (potata) viene rifiutata allo stesso passaggio al di fuori della sincronizzazione in forma sigillata, come PrunedFormOutsideSync. Poi, sempre senza verificare nulla, il pool legge gli header delle proof. Vengono prima tre criteri: una chiamata la cui tabella di input, keccak o sha256 ha meno di 2^7 righe non viene inserita nel pool (CallRevealsPrivateInputs), né lo viene un deploy che nessuna chiamata potrebbe contenere (ProgramUncallable), né una transazione la cui proof di bundle o di chiamata ha un campo di header che il prover onesto non scriverebbe (NonCanonicalProof). Gli ultimi due sono anche regole del ledger sulla chain 20, sotto hardening_v6. Dalla v0.6.8 un header il cui grado dichiarato supera il limite della tabella più alta viene rifiutato come NonCanonicalProof prima che il controllo lo percorra (audit v6, ZKV-4), e un panic in qualsiasi punto di questo pre-screening è un rifiuto di quella transazione (VerifierPanicked), non un nodo morto. Poi la soglia della commissione: sulla chain 20 il pool decodifica il limite di gas dichiarato dalla proof di una chiamata o di un invoke e rifiuta una commissione inferiore a BUNDLE_BASE + gas_price × limit + byte_price × ⌈bytes/1024⌉ ai prezzi della punta, più la commissione di cella di un invoke, come FeeTooLow (Commissioni). È la regola del blocco stesso, quindi il pool esige esattamente ciò che esige un blocco.
  5. La parte costosa, fuori dal ciclo del consenso. La transazione viene messa in coda, con profondità massima 64, per uno di quattro worker di verifica. Un worker esegue il resto di Ledger::validate su un task bloccante: il digest del bundle, il limite di gas dichiarato dalla proof del bundle (sulla chain 20 esattamente 20.479, altrimenti BundleGasLimit), la proof del bundle e, dalla chain 17, la sua proof di autorizzazione rispetto al binding della transazione, e per una chiamata la sua proof e la sua soglia di commissione. Valida rispetto a uno snapshot della punta clonato in modo lazy, al massimo una volta per ogni cambio di punta e solo mentre qualcosa è in attesa. Una transazione ricevuta via gossip che trova la coda piena viene scartata, né verificata né inoltrata, anziché bloccare il ciclo che vota; il successivo ciclo di gossip del peer la porta di nuovo.
  6. Ricontrollo e inserimento nel pool. insert_verified esegue di nuovo il passaggio 4 rispetto alla punta su cui la transazione viene effettivamente inserita, perché la punta può essersi spostata mentre la proof veniva verificata, quindi la ammette e, per il traffico ricevuto via gossip, la inoltra.

I passaggi da 1 a 4 sono Mempool::precheck; il passaggio 5 è l’unico che verifica una STARK. Poiché quel costo è pagato su un worker, un flusso di proof non valide occupa quattro thread e mai il ciclo del consenso. Transazioni e azioni elenca l’ordine di ammissione proprio del ledger, che i passaggi 4 e 5 insieme eseguono.

Due protezioni e una cache per il gossip

Prima dell’ammissione, due elementi si applicano solo alle transazioni che arrivano dai peer; le sottomissioni via RPC li saltano, perché quella porta è dell’operatore stesso ed è già limitata dal limite sul corpo della richiesta. L’unico percorso RPC soggetto a misurazione è il faucet, più avanti.

  • Un contatore di byte per peer. Dalla v0.6.8 ogni frame di gossip viene addebitato al budget di byte di chi lo inoltra ancora prima di essere decodificato: una raffica di otto e una ricarica di due frame della dimensione massima di trasmissione al secondo; oltre questo limite il frame viene scartato senza essere decodificato (audit v6, GOSSIP-1). L’id della transazione, che è l’hash dell’intera prova, viene calcolato solo entro la quota di chi inoltra.
  • Un contatore di transazioni per peer. Un token bucket per ogni peer che inoltra, raffica di 16, ricarica di 4 al secondo (PEER_TX_BURST, PEER_TX_PER_SEC). Misura il peer che ha inoltrato la transazione, non il suo autore, che una chain schermata non può identificare.
  • Una cache dei verdetti. Una transazione che questo nodo ha già rifiutato per un motivo che dipende soltanto dai suoi byte, come una prova non valida, una firma non valida o una parte di dimensioni eccessive, viene rifiutata di nuovo da una cache FIFO di 8.192 voci (REFUSED_CACHE_ENTRIES) invece di essere verificata una seconda volta. Un verdetto che riguarda lo stato di questo nodo, un’ancora sconosciuta o un nullifier già speso, non viene mai messo in cache: un nodo in ritardo di un blocco finirebbe altrimenti per avvelenarsi contro transazioni che stanno per diventare valide. L’elenco è una allowlist, quindi un nuovo errore è stato finché qualcuno non decide diversamente. Queste voci mostrano dove passa il confine:
    • BadPauseSignature (the pause signature does not verify) dalla v0.6.8 non è permanente (audit v6, BRG-18). Verifica la firma rispetto alla chiave di pausa corrente della chain, che le regole del bridge v2 possono ruotare, quindi è stato: un nodo in ritardo di un blocco rispetto a una rotazione rifiuterebbe altrimenti i PauseMints del nuovo titolare fino al riavvio. Un indice di co-firma PQ fuori intervallo è uscito dalla cache per lo stesso motivo. Fino alla v0.6.7, sulle chain 18 e 19, entrambi venivano messi in cache come permanenti. Una pausa non valida ripetuta costa ora una verifica Dilithium2 a ogni consegna, limitata dal contatore per peer.
    • PrunedFormOutsideSync non è volutamente permanente. La forma marcatore di un bundle sigillato ha come hash l’id della transazione grezza stessa, e la cache usa quell’id come chiave: mettere in cache il rifiuto permetterebbe a qualunque peer di gossip di censurare la transazione onesta in tutta la rete.
    • Il limite di gas dichiarato da una prova dipende dai suoi stessi byte confrontati con una costante del genesis, quindi BundleGasLimit e AuthGasLimit sono permanenti. FeeTooLow non lo è: sulla chain 20 la soglia si muove con i prezzi. Neppure le tre politiche sugli header del passo 4 vengono mai messe in cache: ciascuna è conoscenza di questa build, che una build successiva può spostare.
    • Su una chain con una allowlist del faucet (chain 20), FaucetRecipientNotAllowed è permanente: confronta il destinatario del mint con una costante del genesis. FaucetBudgetExhausted non lo è: è stato, e lo stesso mint viene ammesso di nuovo nell’epoca successiva.
    • Anche il BadSignature di un’azione su token non è permanente, a differenza di quello di un’azione di staking: l’autorità di mint di un token è stato, un SetAuthority può cederla a un’altra chiave, e gli stessi byte possono essere rifiutati prima che quella rotazione venga registrata e accettati dopo. I rifiuti a livello di byte sui token (un nome o simbolo non valido, troppi decimali, un importo zero, l’indice di token 0) vengono messi in cache; lo stesso vale per un campo di burn che l’azione non può impostare, per un mint il cui commitment non si apre sul suo importo e per un deposito dal bridge il cui blinding non è quello derivato.

Il limite del faucet

rand_mint è senza commissioni e costa una transazione nel pool a ogni chiamata, e sulla chain 20 il faucet è attivo con un bridge che detiene valore a suo sostegno. Il genesis limita ciò che può fare: un mint paga soltanto i 16 wallet in allowlist e al massimo 10.000 RAND per epoca di 1.000 blocchi (Staking), e può firmarne uno soltanto una delle 18 chiavi nell’elenco staking.faucet_minters del genesis (MinterNotAllowed, mai messo in cache: una regola del ledger dalla chain 16, una politica del nodo sulla chain 15, dove l’elenco coincideva con i validatori del genesis). In aggiunta, ogni nodo misura il proprio faucet: otto mint consecutivi, con ricarica di uno al secondo (FAUCET_MINT_BURST, FAUCET_MINT_PER_SEC). Oltre questo limite la chiamata risponde faucet is rate limited on this node (8 mints back to back, refilling at 1/s); try again shortly. Il limite è per processo del nodo, non per chiamante, perché la porta RPC non ha un’identità del peer da misurare e la risorsa protetta è il pool di questo nodo. Insieme all’esenzione di capacità illustrata sopra, un’inondazione di richieste al faucet non può riempire il pool prima di un PauseMints.

Gossipsub funziona con validazione a livello applicativo, quindi una transazione viene inoltrata agli altri peer solo dopo essere stata verificata qui. Un peer che inonda la rete di transazioni non valide viene limitato dal contatore, ottiene risposta dalla cache e non fa pagare nulla al ciclo di consenso.

Costruire un blocco

Quando un validatore guida una vista, chiede al pool i candidati. candidates_within conserva solo le transazioni che si applicano ancora al ledger su cui sta costruendo, rieseguendo la metà economica dell’ammissione: ancora in finestra, time ancora in finestra, nullifier non ancora spesi, commitment non ancora presenti. Scarta anche un’azione su token il cui mint_nonce o indice di registrazione la chain ha già superato, e una pausa o un listing con un nonce obsoleto. Dalla v0.6.8 ricalcola il prezzo di ogni call e invoke ai prezzi in vigore sulla punta su cui costruisce, a partire dal limite di gas memorizzato all’ammissione, ed esclude quelle la cui commissione è ora sotto la soglia; restano nel pool e vengono riproposte quando i prezzi scendono (audit v6, CH-9). Sulla chain 20 ordina poi le restanti prima la governance, poi per commissione oltre la soglia corrente, per KiB di transazione, poi per commissione, dalla più alta, con parità risolte dall’hash, così che ogni proponente onesto che costruisce sullo stesso pool scelga lo stesso blocco, e riempie fino al budget di 2.000 transazioni e al limite di blocco della chain (MAX_BLOCK_TXS; max_block_bytes, 4 MiB di default e 20 MiB sulla chain 20). Ciò che scarseggia in un blocco sono i byte, ed è per questo che il surplus si misura per KiB. Una chain senza sezione gas e un nodo senza politica di gas saltano quella chiave e ordinano per governance, commissione e hash, come faceva la chain 15. Governance per prima è l’altra metà della garanzia della pausa: un PauseMints non paga commissioni per progetto, perché deve funzionare da un wallet che non detiene RAND, quindi un ordinamento per sola commissione metterebbe il freno di emergenza dietro ogni transazione che paga.

Si applica un’ulteriore regola mentre una call o un invoke è pronto (audit v6, POOL-2; politica del nodo su ogni chain, non una regola di validità). Solo quelli pagano il prezzo per byte, e un trasferimento che paga poco più della sua base fissa li supera per KiB, quindi un flusso di trasferimenti potrebbe tenere una call pronta fuori da ogni blocco. Perciò le transazioni a commissione fissa occupano al massimo tre quarti dei byte del blocco in un primo passaggio; una volta collocate le call, lo spazio rimasto viene riempito con i trasferimenti trattenuti, nel loro ordine. Nessuno spazio di blocco va perso, e se nessuna call è pronta non cambia nulla (Fees).

Una transazione troppo grande per lo spazio rimasto viene saltata, non pone fine al blocco: una più piccola che la segue viaggia comunque. Un trasferimento sulla chain 18 pesa circa 2,85 MB, ossia una prova del bundle da 1,49 MB e una prova di autorizzazione da 1,36 MB, quindi il suo blocco da 20 MiB ne ha spazio per circa sette e un blocco predefinito da 4 MiB per uno. Sulla chain 15, a circa 1,43 MB per trasferimento, gli stessi 20 MiB ne contenevano quattordici. Sotto carico è la commissione a decidere quali (Fees).

Potatura

Dopo ogni commit, prune toglie dal pool esattamente ciò che candidates_within avrebbe saltato: le transazioni la cui nota è stata spesa da qualcun altro, il cui commitment esiste ora, il cui nonce o indice di registrazione la chain ha già superato, o la cui ancora o time è uscita dalla finestra (1.024 blocchi sulla chain 20, 256 fino alla chain 19). Un bundle che perde una corsa al double-spend scompare quindi da solo, senza mai produrre un errore per nessuno. L’accettazione da parte di rand_sendTransaction non è un commit: interroga rand_getTransaction finché non restituisce un blocco e, se la finestra delle ancore passa senza che ciò avvenga, ricostruisci il bundle su un’ancora nuova e invia di nuovo.

Osservarlo

rand_status riporta mempool_size, verify_queue (le transazioni in attesa di un worker) e refused_cache (le voci nella cache dei verdetti). Una verify_queue ferma a 64 significa che le prove arrivano più in fretta di quanto quattro worker riescano a verificarle, circa 16 ms ciascuna con una chiave in cache; un mempool_size che cresce mentre i blocchi restano pieni significa che a decidere l’inclusione è la commissione, non l’ordine di arrivo.

Cosa non c’è ancora

  • Nessuna sostituzione. I prezzi del gas e dei byte della chain 20 spostano ogni blocco il minimo di una chiamata, fino ai rispettivi massimali; il minimo di un trasferimento è fisso. Non esiste la sostituzione di una transazione in pool con un’altra a commissione più alta sulle stesse note, che la regola dei conflitti rifiuta. Una chiamata esclusa dal prezzo attende nel pool, non proposta, finché i prezzi non scendono o la sua finestra di ancoraggio non scade. Su v0.6.7 (chain 18 e 19) una chiamata in pool conservava il minimo con cui era stata ammessa, veniva ancora proposta dopo un aumento e falliva nell’applicazione di prova del proponente come FeeTooLow. Il documento sullo spazio dei blocchi del fullnode indica il mempool ordinato per commissione come il passo che precede un blocco più grande, e l’aggregazione a livello di blocco come ciò che alza il massimale per blocco.
  • Nessuna scadenza per età. Una transazione in pool esce solo per inclusione, per espulsione al raggiungimento del limite di byte, o per le finestre di ancoraggio e di tempo: 1.024 blocchi sulla chain 20, circa 23 minuti a 1,34 s per blocco, come riportato dall’explorer. Fino alla chain 19 le finestre erano di 256 blocchi, circa cinque minuti.
  • Nessuna RPC del mempool. Non esiste alcun metodo che elenchi le transazioni in sospeso; mempool_size in rand_status e il conteggio, i byte e l’età più antica in rand_getMempoolInfo sono le uniche viste, e l’explorer indicizza solo i blocchi confermati.

Ultima verifica rispetto al commit c0878c3f del fullnode (chain 21) il 2026-10-12 e fullnode v0.6.8 `c9c9bd3c` (chain 20): `crates/randprotocol-node/src/mempool.rs` (`MAX_POOL_BLOCKS`, `make_room`, `is_governance`, the call reservation in `candidates_within`), the commits for CH-7, CH-9, POOL-2, BRG-18, GOSSIP-1 and ZKV-4, and `rand_getLimits` on chain 20, fullnode v0.6.7 `86941a1`: `crates/randprotocol-node/src/mempool.rs`, `admission.rs`, `crates/randprotocol-core/src/ledger/mod.rs` (the gas floor), fullnode `90e7716`: `docs/architecture.md` §8, `docs/rpc.md` (changelog, the 2026-09-28 gas entries), fullnode `ed88241`: `docs/confidential.md` (transaction binding). Dove il codice e questa pagina non concordano, ha ragione il codice; segnalacelo.