← Tots els articles

Núm. 06Solucky

Una tirada de daus en què tots els validadors coincideixen

Solucky sorteja un bitllet guanyador al dia a Solana, on cada validador ha de calcular la mateixa resposta. Aquí expliquem d’on surt el seu atzar, quan es llegeix i qui encara el podria decantar.

Alex Colls Outumuro

Cofundador i CTO · · 6 min de lectura

Estampa 06, Suminagashi. Gotes i pintes sobre un bany, treta en construir el web.Passa-hi el punter per deixar caure tinta.

Solucky és una loteria diària a Solana. Cada dia UTC és una ronda: els jugadors compren bitllets numerats, i després de mitjanit se sorteja un bitllet que s’endú el pot. L’acabem de reconstruir a partir del seu prototip del 2022, i el que val la pena explicar és el sorteig. Una blockchain està feta perquè cada validador executi el mateix codi sobre les mateixes dades i arribi a la mateixa resposta. Una loteria necessita una resposta que ningú no pogués conèixer per endavant, ni decantar. Així és com Solucky n’obté una, i on es queda curta.

D’on no pot sortir l’atzar

Un programa no pot cridar un generador de nombres aleatoris. Cada validador torna a executar cada transacció i ha d’arribar al mateix estat, així que tota entrada d’un sorteig ha de ser una dada que tot el clúster ja comparteix. Els candidats evidents fallen per la mateixa raó: es poden conèixer abans que s’executi la transacció. El slot del sysvar Clock avança d’un en un, la seva marca de temps és pública, i una transacció normalment ha de portar un blockhash recent perquè l’acceptin. Un jugador podria simular el sorteig i deixar-lo passar només si guanya. Fins i tot SlotHashes, el sysvar que Solucky sí que llegeix, només guarda slots que ja han passat.

Hi ha dues respostes de manual. Amb commit–reveal, cada jugador publica el hash d’un secret, revela el secret quan es tanquen les vendes, i el sorteig els barreja tots. Ningú no pot decantar el resultat tot sol, però l’últim a revelar el veu primer i s’hi pot negar. Una funció aleatòria verificable, o VRF, passa el secret a un oracle: respon a una llavor amb una sortida i una prova, i qualsevol pot comprovar amb la seva clau pública que no hi havia cap altra sortida possible. L’oracle no pot triar el número. Només pot no respondre. Solucky no fa servir cap de les dues. Parteix el sorteig en el temps: primer fixa un slot que encara no existeix, i després llegeix el hash d’aquest slot quan ja existeix.

Un slot que encara no ha vist ningú

Els bitllets d’un dia només es poden comprar mentre el rellotge del clúster encara és en aquell dia, així que les vendes s’aturen soles a mitjanit UTC. Cada compra s’incorpora a un SHA-256 acumulat que la ronda guarda com a entropy: el valor anterior, el comprador, el número, el slot i l’adreça del bitllet. Després de mitjanit, qualsevol pot cridar close_round. Comprova el rellotge i fixa target_slot, vuit slots més endavant. En aquell moment les vendes ja s’han acabat, i el hash del slot triat encara no existeix.

Quan aquest slot ja ha passat, qualsevol pot cridar draw_round. Llegeix el hash del slot a SlotHashes, la llista dels últims 512 slots produïts i els seus hashes. Si el slot fixat es va saltar, pren el primer slot produït després. Llavors fa el hash de tot plegat i es queda els vuit primers bytes:

let (slot, slot_hash) = {
    let data = ctx.accounts.slot_hashes.try_borrow_data()?;
    find_slot_hash(&data, round.target_slot).ok_or(SoluckyError::RandomnessUnavailable)?
};

let randomness = hashv(&[
    &round.entropy,
    &slot_hash,
    &slot.to_le_bytes(),
    round_key.as_ref(),
])
.to_bytes();
let seed = u64::from_le_bytes(randomness[..8].try_into().unwrap());
let winning_index = (seed % u64::from(round.ticket_count)) as u32;

