Docs·Rewards and mechanics

Rewards and mechanics

The Fortune Wheel

Where a spin comes from, who resolves it and what bounds them, the jackpot, and where the THD goes.

Version
1.2
Updated
2026-09-28
Source
THD contract sources

Status#

The contract holds only the parts that have to be enforceable. A prize is a list of (bytes32 itemType, uint256 amount) pairs, and every item type the contract does not recognise is credited verbatim and is otherwise inert — so adding a segment that pays a new item is a row in an off-chain catalogue rather than a contract change.

Where a spin comes from#

A spin is not bought. spin() is not payable, pulls no token and charges nothing; it consumes an entitlement the account already holds. There are exactly three ways an entitlement comes into existence:

The three sources of a spin
SourceCallWho may callRate
A paid creationmintCreationSpinsAn address on the isCreationMinter allowlistOne ready spin and one unlock slot per creation paid for
A graduationmintGraduationSpinsAn address on the isGraduationMinter allowlistOne ready spin per 2 ETH raised, computed by the caller. No unlock slot.
An arrow in a published rootclaimPrize, crediting ITEM_ARROWAnyone, with a proofBounded by MAX_ARROWS_PER_SPIN_BPS, a lifetime ceiling expressed as a share of the spins actually consumed. The value is not published — see above.

The creation fee itself is the $1 flat creation fee, and the wheel never sees it. It is told how many creations were paid for and by whom; the fee, its currency and the launch stay outside this contract entirely.

How a spin is resolved#

The draw is off chain, and the contract says so in its own source: there is no randomness in the file, by design rather than by omission. No VRF, no blockhash, no prevrandao, no commit-reveal. Provably-fair was considered on 12-09-2026 and deliberately not adopted.

  1. spin(count, clientSeed) consumes the entitlements and emits SpinsConsumed, carrying the first spin id, the count, whether the spins came from locked arrows, the client seed and the pool at that moment. That event is the anchor the off-chain draw resolves against.
  2. The keeper publishes one result batch per day: publishResults({ root, thdAllocated, arrowsAllocated, spinsCovered, jackpotWinner }).
  3. Winners prove their own leaf with claimPrize, which is a permissionless relay — msg.sender appears nowhere in the accounting. The leaf is keccak256(abi.encode(index, account, thdAmount, itemTypes, itemAmounts)).
  4. Prizes unclaimed after CLAIM_WINDOW — 7 days, a constant rather than a setter — return to the pool through the permissionless reclaimExpiredPrizes.

clientSeed is recorded and not used on chain. Carrying it costs one event field and leaves a later commitment scheme available without a redeploy; it is not a commitment to anything today.

Items, arrows and crafting#

Three item types have meaning to the contract. Everything else is a label it carries without interpreting.

ItemWhat the contract does with it
ITEM_WOODEN_SHAFTCraft input. SHAFTS_PER_ARROW is 10.
ITEM_STEEL_TIPCraft input. TIPS_PER_ARROW is 4.
ITEM_ARROWA playable spin. Credited straight to readySpinsOf, never to the item ledger — and refused by creditParts, which is what stops the keeper minting playable spins directly.
Any other bytes32Credited verbatim to the item ledger and otherwise inert.

craft() turns 10 shafts and 4 tips into a locked arrow. A locked arrow needs an unlock slot, and unlock slots are minted only by the paid-creation route — so crafting produces arrows and only creating produces the ability to fire them.

The jackpot#

The jackpot is funded in the currency a creation fee was paid in, by addresses on the isJackpotContributor allowlist, through fundJackpot(currency, amount). A winner takes JACKPOT_WINNER_BPS — 9,000, so 90% — of every registered pot; the 10% left behind is arithmetic rather than policy. The same win also pays THD — the prize the segment pays when the pots are not armed — reserved out of the prize pool at declaration and credited to the winner by claimJackpot. A basket unclaimed after JACKPOT_CLAIM_WINDOW (90 days) returns to the live pots, and its THD to the prize pool, through the permissionless reclaimUnclaimedJackpot.

The one jackpot figure the design does publish
ReadValue
ARMING_THRESHOLD_USD2000 * 10**18 — $2,000, at USD_DECIMALS = 18
armedValueUsd() · isArmed()The pots’ combined value and whether it has reached the threshold. Publishable because it is a balance, not a probability.
MAX_JACKPOT_CURRENCIES32

The pool, and the two ways THD leaves#

pool() is thd.balanceOf(wheel) − totalObligations — what is available to allocate, after everything already owed. It is funded from two places and never from a player:

FunderWhen
THDEmissionControl, the creators bucketDuring the emission
SpoilsVaultAfter the THD/ETH pool is seeded — a 20% baseline of each day’s consumption plus half of each day’s shortfall. See Spoils.

THD leaves by exactly two routes, both to a fixed storage destination and neither taking an address from the caller: settleWinnings to rewardDistributor — callable only by that distributor, ThdRewardVest, which pulls each winner’s THD through settleFrom and vests it to the end of the 90-day window — and sweepRetiredPool to the registry-gated spoilsDestination after a deliberate retire(), which stops spins at once, and a 30-day RETIREMENT_GRACE in which the final results publish. There is no THD sweep and no surplus function: sweepStrandedToken refuses THD outright. There is no receive() and no fallback(), so a plain ETH transfer to the wheel reverts.