Protocol design
View raw

Quiver protocol design

This document explains how Quiver produces verifiable randomness and why it is secure. It is the reference for auditors and integrators who want to understand the mechanism rather than just call the API.

Quiver is an on-chain adaptation of the classic two-party commit–reveal protocol, optimized with a hash chain so a provider can serve an effectively unbounded stream of requests from a single on-chain commitment. This is the design pioneered by Pyth Entropy; Quiver is an independent implementation branded and tuned for Robinhood Chain.


1. The problem with "just use blockhash"

Randomness derived from a single on-chain source that a privileged actor controls is biasable:

  • blockhash / block.prevrandao are chosen (or at least influenced) by the block producer, who can re-roll by discarding blocks they dislike.
  • A single-party oracle that "posts a random number" can pick whichever value benefits it, or front-run/withhold based on the outcome.

The fix is to require two independent contributions, each committed before either value is known, so that:

  • the result is a deterministic function of both contributions, and
  • neither party can change its contribution after seeing the other's.

Then a fair outcome holds as long as at least one contributor is honest.

2. Commitments

The provider's hash chain

A provider picks a secret 32-byte seed and a chain length N, then computes

chain[0] = seed
chain[i] = keccak256(chain[i-1])          for i = 1 … N

It publishes only the tip, commitment = chain[N] = keccak256ᴺ(seed), via register(...). Every other value stays secret.

Only the tip chain[N] is public. The value the provider reveals for the k-th request it serves is a pre-image deeper in the chain:

revelation(k) = chain[N - k]

Why this works: because keccak256 is one-way, nobody can compute chain[N-1], chain[N-2], … from the public chain[N]. But verification is trivial: given a claimed revelation(k) = chain[N-k], hashing it k times must reproduce the tip:

keccak256ᵏ(chain[N-k]) == chain[N] == commitment   ✓

The provider therefore cannot lie about a revealed value — any wrong value fails the hash check — and cannot change it later, because it's fixed the moment the tip is published.

The requester's contribution

The requester picks its own value userRandomNumber and commits userCommitment = keccak256(userRandomNumber). It reveals the pre-image only at reveal time (pull flow) or supplies it up-front (push flow — see §6).

3. Producing the random number

When both revelations are on-chain, the coordinator computes:

randomNumber = keccak256(abi.encodePacked(userRandom, providerRevelation, blockHash));

blockHash is blockhash(requestBlock) when the requester set useBlockhash, else bytes32(0). Because keccak256 is a random oracle over inputs neither party fully controlled, the output is uniform and unpredictable to both.

4. Bounded-gas verification (the anchor trick)

Verifying request k naïvely costs k hashes — unbounded as the chain is consumed. Quiver keeps reveal gas proportional only to the number of outstanding requests.

Each provider record tracks a moving anchor:

  • currentCommitment — the deepest revealed chain value so far, chain[N - b]
  • currentCommitmentSequenceNumber (b) — the sequence number that anchor corresponds to

When a request is assigned sequence number s, it stores its own anchor:

req.providerCommitment = currentCommitment        // chain[N-b] at request time
req.numHashes          = s - b                     // hashes needed to verify

At reveal, the coordinator checks keccak256^(req.numHashes)(providerRevelation) == req.providerCommitment. On success it advances the anchor forward (currentCommitment ← providerRevelation, b ← s) iff s is ahead of the current anchor. So:

  • Sequential reveals cost exactly 1 hash each.
  • Out-of-order reveals still verify against each request's stored anchor, and never corrupt the moving anchor (it only ever moves forward).
  • The maximum work for any reveal is bounded by maxNumHashes, enforced at request time (s - b ≤ maxNumHashes), which also protects revealers from gas griefing.

This is why a request stores numHashes and a 32-byte anchor rather than re-deriving from the tip: it makes every reveal self-contained and order-independent.

Worked example (N = 8)

