Writing

Verifiable Randomness Raffle Protocol: security and architecture walkthrough

How the raffle contract answers the two questions that decide whether it can hold real money: who picks the winner, and whether anyone can hold the prize hostage. Written against the Robinhood Chain and BSC deployments.

A raffle looks like a trivial contract until you ask the two questions that decide whether it can hold real money: who chooses the winner, and can anyone keep the prize hostage? This is a walkthrough of how the protocol answers both — on two chains, with two completely different randomness backends, behind one shared raffle engine.

Nothing in here is a key, a credential, or a private endpoint: every value quoted is either in the public source tree or readable from public chain state with a cast call (the RPC URLs in the verification section are public gateways). The How to verify section lists the exact commands.


1. The product, in one paragraph

Anyone can create a raffle by escrowing a prize — an ERC-20 token amount or an ERC-721 NFT — and setting a ticket price, a ticket cap (maxCap) and a duration (minimum 2 hours). Entrants buy tickets with a stablecoin; the host is not allowed to enter its own raffle. When the raffle expires, a draw is requested from a verifiable randomness source and the winner is selected from the ticket ranges. If the cap was never filled, the prize goes back to the host and the ticket pool is still drawn — one entrant walks away with the money instead of the prize. Ticket payments are pulled in at entry and pushed out at settlement — but with an escrow fallback: if a transfer fails (blacklisted winner, paused token, hostile prize asset), the funds are credited to a claimable balance rather than reverting the settlement. A platform fee, capped at 1000 bps and changeable only behind a 2-day timelock, is taken from the ticket pool (and, for ERC-20 prizes, from the prize itself).


2. Two chains, two randomness backends, one engine

Robinhood Chain mainnet BSC mainnet
chain id 4663 56
contract LotteryCore — 0x04491a792C82Dd66C5993b89E6E5044bF0da797d RaffleCore — 0xd990ca6f045f86df32c115deee4d24e4a911993b
source src/quiver/LotteryCore.sol src/chainlink/RaffleCore.sol
randomness Quiver (two-party commit–reveal, push callback) Chainlink VRF v2.5 (oracle + on-chain proof)
draw request resolver-gated, resolveRaffle(id, salt) permissionless performUpkeep(id)
payment token 0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168 (USDC) 0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d (Binance-PEG USDC)
randomness coordinator 0x8cF4f562301fA966F153eE1e3D46D975DF21C9a3 0xd691f04bc0C9a24Edb78af9E005Cf85768F694C9
active provider 0xeB8E79d3495638Dde48336D01A1f1229822bB016 (fallback 0x062Fa60c76f4755836eE45641d1D45fB6f5E88C1) n/a (VRF subscription, key-hash lane)
trigger self-operated Node keeper (2 cron jobs) self-operated bsc-keeper.sh (3 loops)

The split is not a design choice, it is a constraint. Robinhood Chain has no Chainlink VRF deployment, so a raffle that must be fair there cannot use the standard oracle path; BSC has VRF but its Automation product is being retired, so the same keeper pattern has to drive it. The protocol's answer was to keep one accounting engine and make randomness a thin adapter.

Both contracts share the accounting core almost line for line:

createRaffleERC20 / createRaffleERC721   escrow the prize, validate receipt
enterRaffle / enterFreeRaffle            pull payment, append ticket ranges
resolve -> callback -> RESOLVED          entropy adapter (the only real difference)
settle(id)                               permissionless, selects winner, pays out once
cancelExpiredRaffle / cancelStalled*     liveness fallback, refunds (no fabricated winner)
claimRefund / claim / claimNft           pull-based exits

Both are Pausable, ReentrancyGuard, IERC721Receiver and carry the same EIP-712 free-entry verifier; LotteryCore adds Ownable2Step, RaffleCore inherits Chainlink's ConfirmedOwner (configurable owner at construction, 2-step transfer).

Live state at the time of writing (2026-10-12)

Read from the deployed contracts; raffleCount was 1 on Robinhood and 2 on BSC.

Value LotteryCore (4663) RaffleCore (56)
raffleCount 1 2
platformFeeBps 0 0 (pendingFeeBps = 500, already past its timelock and not yet applied)
minDuration 7200 s (2 h) 7200 s (2 h)
cancel grace RESOLVE_GRACE = 72 h cancelGrace = 72 h
stall/timeout STALL_TIMEOUT = 6 h, MAX_RESOLVE_ATTEMPTS = 3, HARD_DEADLINE = 7 d VRF_TIMEOUT = 24 h
randomnessFee() 0 wei (provider currently charges nothing) n/a

