/

On-chain selection

Audit mode picks the device set on-chain, using a Fisher-Yates shuffle seeded by the SlotHashes sysvar. The selection is a pure function of chain state, which means anyone can replay it.

The problem audit mode solves

In streaming mode, the coordinator picks which devices participate in a round. That's fine for UX-critical flows — the result is still cryptographically unbiasable because of commit-reveal — but it's not reproducible from chain data alone. An auditor who asks "why these four devices and not those other four?" gets the answer "coordinator internal state." Defensible, but not by construction.

Audit mode replaces coordinator authority with a deterministic algorithm run inside select_nodes. The input is on-chain; the output is on-chain; anyone can rerun it.

Seed source · SlotHashes

The program reads the most recent entry of Solana's SlotHashes sysvar at the slot the request lands. This sysvar is a ring-buffer of recent slot → hash mappings, maintained by the runtime. Its value at any given slot is:

  • Unpredictable — depends on all leader-produced blocks up to that slot; no-one knows in advance what will hash to what.
  • Public — any RPC node exposes it. Verifiers can fetch the same seed you used.
  • Non-grindable by the requester — the seed is chosen by the program at request time, not by the caller.

Why not use a commit-reveal for selection too?

We could, but it doubles the number of on-chain transactions per round — two commits, two reveals — for a property SlotHashes already gives us for free. The result randomness is still from commit-reveal; only who runs the round needs the cheaper property.

Fisher-Yates on-chain

The program keeps a canonical, ordered list of registered devices in the NodeRegistry account. Given the seed, it runs a partial Fisher-Yates — shuffle the first node_count positions — and emits that prefix as the selected set. The algorithm is uniform over all nCr(total, node_count)possible subsets given the seed.

"Partial" matters: we don't waste compute shuffling elements we'll never pick. For a 100-device registry with node_count = 4, the shuffle does exactly 4 swaps.

Reproducing the selection

Three inputs, one output. An auditor needs:

  • The registered-device list at the request slot (from NodeRegistry).
  • The SlotHashes entry for the request slot.
  • The node_count argument (from the request TX).

Feed those into the same Fisher-Yates and confirm the output matches the selected_devices recorded on the round account.

Reference implementation

Runnable TypeScript that mirrors the on-chain shuffle byte-for-byte:

reproduce-selection.ts
// Reproduce audit-mode device selection from chain data.
// Same algorithm the on-chain program runs — writing it here for verifiers.
import { createHash } from "node:crypto"

export function reproduceSelection(
  deviceList: string[],        // NodeRegistry-ordered device_ids
  seed: Uint8Array,            // 32 bytes from SlotHashes at request-slot
  nodeCount: number,           // the node_count passed to request_randomness_auto
): string[] {
  if (nodeCount > deviceList.length) {
    throw new Error("node_count exceeds registered devices")
  }

  // Work on a copy; Fisher-Yates shuffles in place.
  const arr = [...deviceList]
  const n = arr.length

  // Expand the 32-byte seed into a keystream as needed using SHA-256
  // in counter mode. This is what the program does — identical bytes out.
  let counter = 0n
  let buf = new Uint8Array(0)
  let offset = 0
  const nextU32 = (): number => {
    while (buf.length - offset < 4) {
      buf = sha256(concat(seed, u64le(counter)))
      counter += 1n
      offset = 0
    }
    const v =
      (buf[offset] | (buf[offset + 1] << 8) |
       (buf[offset + 2] << 16) | (buf[offset + 3] << 24)) >>> 0
    offset += 4
    return v
  }

  // Partial Fisher-Yates: only shuffle the first nodeCount positions.
  for (let i = 0; i < nodeCount; i++) {
    const j = i + (nextU32() % (n - i))
    const tmp = arr[i]
    arr[i] = arr[j]
    arr[j] = tmp
  }
  return arr.slice(0, nodeCount)
}

/* --- tiny helpers --- */
function sha256(b: Uint8Array): Uint8Array {
  return new Uint8Array(createHash("sha256").update(b).digest())
}
function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
  const out = new Uint8Array(a.length + b.length)
  out.set(a, 0); out.set(b, a.length)
  return out
}
function u64le(n: bigint): Uint8Array {
  const out = new Uint8Array(8)
  for (let i = 0; i < 8; i++) { out[i] = Number(n & 0xffn); n >>= 8n }
  return out
}

This is what auditors run

When a regulator asks "reproduce this round's device selection from chain data" — they point their own verifier at an RPC, fetch the three inputs above, and run code shaped like this. If the output matches, the selection is confirmed.

Gotchas

  • NodeRegistry mutations — if devices are added / removed between the request and the audit, use the registry snapshot from the request slot, not the current one. The program stores a registry version on each round for exactly this reason.
  • Seed encoding — the SlotHashes entry is 32 bytes. Pass it as raw bytes, not hex-encoded.
  • Counter length — we use u64 little-endian for the SHA-256 keystream counter. Tests should stress past 2^32 requests against a single seed to catch off-by-one bugs.