ActionsequenceNumber (next)anchor bcurrentCommitment
register(commitment=chain[8])10chain[8]
request → seq 1 (anchor 0, 1 hash)20chain[8]
reveal seq 1 → chain[7]21chain[7]
request → seq 2 (anchor 1, 1 hash)31chain[7]
reveal seq 2 → chain[6]32chain[6]

Reveal seq 1 checks keccak(chain[7]) == chain[8]; reveal seq 2 checks keccak(chain[6]) == chain[7]. Each is one hash.

5. Rotation

A chain is finite (N values). A provider extends or replaces it with rotateCommitment(newCommitment, …, newChainLength, …):

  • The new chain is anchored just below the next sequence number (anchor = sequenceNumber - 1), so the very next request verifies in one hash.
  • In-flight requests are unaffected — they carry their own stored anchors from the old chain and still verify and reveal normally.
  • Rotation is also the recovery path if a seed is suspected compromised: rotate to a fresh seed and all future values are safe again.

Fletcher tracks chain segments (each {seed, anchorSequenceNumber, length}) so it can still reveal old-segment requests after a rotation.

6. Two flows: pull and push

Pull flow — requestreveal

  1. Requester commits userCommitment = keccak256(userRandom), keeping userRandom secret.
  2. request(provider, userCommitment, useBlockhash) reserves sequence s.
  3. Requester obtains providerRevelation for s (from the provider's endpoint) and calls reveal(provider, s, userRandom, providerRevelation), which returns the randomness.

The requester's value stays secret until it chooses to reveal, so it is a genuine independent commitment. Optionally fold in blockhash for extra provider-unpredictability.

Push flow — requestWithCallbackrevealWithCallbackquiverCallback

  1. requestWithCallback(provider, userRandom) — the raw userRandom is passed and the coordinator stores keccak256(userRandom). It is emitted in the event so the keeper can fulfill.
  2. The keeper observes RandomnessRequested, computes the provider's reveal, and calls revealWithCallback(provider, s, userRandom, providerRevelation).
  3. The coordinator produces the randomness and invokes your contract's quiverCallback(s, provider, randomNumber).

Is emitting userRandom a problem? No. Protocol safety rests on the provider's revealed value being committed before the request — the provider cannot re-choose it. The requester's value contributing entropy does not require secrecy in the push flow. The one residual consideration: because useBlockhash is off in the push flow, the provider can compute the outcome once it sees the request, and could choose to withhold the reveal (liveness, not bias). Mitigations are covered in security.md (reputation/staking, using the pull flow with useBlockhash, and callback buffering so a griefing consumer can't block a provider).

7. Callback safety

revealWithCallback performs all state changes (advance anchor, delete the request) before calling the consumer, and wraps the call in try/catch:

  • SuccessCallbackSucceeded.
  • Revert / out-of-gas → the randomness is written to a retry buffer and CallbackFailed is emitted. Anyone can later call retryCallback(provider, s) to re-deliver it. The provider is paid and unblocked regardless of a misbehaving consumer.

This makes fulfillment robust: a buggy or malicious consumer harms only itself.

8. What Quiver guarantees — and what it doesn't

Guarantees (given an honest coordinator deployment):

  • The output is a fixed function of two pre-committed values; neither party alone can bias it.
  • A provider cannot forge or alter a revealed value (hash-chain verification).
  • A reveal cannot be replayed (the request is deleted on reveal).
  • Randomness for a failed callback is never lost (retry buffer).

Non-guarantees / trust assumptions:

  • Liveness depends on at least one party revealing. A provider (or, in the pull flow, a requester) can refuse — this cannot bias results but can delay them.
  • The push flow without useBlockhash lets the provider predict (not change) the outcome, enabling selective withholding. Use provider reputation/staking, or the pull flow with useBlockhash, where stronger guarantees are needed.
  • Security assumes keccak256 pre-image and collision resistance, and a provider that keeps its seed secret until each reveal.

See security.md for the full threat model and mitigations.