← Todos los artículos

N.º 06Solucky

Una tirada de dados en la que todos los validadores coinciden

Solucky sortea un boleto ganador al día en Solana, donde cada validador tiene que calcular la misma respuesta. Aquí contamos de dónde sale su azar, cuándo se lee y quién podría todavía inclinarlo.

Alex Colls Outumuro

Cofundador y CTO · · 6 min de lectura

Estampa 06, Suminagashi. Gotas y peines sobre un baño, sacada al construir el sitio.Pasa el puntero por encima para dejar caer tinta.

Solucky es una lotería diaria en Solana. Cada día UTC es una ronda: los jugadores compran boletos numerados, y después de medianoche se sortea un boleto que se lleva el bote. Acabamos de reconstruirla a partir de su prototipo de 2022, y lo que merece contarse es el sorteo. Una blockchain está hecha para que cada validador ejecute el mismo código sobre los mismos datos y llegue a la misma respuesta. Una lotería necesita una respuesta que nadie pudiera conocer de antemano, ni inclinar. Así es como Solucky la consigue, y dónde se queda corta.

De dónde no puede salir el azar

Un programa no puede llamar a un generador de números aleatorios. Cada validador vuelve a ejecutar cada transacción y tiene que llegar al mismo estado, así que toda entrada de un sorteo debe ser un dato que todo el clúster ya comparte. Los candidatos obvios fallan por la misma razón: se pueden conocer antes de que se ejecute la transacción. El slot del sysvar Clock avanza de uno en uno, su marca de tiempo es pública, y una transacción normalmente tiene que llevar un blockhash reciente para que la acepten. Un jugador podría simular el sorteo y dejarlo pasar solo si gana. Incluso SlotHashes, el sysvar que Solucky sí lee, solo guarda slots que ya han pasado.

Hay dos respuestas de manual. Con commit–reveal, cada jugador publica el hash de un secreto, revela el secreto cuando se cierran las ventas, y el sorteo los mezcla todos. Nadie puede inclinar el resultado por sí solo, pero el último en revelar lo ve primero y puede negarse. Una función aleatoria verificable, o VRF, pasa el secreto a un oráculo: responde a una semilla con una salida y una prueba, y cualquiera puede comprobar con su clave pública que no había otra salida posible. El oráculo no puede elegir el número. Solo puede no responder. Solucky no usa ninguna de las dos. Parte el sorteo en el tiempo: primero fija un slot que todavía no existe, y luego lee el hash de ese slot cuando ya existe.

Un slot que nadie ha visto todavía

Los boletos de un día solo se pueden comprar mientras el reloj del clúster sigue en ese día, así que las ventas se paran solas a medianoche UTC. Cada compra se incorpora a un SHA-256 acumulado que la ronda guarda como entropy: el valor anterior, el comprador, el número, el slot y la dirección del boleto. Después de medianoche, cualquiera puede llamar a close_round. Comprueba el reloj y fija target_slot, ocho slots más adelante. En ese momento las ventas ya han terminado, y el hash del slot elegido todavía no existe.

Cuando ese slot ya ha pasado, cualquiera puede llamar a draw_round. Lee el hash del slot en SlotHashes, la lista de los últimos 512 slots producidos y sus hashes. Si el slot fijado se saltó, toma el primer slot producido después. Luego lo hashea todo junto y se queda con los ocho primeros 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;

Todos los validadores leen el mismo sysvar, así que todos llegan al mismo índice. El campo entropy no añade ningún secreto, porque todas sus entradas son públicas: ata el resultado a la lista exacta de boletos vendidos, y el azar lo pone el hash del slot. El índice elige una compra, no un número, y el número ganador se lee después en ese boleto. Así que siempre hay exactamente un ganador y nunca un empate, y como una ronda solo existe desde que se vende su primer boleto, no se acumula nada. Tus probabilidades son tus boletos entre todos los boletos vendidos ese día. Después cualquiera puede enviar claim_prize, que paga el bote, menos una comisión de la casa de como mucho el 10 %, al dueño de ese boleto y a nadie más.

Ocho bytes, módulo n

Esa última línea merece una segunda mirada. Reducir un número uniforme módulo n solo es exactamente justo cuando n divide el rango. Un caso pequeño muestra el sesgo. Saca un byte, 256 valores, y redúcelo módulo 3:

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

El boleto 0 tiene 86/85 veces las probabilidades de los demás, alrededor de un 1,2 % más. En general, escribamos 2⁶⁴ = q·n + r: los r primeros índices reciben un valor más que el resto, una ventaja de 1/q. Una ronda cuenta sus boletos en un u32, así que n es menor que 2³², q es mayor que 2³², y la ventaja queda por debajo de 2⁻³², menos de una parte entre cuatro mil millones. Solucky no la elimina con muestreo por rechazo, que descartaría toda semilla igual o mayor que q·n y volvería a hashear. Con 64 bits, el sesgo existe, y es minúsculo.

Quién puede inclinar todavía el sorteo

Este sorteo es justo frente a los jugadores, no frente a los validadores. El hash de un slot es un bank hash: depende de las transacciones del bloque, y las elige el líder de ese slot. Un líder que tenga un boleto puede calcular el resultado de cada bloque que podría publicar antes de publicar uno, o saltarse el slot, y entonces el sorteo pasa al siguiente, que también puede ser suyo, porque los líderes producen cuatro slots seguidos. Y como el calendario de líderes se conoce de antemano, un validador que envíe él mismo close_round puede apuntar el slot fijado a sus propios slots.

El segundo punto débil es el tiempo. En cuanto existe el slot fijado, cualquiera puede calcular el ganador antes de sortear. SlotHashes solo guarda 512 slots, y close_round solo fija un slot nuevo cuando el sysvar demuestra que el anterior ha desaparecido para siempre, así que nadie puede descartar un resultado conocido antes de tiempo. Pero un jugador al que no le guste el resultado podría esperar y confiar en que nadie sortee a tiempo. Frente a eso, sortear está abierto a cualquiera: el repositorio incluye un crank, un script que cierra, sortea y paga cada ronda terminada, y cualquier jugador puede hacer lo mismo desde la app.

Este sorteo es justo frente a los jugadores, no frente a los validadores.

Todo lo que usó el sorteo queda en la cuenta de la ronda: entropy, el slot leído, su hash y el resultado. La página de la ronda, en la app, vuelve a calcular el último SHA-256 y el módulo en tu navegador, y te dice si coinciden. Eso comprueba la aritmética. El hash del slot lo leyó el programa del sysvar, y entropy se puede reconstruir a partir de las transacciones buy_ticket de la ronda, en orden, aunque el repositorio todavía no incluye ninguna herramienta para hacerlo. Para botes con dinero real, nuestro README lo dice claro: hay que sustituir este sorteo por un oráculo VRF como Switchboard On-Demand u ORAO VRF. El sorteo es una sola función. De momento, la app pública funciona en devnet, con SOL de prueba.

Alex