Two useful facts fall out of that table. First, the platform fee is live at zero on both chains — the fee machinery exists, is capped, is timelocked, and is currently not charging. Second, the raffle history so far already covers every terminal shape that matters: a normal draw, a zero-participant expiry, and a cancel-with-refunds (see Live examples).


3. The accounting core: why winner selection and refunds are cheap

Every ticket purchase appends to a per-raffle array of ranges rather than storing a ticket per unit:

struct TicketRange { address owner; uint96 endTicket; }   // one 32-byte slot per range
mapping(uint256 => TicketRange[]) public ticketRanges;
mapping(uint256 => uint96) public totalTickets;
mapping(uint256 => mapping(address => uint256)) public refundableAmount;

Consecutive purchases by the same buyer are merged into the existing range, so a whale buying 1,000 tickets costs one slot, not 1,000. Winner selection is then a binary search over ranges (_findWinner, O(log N)); refunds are O(1) per user per raffle, accumulated at entry time — which also makes refund claims immune to gas-griefing by users with long purchase histories.

The raffle record itself is packed into five slots so getRaffle ABI decoding stays stable across upgrades:

struct RaffleData {
    address host; uint48 expiry; RaffleStatus status; bool underfilled; PrizeType prizeType; // slot 0
    address prizeAsset; uint96 ticketsSold;                                                 // slot 1
    uint256 prizeAmountOrTokenId; uint256 ticketPrice; uint256 maxCap;                       // slots 2-4
}

Status is a five-state machine (OPEN → PENDING_VRF → RESOLVED → COMPLETED, plus CANCELLED), with RESOLVED deliberately appended at index 4 so indices 0–3 keep their original meaning for every already-deployed frontend and indexer.

Note the implicit invariant that settle() relies on: RESOLVED can only be reached from a raffle that had at least one ticket (resolveRaffle reverts RaffleNoTickets otherwise, and tickets cannot be added after expiry), so randomness % totalTickets can never divide by zero.


4. Draw integrity #1 — Quiver on Robinhood Chain

Quiver is a two-party commit–reveal protocol. A provider publishes a hash-chain commitment in advance and the final word is

randomNumber = keccak256(userRandom ‖ providerRevelation ‖ blockHash?)

Neither side can bias the output unilaterally, because both contributions are fixed before either is revealed.

The problem with naively using the push flow

In Quiver's push/callback flow the requester's contribution travels in calldata (requestWithCallback(provider, userRandomNumber)), so it is public. A provider that sees the requester's value before revealing its own could, in principle, pick amongst its remaining chain values to bias the draw. This is exactly the class of bug that kills "on-chain randomness" integrations in production.

What the contract does about it

resolveRaffle(id, salt) takes a fresh 32-byte CSPRNG salt that stays secret until the transaction lands, and folds it into the request's contribution:

bytes32 userRandom = keccak256(abi.encode(_salt, _raffleId, attempt, address(this), block.chainid));

Because the salt is unknown when the reveal is computed, the provider cannot precompute the outcome, and because it is bound to the raffle id, the attempt number, the contract and the chain id, it cannot be replayed or transplanted. The contract additionally rejects a zero salt (SaltZero) and keeps a per-raffle replay guard (usedSalt / SaltAlreadyUsed); the keeper generates a new salt for every request, retry and recovery attempt, never logs it, and stores it only in a local state file.

The result is a precise guarantee, and it is worth stating as narrowly as it holds: the randomness provider cannot choose the winner. It can only refuse to reveal, which stalls the draw — so a stalling provider is a liveness attack, not a theft.

Bounding the stall

A stalled draw has three bounded escape hatches, all of them bounded by constants that cannot be changed after deployment:

resolveRaffle(id, salt)            OPEN + expired + ticketed        → PENDING_VRF (attempt 1)
retryResolve(id, freshSalt)        PENDING_VRF + 6 h stall timeout  → attempts 2, 3 (fallback provider)
cancelStalledRaffle(id)            attempts exhausted (3) or expiry + 7 d HARD_DEADLINE
cancelExpiredRaffle(id)            never requested, expiry + 72 h grace
pokeFailedCallback(id)             permissionless: redeliver a buffered callback

