Skip to content
D20DAO

Build a Verifiable Giveaway on Robinhood Chain

Freeze an entrant list, request D20DAO VRF in Solidity, and independently verify the winner of a giveaway on Robinhood Chain.

One list. One request. A winner anyone can check.

A giveaway announcement usually tells people who won. A verifiable giveaway also lets them check how that winner was selected. In this guide, we build a small Solidity contract that commits to an ordered list of wallet addresses, requests randomness from D20DAO on Robinhood Chain, and selects one address from that list. Anyone can inspect the list and repeat the calculation.

  1. The example selects a winner only. It does not sell tickets, hold prizes or transfer rewards.
  2. The contract and its failure paths are tested on a local EVM with a mock coordinator. The public mainnet evidence later in this guide belongs to an existing dice request, not a deployment of this giveaway.

Fix the entries before requesting randomness

The constructor accepts between 1 and 64 unique, nonzero wallet addresses. It stores their order and hashes the full list with keccak256(abi.encode(fixedEntrants)). There is no function to add, remove or reorder entries later. The hash becomes the request's client seed, binding the request to that exact list. It is a commitment, not a secret source of entropy.

  1. Publish this giveaway's contract address and eligibility rules before anyone draws. Participants need one agreed contract; an organizer could otherwise deploy several copies and announce only a preferred result.
  2. Each address occupies one position. This is not proof of one entry per person: eligibility and resistance to multiple-wallet entries are application responsibilities.
  3. The 64-entry limit keeps this teaching example small. It is not D20DAO's general capacity limit. A large campaign needs a separately designed entry-list and proof scheme.

Start on Robinhood Chain Testnet

Use Node.js 22.13 or newer and compile with Solidity 0.8.28, targeting Cancun. This example is pinned to @d20dao/vrf-sdk 0.6.2. Its base consumer authenticates coordinator callbacks; its mapping library supplies the same ChooseOne calculation used by the protocol.

  1. Robinhood Chain Testnet: chain ID 46630; coordinator proxy 0x2f26513DE4Ed388947f5d22FD395D5E06f472f05.
  2. Robinhood Chain: chain ID 4663; coordinator proxy 0xEc8b95B168c87294c45727Bd2ac903d09316D132. Use testnet for your first deployment and request.
  3. Deploy OneShotGiveaway(coordinator, fixedEntrants) using your Solidity tooling. Pass the coordinator proxy for the selected chain and the final ordered list of addresses. Verify the deployed consumer source and retain its creation transaction.
  4. The local-check script compiles and exercises the contract without a public deployment. Follow its README to run it before connecting a funded wallet.
Install the tutorial dependencies
npm install @d20dao/vrf-sdk@0.6.2 ethers@6.17.0
npm install --save-dev solc@0.8.28

Open exactly one draw

Anyone can sponsor the draw by paying its request fee and transaction gas. draw() changes state before calling the coordinator, so another call cannot start a second request. The sponsor becomes the fixed recipient of any service-fee refund and excess-payment credit. If the request transaction reverts, all its state changes revert too, and the draw has not started.

Giveaway.sol — request
    function draw() external payable {
        if (state != State.Ready) revert AlreadyStarted();
        // Effects precede the external request; no caller can start a second draw.
        state = State.Pending;
        // The coordinator checks its exact quote and credits excess to this fixed sponsor.
        requestId = ID20VRF(vrfCoordinator).requestMappedRandomness{value: msg.value}(
            entrantsHash, CALLBACK_GAS, msg.sender, _choice()
        );
        emit DrawRequested(requestId, entrantsHash);
    }

Quote the fee from the current block

Robinhood Chain requests are paid in ETH. In a browser, use the SDK's quoteRequestFee helper: it reads the block's base fee and calls quoteFeeAt with a buffer. A plain off-chain quoteFee call can see a zero base fee and underestimate the cost. The coordinator computes the actual fee inside the request transaction; unused headroom becomes the sponsor's refund credit, withdrawable from the coordinator with withdrawRefundCredit(recipient). Transaction gas is separate.

  1. Call this from an explicit user action. Disable the draw button immediately while a wallet prompt or request is pending. The one-shot contract prevents a second successful draw, but the UI should also avoid repeated prompts.
  2. Pass a saveTransactionHash function that persists the hash in your app as soon as the wallet returns it; after confirmation, save the request ID. If waiting fails after broadcast, recover from that saved hash. A confirmed request transaction means the request exists, not that a winner has arrived.
  3. A fee quote is not a price reservation. If the base fee or pricing configuration changes enough before inclusion, an underpaid request reverts. Re-quote only after checking whether the earlier transaction actually created a request.
TypeScript — call from your connected wallet flow
import { Contract, type BrowserProvider } from "ethers";
import { quoteRequestFee } from "@d20dao/vrf-sdk";

const coordinator = "0x2f26513DE4Ed388947f5d22FD395D5E06f472f05";

export async function requestDraw(
  provider: BrowserProvider,
  giveawayAddress: string,
  saveTransactionHash: (hash: string) => void,
) {
  if ((await provider.getNetwork()).chainId !== 46630n) {
    throw new Error("Switch to Robinhood Chain Testnet first.");
  }
  const giveaway = new Contract(giveawayAddress, [
    "function vrfCoordinator() view returns (address)",
    "function draw() payable",
    "function requestId() view returns (uint256)",
  ], await provider.getSigner());
  if ((await giveaway.vrfCoordinator()).toLowerCase() !== coordinator.toLowerCase()) {
    throw new Error("This giveaway uses a different coordinator.");
  }
  const { value } = await quoteRequestFee(provider, coordinator, 100_000);
  const tx = await giveaway.draw({ value });
  saveTransactionHash(tx.hash); // Persist before waiting, for reload/error recovery.
  await tx.wait();
  return { transactionHash: tx.hash, requestId: await giveaway.requestId() as bigint };
}

