# UNIPIX reveal — `UNIPIX-REVEAL-v1` This document is the public, byte-exact specification of how UNIPIX token ids are matched to artworks. Anyone can re-implement it and check the published result. It is pinned on IPFS together with the artwork manifest **before claims open**, and every unrevealed token's description cites that pin (`ipfs:///REVEAL.md`), so the rule is timestamped by the contract deployment itself. The reference implementation is `collection/src/reveal.mjs` and `collection/src/reveal-chain.mjs`. ## Why a reveal The UNIPIX contract mints token ids sequentially (1, 2, 3 …). If token metadata were final at mint time, anyone watching the counter could time a claim to land on a known rare id. Therefore: 1. **Placeholder phase.** The contract is deployed with `baseURI = ipfs:///`. Every token shows the same unrevealed bot. Nobody — including the team — knows which artwork a token will get. 2. **Minting ends** with whichever comes first: the mint of token #1000 (sell-out) or the owner's one-way `closeMinting()` (event `MintClosed(finalSupply)`; claims close 72 hours after they open). After that no token can ever be minted again. 3. **Reveal.** The hash of a block produced 100 blocks after minting ended — unknown to everyone while claims are open — seeds a deterministic shuffle. Final metadata is generated from it, published, and the owner calls `setBaseURI("ipfs:///")`. The contract emits `BatchMetadataUpdate(1, totalSupply)` so marketplaces refresh. 4. **Freeze.** After the revealed metadata is checked, the owner calls `freezeMetadata()` (irreversible). ## Fixed commitments (published before claims open) | Item | Value | | --- | --- | | Algorithm | `UNIPIX-REVEAL-v1 sha256 fisher-yates` | | Chain | Robinhood Chain, chain id `4663` | | Images CID (1000 artworks, `ipfs:///.png`, k = 1..1000) | `bafybeihafsf7x2iysvf3uqogtr4ivsublsibb2saiei5hndripaz2g575q` | | Manifest SHA-256 (artwork k → traits, see below) | `3602e2184bb54a02b7020763c626b1cf217bfe94b0bf961761a90e12679de0ed` | | Provenance pin | `ipfs:///REVEAL.md` (this file) and `ipfs:///manifest.json` | | Contract `initialBaseURI` | `ipfs:///` — every placeholder cites the provenance pin | ### `manifestSha256` is not the SHA-256 of the file `manifestSha256` = lower-case hex SHA-256 of the UTF-8 JSON text of the manifest **without** its `manifestSha256` field, with object keys sorted recursively (array order kept) and no whitespace. The file `manifest.json` itself is pretty-printed and contains the field, so `sha256sum manifest.json` gives a different value (`5c844848…e8c1`); that is expected, not a substitution. In Python: ```python import hashlib, json m = json.load(open("manifest.json", encoding="utf-8")) unsigned = {k: v for k, v in m.items() if k != "manifestSha256"} text = json.dumps(unsigned, sort_keys=True, separators=(",", ":"), ensure_ascii=False) print(hashlib.sha256(text.encode("utf-8")).hexdigest()) # 3602e218…de0ed ``` ### The superseded development-order metadata is an artwork catalogue `ipfs://bafybeifbqggaplk56wfrzwqlyssvoyywqonmhfuswfhmrcpvdi4evgyvvy/.json` was published before the reveal design. It is **superseded** and must never become the contract `baseURI`. It stays pinned because it is a second public commitment "artwork k → traits": its `name` values (`UNIPIX #k`) are **artwork numbers, not token numbers**. After the reveal, `revealed/.json` equals that file for `k = a[t-1]` with only `name` replaced. ## The rule (announced before claims open) ```text Minting ends with whichever comes first: the transaction that mints token #1000 (sell-out) or the one-way closeMinting() transaction (event MintClosed). B_end is the Robinhood Chain (chain id 4663) block containing that transaction. seedHash is the 32-byte hash of the first block whose number is >= B_end + 100; block numbers are consecutive, so it is block B_end + 100. Token t receives artwork a[t-1], where a is the UNIPIX-REVEAL-v1 sha256 Fisher-Yates permutation of 1..1000 seeded with seedHash. Token ids that were never minted never exist. This rule is announced before claims open. ``` The same text is stored verbatim in the `rule` field of `reveal.json`. - If minting sells out, `B_end` is the block of the `rewardMint` transaction that emits `RewardMinted(rewardId, recipient, 1000)`. The final supply is 1000. - If claims close first, `B_end` is the block of the `closeMinting()` transaction that emits `MintClosed(finalSupply)`, with `1 <= finalSupply < 1000`. Tokens `finalSupply + 1 … 1000` never exist: `tokenURI` reverts for them and nothing can mint them later. - The permutation always covers all 1000 artworks. Token `t` (for every token that exists) gets `a[t-1]`. ## Algorithm (byte-exact) Notation: `||` is byte concatenation, `uint32be(x)` is 4 bytes big-endian, hashes are SHA-256. 1. **Seed.** `seed` = the 32 raw bytes of the block hash (`0x` + 64 hex digits, case-insensitive). 2. **Word stream.** For `c = 0, 1, 2, …` compute `H_c = sha256( seed || ascii("UNIPIX-REVEAL-v1") || uint32be(c) )` (32 + 16 + 4 = 52 input bytes). Each `H_c` yields 8 words: bytes `[0..4)`, `[4..8)`, …, `[28..32)`, each read as an unsigned 32-bit **big-endian** integer. Words are consumed strictly in this order. 3. **Unbiased index.** To draw an index below `n`: `limit = floor(2^32 / n) * n`; take the next word `r`; if `r >= limit`, discard it and take the next one; repeat until `r < limit`; then `j = r mod n`. 4. **Fisher–Yates.** `a = [1, 2, …, 1000]` (0-based positions). For `i = 999` down to `1`: `n = i + 1`, draw `j` as in step 3, swap `a[i]` and `a[j]`. 5. **Assignment.** Token `t` (1..1000) receives artwork `k = a[t-1]`. 6. **Mapping hash.** `mappingSha256` = lower-case hex SHA-256 of the ASCII string `a[0] "," a[1] "," … "," a[999]` — decimal numbers, comma-separated, no spaces, no trailing newline. ### Test vectors | Seed | First 12 entries of `a` | `mappingSha256` | | --- | --- | --- | | `0x00…00` (32 zero bytes) | `266, 738, 35, 742, 298, 714, 513, 924, 949, 926, 846, 525` | `0db1063a01e7bf4a4f556468a4dce8df28398d170a1aedf51a3906a5caf88510` | | `0xff…ff` (32 bytes `0xff`) | `63, 627, 790, 811, 169, 486, 934, 593, 342, 220, 479, 155` | `4189576eebb16520c8b11ba683a9ed46f9e136e4bfaa16330b1b74b07aaa680c` | | `0xb413d549b952398a9d9276e3ad08968f06f422ef97f9412aebd1098132b3475a` (= sha256 of ASCII `rej-17616`) | `800, 584, 812, 49, 780, 994, 619, 341, 449, 380, 989, 392` | `ffa9217ef61a8c01b603aefc7078306c39e629c1e6f35eaa068b18a3697d15d9` | For the zero seed, `H_0 = 451783e60875d50e92b412732964da72be69562fa6d7260ef2f5d63ca9942725`, so the first words are `0x451783e6, 0x0875d50e, …`. The third vector exercises the rejection in step 3 exactly once: at `i = 984` (`n = 985`, `limit = 4294966420`) word number 15 of the stream (0-based) is `r = 4294967012 >= limit` and is discarded; the whole shuffle consumes 1000 words. An implementation with a plain `r mod n` passes the first two vectors but fails this one. ### Independent reference implementation (Python 3, standard library only) ```python import hashlib, struct def permutation(seed: bytes, size: int = 1000) -> list[int]: def words(): c = 0 while True: h = hashlib.sha256(seed + b"UNIPIX-REVEAL-v1" + struct.pack(">I", c)).digest() for k in range(8): yield struct.unpack(">I", h[4 * k:4 * k + 4])[0] c += 1 stream = words() a = list(range(1, size + 1)) for i in range(size - 1, 0, -1): n = i + 1 limit = (2 ** 32 // n) * n r = next(stream) while r >= limit: r = next(stream) j = r % n a[i], a[j] = a[j], a[i] return a a = permutation(bytes(32)) print(a[:12], hashlib.sha256(",".join(map(str, a)).encode("ascii")).hexdigest()) ``` ## Metadata All JSON files are serialized as `JSON.stringify(value, null, 2)` followed by one `\n` (UTF-8, LF line endings). **Placeholder** — `placeholder/.json`, t = 1..1000; field order `name, description, image, external_url, attributes`: ```json { "name": "UNIPIX #", "description": "", "image": "ipfs:///unrevealed.png", "external_url": "https://unipcs.family", "attributes": [{ "trait_type": "Status", "value": "Unrevealed" }] } ``` The description is one line (shown wrapped here): ```text UNIPIX is an independent pixel-art tribute dedicated to Unipcs and made for the FOMO community. Not an official Unipcs or FOMO collection. This bot is unrevealed: all minted UNIPIX bots are revealed together after mint closes, using a future Robinhood Chain block hash, so nobody — including the team — knows which bot you get. Reveal rule UNIPIX-REVEAL-v1: the seed is the hash of Robinhood Chain block B_end + 100, where B_end is the block of the transaction that ends minting (the mint of token #1000 or closeMinting). Specification: ipfs:///REVEAL.md. Artwork manifest: ipfs:///manifest.json (manifestSha256 ). Artworks: ipfs:/// (1.png to 1000.png). ``` **Revealed** — `revealed/.json`, built from manifest artwork `k = a[t-1]`: - `name`: `UNIPIX #`; for the five 1/1 artworks `UNIPIX # — ` (em dash U+2014); - `description`: `UNIPIX is an independent pixel-art tribute dedicated to Unipcs and made for the FOMO community. Not an official Unipcs or FOMO collection.`; - `image`: `ipfs:///.png`; - `external_url`: `https://unipcs.family`; - `attributes`: the artwork traits in order `Rarity, Outfit, Variant, Background, Companion`. Field order: `name, description, image, external_url, attributes`. The revealed directory also contains a byte-identical copy of `reveal.json`, so `ipfs:///reveal.json` sits next to the token files. ## `reveal.json` ```json { "schemaVersion": 1, "algorithm": "UNIPIX-REVEAL-v1 sha256 fisher-yates", "chainId": 4663, "contractAddress": "0x<40 lower-case hex> | null", "mintEndTxHash": "0x<64 lower-case hex> | null", "anchorBlock": | null, "finalSupply": <1..1000> | null, "blockNumber": , "blockHash": "0x<64 lower-case hex>", "rule": "", "imagesCid": "", "manifestSha256": "", "mappingSha256": "", "mapping": [{ "tokenId": 1, "artworkId": }, …, { "tokenId": 1000, "artworkId": }] } ``` `contractAddress`, `mintEndTxHash` and `finalSupply` are set together by the read-only chain confirmation (below) or are all `null` in an offline rehearsal record, which the tooling refuses to publish. When `anchorBlock` is set, `blockNumber` must equal `anchorBlock + 100` exactly. ## How anyone verifies the reveal 1. **Placeholder phase.** In the contract-creation transaction, `initialBaseURI` is `ipfs:///`, and every file there equals the canonical placeholder above (citing this specification). Before `freezeMetadata`, the owner can change `baseURI`; check that the contract emitted exactly one `BaseURIUpdated` event after deployment, with value `ipfs:///`, in a block strictly after `blockNumber` of `reveal.json`. 2. **End of minting.** Find the transaction `mintEndTxHash` on Robinhood Chain. It must be the mint of token #1000 (event `RewardMinted` with tokenId 1000) or `closeMinting()` (event `MintClosed(finalSupply)`), emitted by `contractAddress`. Its block is `B_end = anchorBlock`, and `totalSupply()` equals `finalSupply`. 3. **Seed block.** `blockNumber = B_end + 100`; compare `blockHash` with any explorer or RPC (`eth_getBlockByNumber`). 4. **Mapping.** Recompute `a` with the Python snippet above and compare with `mapping` and `mappingSha256`. 5. **Metadata.** Rebuild each `revealed/.json` from the manifest (or from the superseded catalogue, replacing `name`) and compare with `ipfs:///.json`. 6. **Freeze.** `MetadataFrozen(baseURI, contractURI)`, if emitted, carries the same revealed base URI. Operators run steps 2–4 plus a byte-for-byte comparison of the local `revealed/` directory with `npm --prefix collection run verify-reveal -- --rpc`. The publication tool computes the IPFS CID of the checked directory offline and records a pin only if Pinata returns exactly that CID. Because a CID is a hash of the content, a matching CID proves the pinned files are the verified ones. ## Trust assumptions and known limits - **Owner before freeze.** The contract checks only the shape of a URI. Until `freezeMetadata()` the owner can set any `baseURI`, including the superseded development-order CID. Honesty is checked through the events in step 1. The owner freezes right after the public check. - **Sequencer.** Robinhood Chain is an Arbitrum-stack L2 with a single sequencer, which orders transactions and produces block `B_end + 100`. Its operator could in principle grind that block's hash. On a quiet chain, a technically skilled participant whose own transaction is the only one in that block could also try to predict the block header and search over their transaction data for a favourable hash. `UNIPIX-REVEAL-v1` accepts this sequencer trust. Stronger designs (a team commitment `sha256(S)` published before claims with `seed = sha256(blockHash || S)`, or a pre-announced drand round) were considered and not adopted, to keep the procedure simple and verifiable by anyone with one block hash. - **Finality.** The tooling uses only blocks that the chain reports as `finalized` (batch final on the parent chain). Until then it refuses to reveal. - **Early knowledge.** From the moment block `B_end + 100` exists, anyone running this algorithm can compute the mapping before the revealed metadata is published. The team publishes the revealed metadata and calls `setBaseURI` as soon as the block is finalized, and does not trade UNIPIX before the reveal. - **Timing of the close.** The owner chooses when to call `closeMinting()`, but the hash of a block 100 blocks later is unknown at that moment. `closeMinting()` is one-way and succeeds at most once, and only before a sell-out. - **Marketplaces.** `MetadataFrozen` is the contract's own event. Marketplaces may not show a "frozen" badge for it. ## Publishing and contract values `node collection/publish-ipfs.mjs [--dry-run]` pins each directory on IPFS; files sit at the CID root. Every stage performs all local checks first and computes the expected CID offline; `--dry-run` stops there, prints that exact CID and sends no request. | Stage | Local check before any request | Value printed | | --- | --- | --- | | `provenance` | `REVEAL.md` contains the rule, algorithm, test vectors, manifest hash and images CID verbatim; `manifest.json` is byte-identical to the validated manifest | `ipfs:///REVEAL.md` — announce before claims | | `placeholder-image` | exactly `unrevealed.png`, 1024×1024 PNG, every 4×4 block one colour | `ipfs:///unrevealed.png` | | `placeholder` | all 1000 files byte-equal to the canonical placeholder for the recorded `placeholder-image` and `provenance` CIDs | `baseURI = "ipfs:///"` — constructor `initialBaseURI` | | `revealed` | chain-confirmed `reveal.json` recomputed; all 1000 token files and the `reveal.json` copy byte-equal to the regeneration | `baseURI = "ipfs:///"` — `setBaseURI` after minting ended |