Docs
Mechanics, the evidence behind the three gates, the randomness scheme and its weaknesses, the security argument, the risks, and how to deploy.
Mechanics
What happens, in order, when someone buys a pack, opens it, holds a slip, seals a set, or redeems. Every number here is a contract constant; every claim is backed by a test named in SECURITY.md.
Objects
| Object | On-chain representation | Value floor |
|---|---|---|
| Slip | ERC-721 in Scrip; Slip { token, rawAmount, multAtMint, serial, seriesId, mintedAt, packId, slot, usdgSpent, refPrice } | rawAmount of token, redeemable at any time |
| Pack | Pack { buyer, seriesId, status, committedAt, commitL2Block, escrow, packNumber, slipIds[] }; status ∈ {Committed, Opened, Refunded} | The escrowed USDG until opened; five slips afterwards |
| Series | SeriesRegistry.Series + Entry[] { token, maxSlips, minted }; immutable after creation | — |
| Set | SeriesRegistry.SetDef { seriesId, name, entryIdx[] }; a sealed set is an ERC-721 in Scrip holding tokens[] / rawAmounts[] | The sum of its parts, redeemable at any time |
Constants
| Constant | Value | Where |
|---|---|---|
slipsPerPack | 5 (per series) | SeriesRegistry |
MAX_DEVIATION_BPS | 200 | Scrip |
MAX_REDRAWS | 8 | Scrip |
REFUND_DELAY | 24 hours | Scrip |
REVEAL_BOUNTY | 0.25 USDG | Scrip |
ROYALTY_BPS | 200 (2 %) | Scrip (EIP-2981) |
revealDelayL2 | 4 L2 blocks | BlockhashRandomness (deploy constant, ≥ 2) |
depthFloorUsdg | deployment constant from Gate 2 | AssetRegistry (immutable) |
PROBE_AMOUNT | 1e12 raw units | AssetRegistry |
| Primary fee | 0 | — |
| Redemption fee | 0 | — |
| Sealing fee | 0 | — |
USDG has 6 decimals on Robinhood Chain; Stock Tokens have 18.
1. Publishing a series (owner, 48-hour timelock)
SeriesRegistry.createSeries(SeriesParams) is callable only by the TimelockController. It checks, for every entry: the token is registered and enabled in AssetRegistry, its Chainlink feed answers fresh, its oraclePaused() is false, its rebasing canary is intact, and its route's pool holds at least the token's depth floor in USDG. It requires Σ maxSlips % slipsPerPack == 0 so the run ends in whole packs, and rejects duplicate tokens and malformed sets.
After this call there is no function that changes an entry, a cap, a price, a set, or the opening time. The only later mutation is consume, callable by Scrip alone, which increments minted and reverts past maxSlips.
2. Buying: commit(seriesId, userSalt)
- The series must be open (
block.timestamp ≥ opensAt). - Inventory check:
totalRemaining − reservedSlips ≥ slipsPerPack. Every committed-but-unopened pack reserves five units, so two buyers can never both be promised the last pack. - Market check: at least
slipsPerPackcompanies must be priceable right now (freshEntries). Outside the 24/5 feed window this reverts withMarketClosedrather than accepting money a reveal could not spend. packPriceUSDG is pulled into escrow (escrowTotal += packPrice).randomness.request(packId, keccak(buyer, seriesId, packId, userSalt))fixes the entropy target (see RANDOMNESS.md).Committedis emitted with the L2 block so the UI can count down.
There is no cancel. The only exits are reveal and, after 24 hours unopened, refund.
3. Opening: reveal(packId) — permissionless
Anyone may call it once packReadiness(packId) reports ready (both the L2 delay and the L1 target block are in the past).
randomness.consume(packId)returns the seed, orok = falseif the entropy window lapsed, in which case the pack is retargeted,RevealDeferredis emitted and the call returns.- The escrow is split into
slipsPerPackequal parts; the last part absorbs the division dust so the sum is exactly the pack price (test_usdgSplit_firstSeriesRounding_spendsExactlyPackPrice). - For each slot,
DrawLib.drawSlotpicks an entry with probabilityremaining_i / Σ remaining, preferring companies not yet in this pack, consuming one unit. - For the drawn company
_tryBuyruns, in order:AssetRegistry.tryPrice(enabled, oracle not paused, canary intact, feed fresh and positive), the depth floor (USDG held by the route's pool ≥ minDepthUsdg), then the swap through Uniswap withamountOutMinimum = expected × (1 − 200 bps)whereexpectedis computed from the Chainlink Total Return Value price. The amount recorded is the measured balance delta. - Success:
SeriesRegistry.consumeincrementsminted, the slip is minted to the buyer withserial = minted,multAtMint = uiMultiplier(),refPrice= the Chainlink price used, andbacking[token] += rawAmount. - Failure of any check: the unit goes back, the company is blocked for this pack only,
Redrawn(packId, slot, entryIdx, token, reason)is emitted with reasonprice,depthorswap, and the slot is redrawn from the remaining pool. AfterMAX_REDRAWSfailuresrevealreverts withPackNotCompletable; nothing is spent, the pack stays committed and can be retried with the same seed. - When all slots are filled:
escrowTotal −= escrow, the reservation is released, the pack becomesOpenedwithpackNumber = ++packsOpened[seriesId], and the revealer receivesmin(0.25 USDG, freeUsdg())from the royalty balance. The pack's escrow is never touched for the bounty.
The seed is fixed once the target block exists. Retrying a failed reveal later reproduces the same draws; only companies that become buyable change the outcome, never the buyer's or keeper's choices.
4. Refund: refund(packId)
Callable by anyone 24 hours after commit if the pack is still Committed. Sends the full escrow to the buyer, releases the reservation, marks the pack Refunded. A refunded pack can never be revealed; an opened pack can never be refunded (test_attack_refundAfterSuccessfulReveal).
5. Holding
sharesNow(tokenId) = rawAmount × uiMultiplier(token) / 1e18;sharesAtMintusesmultAtMint. When the issuer processes a dividend the multiplier rises and the slip represents more shares without any transaction (Gate 3 in EVIDENCE.md).- Slips are plain ERC-721s: transferable, listable anywhere.
royaltyInforeturns 2 % to theScripcontract. tokenURIis on-chain JSON with the token, serial, series, raw amount and multiplier at mint.
6. Sealing a set: sealSet(setId, slipIds[])
The caller passes one slip per entry of the set, in the set's definition order. Each must be owned by the caller, belong to the set's series and hold the expected company. All are burned; one Set NFT is minted holding every token amount. backing is unchanged, because the stock moved from five slips to one set (test_seal_thenRedeemSet_returnsEveryCompany). A set redeems exactly like a slip.
7. Redeeming: redeem(tokenId)
ownerOf(tokenId) == msg.sender, burn, backing[token] −= rawAmount, transfer. No fee, no pause modifier, no price read, no access control beyond ownership, no time limit. It works while the oracle is paused and while the feed is stale (test_redeem_returnsExactRawAmount_noFee_evenWhenOracleIsPaused).
8. Odds
oddsNow(seriesId) returns per company remaining, maxSlips and remaining / Σ remaining in bps: the probability of that company on any single draw right now. Because a pack draws distinct companies first, the chance that a pack contains company i is higher than the single-draw share when fewer than slipsPerPack × 2 companies remain. The UI shows both the single-draw share and the exact remaining counts, which are the ground truth.
9. Money flow
| Flow | Amount | From → To |
|---|---|---|
| Commit | packPrice | buyer → Scrip escrow |
| Reveal | packPrice (split 5 ways) | escrow → Uniswap pools; stock → Scrip backing |
| Reveal bounty | 0.25 USDG | Scrip free balance → revealer |
| Secondary sale | 2 % of price (marketplace-enforced) | buyer → Scrip free balance |
| Redeem | rawAmount of stock | Scrip backing → holder |
withdrawRoyalties | ≤ free balance only | Scrip → owner-chosen address |
The free balance of any token is balance − backing[token], and of USDG is balance − escrowTotal. The owner (timelock) can never withdraw more than that (test_attack_ownerCannotTouchBackingOrEscrow).


