# One-shot NFT metadata assignment on Robinhood Chain

`NFTReveal.sol` assigns a fixed list of token IDs to a shuffled list of metadata content hashes, using D20DAO's published consumer and `Shuffle` mapping. It is an educational **assignment module**, not an ERC-721 collection. It neither mints NFTs nor serves token URIs, closes minting, verifies token existence, or freezes ownership.

## Download and run locally

Save these four files in a new directory outside your website's public assets:

- [NFTReveal.sol](./NFTReveal.sol) — `OneShotNFTReveal`.
- [package.json](./package.json) — SDK 0.6.2, ethers 6.17.0 and solc 0.8.28, pinned.
- [local-check.mjs](./local-check.mjs) — compilation and lifecycle assertions.
- [MockCoordinator.sol](./MockCoordinator.sol) — local-only coordinator, collection and sponsor stubs. They accept arbitrary words and do not verify VRF or beacon proofs; do not use them as a deployed randomness service or NFT collection.

With Node.js 22.13 or newer:

```sh
npm install --ignore-scripts --no-audit --no-fund
npm run compile
```

For the lifecycle checks, install [Foundry's Anvil](https://getfoundry.sh/anvil/overview/), make `anvil` available on your PATH, and run:

```sh
npm run check
```

The script starts and stops an isolated Anvil at `127.0.0.1`, chain 31337, using disposable unlocked local accounts. It does not read `.env`, private keys or a live RPC address. Compiler artifacts and `check-report.json` go into a new operating-system temporary directory printed by the script, outside the downloaded folder. The [included validation report](./validation-report.json) records an actual local run.

Compilation uses Solidity 0.8.28, Cancun and optimizer runs 200. Every imported Solidity file is checked against the installed SDK's `PROTOCOL-PROVENANCE.json`; request selectors and output types are compared with the packaged Robinhood coordinator ABI.

## Fixed inputs and assignment direction

Deploy:

```solidity
new OneShotNFTReveal(coordinator, collection, fixedTokenIds, fixedMetadataHashes);
```

Both `coordinator` and `collection` must already contain contract code. Deploy the collection first, then the module, then permanently bind the collection to that module through a carefully restricted, one-time setup step. Creating the module with `address(this)` inside the collection's constructor will fail because that address has no runtime code yet. The code check does not establish ERC-721 compliance or a safe collection implementation.

The lists must have equal lengths from 1 to 64. Token IDs must be unique; **token ID 0 is valid**. Metadata hashes must be nonzero. Duplicate hashes are allowed when different tokens intentionally share the same content. The 64-item cap keeps this example bounded; it is not the randomness service's general capacity limit.

The constructor stores the arrays in their exact supplied order and computes:

```solidity
commitment = keccak256(abi.encode(collection, fixedTokenIds, fixedMetadataHashes));
```

Publish that order and the module address before requesting a reveal. There are no setters, owner controls, upgrade hooks, cancellation or replacement-request functions in this module. Publishing one agreed module matters: an organizer could otherwise deploy multiple modules and choose which result to announce.

After successful delivery, the relation is:

```text
tokenIds[position] -> metadataHashes[permutation[position]]
```

`metadataIndex(position)` and `assignedMetadataHash(position)` take an **array position, not a token ID**. For token IDs `[0, 7, 42]` and permutation `[2, 0, 1]`, token 0 receives metadata index 2, token 7 receives index 0, and token 42 receives index 1. All assignment views reject before reveal and after terminal expiry. Out-of-range positions also revert.

`permutation()` computes the published SDK's Fisher–Yates mapping with rejection sampling on demand. Every metadata index appears exactly once. If hashes repeat, different indices can still refer to identical metadata content. No shuffle or NFT transfer runs in the callback.

## Collection responsibilities

Only the immutable `collection` address can call `requestReveal(refundAddress)`. Before it forwards that request, the real collection must:

1. Finish and permanently close minting for the committed token list; establish that the listed IDs are the intended existing tokens.
2. Freeze the ownership and other eligibility changes that could exploit a visible pending result, through reveal finalization. The pending fulfillment can reveal the word before callback delivery.
3. Keep its module reference fixed and actually use this module's assignment in its metadata logic. An upgradeable collection or mutable URI handler could otherwise ignore a correct result.
4. Forward the original sponsor's address as `refundAddress`. The module rejects zero and its own address, but cannot establish who originally funded a call through arbitrary collection code.

The included `TestCollection` models only a closed flag and sponsor forwarding for local tests. Its unrestricted `closeMinting`, caller-supplied module argument and test reentry functions are deliberately not a production collection design.

## Request, delivery and recovery

The collection forwards the fee in ETH to `requestReveal`. For an off-chain quote, use `quoteRequestFee(provider, coordinator, 100_000)` from the SDK and forward its buffered `value` through your collection's request method. Do not use plain `quoteFee` via `eth_call`: its base fee may read as zero. The coordinator checks the actual current fee in the transaction and credits any excess to the fixed refund recipient. Gas is separate.

The states are `Ready = 0`, `Pending = 1`, `Revealed = 2`, and `Expired = 3`:

- `requestReveal` sets `Pending` before calling the coordinator with `commitment`, a 100,000-gas callback budget, the fixed refund recipient and `Shuffle(n,n)`. A request that reverts rolls back all state changes.
- `D20VRFConsumer` authenticates result and refund callbacks. The module checks the stored request ID. Successful delivery stores only the word and `Revealed` state; even the all-zero word is valid. Repeating the same ID and word is harmless; a replacement word or mismatched ID reverts.
- If a proof is accepted but delivery fails, call the real coordinator's `retryCallback(requestId, gasLimit)` with sufficient transaction gas. It delivers the same accepted word without another service fee. Do not request another outcome.
- Past the deadline without an accepted proof, call the real coordinator's `refundRequest(requestId)`. It settles the request's refundable fee share to its fixed recipient or records backed credit. Application payments and transaction gas are outside that refund.
- If refund notification fails, the module can still show `Pending`. `retryRefundCallback(requestId, gasLimit)` retries notification without paying again. A successful notification makes the module permanently `Expired`, with no assignment and no second request.

The refund recipient withdraws surplus or failed-refund credit using `withdrawRefundCredit(recipient)` on the coordinator. A contract recipient needs a way to make that call or receive the native refund. The module itself has no withdrawal function. Its service-fee recovery does not reimburse an NFT purchase or provide a replacement reveal; the collection must publish a separate failure policy.

Use the [deployment guide](https://d20dao.org/docs/deployments) to select and recheck the live coordinator proxy and current implementation. On Robinhood Chain, use normal sequencer transactions: forcing a request through Ethereum's delayed inbox can give it an earlier timestamp and bind a drand round that is already public. The mock's adjustable delivery/refund gas methods are test controls, not the real coordinator API.

## Independently reproduce the assignment

The content-hash convention used in the local checks is Keccak-256 of the exact metadata file bytes. Publish the files and their hash convention; whitespace or a trailing newline changes the bytes and hash. A hash alone neither serves a file nor guarantees it remains available. It is not encryption, and public metadata may already reveal rarity information.

```js
import { AbiCoder, keccak256 } from 'ethers';
import { builtins, mapRandomness } from '@d20dao/vrf-sdk';

const expectedCommitment = keccak256(AbiCoder.defaultAbiCoder().encode(
  ['address', 'uint256[]', 'bytes32[]'],
  [collection, tokenIds, metadataHashes],
));
// Compare expectedCommitment with the deployed module's commitment().
const permutation = mapRandomness(word, builtins.shuffle(tokenIds.length));
const assignments = tokenIds.map((tokenId, position) => ({
  tokenId,
  metadataIndex: permutation[position],
  metadataHash: metadataHashes[Number(permutation[position])],
}));
```

Read `word`, lists, collection and commitment from the agreed module, and check each retrieved metadata file against its assigned hash. This reproduces the mapping; it does not authenticate the random word. For a live reveal, separately verify the Robinhood request's beacon and VRF evidence with the SDK's `readRoundRequestEvidence` and `replayRoundRequest`, canonical receipts and the historical coordinator implementation. The commitment does not prove token existence, mint closure, ownership, metadata availability, or that the collection follows the assignment.

## Measured checks and limits

The included report records compilation against published SDK 0.6.2, whose provenance names protocol source commit `b733d8d144649bd92a16158fe0b5d48eb6b27b1f`. Compilation produced no diagnostics: 6,192 bytes of creation bytecode and 4,911 bytes of runtime bytecode. Nonzero and zero-word delivery succeeded within the 100,000-gas callback allowance, including a 64-item list. The separate 64-item `permutation()` view estimated 114,057 gas locally, which is why the shuffle is kept out of the callback.

Checks cover invalid/frozen inputs, collection gating, mint-close gating in the stub, actual underpayment rollback, authorized reentry, fees and sponsor credits, callback authentication, mismatched IDs, idempotency, forward assignment direction, singleton/64-item bijections, duplicate metadata hashes, failed-callback same-word retry, expiry, notification-only retry, and backed credit after a failed native refund.

These tests inject words through a mock and validate this module's behavior. They do not test a full ERC-721 integration, live beacon/VRF proofs, a public deployment, or production funding. They are not an external security audit. No live NFT reveal was deployed or funded for this example.
