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

ObjectOn-chain representationValue floor
SlipERC-721 in Scrip; Slip { token, rawAmount, multAtMint, serial, seriesId, mintedAt, packId, slot, usdgSpent, refPrice }rawAmount of token, redeemable at any time
PackPack { buyer, seriesId, status, committedAt, commitL2Block, escrow, packNumber, slipIds[] }; status ∈ {Committed, Opened, Refunded}The escrowed USDG until opened; five slips afterwards
SeriesSeriesRegistry.Series + Entry[] { token, maxSlips, minted }; immutable after creation—
SetSeriesRegistry.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

ConstantValueWhere
slipsPerPack5 (per series)SeriesRegistry
MAX_DEVIATION_BPS200Scrip
MAX_REDRAWS8Scrip
REFUND_DELAY24 hoursScrip
REVEAL_BOUNTY0.25 USDGScrip
ROYALTY_BPS200 (2 %)Scrip (EIP-2981)
revealDelayL24 L2 blocksBlockhashRandomness (deploy constant, ≥ 2)
depthFloorUsdgdeployment constant from Gate 2AssetRegistry (immutable)
PROBE_AMOUNT1e12 raw unitsAssetRegistry
Primary fee0—
Redemption fee0—
Sealing fee0—

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)

  1. The series must be open (block.timestamp ≥ opensAt).
  2. Inventory check: totalRemaining − reservedSlips ≥ slipsPerPack. Every committed-but-unopened pack reserves five units, so two buyers can never both be promised the last pack.
  3. Market check: at least slipsPerPack companies must be priceable right now (freshEntries). Outside the 24/5 feed window this reverts with MarketClosed rather than accepting money a reveal could not spend.
  4. packPrice USDG is pulled into escrow (escrowTotal += packPrice).
  5. randomness.request(packId, keccak(buyer, seriesId, packId, userSalt)) fixes the entropy target (see RANDOMNESS.md).
  6. Committed is 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).

  1. randomness.consume(packId) returns the seed, or ok = false if the entropy window lapsed, in which case the pack is retargeted, RevealDeferred is emitted and the call returns.
  2. The escrow is split into slipsPerPack equal parts; the last part absorbs the division dust so the sum is exactly the pack price (test_usdgSplit_firstSeriesRounding_spendsExactlyPackPrice).
  3. For each slot, DrawLib.drawSlot picks an entry with probability remaining_i / Σ remaining, preferring companies not yet in this pack, consuming one unit.
  4. For the drawn company _tryBuy runs, 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 with amountOutMinimum = expected × (1 − 200 bps) where expected is computed from the Chainlink Total Return Value price. The amount recorded is the measured balance delta.
  5. Success: SeriesRegistry.consume increments minted, the slip is minted to the buyer with serial = minted, multAtMint = uiMultiplier(), refPrice = the Chainlink price used, and backing[token] += rawAmount.
  6. 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 reason price, depth or swap, and the slot is redrawn from the remaining pool. After MAX_REDRAWS failures reveal reverts with PackNotCompletable; nothing is spent, the pack stays committed and can be retried with the same seed.
  7. When all slots are filled: escrowTotal −= escrow, the reservation is released, the pack becomes Opened with packNumber = ++packsOpened[seriesId], and the revealer receives min(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; sharesAtMint uses multAtMint. 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. royaltyInfo returns 2 % to the Scrip contract.
  • tokenURI is 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

FlowAmountFrom → To
CommitpackPricebuyer → Scrip escrow
RevealpackPrice (split 5 ways)escrow → Uniswap pools; stock → Scrip backing
Reveal bounty0.25 USDGScrip free balance → revealer
Secondary sale2 % of price (marketplace-enforced)buyer → Scrip free balance
RedeemrawAmount of stockScrip backing → holder
withdrawRoyalties≤ free balance onlyScrip → 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).