Retries can be pointed at a second provider (fallbackProvider), and the request index is keyed by (provider, sequenceNumber) rather than sequence number alone, so a fallback provider's numbering cannot collide with the active provider's. If the coordinator has already buffered the reveal (the callback reverted on gas, not on liveness), retryResolve reverts FailedCallbackPending and the keeper calls the permissionless pokeFailedCallback instead of burning attempts.

Cancellation never fabricates a winner: it returns the prize to the host and opens O(1) pull refunds for entrants. That is the worst case a hostile provider can force, and it costs the host a failed launch rather than the entrants their money.

The residual trust surface (stated plainly)

The resolver keeper is allowlisted (setResolver), and the salt is generated by that keeper. So the honest statement of the trust model is: an honest resolver + any provider ⇒ fair draw; a colluding resolver and provider ⇒ up to MAX_RESOLVE_ATTEMPTS (3) on-chain-visible redraws to pick from. Because each redraw requires withholding the previous reveal (and every attempt is emitted as RandomnessRequested with its attempt number, with RaffleStalled on retry), the grinding window is small, bounded, and publicly auditable after the fact. There is no way to hide it. This is the trade-off the protocol made in exchange for not depending on Chainlink on a chain that has no Chainlink VRF, and it is the main thing to weigh before treating a raffle as trust-minimised.

Two more operator-controlled surfaces deserve the same honesty:

  • proposeProviderChange / applyProviderChange have a 2-second timelock by design, so a dead provider can be swapped without stalling live raffles. It is an anti-stall mechanism, not a trust boundary: the owner can install a provider it controls. Draw integrity then rests on the per-request salt, not on this delay.
  • withdrawNative is owner-only and can sweep the contract's native fee balance (the randomness fee is currently 0 wei, so the balance is empty in practice). A sweep makes the next resolveRaffle revert InsufficientFeeBalance until someone calls fundRandomnessFees(); it cannot move prize or ticket funds. Anyone can refund the contract's fee balance — it is not privileged.

Watch the callback, not the settlement

_fulfillRandomness is deliberately the thinnest function in the contract: it looks up (provider, seq), rejects unknown/stale/duplicate callbacks with an event instead of a revert, stores the word, flips the status to RESOLVED, and does nothing else — no external calls, no transfers, O(1) gas. If it ever reverted, the coordinator would buffer the reveal instead of burning it, recoverable via pokeFailedCallback. All economic movement happens later, in the permissionless settle(), so a hostile prize token can slow settlement, never the draw.


RaffleCore asks Chainlink's coordinator for one word — 3 confirmations, 300k callback gas, LINK-paid subscription (ExtraArgsV1({nativePayment: false})) — and gets back a requestId that is mapped to the raffle:

try s_vrfCoordinator.requestRandomWords(
    VRFV2PlusClient.RandomWordsRequest({
        keyHash: s_keyHash, subId: s_subId,
        requestConfirmations: REQUEST_CONFIRMATIONS, callbackGasLimit: CALLBACK_GAS_LIMIT,
        numWords: NUM_WORDS, extraArgs: VRFV2PlusClient._argsToBytes(ExtraArgsV1({nativePayment: false}))
    })
) returns (uint256 requestId) {
    requestIdToRaffleId[requestId] = raffleId;
    raffleVrfRequestedAt[raffleId] = uint48(block.timestamp);
    raffle.status = RaffleStatus.PENDING_VRF;
    emit VRFRequested(raffleId, requestId);
} catch (bytes memory reason) {
    emit VRFRequestFailed(raffleId, reason);   // raffle stays OPEN and is retried by the sweep
}

That try/catch is the whole failure policy, and it is a deliberate inversion of the obvious implementation. A drained subscription or a misconfigured coordinator used to make the upkeep call revert opaquely; now it emits a cause and leaves the raffle retryable — the keeper's sweep is the retry, with no separate retry state machine. requestIdToRaffleId is deliberately not deleted on fulfilment so keepers and indexers can always resolve request → raffle.

The callback mirrors LotteryCore's discipline: internal, reachable only through the coordinator-gated rawFulfillRandomWords, guarded against unknown request ids, wrong status, late/duplicate deliveries and an empty word array, and it performs zero transfers. Winner selection and payouts live in the permissionless settle(id).

