Docs

Mechanics, the evidence behind the three gates, the randomness scheme and its weaknesses, the security argument, the risks, and how to deploy.

Randomness

SCRIP is a gacha. If the draw can be gamed, the print run is meaningless and the product is a scam. This document states exactly where the entropy comes from, what nobody can do, what the remaining weaknesses are, and why the scheme was chosen.

Decision

Chainlink VRF is not deployed on Robinhood Chain (mainnet 4663 or testnet 46630) as of 2026-09-24. The evidence is in EVIDENCE.md, Gate 1. SCRIP therefore ships with BlockhashRandomness, a commit–reveal scheme, behind the IRandomness interface. VrfRandomness implements the same interface against Chainlink VRF v2.5 and is unit-tested against a mock coordinator, so the core contract can be repointed to VRF the day a coordinator exists on this chain without touching Scrip.sol.

Chain facts the scheme depends on

Robinhood Chain is an Arbitrum Orbit chain. Three facts matter (all verified on mainnet, see EVIDENCE.md):

FactValue on Robinhood ChainConsequence
block.numberAn L1 (Ethereum) block estimate, advancing roughly every 12 s. Sampled: L2 blocks 71 273 076…71 273 196 all reported L1 26 046 513 or 26 046 514.A delay expressed in block.number is a delay in L1 blocks.
L2 heightArbSys(0x64).arbBlockNumber(), ~10 blocks per second (20 L2 blocks spanned 2 s of timestamps)."At least two blocks" must be enforced on L2 height, not block.number.
blockhash(x)Observable only for block.number − 256 ≤ x < block.number; keyed by the L1 estimate; returns a hash derived from L2 blocks, not from L1. Robinhood's docs state block.prevrandao is constant and blockhash is unreliable beyond the recent window.Entropy expires ~51 minutes after its target block. prevrandao cannot be used at all.

The scheme

commit(pack):
    salt      = keccak(buyer, seriesId, packId, userSalt)      // recorded by Scrip
    l2Anchor  = ArbSys.arbBlockNumber()
    targetL1  = block.number + 1                               // the *next* L1 block estimate
    store {targetL1, salt, l2Anchor, consumed=false}

ready(pack):
    ArbSys.arbBlockNumber() >= l2Anchor + REVEAL_DELAY_L2      // REVEAL_DELAY_L2 = 4  (≥ 2 required)
 && block.number            >  targetL1                        // blockhash(targetL1) is now observable

consume(pack):   // only the Scrip contract, only once
    h = blockhash(targetL1)
    if h == 0:   // window expired
        targetL1 = block.number + 1; l2Anchor = now; return (ok=false)
    consumed = true
    seed = keccak(salt, h, targetL1)

The seed feeds DrawLib.drawPack, which draws five entries from the remaining inventory, weighted by maxSlips − minted, distinct companies first, one unit consumed per pick.

What is unknowable at commit time

The hash recorded for L1 number targetL1 is the hash of an L2 block that has not been produced when the commit transaction executes. Its value depends on every transaction the sequencer will include between now and then, in the order the sequencer receives them. No user can compute it.

Why the buyer's salt is public

IRandomness.request takes the salt in the clear. A hidden secret revealed later would force the reveal to be made by the buyer, contradicting the permissionless keeper-driven reveal, and worse: any design in which the pack can be revealed with or without the buyer's secret gives the buyer two candidate seeds and therefore a choice. SCRIP has exactly one seed per pack. The salt only separates packs that share a target block.

Properties proven by test (test/unit/Randomness.attacks.t.sol)

The attack tests were written before the implementation and every one passes:

  1. No post-commit influence. After request, no function on the contract changes salt, targetL1 or l2Anchor; the only writer is the consumer, and the consumer's only write path is consume.
  2. No cancel, no re-roll. There is no function that clears a request; consume is one-shot and reverts with AlreadyConsumed afterwards. The seed is a pure function of stored state (asserted by recomputation in the test).
  3. Reordering within the reveal block cannot change the outcome. The seed does not read msg.sender, tx.origin, gas price, block.timestamp, prevrandao, or the reveal block's own hash. The test consumes the same pack under two different origins, timestamps, gas prices and even 100 L1 blocks later and gets the same seed.
  4. A hostile revealer cannot brick the pack. The randomness contract makes no external call to its caller; the test reveals from a contract that reverts on any incoming call.
  5. Early attempts leave no trace. consume before readiness reverts; nothing is written.
  6. Both clocks are required. Readiness needs the L2 delay and the L1 target to be strictly in the past; either alone is refused.
  7. Expiry never yields a chosen value. After 256 L1 blocks the contract retargets to a future block and returns ok = false; it never substitutes the current or any caller-selected hash.

Honest weaknesses

These are real. They are listed in order of practical relevance.

1. The sequencer can bias the draw

Robinhood Chain has a single, permissioned sequencer (first-come-first-served ordering, no fee-based reordering, per Robinhood's docs). The L2 block whose hash becomes blockhash(targetL1) is assembled by that sequencer. An operator willing to grind block contents could steer a pack's seed. This is the same trust assumption every application on the chain already makes about transaction ordering and inclusion, and it is strictly weaker than the trust a user places in Robinhood as the issuer of the Stock Tokens themselves. It is not fixable without VRF or an external beacon; it is disclosed here and in RISK.md.

2. A lapsed window produces a fresh, unknown draw

If no one calls reveal for ~51 minutes (256 L1 blocks) after the target, the entropy expires and the pack is retargeted. A buyer who can compute the expired seed and dislikes it gets a new unknown draw. This is not a re-roll into a chosen outcome, but it is a second sample. Three things make it impractical:

  • reveal is permissionless and pays a flat 0.25 USDG bounty from the royalty balance. The protocol runs its own keeper, and anyone else can. The buyer cannot prevent a third party from revealing during the window.
  • The buyer must wait 51 minutes per extra sample, during which anyone can close the door.
  • The retargeted draw is again unknowable at retarget time.

If the protocol's keeper and every other keeper are offline for 51 minutes, this weakness is live. The keeper's liveness is therefore part of the security model and is monitored (docs/DEPLOY.md).

3. Blockhash keyed by an L1 estimate

Because block.number advances in ~12 s steps, targetL1 = block.number + 1 is observable only once the estimate has ticked twice. A pack is typically revealable 12–24 s after commit. The UI shows this as the L1 countdown alongside the L2 delay.

4. Modulo bias

DrawLib reduces a 256-bit word modulo the remaining inventory (< 2⁴⁸). The bias is below 2⁻²⁰⁰ and is irrelevant.

What was rejected

  • block.prevrandao — constant on Robinhood Chain (Robinhood docs, "Differences from Ethereum"). Unusable.
  • The reveal block's own blockhash(block.number − 1) — chosen by whoever times the reveal; a keeper could grind reveal timing. Rejected.
  • Buyer-secret commit–reveal — creates two seeds per pack (with and without the secret) or makes the reveal non-permissionless. Rejected.
  • Refund on expiry — turns "wait 51 minutes" into a cancel button after seeing the draw. Rejected; expiry retargets instead.
  • Timestamp-derived entropy — sequencer-chosen and coarsely predictable. Rejected.

Test model

Foundry does not emulate ArbOS. Tests vm.etch a MockArbSys at 0x64 whose arbBlockNumber() advances independently of vm.roll, and use vm.setBlockhash to model the observable window. Testnet deployment is the only place where the real blockhash behaviour is exercised end to end; script/ExhaustSeries.s.sol runs commit → wait → reveal loops there and its transaction hashes are recorded in EVIDENCE.md.