가챠 공정성 증명
각 뽑기는 해당 기간에 커밋된 서버 시드와 클라이언트 nonce로 결정론적으로 계산됩니다. 시드는 매진·종료·보관 또는 수동 공개 후 공개됩니다. 라이브 중 재입고가 있으면 공정성이 에포크 단위로 나뉘며, 재입고마다 이전 에포크 시드가 공개되고 새 커밋으로 다음 구간이 이어져 과거 뽑기도 검증할 수 있습니다.
뽑기 알고리즘과 각 단계의 풀 상태를 검증합니다. 실물 재고나 배송을 증명하지는 않습니다.
팩 불러오기
공개된 팩에서 선택
서버 시드가 공개된 최근 팩이 여기에 표시됩니다. 선택하면 메타데이터와 에포크를 불러오거나, 아래에 슬러그를 직접 입력할 수 있습니다.
목록 불러오는 중…
또는 슬러그 입력
재생 검증
검증 방법
에포크 번호(0, 1, 2, …)는 시간순 공정성 구간입니다. 0은 공개 직후부터입니다. 재입고 시 현재 에포크 시드가 공개되고 새 시드로 다음 에포크가 시작됩니다. 표의 "에포크 #"는 그 작은 정수이며, 암호 메시지에는 JSON의 에포크 행 id 문자열이 사용됩니다.
- 공개 시 32바이트 시드를 만들고 SHA-256(시드)를 커밋으로 저장하며 공정성 에포크 0을 기록합니다. 재입고마다 새 시드로 다음 에포크를 만들고 이전 에포크 시드를 공개합니다.
- 각 카드 뽑기 전, 남은 풀(id, tier, weight)을 정렬한 JSON의 SHA-256이 poolSnapshotHash입니다.
- PF v2: HMAC-SHA256(키=해당 뽑기 에포크의 serverSeed, 메시지=version|packId|epochId|packPullIndex|clientNonce|poolSnapshotHash). epochId는 번들의 DB id 문자열이며 에포크 인덱스 정수가 아닙니다. PF v1(레거시)은 epochId 없음: version|packId|packPullIndex|clientNonce|poolSnapshotHash. 다이제스트 앞 8바이트로 두 번의 난수를 씁니다.
- 티어는 드롭률로, 카드는 티어 내 가중치로 선택됩니다(라이브 서버와 동일).
메시지 형식(파이프 구분 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)