Docs

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

Security

Two claims carry the product. This document proves them by reading the code and names the tests that hold them.

  1. No path reaches a slip's backing. For every token, the contract's balance equals the sum of rawAmount over all unredeemed slips and sealed sets, and no function other than redeem by the owner of the token can reduce a slip's share of it.
  2. No path pauses redeem. There is no pause modifier, no access control beyond ownerOf, no dependency on prices, feeds, the issuer's oracle flag, the randomness contract, or the registries.

A third claim makes the first two worth anything:

  1. A series is immutable. No function raises a cap, adds or removes a company, or reopens a closed series.

1. The backing invariant

State

Scrip tracks backing[token], incremented exactly once per slip mint (_mintSlip) by the measured balance delta of the swap, and decremented exactly once per redemption (redeem). sealSet moves amounts from slips into a set record and leaves backing untouched.

Every function that moves an ERC-20 out of Scrip

FunctionTokenAmountGuard
redeem (slip)slip.tokenslip.rawAmountownerOf(tokenId) == msg.sender; burn before transfer; backing −=
redeem (set)each set.tokens[i]set.rawAmounts[i]same
refundUSDGpack.escrowpack Committed and ≥ 24 h old; escrowTotal −=
_payBounty (inside reveal)USDGmin(0.25, freeUsdg())freeUsdg = balance − escrowTotal; escrow is never available
executeSwap (inside reveal)USDGone part of the pack's escrowmsg.sender == address(this); the escrow is being converted into the buyer's slips
withdrawRoyaltiesany≤ balance − backing[token] (USDG: ≤ freeUsdg())onlyOwner; reverts with ExceedsFree otherwise
withdrawEthETHanyonlyOwner; ETH never backs anything

There is no transfer, approve, sweep, rescue, migrate, upgrade, delegatecall or selfdestruct path anywhere in Scrip. The contract is not upgradeable. approve is called only inside SwapLib.swapExactIn, for exactly amountIn of USDG, to the immutable router.

Owner powers, exhaustively

Scrip is Ownable2Step; the owner is the TimelockController. The owner can call: withdrawRoyalties, withdrawEth, transferOwnership, acceptOwnership. Nothing else. In particular the owner cannot: pause, mint, burn, change a router, change a registry, change the randomness contract, change fees, or touch escrow or backing.

AssetRegistry's owner can register a token, enable or disable it, and change the staleness grace. Disabling a token stops it from being drawn; it has no effect on slips that already hold it and no effect on redeem.

SeriesRegistry's owner can only create series.

Tests

TestWhat it holds
invariant_backingEqualsBalanceExactlyFor every token: balanceOf(Scrip) == Σ rawAmount(live slips) + Σ rawAmounts(live sets) == backing[token], after random sequences of commit / reveal / refund / redeem / transfer / seal / multiplier changes. Default profile 64 × 64 = 4 096 calls; FOUNDRY_PROFILE=deep runs 800 × 64 = 51 200 calls.
invariant_mintedNeverExceedsMaxminted ≤ maxSlips per entry and minted + reserved ≤ totalSlips.
invariant_escrowFullyAccountedescrowTotal == Σ escrow(committed packs), USDG balance ≥ escrowTotal, every pack is in exactly one state and finalised packs never change state.
invariant_openedPacksAreCompleteEvery opened pack has exactly slipsPerPack slips.
test_attack_ownerCannotTouchBackingOrEscrowwithdrawRoyalties reverts at one unit above the free balance for USDG and for a backing token; a stray donation above backing is withdrawable, backing is not.
test_attack_reentrancyOnRedeem_isBlockedA hostile Stock Token re-entering redeem during its own transfer cannot double-withdraw; the outer call reverts atomically.
test_attack_reentrancyOnReveal_hostileTokenGetsRedrawnA hostile Stock Token re-entering reveal during the swap fails the swap and is redrawn; the pack completes without it.
test_exhaustSeries_countsMatchPrintRun_everySlipRedeemsAfter a full run every slip redeems for exactly rawAmount and the contract ends with zero balance of every token.
test_fork_realTokensFeedsAndPools_commitRevealRedeemSame on a mainnet fork with real Stock Tokens, feeds and Uniswap v3 pools.

2. redeem cannot be paused

The full body of redeem, paraphrased: check ownerOf(tokenId) == msg.sender; if set, burn and transfer each token; else burn, decrement backing, transfer. It reads no price, no feed, no registry, no randomness. Its only modifier is nonReentrant, which blocks re-entry inside a single transaction and cannot be set from outside.

