가챠 공정성 증명

각 뽑기는 해당 기간에 커밋된 서버 시드와 클라이언트 nonce로 결정론적으로 계산됩니다. 시드는 매진·종료·보관 또는 수동 공개 후 공개됩니다. 라이브 중 재입고가 있으면 공정성이 에포크 단위로 나뉘며, 재입고마다 이전 에포크 시드가 공개되고 새 커밋으로 다음 구간이 이어져 과거 뽑기도 검증할 수 있습니다.

뽑기 알고리즘과 각 단계의 풀 상태를 검증합니다. 실물 재고나 배송을 증명하지는 않습니다.

팩 불러오기

공개된 팩에서 선택

서버 시드가 공개된 최근 팩이 여기에 표시됩니다. 선택하면 메타데이터와 에포크를 불러오거나, 아래에 슬러그를 직접 입력할 수 있습니다.

목록 불러오는 중…
또는 슬러그 입력
재생 검증
검증 방법

에포크 번호(0, 1, 2, …)는 시간순 공정성 구간입니다. 0은 공개 직후부터입니다. 재입고 시 현재 에포크 시드가 공개되고 새 시드로 다음 에포크가 시작됩니다. 표의 "에포크 #"는 그 작은 정수이며, 암호 메시지에는 JSON의 에포크 행 id 문자열이 사용됩니다.

  1. 공개 시 32바이트 시드를 만들고 SHA-256(시드)를 커밋으로 저장하며 공정성 에포크 0을 기록합니다. 재입고마다 새 시드로 다음 에포크를 만들고 이전 에포크 시드를 공개합니다.
  2. 각 카드 뽑기 전, 남은 풀(id, tier, weight)을 정렬한 JSON의 SHA-256이 poolSnapshotHash입니다.
  3. PF v2: HMAC-SHA256(키=해당 뽑기 에포크의 serverSeed, 메시지=version|packId|epochId|packPullIndex|clientNonce|poolSnapshotHash). epochId는 번들의 DB id 문자열이며 에포크 인덱스 정수가 아닙니다. PF v1(레거시)은 epochId 없음: version|packId|packPullIndex|clientNonce|poolSnapshotHash. 다이제스트 앞 8바이트로 두 번의 난수를 씁니다.
  4. 티어는 드롭률로, 카드는 티어 내 가중치로 선택됩니다(라이브 서버와 동일).

메시지 형식(파이프 구분 UTF-8)

// HMAC input message (single UTF-8 string, fields joined with "|") // PF v2/v3 — epochId is the fairness-epoch row id from the JSON (string cuid), NOT the small integer "epoch #" column in the replay table `3|packId|epochId|packPullIndex|clientNonce|poolSnapshotHash` // PF v1 (legacy, one seed for the whole pack) `1|packId|packPullIndex|clientNonce|poolSnapshotHash`

풀 JSON은 item id로 정렬해 해시를 안정적으로 유지합니다.

Node.js(crypto)
import { createHash, createHmac } from "crypto"; import { buildProofMessage, canonicalPoolJson, pickCardIdFromEightBytes, } from "./src/lib/gacha-proof/core"; import { eightBytesForHmacAttempt } from "./src/lib/gacha-proof/digest-eight-for-attempt"; const PF = 3; // v3: stationary pool; pool item id = cardId 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 }[], expectedCardId: string, rerollAttemptIndex: number ) { const poolSnapshotHash = sha256HexUtf8(canonicalPoolJson(pool)); const message = buildProofMessage(PF, packId, packPullIndex, clientNonce, poolSnapshotHash, epochId); const digest = new Uint8Array(proofHmac(serverSeedHex, message)); const eight = eightBytesForHmacAttempt(digest, rerollAttemptIndex); const picked = pickCardIdFromEightBytes(eight, pool, dropRates); if ("error" in picked) throw new Error(picked.error); return picked.cardId === expectedCardId; }
브라우저(Web Crypto)
// 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(...), eightBytesForHmacAttempt(digest, rerollAttemptIndex), // pickCardIdFromEightBytes(eight, canonicalPool, dropRates)