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/applyProviderChangehave 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.withdrawNativeis 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 nextresolveRafflerevertInsufficientFeeBalanceuntil someone callsfundRandomnessFees(); 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.
5. Draw integrity #2 — Chainlink VRF v2.5 on BSC
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 |
6. The self-operated keeper model vs Chainlink Automation
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
RandomnessFulfilledlogs and walksraffleCountreading status directly. That redundancy exists because a real incident produced it: a log RPC that rejects historicaleth_getLogs(archive-only endpoints, or a Cloudflare 403) left an already-resolved raffle unsettled. The on-chain status sweep is authoritative andgetLogs-independent; either path alone is enough. - Adaptive log chunking. Managed RPCs cap the block span of one
eth_getLogscall. 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/NftEscrowedis marked settled and never retried: the escrow is the success state, and the recipient pulls it withclaim/claimNft. Reverts are classified (race conditions are silentdebugskips,NotResolverandInsufficientFeeBalanceare 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…f947resolved 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 withticketsSold = 0: statusCOMPLETED,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 throughclaimRefund. ItsprizeDisposedflag 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.
verifyAndClaimisinternal, called only fromenterFreeRaffle, 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
- 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. - 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.
- Validate escrow on the way in. Fee-on-transfer and short-paying tokens are silent until the day they strand a payout.
- 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. - 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.
- 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 coveragecannot be produced for these contracts (via_irhas 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,
Ownable2Stepis used buttransferOwnershipto a multisig has not been done;setResolverand 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.