Tots els validadors llegeixen el mateix sysvar, així que tots arriben al mateix índex. El camp entropy no afegeix cap secret, perquè totes les seves entrades són públiques: lliga el resultat a la llista exacta de bitllets venuts, i l’atzar el posa el hash del slot. L’índex tria una compra, no un número, i el número guanyador es llegeix després en aquell bitllet. Així que sempre hi ha exactament un guanyador i mai un empat, i com que una ronda només existeix des que es ven el seu primer bitllet, no s’acumula res. Les teves probabilitats són els teus bitllets entre tots els bitllets venuts aquell dia. Després qualsevol pot enviar claim_prize, que paga el pot, menys una comissió de la casa com a molt del 10 %, al propietari d’aquell bitllet i a ningú més.

Vuit bytes, mòdul n

Aquesta darrera línia mereix una segona mirada. Reduir un nombre uniforme mòdul n només és exactament just quan n divideix el rang. Un cas petit mostra el biaix. Treu un byte, 256 valors, i redueix-lo mòdul 3:

256 = 3 × 85 + 1
P(0) = 86/256    P(1) = P(2) = 85/256

El bitllet 0 té 86/85 vegades les probabilitats dels altres, al voltant d’un 1,2 % més. En general, escrivim 2⁶⁴ = q·n + r: els r primers índexs reben un valor més que la resta, un avantatge d’1/q. Una ronda compta els seus bitllets en un u32, així que n és menor que 2³², q és més gran que 2³², i l’avantatge queda per sota de 2⁻³², menys d’una part entre quatre mil milions. Solucky no l’elimina amb mostreig per rebuig, que descartaria tota llavor igual o més gran que q·n i tornaria a fer el hash. Amb 64 bits, el biaix hi és, i és minúscul.

Qui encara pot decantar el sorteig

Aquest sorteig és just davant dels jugadors, no davant dels validadors. El hash d’un slot és un bank hash: depèn de les transaccions del bloc, i les tria el líder d’aquell slot. Un líder que tingui un bitllet pot calcular el resultat de cada bloc que podria publicar abans de publicar-ne un, o saltar-se el slot, i llavors el sorteig passa al següent, que també pot ser seu, perquè els líders produeixen quatre slots seguits. I com que el calendari de líders es coneix per endavant, un validador que enviï ell mateix close_round pot apuntar el slot fixat als seus propis slots.

El segon punt feble és el temps. Tan bon punt existeix el slot fixat, qualsevol pot calcular el guanyador abans de sortejar. SlotHashes només guarda 512 slots, i close_round només fixa un slot nou quan el sysvar demostra que l’anterior ja no hi és, així que ningú no pot llençar un resultat conegut abans d’hora. Però un jugador a qui no li agradi el resultat podria esperar i confiar que ningú no sortegi a temps. Davant d’això, sortejar és obert a tothom: el repositori inclou un crank, un script que tanca, sorteja i paga cada ronda acabada, i qualsevol jugador pot fer el mateix des de l’app.

Aquest sorteig és just davant dels jugadors, no davant dels validadors.

Tot el que va fer servir el sorteig queda al compte de la ronda: entropy, el slot llegit, el seu hash i el resultat. La pàgina de la ronda, a l’app, torna a calcular l’últim SHA-256 i el mòdul al teu navegador, i et diu si coincideixen. Això comprova l’aritmètica. El hash del slot el va llegir el programa del sysvar, i entropy es pot reconstruir a partir de les transaccions buy_ticket de la ronda, en ordre, tot i que el repositori encara no inclou cap eina per fer-ho. Per a pots amb diners reals, el nostre README ho diu clar: cal substituir aquest sorteig per un oracle VRF com Switchboard On-Demand o ORAO VRF. El sorteig és una sola funció. De moment, l’app pública funciona en devnet, amb SOL de prova.

Alex