Trust model, stated plainly: Chainlink VRF is an oracle that returns a value together with a proof verifiable on-chain, so the oracle cannot bias the output without being detected; the cost is that draw integrity depends on the oracle key/coordinator being what it claims to be, and liveness depends on the subscription being funded with LINK (on BSC, the official BEP-20 LINK — the BNB-bridged LINK is not ERC-677 compatible and cannot be used to fund a subscription).

Side by side:

Quiver (Robinhood) Chainlink VRF v2.5 (BSC)
construction two-party commit–reveal with a prior provider commitment oracle keypair + on-chain proof
who can bias the draw neither party alone nobody (proof)
who can stall the provider the oracle / an unfunded subscription
app-level mitigation fresh secret salt, bounded retries, fallback provider try/catch → VRFRequestFailed → sweep retry
request trigger resolver-gated (salt must be secret) permissionless (no secret input)
exit if entropy never arrives stall cancel after 3 attempts / 7 d emergencyFinalize after 24 h

Chainlink Automation is being retired (v1.x deprecated 2026-06-30, v2.1 2026-07-31, with a documented migration to CRE). Building a fresh BSC launch on a deprecated upkeep product would have been the wrong bet, so both chains are driven by a self-operated keeper — which also happens to be less machinery than Automation needs, because the contract now exposes first-class, cursor-paginated views instead of forcing an off-chain client to decode checkUpkeep:

Loop View Action Who may call
resolve (LotteryCore) pendingResolution(cursor, limit) resolveRaffle(id, salt) resolver allowlist
stalled (LotteryCore) stalledRaffles(cursor, limit) retryResolve(id, freshSalt) / cancelStalledRaffle(id) resolver / permissionless
resolve (RaffleCore) pendingResolution(cursor, limit) performUpkeep(abi.encode(id)) permissionless
timeout (RaffleCore) pendingFinalize(cursor, limit) emergencyFinalize(id) permissionless
settle (both) pendingSettle(cursor, limit) / status sweep settle(id) permissionless

The Robinhood keeper is a small Node/viem service with two cron jobs (resolve every 2 minutes, settle every 45 seconds); the BSC keeper is a cast + jq shell script running three loops. Their error handling is the interesting part:

  • Two independent discovery paths for settlement. The settle job scans RandomnessFulfilled logs and walks raffleCount reading status directly. That redundancy exists because a real incident produced it: a log RPC that rejects historical eth_getLogs (archive-only endpoints, or a Cloudflare 403) left an already-resolved raffle unsettled. The on-chain status sweep is authoritative and getLogs-independent; either path alone is enough.
  • Adaptive log chunking. Managed RPCs cap the block span of one eth_getLogs call. The scanner reads the provider's rejection, shrinks the chunk to the allowed range, retries the same span, and remembers the working size keyed to that RPC URL — a 10-block cap learned from a metered key must never be inherited by a wide-range endpoint, or the cursor can never reach head. Cursors are checkpointed per chunk, so a crash resumes instead of replaying.
  • Idempotent by construction. A raffle whose receipt contains PayoutEscrowed / NftEscrowed is marked settled and never retried: the escrow is the success state, and the recipient pulls it with claim / claimNft. Reverts are classified (race conditions are silent debug skips, NotResolver and InsufficientFeeBalance are loud alerts) so retries only happen where retrying can help.
  • State is rebuildable. The keeper's JSON state (queue, cursors, salt registry) is pid-locked and atomically written, but deleting it is safe: the queue rebuilds from the on-chain sweep and the lookback window.

What a compromised keeper key can actually do is worth spelling out, because it is the reason the model is defensible: on BSC all three keeper actions are permissionless and a compromised key cannot select a winner, redirect escrowed funds, or change any payout. It can censor (refuse to trigger a draw) — which is exactly why the cancellation paths are permissionless too, with grace periods the keeper cannot shorten. On Robinhood a compromised resolver key is the sharper case: it can request draws (with its own salt) but still cannot choose the outcome (section 4); a leaked resolver key is best rotated with setResolver, and a leaked free-entry signer with setTrustedSigner.

