Gacha fairness proof
Each draw uses a deterministic roll from a server seed (committed for that period) and your client nonce. Seeds are published when a pack sells out, is closed or archived, or manually. If a live pack was restocked, fairness runs in epochs: each restock reveals the previous epoch's seed and starts the next with a new commitment, so you can still verify older draws against the seed that was active then.
This verifies the draw algorithm and pool state at each step. It does not prove physical inventory or shipping.
Epoch numbers (0, 1, 2, …) are chronological fairness periods. Epoch 0 begins when the pack is published. Replenishing stock while active ends the current epoch (its seed is revealed) and opens the next with a new seed. The replay table's "Epoch #" is that small integer; the cryptographic message uses the epoch's row id string from the JSON.
- At publish we generate a 32-byte server seed and store SHA-256(seed) as the public commitment, and record fairness epoch 0. After each restock, a new epoch gets a new seed while the prior epoch's seed is published.
- For each card drawn, we hash the sorted JSON of remaining pool items (id, tier, weight) to get poolSnapshotHash.
- For PF v2, HMAC-SHA256(key = that draw's epoch server seed, message = version|packId|epochId|packPullIndex|clientNonce|poolSnapshotHash). Here epochId is the database id string in the bundle, not the epoch index integer. PF v1 (legacy) omits epochId: version|packId|packPullIndex|clientNonce|poolSnapshotHash. The first 8 bytes of the digest map to two rolls for tier and card.
- Selection logic matches the live server: weighted tier from drop rates, then weighted card within the tier.
Message layout (pipe-separated UTF-8)
// HMAC input message (single UTF-8 string, fields joined with "|")
// PF v2 — epochId is the fairness-epoch row id from the JSON (string cuid), NOT the small integer "epoch #" column in the replay table
`2|packId|epochId|packPullIndex|clientNonce|poolSnapshotHash`
// PF v1 (legacy, one seed for the whole pack)
`1|packId|packPullIndex|clientNonce|poolSnapshotHash`Pool JSON is sorted by item id so the hash is stable.
import { createHash, createHmac } from "crypto";
import {
buildProofMessage,
canonicalPoolJson,
pickItemIdFromHmacDigest,
} from "./src/lib/gacha-proof/core"; // adjust path
const PF = 2; // v2: include epochId in buildProofMessage
function sha256HexUtf8(s: string) {
return createHash("sha256").update(s, "utf8").digest("hex");
}
function proofHmac(serverSeedHex: string, message: string) {
const key = Buffer.from(serverSeedHex, "hex");
return createHmac("sha256", key).update(message, "utf8").digest();
}
function verifyStep(
serverSeedHex: string,
packId: string,
epochId: string,
packPullIndex: number,
clientNonce: string,
pool: { id: string; tier: string; weight: number }[],
dropRates: { tier: string; rate: number }[],
expectedItemId: string
) {
const poolSnapshotHash = sha256HexUtf8(canonicalPoolJson(pool));
const message = buildProofMessage(PF, packId, packPullIndex, clientNonce, poolSnapshotHash, epochId);
const digest = proofHmac(serverSeedHex, message);
const picked = pickItemIdFromHmacDigest(new Uint8Array(digest), pool, dropRates);
if ("error" in picked) throw new Error(picked.error);
return picked.itemId === expectedItemId;
}// Web Crypto — same semantics as the server (HMAC-SHA256, UTF-8 message).
async function hexToBytes(hex: string) {
const out = new Uint8Array(32);
for (let i = 0; i < 32; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
return out;
}
async function proofHmacBrowser(serverSeedHex: string, message: string) {
const key = await crypto.subtle.importKey(
"raw",
await hexToBytes(serverSeedHex),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
return new Uint8Array(
await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(message))
);
}
// Then: buildProofMessage(...) from @/lib/gacha-proof/core,
// pickItemIdFromHmacDigest(digest, unrevealedItems, dropRates)