Skip to content
D20DAO

Verifiable NFT Reveals on Robinhood Chain

Commit the metadata order, request D20DAO VRF, and verify a one-time NFT reveal on Robinhood Chain with a Solidity assignment module and SDK mappings.

Make the assignment checkable

An NFT reveal assigns metadata to token IDs. Collectors need to check both the list being assigned and the randomness used to assign it. Here we freeze both ordered lists, request one random word from D20DAO, and use its Shuffle mapping to assign a metadata slot to each token. Collectors can repeat the calculation.

  1. The downloadable OneShotNFTReveal contract is an assignment module for an existing collection. Your ERC-721 contract still controls minting, ownership and tokenURI.
  2. This example supports 1 to 64 tokens. It stores the accepted word in a small callback and computes the permutation when read. It has no function to replace the lists or start another reveal.

Commit before minting starts

Prepare an ordered manifest of the final metadata files. For each slot, hash the exact metadata JSON bytes with keccak256. Store those hashes and the agreed token-ID order in the module's constructor. It commits to keccak256(abi.encode(collection, fixedTokenIds, fixedMetadataHashes)). Publish the collection address, module address and this commitment before minting begins, so the organizer cannot pick a different metadata list after seeing who owns which token.

  1. Hash file bytes, not a JavaScript object. Whitespace or property-order changes change the hash even when two JSON documents describe the same traits. Preserve the original bytes for later verification.
  2. Use content-addressed references for the metadata and its image or animation. A hash of JSON containing a mutable HTTPS image URL does not freeze the image served at that URL.
  3. A commitment does not make the files available. Publish the manifest and metadata files at reveal, and arrange persistent hosting or pinning for the referenced assets.
  4. Freezing a list does not prove that its rarity distribution was reasonable. Publish the supply and trait rules separately. Repeated metadata is allowed: the shuffle assigns each slot once, not necessarily a different image to every token.

Close minting before requesting the reveal

The module accepts reveal requests only from its immutable collection contract. That collection must close minting, freeze the agreed eligible token set, and apply any announced transfer restrictions before it calls requestReveal(refundAddress). The module authenticates its caller; it cannot prove that the collection actually enforced those rules. Inspect and test that collection code too.

  1. Use the exact stored order. Token ID 0 is valid, token IDs may have gaps, and an ERC-721 enumeration order is not automatically your reveal order. This module rejects duplicate token IDs.
  2. At the request, all token IDs in the batch should already be minted and further minting for that batch closed. Otherwise someone could choose what to mint after the assignment becomes knowable.
  3. A pending fulfillment transaction can expose the word before its callback is confirmed. If trading on an unrevealed token is part of the sale rules, enforce the announced transfer policy in the collection through this interval; a hidden frontend button is insufficient.
  4. The module commits to token IDs, not their owners. If an allocation depends on a particular owner snapshot, commit to that snapshot separately rather than inferring it from later ownership.

Set up the Solidity module

Use Node.js 22.13 or newer, Solidity 0.8.28 and the Cancun EVM target. This tutorial is pinned to @d20dao/vrf-sdk 0.6.2. Deploy the collection first, then the module with the selected coordinator, that collection address, the ordered token IDs and the same-length metadata hash list. Lock the collection's one-time module reference before minting starts. The module requires an already deployed collection contract, so do not construct it with address(this) from inside the collection's constructor.

  1. Robinhood Chain Testnet: chain 46630; coordinator proxy 0x2f26513DE4Ed388947f5d22FD395D5E06f472f05. Start here for a public deployment.
  2. Robinhood Chain: chain 4663; coordinator proxy 0xEc8b95B168c87294c45727Bd2ac903d09316D132. Check the deployment manifest and active implementation before funded use.
  3. The module contains no ERC-721 minting logic, sale, prize transfer or mutable base URI. Its local test collection is a lifecycle stub, not an NFT contract to deploy for a sale.