Chainlink Automation Self-operated keeper
liveness depends on Chainlink network + upkeep funding our process, cron, RPC, gas balance
who pays LINK upkeep balance keeper wallet gas (+ contract native fee balance on Robinhood)
on-chain requirement checkUpkeep/performUpkeep interface any permissionless function + a view
observability Automation UI + events our logs, alerts, receipts, and an on-chain status sweep
failure mode silent upkeep failure explicit alert, bounded retry, resumable cursor, permissionless backstop
exit if the keeper dies stuck until re-registered anyone can call the permissionless paths

The last row is the real argument: with the keeper dead, settle, pokeFailedCallback, emergencyFinalize, cancelExpiredRaffle and cancelStalledRaffle are all callable by anybody, including a user with a cast one-liner. The keeper is a convenience, not a dependency.


7. Escrow safety and failure handling

The single most important property in the codebase is that no terminal path can revert because of a hostile token or a hostile recipient. Every outbound ERC-20/721 move in settle, performUpkeep, emergencyFinalize and the cancel paths goes through one helper:

function _payout(address _token, address _to, uint256 _amount) internal {
    if (_amount == 0) return;
    if (_tryTransfer(_token, _to, _amount)) return;
    claimable[_token][_to] += _amount;             // escrow, do not revert
    emit PayoutEscrowed(_token, _to, _amount);
}

_tryTransfer bounds the callee to 200k gas and copies at most 32 bytes of returndata, so a reverting token, a token that returns false, and a "returndata bomb" are all handled without bricking settlement. NFT payouts use transferFrom (not safeTransferFrom) so a recipient's onERC721Received cannot grief the transfer either, and failures land in nftClaimant.

Two complementary guards make exactly-once semantics mechanical rather than argued:

  • prizeDisposed[raffleId] — the raw prize moves once, on whichever terminal path lands first. It is never disposed at request time, where a retried request could double-transfer.
  • rafflePaymentPool[raffleId] is deleted before distribution, so the pool cannot be paid twice.

And the intake side is validated just as hard, because a fee-on-transfer prize silently escrowing less than recorded is precisely how funds get stranded later:

uint256 balBefore = IERC20(_asset).balanceOf(address(this));
IERC20(_asset).safeTransferFrom(msg.sender, address(this), _amount);
uint256 received = IERC20(_asset).balanceOf(address(this)) - balBefore;
if (received != _amount) revert PrizeAmountMismatch(_amount, received);

For NFTs the check is custody itself (ownerOf(tokenId) == address(this)), not merely that the call returned. This validation, the escrow fallback and the idempotent disposal were the three workstreams that turned the BSC contract from "correct on the happy path" into "cannot strand funds" — the earlier assessment found a case where a reverting prize asset made both the VRF callback and the emergency escape hatch revert, permanently locking participant payments with no contract-level exit.

Failure taxonomy

Condition What happens Money
randomness request reverts RandomnessRequestFailed / VRFRequestFailed; raffle stays OPEN untouched; sweep retries
callback reverted on gas randomness buffered by the coordinator; pokeFailedCallback (Quiver) or Chainlink's own retry redelivers it untouched
provider withholds the reveal 6 h stall timeout → retryResolve (≤3 attempts, fallback provider) untouched
still no reveal cancelStalledRaffle (or HARD_DEADLINE 7 d) → CANCELLED prize to host, entrants claimRefund
never requested, expired cancelExpiredRaffle after 72 h grace same as above
BSC VRF silence emergencyFinalize after VRF_TIMEOUT (24 h) → CANCELLED prize to host, refunds open
expired with zero entrants completeEmptyRaffle / performUpkeep path prize back to host, no draw
raffle underfilled at expiry prize back to host; pool is still drawn to a random entrant pool minus fee to a random entrant
prize token hostile on payout _payout escrows instead of reverting claim / claimNft
winner blacklisted settlement completes; winner pulls claim
loser tries to grief cancellation cancel paths are permissionless and grace-gated refunds unaffected
keeper wallet out of gas alert, backoff, resume from checkpoint untouched
log RPC cannot serve history authoritative on-chain status sweep untouched
owner drains native fee balance next resolve reverts InsufficientFeeBalance until anyone re-funds prize/pool untouched

The invariant test suite encodes the top-level version of all of this: invariant_noStrandedFunds asserts that a terminal raffle has its prize disposed, that ticket counts respect the cap, and that the contract always covers outstanding refunds plus escrow.