No contract in the system inherits Pausable. Grep contracts/src for pause, Pausable, whenNotPaused: the only hits are reads of the issuer's oraclePaused() flag, which gate drawing a company, never redeeming one.

What SCRIP cannot control: the Stock Token itself is issued by Robinhood behind an upgradeable beacon and exposes pause() and isBlocked(address) (verified on the mainnet implementation, see EVIDENCE.md). If the issuer pauses transfers or blocklists an address, redeem will revert at the token's transfer, exactly as any holder of the raw token would be frozen. SCRIP adds no freezing power of its own and cannot remove the issuer's. This is disclosed in RISK.md.

Tests: test_redeem_returnsExactRawAmount_noFee_evenWhenOracleIsPaused (oracle paused and feed stale; redeem still works), test_attack_redeemTwice_andRedeemNotOwned.

3. Series immutability

SeriesRegistry has exactly three non-view external functions: createSeries (owner), consume (Scrip only, minted += n, reverts past maxSlips), and the Ownable2Step pair. Series, Entry[] and SetDef are written once inside createSeries. There is no setter. MAX_ENTRIES = 64 bounds the reveal loop.

Reservations: commit reserves slipsPerPack units per unopened pack and refuses to sell when totalRemaining − reserved < slipsPerPack, so concurrent packs cannot overdraw the print run; reveal consumes exactly the reserved units and refund releases them.

Tests: test_series_isImmutable_noMutatorExists, test_concurrentRevealsInOneBlock_neverExceedMax_serialsUnique, invariant_mintedNeverExceedsMax, test_exhaustSeries_countsMatchPrintRun_everySlipRedeems (a sixth pack cannot be committed once 25 of 25 are minted).

4. Randomness

Proven in RANDOMNESS.md and test/unit/Randomness.attacks.t.sol: no post-commit influence, no cancel, no re-roll, reveal-block reordering irrelevant, hostile revealer harmless, expiry never yields a chosen value.

5. Reveal path hardening

  • reveal mints with _mint, not _safeMint: a buyer contract that reverts on onERC721Received cannot stop its own pack from opening (test_attack_buyerContractThatRevertsOnReceipt_stillGetsSlips).
  • The bounty is an ERC-20 transfer with no callback; a revealer contract that reverts on receipt cannot brick the pack (test_attack_revealFromContractThatRevertsOnReceipt_cannotBrick).
  • Swaps run through an external self-call inside try/catch, so a reverting router, pool, or token becomes a redraw, not a stuck pack (test_redraw_onBrokenSwap). All reads of feeds and ERC-8056 flags are length-checked staticcalls (OracleLib).
  • Every failure mode listed in the spec causes a redraw and never a bad slip: paused oracle (test_redraw_onPausedOracle), stale feed (test_redraw_onStaleFeed), drained pool (test_redraw_onDrainedPool), deviation breach (test_redraw_onDeviationBreach). Beyond MAX_REDRAWS the pack stays refundable and retryable (test_exceedingMaxRedraws_leavesPackRefundable_andRetryable, test_refund_after24h_returnsEscrow_releasesReservation).

6. Token admission

AssetRegistry.register rejects non-18-decimal tokens, tokens without the ERC-8056 surface, feeds without 1–18 decimals or a positive answer, routes without a pool, per-token floors below the deployment floor, and fee-on-transfer tokens (probe). It keeps PROBE_AMOUNT as a canary; if the held balance ever differs from the recorded one the token is treated as rebasing and tryPrice returns false, so it is redrawn (test_registry_rejectsNon18Decimals_feeOnTransfer_andFlagsRebasing).

7. Known limits

  • The sequencer can bias the draw (RANDOMNESS.md §1). Not fixable without VRF.
  • A lapsed 51-minute reveal window gives a fresh unknown draw (RANDOMNESS.md §2). Mitigated by the keeper bounty and the protocol's keeper.
  • freshEntries costs a loop over the series' entries (≤ 64 × 3 staticcalls) on every commit; on an Orbit L2 this is a few cents.
  • Off-hours: Chainlink equity feeds hold their last price and stop heartbeating outside 24/5 trading. With heartbeat = 86 400 and a grace of 1 hour, commits are refused from roughly Saturday 02:00 CET until the first Monday update, by design.