When a commitment is submitted to multiple servers in parallel, each server independently records a registered_at timestamp and computes the selection using nextRoundAfter(registered_at) — the first drand round strictly after its own timestamp.
drand quicknet ticks every 3 seconds. If one server receives the commitment slightly before a tick and another slightly after, they use different beacon rounds for selection, producing different reveal decisions.
cloudflare-1: registered 16:02:32.434Z → arrival round 25892262 → selection round 25892263
google-cloud-1: registered 16:02:32.270Z → arrival round 25892262 → selection round 25892263
supabase-1: registered 16:02:34.321Z → arrival round 25892263 → selection round 25892264
^^^^^^^^^^^^^^^^^^^^^^^^
DIFFERENT ROUND
Supabase was ~2 seconds slower and landed in a different beacon period. Cloudflare and GCP agree on round 25892263, but Supabase uses round 25892264.
packages/pb-demo/src/routes/step5-selection.js), which biases toward more reveals and is gameableThe selection round is derived from each server's local timestamp, not from a value agreed upon before submission. With N servers, you get up to N different registered_at values, and any pair that straddles a 3-second drand boundary will disagree.
The client observes the current drand round before submitting, includes it in the commitment as anchor_round, and all servers use anchor_round + 1 for selection.
Protocol change:
{
"spec_version": "0.3.0",
"items": [...],
"reveal_probability": 0.5,
"beacon": {
"type": "drand",
"chain_hash": "52db9ba...",
"anchor_round": 25892262
},
...
}
Server behavior:
anchor_round is valid: its randomness must already be public (round time ≤ now)anchor_round is recent: must be within MAX_ANCHOR_AGE rounds of current (e.g., 3 rounds = 9 seconds)anchor_round + 1 for selection (instead of nextRoundAfter(registered_at))arrival_beacon and registered_at as independent timestamp evidenceCheat analysis:
| Attack | Blocked? | Why |
|---|---|---|
| Client uses anchor_round whose randomness they already know | Yes | Selection uses anchor_round + 1, whose randomness is unknown |
| Client tries many anchor_rounds to find favorable selection | Yes | Each attempt is a new commitment (different committed_at, signature), signer_activity increments, rate limiting applies |
| Client sets anchor_round far in the future | Yes | Server rejects: round hasn't happened yet |
| Client sets anchor_round far in the past | Yes | Server rejects: exceeds MAX_ANCHOR_AGE |
| Client waits to see round R+1's randomness, then submits with anchor_round=R | Yes | By the time R+1 is public, R is too old (>3s), server rejects for staleness |
Pros:
Cons:
Client checks position within current 3-second beacon period. If >66% through, wait for next tick before submitting.
Pros: No spec change, simple Cons: Only reduces probability, doesn't eliminate it. Network latency is unpredictable. Client could deliberately submit at boundaries to game it.
Verdict: Useful as defense-in-depth alongside Strategy A, but insufficient alone.
Server uses nextRoundAfter(registered_at) + 1 instead of nextRoundAfter(registered_at).
Pros: Simple server-side change, more buffer
Cons: Servers can STILL disagree on nextRoundAfter(registered_at) — adding 1 to both sides doesn't help. Also adds 3s latency.
Verdict: Does not solve the problem.
Client collects receipts, groups by beacon round, uses the round that majority agrees on.
Pros: Handles "one slow server" naturally Cons: Not cheat-proof if client controls servers. Ambiguous on ties. Third-party verifiers can't reproduce without all receipts.
Verdict: Reasonable fallback for old commitments, but not a primary solution.
Client uses the earliest (lowest) beacon round among all receipts.
Pros: Simple deterministic tiebreaker, strongest temporal guarantee Cons: A malicious server could claim early arrival to force a specific round. Not reproducible without receipts.
Verdict: Good tiebreaker rule for legacy/fallback scenarios.
anchor_round as an optional field in the beacon objectpb-wasm / Rust core — Include anchor_round in commitment signing payload when presentpb-js) — Before committing:anchor_round: currentRound in beacon objectpb-node + all deploy targets) — When anchor_round is present:anchor_round + 1 for selection instead of nextRoundAfter(registered_at)anchor_round is absent, fall back to current behavior (backward compat)anchor_round in commitment matches selection.beacon_output.round - 1Even with anchor_round, add a timing guard so the anchor_round is fresh:
// Before committing, ensure we're early in a beacon period
const currentRound = await fetchCurrentDrandRound();
const roundTime = drandRoundTime(currentRound);
const nextRoundTime = drandRoundTime(currentRound + 1);
const elapsed = Date.now() / 1000 - roundTime;
const period = nextRoundTime - roundTime; // 3 seconds
if (elapsed > period * 0.7) {
// Too close to boundary — wait for next round
await sleep((period - elapsed + 0.5) * 1000);
currentRound += 1;
}
The demo's union approach (step5-selection.js:26-33) is wrong. With anchor_round, all servers agree on the same selection, so the union is unnecessary. But for robustness:
pb verify-receipt should check:
anchor_round present: verify selection.beacon_output.round == anchor_round + 1anchor_round absent: verify selection.beacon_output.round == nextRoundAfter(registered_at)MAX_ANCHOR_AGE = 3 rounds (9 seconds) — reject if anchor_round is too old
MIN_ANCHOR_FRESHNESS = must be ≤ current_round — reject if in the future
DRAND_PERIOD = 3 seconds
The MAX_ANCHOR_AGE of 3 rounds (9 seconds) gives comfortable buffer for:
| Approach | Deterministic? | Cheat-proof? | Spec change? | Chosen? |
|---|---|---|---|---|
| A: Client anchor round | Yes | Yes | Yes (minor) | PRIMARY |
| B: Timing guard | No | No | No | SUPPLEMENT |
| C: Wait 2 periods | No | N/A | No | REJECTED |
| D: Majority vote | Mostly | Partially | No | LEGACY FALLBACK |
| E: Earliest-round-wins | Yes | Partially | No | TIEBREAKER |
The anchor round approach is the only strategy that is both fully deterministic AND cheat-proof. All others either leave room for disagreement or can be gamed. The timing guard and earliest-round-wins serve as defense-in-depth layers.