Install the pinned tutorial dependencies
npm install @d20dao/vrf-sdk@0.6.2 ethers@6.17.0
npm install --save-dev solc@0.8.28

One request for the whole batch

requestReveal(refundAddress) moves the module from Ready to Pending before making its external coordinator call. It forwards the supplied payment, binds the frozen commitment as the client seed, and requests Shuffle with count and population both equal to the token count. The seed is a public commitment, not secret entropy. Robinhood Chain binds the request to a future drand round and verifies both its BLS signature and the operator's VRF proof before accepting the word.

  1. The collection chooses the fixed refund address. Forward the original sponsor deliberately; msg.sender inside the module is the collection, not the connected wallet.
  2. Requests and transaction gas are paid in ETH. Read the current fee rather than copying a price from a tutorial. An off-chain sender uses quoteRequestFee with the latest base fee and a buffer; a quoteFee eth_call alone can underestimate the fee.
  3. The coordinator credits any excess request payment to the fixed refund address. That address can withdraw its surplus with withdrawRefundCredit. A service-fee refund does not refund NFT purchases or transaction gas.
  4. Save the transaction hash immediately after broadcast and the request ID after confirmation. Disable repeated wallet prompts while pending, and recover a submitted transaction before attempting another call.
NFTReveal.sol — request after the collection closes minting
    function requestReveal(address refundAddress) external payable {
        if (msg.sender != collection) revert NotCollection();
        if (state != State.Ready) revert AlreadyStarted();
        if (refundAddress == address(0) || refundAddress == address(this)) revert InvalidRefundAddress();
        state = State.Pending;
        requestId = ID20VRF(vrfCoordinator).requestMappedRandomness{value: msg.value}(
            commitment, CALLBACK_GAS, refundAddress, _shuffle()
        );
        emit RevealRequested(requestId, commitment);
    }

Keep the callback small

D20VRFConsumer authenticates callbacks against the configured coordinator. The module also checks the request ID and pending state, then stores only the delivered word and the Revealed state. A repeated callback carrying that same ID and word is harmless; a different word is rejected. A zero-valued word is valid, so state must not be inferred from word != 0.

  1. The callback does not loop over tokens, write every metadata URI or transfer NFTs. The local harness tests delivery with the configured 100,000-gas callback budget.
  2. metadataIndex(position) recomputes this bounded shuffle. For a bulk off-chain display, read permutation() once rather than requesting the whole calculation again for every token.
  3. The collection's tokenURI logic should use the accepted assignment only after the module is Revealed. Before then it can serve the announced placeholder. For each valid token ID, resolve its frozen position, read metadataIndex(position), and serve the committed file at that slot.
  4. Changing a base URI or metadata setter later can bypass the reveal commitment. Remove that ability or make any remaining authority explicit, and ensure the returned file bytes match the committed hash.

A permutation, with a clear direction

The assignment is tokenIds[position] → metadata[permutation[position]]. Positions and metadata indices are zero-based; token IDs are the actual values in your list. For an illustrative permutation [2, 0, 1], the first token receives metadata slot 2, the second receives slot 0, and the third receives slot 1. That illustration is not a recorded VRF result.

  1. The SDK uses Fisher–Yates with rejection-sampled choices. Use its exact mapping, not a separate Math.random shuffle or your own word % supply offset.
  2. A random offset produces only rotations of a fixed order. A shuffle changes the ordering more generally; it still derives from one 256-bit word, not unlimited entropy or a guarantee of reaching every possible permutation for arbitrarily large lists.
  3. The built-in Shuffle limit is 256 items; this teaching module deliberately caps its batch at 64. A 10,000-token collection needs a separately reviewed large-collection assignment design, not a raised constant or independent batches presented as one global shuffle.
TypeScript — reproduce the commitment and assignment
import { AbiCoder, keccak256 } from "ethers";
import { builtins, mapRandomness } from "@d20dao/vrf-sdk";