Store the authenticated result

D20VRFConsumer rejects calls from addresses other than the configured coordinator. Our callback also checks the request ID and stores the word only for the pending draw. Repeating the same ID and word has no further effect. A different word or an unrelated request is rejected. Prize transfers stay outside the callback, keeping delivery small and independent of whether a winner can receive an asset.

Giveaway.sol — callback
    function _fulfillRandomness(uint256 id, bytes32 randomness) internal override {
        if (requestId == 0 || id != requestId) revert UnexpectedCallback();
        if (state == State.Drawn && randomness == word) return;
        if (state != State.Pending) revert UnexpectedCallback();
        word = randomness;
        state = State.Drawn;
        emit DrawCompleted(id, randomness);
    }

Map the word to one address

ChooseOne returns a zero-based index into the fixed list. The full contract uses RandomnessMapping.map with that same mapping to expose winnerIndex() and winner(). Use the SDK mapping when checking the result off chain; replacing it with your own modulo calculation can produce a different answer. Read the ordered addresses and delivered word from the giveaway contract, then run:

  1. Check the list hash against entrantsHash() and the request's client seed. Include the order when hashing: the same addresses in a different order can select a different winner.
  2. Match the request's consumer address, chain, coordinator, request ID and ChooseOne population to this giveaway. A valid proof from another request is not its result.
  3. Recomputing the index checks the application mapping only. Verify the underlying VRF and drand evidence, canonical receipts and historical implementation separately in the Explorer or SDK.
TypeScript — reproduce the winner calculation
import { builtins, mapRandomness } from "@d20dao/vrf-sdk";

export function reproduceWinner(word: string, orderedEntrants: string[]) {
  const [index] = mapRandomness(word, builtins.chooseOne(orderedEntrants.length));
  return { index, winner: orderedEntrants[Number(index)] };
}

Recover delivery without redrawing

Keep transaction confirmation, proof acceptance and callback delivery as separate states in your UI. getRoundRequest(requestId) reports fulfilled, delivered and refunded. A failed callback after proof acceptance does not make the draw eligible for another random result.

  1. If fulfilled is true and delivered is false, call retryCallback(requestId, gasLimit) on the coordinator. It delivers the same stored word with no second randomness-service fee; the retry transaction still costs gas.
  2. If no proof was accepted before the request's deadline, refundRequest(requestId) can be called strictly after expiry. The refundable service fee goes to the fixed sponsor, as a payment or backed credit. Gas and application costs are not refunded.
  3. The giveaway's authenticated _onRefund hook marks it Expired. It never resets requestId or returns to Ready. If refund notification fails, the coordinator's refunded state is authoritative and the notification can be retried; starting a new draw remains blocked.
  4. An unavailable RPC is an unknown state, not evidence of failure. Retain the transaction and request IDs, back off reads, and recover their status instead of creating a replacement giveaway automatically.

Inspect a real Robinhood Chain request

Robinhood Chain mainnet request #2 is public evidence you can inspect now. It was requested on 9 October 2026 and delivered three d20 results: 1, 4 and 10. It uses drand round 21,344,827; the request is in block 84,221,531 and fulfillment in block 84,221,592. This is an existing dice request, not the giveaway contract above. It demonstrates the request evidence and replay workflow; its outputs must not be relabeled as giveaway winners.

What participants can and cannot verify

When a request is made, the contract binds it to a future round of drand's evmnet beacon: the first round scheduled at least 3 seconds after the request. Until that round is published, nobody can know or choose the result: not the requester, the operator, the sequencer or drand. The keeper then delivers the operator's VRF output over the request's fixed fields and the round's randomness. The contract verifies the round's BLS signature and the VRF proof before accepting it, and anyone can replay the result from public chain data.

  1. Shared with every Robinhood Chain application: D20DAO relies on the chain's sequencer for ordering and confirmation.
  2. Shared with other verifiable-randomness services: results assume the operator's VRF key and the sequencer act independently.
  3. drand: evmnet is run by the League of Entropy, and its signatures rest on a threshold of independent operators.
  4. The coordinator is upgradeable by its owner. A fixed proxy address does not prove unchanged behavior: check the implementation and configuration active at the request and fulfillment receipts.
  5. A proof does not force an operator to deliver a result or an organizer to honor a prize. This example makes a completed selection inspectable; prize custody, eligibility and settlement require their own design.
  6. Send requests as normal transactions through the sequencer. A request forced through Ethereum's delayed inbox takes an earlier timestamp and may bind a round that is already public.

Run the checks, then make your own testnet request

The downloadable local check compiles Giveaway.sol with Solidity 0.8.28 and SDK 0.6.2, then exercises it against a mock coordinator on Anvil. It checks invalid lists, fees and refund credit, one-shot requests, callback authentication, winner mapping and terminal expiry. It is a reproducible integration check, not a cryptographic audit or evidence of a public-chain deployment.

  1. Run the local checks, deploy your own consumer to Robinhood Chain Testnet, verify its source and publish its address and fixed list.
  2. Make one wallet-authorized testnet request. Preserve the request and fulfillment transactions and verify the result against the original ordered list.
  3. Before adding prizes, implement and test their funding and claim rules separately. Publish those rules with the entry conditions so participants know what the proof covers.
  4. Building on Arc too? D20DAO exposes the same consumer interface there, with native USDC fees and epoch-based drand inputs. Use Arc's coordinator and network configuration; the x402 agent API currently opens requests on Arc.