Clutch 如何以 SHA-256 和公開 GitHub 儲存庫封存 NBA 賽前預測
I seal every NBA prediction before tip-off with SHA-256 and a public Git repo
作者為 NBA 應用 Clutch 建立賽前預測存證流程,將每場預測封存為含 SHA-256 指紋的檔案,並推送至公開 GitHub 儲存庫。GitHub 收到推送的時間用來核對預測是否早於開賽,檔案透過前一份指紋串成雜湊鏈,讓舊紀錄遭修改或刪除時可被察覺。付費會員數據則先以 SHA-256 承諾、賽後揭示;截至 Oct 25,Ledger 記錄 36 次已評分預測,戰績為 24–12。
Every sports prediction account has a great record. That's the problem. Once the final score is in, anyone can say they called it, and the easy ways to fake a track record don't need a single lie you can point to: post the calls that came in, quietly delete the ones that didn't, nudge a number after the injury news, or write the "prediction" up after the game.
I build Clutch, an NBA app that puts one number on every game: the chance the side it favours wins. I wanted its record to be something you can check without trusting me. Here's how it works, and the code to verify it yourself.
The three things a real pregame prediction needs
- A fingerprint of the numbers, published before tip. If the numbers change later, the fingerprint won't match.
- A clock the forecaster can't set. A date I type proves nothing.
- A record that keeps the misses. Old entries can't be swapped out or dropped.
None of this needs a blockchain. A hash function, a public Git host and a little discipline cover all three.
1. Seal: hash the night's numbers before the games
Each morning (US Eastern), a scheduled job writes every game of the night still to tip into a JSON file, a seal, with the number the app shows everyone for it:
{
"format": "clutch-receipt-v1",
"kind": "seal",
"slate_date": "2026-10-24",
"season": "2026-27",
"sealed_at": "2026-10-24T14:01:23Z",
"prev_sha256": "…",
"n_games": 8,
"games": [
{
"game_id": "…",
"home": "ATL", "away": "HOU",
"tip_utc": "2026-10-25T00:30:00Z",
"prediction_id": "…",
"model_version": "clutch-20260929",
"home_win_prob": "0.5094",
"pro_commitment": "5c9f7129eb71e43e…14adc888"
}
]
}
The file's SHA-256 is its fingerprint: 64 hex characters computed from its bytes. Change one digit of one probability and the fingerprint is completely different.
If news moves a number during the day (a star ruled out at 5 PM, say), the job writes a new seal. Nothing is edited in place. The game is graded on the last seal that arrived before its tip. That Hawks–Rockets number above was sealed seven times on Oct 24, drifting from 50.9% to 48.8% for Atlanta; the last one is what got graded (Houston won 121–114, so the call held).
2. The clock: GitHub's, not mine
The seal is pushed to a public repository, uguraltunbas/clutch-receipts.
One subtlety: commit dates are worthless as proof, because whoever makes the commit writes them. What counts is when GitHub received the push. The repository's Activity view lists every push with GitHub's own timestamp, and it lists force pushes too, so rewriting history would show.
On Oct 24 the night's first seal arrived 7 hours 58 minutes before the first tip.
The job pushes with a deploy key that can write to that one repository only.
3. The chain: misses can't be dropped
Every file includes prev_sha256, the fingerprint of the file before it. That makes the repository a hash chain: change or delete any old file and every later file stops matching. index.jsonl lists every file in order with its fingerprint.
Checking the whole chain takes a few lines of Python, standard library only:
import hashlib
import json
prev = None
for line in open("index.jsonl", encoding="ascii"):
entry = json.loads(line)
data = open(entry["path"], "rb").read()
receipt = json.loads(data)
assert hashlib.sha256(data).hexdigest() == entry["sha256"], entry["path"] + ": fingerprint"
assert receipt["prev_sha256"] == entry["prev_sha256"] == prev, entry["path"] + ": chain"
prev = entry["sha256"]
print("chain ok, latest", prev)
Or one file at a time, with what your computer already has:
git clone https://github.com/uguraltunbas/clutch-receipts
cd clutch-receipts
shasum -a 256 2026-27/2026-10-20/seal-182205Z.json # Linux: sha256sum, Windows: certutil -hashfile
4. Commit-reveal for the numbers I can't publish yet
Some numbers are for paying members before tip (the model's own probability next to the market's). I still want those provably fixed before the game, without giving them away.
That's a classic commit-reveal scheme. Before tip, the seal carries only pro_commitment: the SHA-256 of those numbers plus a long random secret (a salt), serialized canonically. The salt matters: a probability with four decimals has only 10,000 possible values, so without it anyone could find the hidden number by hashing every one.
After the final, a reveal file publishes the numbers and the salt, plus the score and whether the call was right. Anyone can recompute the hash:
import hashlib
import json
def canonical(obj):
return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("ascii")
def fingerprint(game_id, opening):
return hashlib.sha256(canonical({
"format": "clutch-commitment-v1",
"game_id": game_id,
"prediction_id": opening["prediction_id"],
"pro": opening["pro"],
"salt": opening["salt"],
})).hexdigest()
If the result is in a seal from before tip, the revealed numbers are the numbers that were sealed. (The canonical serialization, with sorted keys and fixed separators, is what makes the hash reproducible in any language.)
5. Grading, misses included
After each final, the Ledger grades the sealed number: right or wrong, next to ESPN's BPI, the market's price and the dumbest baseline there is, always picking the home team. Games whose number can't be shown to predate tip aren't graded at all, and the Ledger says how many.
The live record is on the Ledger: 24–12 on 36 graded calls as of Oct 25. That's early-season noise, and the Ledger says so. The more useful question is calibration: when Clutch says 70%, does it happen about 70% of the time? In a replay of the last two seasons (2,631 games, each predicted only from games before it), every band landed within 2.4 points of what it said. The accuracy page has the bands, and I wrote up calibration and the Brier score separately.
What a seal doesn't prove
A seal proves when a number was made, not that it was any good. One night, or one week, tells you nothing. That's why the misses have to stay: a record is only worth something if it's complete.
The stack, briefly
- Python jobs on GitHub Actions write predictions to Postgres (Supabase) and push the receipts.
- The app (Expo / React Native, iPhone and web) reads only what each tier may see, enforced by row-level security.
- clutchledger.com is a static site rebuilt from the same tables: a page per game, per team and per matchup.
If you try the verification and something doesn't match, I want to know. That's the whole point. The receipts repo is here, and the step-by-step check is in its VERIFY.md.
來源:DEV Community · dev.to