Live examples on mainnet

The three terminal shapes have already occurred on chain (read directly from the contracts):

  • A normal draw. The pre-rebrand Robinhood deployment at 0x7Df6…f947 resolved raffle #1 on its first attempt (attempts = 1, underfilled = false, prize disposed) — the happy path.
  • A zero-participant expiry. Robinhood raffle #1 on the current deployment (0x0449…797d) expired with ticketsSold = 0: status COMPLETED, underfilled = true, the 50 USDC prize back to the host and no draw requested.
  • A cancel with refunds. BSC raffle #1 sold all 5 of its 5 tickets (a 50 USDT prize, 1 USDT per ticket, cap 5) and ended CANCELLED — the liveness-fallback path, with entrants able to pull their exact contributions through claimRefund. Its prizeDisposed flag is set and no escrow entry was created, i.e. the prize really did move back to the host. BSC raffle #2 expired with zero entrants (COMPLETED, underfilled), which is the zero-ticket path where no grace period applies and the prize returns immediately. Those two raffles also happen to illustrate that the prize asset is independent of the payment token: tickets are priced in USDC while the prize escrowed in both was BSC USDT.

A cancelled sold-out raffle is the scenario a naive design gets catastrophically wrong, and it is worth saying what is not in this history: no winner was fabricated, no ticket money was trapped, and the prizes were returned to their hosts.


8. Access control

Capability Mechanism Notes
owner Ownable2Step (LotteryCore) / Chainlink ConfirmedOwner (RaffleCore) 2-step transfer; owner set at construction
request a draw isResolver[caller] allowlist (LotteryCore only) BSC request is permissionless
sign free entries trustedSigner, owner-settable EIP-712 FreeEntry(raffleId,user)
fees proposeFeeChange → 2-day timelock → applyFeeChange; MAX_PLATFORM_FEE_BPS = 1000 currently 0 bps on both chains
randomness provider proposeProviderChange → 2 s timelock (anti-stall, not a trust boundary) LotteryCore only
cancel grace / min duration setCancelGrace (≥1 h) / setMinDuration (≥2 h floor) floors enforced in code
pause pause() — blocks creation and entry only resolution, settlement, refunds, claims stay live
payment token, treasury immutable settled at construction; cannot be redirected later

Design rules worth calling out:

  • Pausing cannot trap funds. The emergency brake only stops new money coming in; every exit path (settle, cancel, refunds, claims) is explicitly outside whenNotPaused.
  • Signature claims are internal. verifyAndClaim is internal, called only from enterFreeRaffle, so an external caller cannot burn someone else's signature without entering the raffle — and per-user-per-raffle double claims are blocked (freeEntryClaimed). The EIP-712 domain is fixed in the verifier (LotteryCore/1) and was not renamed in the rebrand, so front-ends must sign that domain string, not the contract's display name; the repository README records that an earlier mainnet deployment is not signature-compatible with it.
  • Fee changes are timelocked but the fee is bounded. Even a compromised owner cannot push the fee past 10 %, and cannot make it effective before 2 days — which is enough time to observe a proposal on chain.
  • The weakest link is the owner key. Both deployments are owned by a single EOA. That owner cannot take a raffle's escrow or divert ticket money — settlement and refunds are permissionless and payouts are computed from on-chain state — but it can pause new business, change the fee (after 2 days), swap the randomness provider (after 2 s), change the free-entry signer, and drain the native fee balance. The right hardening for a production launch is a multisig (and, on Robinhood, moving setResolver + provider rotation behind it). This is a policy gap, not a code gap, and it is worth stating rather than hand-waving.

9. What I would tell another engineer building this

  1. Make the callback boring. Store the word, flip a status, return. Every atom of complexity you move out of the randomness callback into a permissionless settle() is a class of failure (gas, reentrancy, hostile token, hostile recipient) that can no longer destroy a draw.
  2. Never push a token transfer you have not bounded. 200k gas plus 32 bytes of returndata is the difference between a degraded settlement and a permanently stranded prize.
  3. Validate escrow on the way in. Fee-on-transfer and short-paying tokens are silent until the day they strand a payout.
  4. Make the terminal paths permissionless and idempotent (prizeDisposed), then the worst case for any adversary is a cancelled raffle with refunds — not a stolen prize.
  5. Treat the entropy adapter as an adapter. The two contracts differ in exactly one section (how the word arrives) and share everything downstream; that is why porting the hardening from one to the other was a small, testable diff.
  6. Write down the trust model you actually have. "The provider cannot pick the winner, but a colluding keeper and provider get a bounded, on-chain-visible redraw window" is a usable statement; "trustless randomness" is not.