export function reproduceReveal(
  collection: string,
  tokenIds: bigint[],
  metadataHashes: string[],
  word: string,
) {
  if (tokenIds.length < 1 || tokenIds.length > 64 || tokenIds.length !== metadataHashes.length) {
    throw new Error("Expected matching frozen lists with 1 to 64 slots.");
  }
  const commitment = keccak256(AbiCoder.defaultAbiCoder().encode(
    ["address", "uint256[]", "bytes32[]"],
    [collection, tokenIds, metadataHashes],
  ));
  const permutation = mapRandomness(word, builtins.shuffle(tokenIds.length));
  return {
    commitment,
    assignments: tokenIds.map((tokenId, position) => ({
      tokenId,
      metadataIndex: permutation[position],
      metadataHash: metadataHashes[Number(permutation[position])],
    })),
  };
}

Verify the reveal from files to proof

Start with the exact collection and module published before minting. Read their frozen arrays and commitment, then hash the revealed metadata bytes and compare each slot. Use reproduceReveal with the module's accepted word, and compare its commitment and every assigned metadata hash to the contract. The helper checks the application calculation; it does not verify a VRF proof or establish chain inclusion.

  1. Match the request's chain ID, coordinator, consumer module, request ID, client seed and Shuffle population to this batch. A valid proof from another consumer or a different list is not its reveal.
  2. Open that request in the D20DAO Explorer and replay the drand and VRF evidence. The SDK also exposes readRoundRequestEvidence and replayRoundRequest for Robinhood Chain; use the installed declarations and the verification guide for their complete inputs.
  3. Check canonical request and fulfillment receipts, the coordinator implementation and configuration active at those receipts, and the chain's settlement state. Mathematical proof validity alone does not establish those facts.
  4. Finally compare what tokenURI actually serves to the assigned committed bytes, and inspect collection permissions. A correct randomness proof cannot prevent an unrelated metadata setter from returning a different asset.

Recover delivery without replacing the reveal

If a proof was accepted but its callback failed, retryCallback delivers the same stored word. There is no second randomness-service fee, though the retry transaction costs gas. Keep proof acceptance and callback delivery as separate UI states. An RPC timeout means the state is unknown; retain the transaction and request IDs, back off reads and recover them.

  1. If no proof was accepted by the request's deadline, refundRequest can settle the refundable service fee strictly after expiry. Its recipient was fixed when requesting.
  2. The module's authenticated refund notification moves it to Expired and never back to Ready. If notification fails, the coordinator's refunded state remains authoritative and retryRefundCallback can update the module without another payment.
  3. Expiry can leave the collection unrevealed. Publish that failure policy before the sale. Deploying another module or changing to a fallback word after inspecting a candidate outcome would defeat the one-request rule.
  4. A proof does not force the keeper to deliver or the organizer to publish metadata. Robinhood Chain ordering, drand's threshold assumptions, VRF-key independence and coordinator upgrade authority remain part of the trust model.
  5. 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.

Use the same assignment module on Arc

The consumer interface and deterministic Shuffle mapping are the same on Arc. Deploy with Arc's coordinator and use native USDC for request fees and gas instead of ETH. Arc binds randomness through drand-backed epochs and a future target block; Robinhood Chain binds a future drand round per request. A reveal on one chain does not automatically prove or update a collection on another.

Run the example before connecting a funded wallet

The download includes the module, a mock coordinator, a collection lifecycle stub and an isolated Anvil check. The mock accepts test words and performs no VRF or BLS verification. These checks establish consumer behavior and agreement with the SDK mapping; they are not an audit, an end-to-end NFT sale test or evidence of a deployment of this module on Robinhood Chain.

  1. Run the documented local checks, then integrate the module with your own collection and test mint closure, transfer policy, tokenURI content and failure handling together.
  2. For a public test, deploy on Robinhood Chain Testnet with your own wallet authorization, preserve the request and fulfillment receipts, and verify the complete evidence and assignment before mainnet use.
  3. Publish the metadata commitment, eligible-token rules, contract addresses and expiry policy before inviting collectors to mint.