How to verify every claim in this post

Everything above is checkable against the repository (src/quiver/LotteryCore.sol, src/chainlink/RaffleCore.sol, keeper/, test/) and public chain state. All of these are read-only; none of them needs a key:

# Robinhood Chain
RPC=https://rpc.mainnet.chain.robinhood.com
LC=0x04491a792C82Dd66C5993b89E6E5044bF0da797d
cast call $LC "raffleCount()(uint256)"            --rpc-url $RPC
cast call $LC "platformFeeBps()(uint256)"         --rpc-url $RPC
cast call $LC "activeProvider()(address)"         --rpc-url $RPC
cast call $LC "getResolutionState(uint256)((uint8,uint8,address,uint64,uint48,bool,bool))" 1 --rpc-url $RPC
cast call $LC "getRaffle(uint256)((address,uint48,uint8,bool,uint8,address,uint96,uint256,uint256,uint256))" 1 --rpc-url $RPC

# BSC
RPC=https://bsc-dataseed.binance.org
RC=0xd990ca6f045f86df32c115deee4d24e4a911993b
cast call $RC "getCoordinator()(address)"         --rpc-url $RPC
cast call $RC "cancelGrace()(uint256)"            --rpc-url $RPC
cast call $RC "VRF_TIMEOUT()(uint256)"            --rpc-url $RPC
cast call $RC "pendingSettle(uint256,uint256)(uint256[],uint256)" 0 50 --rpc-url $RPC

Deployment records live under broadcast/ keyed by chain id (DeployLotteryCore.s.sol/4663, Deploy7.s.sol/56), including constructor arguments.

The contract claims are backed by the repository's own suite, which is runnable end to end:

forge test
# Ran 14 test suites: 545 tests passed, 0 failed, 9 skipped (554 total)
#   skipped = opt-in mainnet-fork smoke tests (need an archive RPC)

The suites that matter for the properties above are RaffleCoreSettlement.t.sol (blacklisted winner/host, reverting / return-false / returndata-bomb prize tokens, NFT and fee escrow, reentrancy on settle and claim, disposal-exactly-once), RaffleCoreFailover.t.sol (request failure and retry, VRF timeout, late/duplicate/unknown callbacks, grace boundary and griefing, keeper-loop simulation, callback and settle gas ceilings), RaffleCoreInvariant.t.sol (invariant_noStrandedFunds), RaffleCoreFuzz.t.sol (winner vs a linear reference, random lifecycles, terminal solvency) and the LotteryCore* equivalents.

Known gaps and open items

Listed because a walkthrough that only claims strengths is marketing:

  • The BSC launch is driven by a shell-script keeper whose loop logic is covered by keeper-loop tests but which has not been exercised against a live BSC RPC in this repository's CI.
  • forge coverage cannot be produced for these contracts (via_ir has to stay on, and a fuzz test hits a coverage-only "stack too deep"); coverage-by-instrumentation is the one quality signal that is missing.
  • Opt-in mainnet fork smoke tests are skipped unless an archive RPC is supplied.
  • On Robinhood, Ownable2Step is used but transferOwnership to a multisig has not been done; setResolver and provider rotation are still single-EOA operations.
  • Provider rotation is anti-stall rather than trust-minimised (2-second timelock); draw integrity rests on the per-request salt and the bounded, visible redraw window described in section 4.
  • The README's deployment table describes an earlier Robinhood mainnet deployment; the address recorded in broadcast/DeployLotteryCore.s.sol/4663 (and used in this post) is the current one. Re-read live values rather than trusting any table, including this one.

Repo: Verifiable-Randomness-Raffle-Protocol. This document is written against the tree at commit f97172a on branch rafflecore-bsc-hardening; live values were read on 2026-10-12 and will drift.

Adapted from the protocol repository's docs/walkthrough.md, where the draft lives next to the contracts.

Questions or corrections: smuel.rr@gmail.com.