This is the third game on the Divi Games sidechain. Tic-tac-toe needs no randomness at all. Tower defense can use public randomness. Blackjack needs something harder: cards that nobody at the table can predict and one card the player must not see yet. This page walks through crates/dg-games/src/blackjack.rs (1,241 lines, 21 tests) and the house task in bins/divi-gamechain/src/house.rs, then plays and verifies real hands on divi-games-testnet-1. If the words "sidechain" and "sequencer" are new, read What is a sidechain first; for the SDK calls used below, see the divigames.js tutorial.
1Why public randomness is not enough
The gamechain already has a source of randomness that neither the player nor the sequencer controls: the hash of the Divi checkpoint block, mixed with the txid (H(divi_checkpoint.hash || txid)). It is fine for a tower-defense map seed. It fails for cards, for two reasons:
- Everything on-chain is public. If the dealer's hole card were derived from public data, the player would read it in the state before deciding to hit or stand. Blackjack is a hidden-information game; a public seed has no hidden information.
- The house must not choose. The sequencer orders every block. If "the next random value" were something it could nudge, pick or re-roll, it could deal itself twenty-one every hand. The house needs randomness it has already fixed before it sees your bet, yet nobody else can read until it is needed.
The answer is a committed hash chain. Off-chain, the house keeps one 32-byte secret. For each chain (an epoch) it derives a root, root = SHA256(secret || epoch_le64), then hashes it again and again. Link k of an n-link chain is p_k = H^(n-k)(root). Link 0, the anchor, is the root hashed n times; it is the only link published at the start (house_commit {anchor, n}). Every later link p_k is one hash closer to the root, so SHA256(p_k) = p_(k-1): anyone can check a revealed link against the one before it, but nobody can compute the next one from the current one.
The house fixed all 10,000 links before it saw your seed, so it cannot pick a card for you. You cannot see p_k before the house reveals it, so you cannot read the hole card. And once revealed, p_k plus the public hand data determines every card, so any third party can recompute the hand. Each reveal is a sequencer-only op, house_reveal {preimage}; the module rejects a preimage that does not hash to the previous link.
The live house commits chains of 10,000 links (CHAIN_LEN in house.rs). A reveal is consumed per hand-step, not per card: every card the house owes in that step comes from the same link. On the live chain so far the house has used 3 links for the two hands on this page.
2The card formula
This is the whole randomness rule (draw_card, blackjack.rs lines 204 to 219). Each card mixes the revealed link, the player's seed, the hand id and the card's position in the deal:
d = SHA256( p_k[32 raw bytes] || utf8(seed) || utf8(hand_id) || u32_be(index) )
card = undealt[ u32_be(d[0..4]) mod undealt.len() ]
undealt = ascending list of the cards not yet dealt in this hand (a fresh 52-card deck per hand)
pub fn draw_card(preimage: &[u8; 32], seed: &str, hand_id: &str, index: u32, dealt: u64) -> Option<u8> {
let mut buf = Vec::with_capacity(32 + seed.len() + hand_id.len() + 4);
buf.extend_from_slice(preimage);
buf.extend_from_slice(seed.as_bytes());
buf.extend_from_slice(hand_id.as_bytes());
buf.extend_from_slice(&index.to_be_bytes());
let d = sha256(&buf);
let r = u32::from_be_bytes([d[0], d[1], d[2], d[3]]) as usize;
let undealt: Vec<u8> = (0..52u8).filter(|c| dealt & (1u64 << c) == 0).collect();
if undealt.is_empty() {
return None;
}
Some(undealt[r % undealt.len()])
}
Cards are the numbers 0..52: rank is card % 13 (0 = ace, 1 to 8 = two to nine, 9 to 12 = ten, jack, queen, king) and suit is card / 13. The module never names a suit; the browser verifier verify.js labels them 0 spades, 1 hearts, 2 diamonds, 3 clubs, and this page uses the same labels. So card 11 is the queen of spades, card 39 is the ace of clubs. index counts every card of the hand in deal order, player and dealer alike, starting at 0. The dealt bit set keeps the same card from appearing twice.
The deal order after bet is player, dealer-up, player (indexes 0, 1, 2). The dealer's hole card is not dealt then: there is no hidden state on a chain, so the module simply does not draw it until the player has finished. After you stand or double, the next reveal draws the dealer's second card and keeps drawing while the dealer's total is below 17. If your first two cards are a natural (21), no decision is left, so the dealer finishes from the same link.
Both the Rust module and the browser verifier (web/play/blackjack/verify.js) are pinned to the same vector: with a preimage of 32 bytes of 0x01, seed abc and hand id 0000000000000001, indexes 0, 1, 2 give cards [8, 25, 7]. If your own reimplementation gets those three numbers, it will agree with the chain.
3Ops, limits, bankroll and payouts
Every op is a game action: {"type":"game","game":"blackjack","op":{"kind":"…", …}}, signed by the sender's key.
| kind | Fields | Who | What it does |
|---|---|---|---|
house_commit | anchor (64 hex), n (1 to 100,000) | sequencer | Starts a new epoch. Refused while the current chain is still in use. |
house_reveal | preimage (64 hex) | sequencer | Publishes the next link; it must hash to the previous one. Deals every pending hand, up to 50 per reveal. |
fund | amount | anyone | Adds GOLD to the bankroll at module:blackjack. |
bet | amount, seed (1 to 64 chars) | player | Escrows the stake, opens a hand waiting for the house to deal. |
hit / stand / double | hand | the player | Queues the move; the next reveal deals it. double escrows a second stake and allows one card. |
refund | hand | the player | Full refund if the house missed 150 blocks or replaced its chain. |
expire | hand | anyone | After the player idles 3,600 blocks on their turn, the stake goes to the bankroll. |
Limits
| Constant | Value | Meaning |
|---|---|---|
MIN_BET / MAX_BET | 100 / 10,000 | GOLD base units; GOLD has 2 decimals, so 1.00 to 100.00 GOLD. |
TIMEOUT_BLOCKS | 150 | Blocks a hand may wait for the house before refund works. Live blocks are 2 s apart (block_ms = 2000), so about 5 minutes. |
IDLE_BLOCKS | 3,600 | Blocks a player may sit on their turn before anyone can expire the hand (about 2 hours). |
MAX_CHAIN | 100,000 | Longest chain house_commit accepts. |
MIN_LINKS | 8 | A bet is refused if fewer links remain; the house then commits a new chain. |
REVEAL_BATCH | 50 | Pending hands dealt per reveal. |
MAX_SEED | 64 | Longest player seed. |
Bankroll
GOLD is issued by the core module, so blackjack cannot mint a payout. Its bankroll is the GOLD held at module:blackjack. A bet is accepted only if the free bankroll (balance minus player stakes minus reservations) is at least 2x the bet, the worst case of a doubled win or a 3:2 natural. The bet then reserves 2x; double escrows the second stake but makes no new reservation, because the reservation already covers it. A payout therefore never fails.
Payouts
| Result | Paid to the player | When |
|---|---|---|
bust | 0 | Player total over 21 (the dealer does not play). |
push | stake | Both naturals, or equal totals. |
blackjack | stake + stake × 3 / 2 (floored) | Player natural, dealer not. |
lose | 0 | Dealer natural, or dealer total higher without busting. |
win | 2 × stake | Dealer busts, or player total higher. |
"Stake" is the bet, or twice the bet after double. The dealer stands on every 17, soft 17 included: dealer_plays draws while the total is below 17 and stops. No splitting, no insurance.
4State, events and views
Everything lives under the module's own key prefix. The important keys:
| Key | Holds |
|---|---|
house | {epoch, chain: {anchor, n, pos, last, epoch}, staked, reserved}. last is the most recently revealed link (the anchor at first); pos how many links have been revealed. |
hand/<id> | The whole hand: player, seed, bet, doubled, status (waiting, playing, finished, refunded, forfeited), waiting_for (deal, hit, stand, double or null), the anchor and epoch it was bound to, both card lists, every draw with its preimage and pos, result and payout. |
pending/<id> | Present while the hand waits for the house. The reveal scans this prefix. |
active/<player> | The player's one open hand id. A second bet is refused while it exists. |
player/<address>/<id> | Index for the ?player= view. |
The hand id is the first 16 hex characters of the bet's txid, the same rule tic-tac-toe uses for tables.
Events
| Event | Data |
|---|---|
blackjack.house_committed | {anchor, n, epoch} |
blackjack.revealed | {pos, preimage, hands} |
blackjack.funded | {from, amount} |
blackjack.bet | {hand, player, amount, seed} |
blackjack.action | {hand, kind} |
blackjack.dealt | {hand, who, card, index, pos}, one per card |
blackjack.finished | {hand, result, payout, player_total, dealer_total} |
blackjack.refunded | {hand, amount, reason}, reason house_timeout or chain_changed |
Views
All under GET /v1/games/blackjack:
?hand=<id>: the full hand, including every draw's preimage. This is what the verifier reads.?player=<address>:{"active": id|null, "hands": [...]}, newest first, up to 50.?view=house:{epoch, anchor, n, pos, last, exhausted, pending, balance, staked, reserved, free_bankroll}.- no query: the limits.
$ curl -s 'https://games.divi.domains/chain/v1/games/blackjack'
{"game":"blackjack","idle_blocks":3600,"max_bet":"10000","min_bet":"100","min_links":8,"timeout_blocks":150}
$ curl -s 'https://games.divi.domains/chain/v1/games/blackjack?view=house'
{"anchor":"17f19ec9eac33b2195d7c75c0c5b4a9a000840a3b405874069858e764e7bff08","balance":"100000","epoch":1,
"exhausted":false,"free_bankroll":"100000","last":"17f19ec9eac33b2195d7c75c0c5b4a9a000840a3b405874069858e764e7bff08",
"n":10000,"pending":[],"pos":0,"reserved":"0","staked":"0"}
That was the live house before our hands: epoch 1, a 10,000-link chain with nothing revealed yet (pos 0, so last is still the anchor), and a 1,000.00 GOLD bankroll.
5Code walk: bet, deal, settle, refund
Four functions carry the design. All excerpts are from crates/dg-games/src/blackjack.rs at the commit the live chain runs.
bet: check, escrow, reserve, open the hand
fn bet(ctx: &mut dyn Ctx, sender: &str, op: &Value) -> Result<(), ActionError> {
let amount = op_amount(op, "amount")?;
if !(MIN_BET..=MAX_BET).contains(&amount) {
return Err(ActionError::invalid(format!("bet must be {MIN_BET} to {MAX_BET} base units")));
}
let seed = op_str(op, "seed")?;
if seed.is_empty() || seed.len() > MAX_SEED {
return Err(ActionError::invalid(format!("`seed` must be 1 to {MAX_SEED} characters")));
}
if ctx.get(&active_key(sender)).is_some() {
return Err(ActionError::Conflict("you already have an open hand".into()));
}
let mut house = load_house(ctx)?;
let chain = house.chain.clone().ok_or_else(|| ActionError::invalid("the house has not committed a chain yet"))?;
if chain.n - chain.pos < MIN_LINKS {
return Err(ActionError::invalid("the house is out of chain links; try again after it commits a new chain"));
}
if free_bankroll(ctx, &house) < amount * 2 {
return Err(ActionError::invalid("the house bankroll is too low for this bet"));
}
let id = ctx.txid().get(..16).ok_or_else(|| ActionError::Internal("txid is too short".into()))?.to_string();
if ctx.get(&hand_key(&id)).is_some() {
return Err(ActionError::Conflict(format!("hand {id} exists")));
}
ctx.escrow_in(sender, GOLD, amount)?;
house.staked.0 += amount;
house.reserved.0 += amount * 2;
ctx.put_json("house", &house);
let height = ctx.height();
let hand = Hand {
id: id.clone(),
player: sender.to_string(),
seed: seed.to_string(),
bet: Amount(amount),
doubled: false,
status: Status::Waiting,
waiting_for: Some(Pending::Deal),
anchor: chain.anchor,
epoch: chain.epoch,
player_cards: vec![],
dealer_cards: vec![],
draws: vec![],
next_index: 0,
dealt: 0,
result: None,
payout: Amount::ZERO,
created_height: height,
updated_height: height,
};
save_hand(ctx, &hand);
ctx.put(&pending_key(&id), b"1".to_vec());
ctx.put(&active_key(sender), id.as_bytes().to_vec());
ctx.put(&player_key(sender, &id), b"1".to_vec());
ctx.emit("blackjack.bet", json!({ "hand": id, "player": sender, "amount": Amount(amount), "seed": seed }));
Ok(())
}
Note the order of checks: amount, seed, one open hand per player, a committed chain with at least MIN_LINKS left, and free bankroll of 2x the bet. Only then is GOLD escrowed. The hand is bound to the chain's anchor and epoch, so a later chain swap can be detected and refunded.
deal_pending and dealer_plays: one reveal, one step of a hand
fn deal_pending(
ctx: &mut dyn Ctx,
house: &mut House,
hand: &mut Hand,
preimage: &[u8; 32],
preimage_hex: &str,
pos: u64,
) -> Result<(), ActionError> {
let Some(waiting) = hand.waiting_for else {
return Err(ActionError::Internal(format!("hand {} is pending but not waiting", hand.id)));
};
let deal = |ctx: &mut dyn Ctx, hand: &mut Hand, to_player: bool| deal_card(ctx, hand, to_player, preimage, preimage_hex, pos);
ctx.delete(&pending_key(&hand.id));
hand.waiting_for = None;
hand.updated_height = ctx.height();
match waiting {
Pending::Deal => {
deal(ctx, hand, true)?;
deal(ctx, hand, false)?;
deal(ctx, hand, true)?;
if is_natural(&hand.player_cards) {
// No decision is left for the player, so the dealer finishes from this same link.
dealer_plays(ctx, hand, preimage, preimage_hex, pos)?;
return settle(ctx, house, hand);
}
}
Pending::Hit => {
deal(ctx, hand, true)?;
if total(&hand.player_cards) > 21 {
return settle(ctx, house, hand);
}
}
Pending::Stand => {
dealer_plays(ctx, hand, preimage, preimage_hex, pos)?;
return settle(ctx, house, hand);
}
Pending::Double => {
deal(ctx, hand, true)?;
if total(&hand.player_cards) <= 21 {
dealer_plays(ctx, hand, preimage, preimage_hex, pos)?;
}
return settle(ctx, house, hand);
}
}
hand.status = Status::Playing;
save_hand(ctx, hand);
Ok(())
}
/// The dealer draws to 17 or more.
fn dealer_plays(ctx: &mut dyn Ctx, hand: &mut Hand, preimage: &[u8; 32], preimage_hex: &str, pos: u64) -> Result<(), ActionError> {
while total(&hand.dealer_cards) < 17 {
deal_card(ctx, hand, false, preimage, preimage_hex, pos)?;
}
Ok(())
}
This runs inside house_reveal for every hand under pending/. Whatever the hand was waiting for decides how many cards come out of this link: three for a fresh deal (then straight to the dealer if it is a natural), one for a hit, the dealer's whole turn for a stand, and one card plus the dealer's turn for a double.
settle: pay out or collect
/// Pay out or collect a hand whose cards are all dealt.
fn settle(ctx: &mut dyn Ctx, house: &mut House, hand: &mut Hand) -> Result<(), ActionError> {
let (p, d) = (total(&hand.player_cards), total(&hand.dealer_cards));
let stake = hand.stake();
let player_natural = is_natural(&hand.player_cards);
let dealer_natural = is_natural(&hand.dealer_cards);
let (result, payout) = if p > 21 {
("bust", 0)
} else if player_natural && dealer_natural {
("push", stake)
} else if player_natural {
("blackjack", stake + stake * 3 / 2)
} else if dealer_natural || (d <= 21 && d > p) {
("lose", 0)
} else if d > 21 || p > d {
("win", stake * 2)
} else {
("push", stake)
};
if payout > 0 {
ctx.escrow_out(&hand.player, GOLD, payout)?;
}
house.staked.0 -= stake;
house.reserved.0 -= hand.reserve();
hand.status = Status::Finished;
hand.result = Some(result.to_string());
hand.payout = Amount(payout);
ctx.delete(&active_key(&hand.player));
save_hand(ctx, hand);
ctx.emit(
"blackjack.finished",
json!({ "hand": hand.id, "result": result, "payout": Amount(payout), "player_total": p, "dealer_total": d }),
);
Ok(())
}
The stake and the 2x reservation are released here, so the bankroll accounting always closes; one of the tests plays many hands and asserts GOLD is conserved and every reservation released.
refund: the player's protection
fn refund(ctx: &mut dyn Ctx, sender: &str, op: &Value) -> Result<(), ActionError> {
let mut hand = load_hand(ctx, op)?;
if sender != hand.player {
return Err(ActionError::unauthorized("not your hand"));
}
if !matches!(hand.status, Status::Waiting | Status::Playing) {
return Err(ActionError::invalid("the hand is over"));
}
let mut house = load_house(ctx)?;
let rotated = house.chain.as_ref().is_none_or(|c| c.epoch != hand.epoch);
let timed_out = hand.status == Status::Waiting && ctx.height() >= hand.updated_height + TIMEOUT_BLOCKS;
if !rotated && !timed_out {
return Err(ActionError::invalid("the house still has time to deal this hand"));
}
let reason = if rotated { "chain_changed" } else { "house_timeout" };
release(ctx, &mut house, &mut hand, Status::Refunded, reason)?;
ctx.put_json("house", &house);
Ok(())
}
The house is a single process that could go quiet. If a hand has been waiting on it for 150 blocks, or the house has committed a new chain since the hand began, the player takes the full stake back with refund. Nobody else can do it, and it is refused while the house still has time.
6Play two hands on the live chain
We played on https://games.divi.domains/chain (chain id divi-games-testnet-1) with a fresh SDK key, from a Linux box with Node 20 and a checkout of the divi_games repo for web/sdk/divigames.js. Nothing here costs real coins: GOLD comes from the free faucet. The script below is exactly what we ran. It writes the key's WIF to a 0600 file and never prints it. The import on its first line names that checkout; point it at your own web/sdk/divigames.js.
// bj-play.mjs: play real blackjack hands on a Divi Games gamechain with the SDK.
// DG_URL=https://games.divi.domains/chain HANDS=2 node bj-play.mjs
import fs from 'node:fs';
import { Chain, Key } from '/home/bshuler/remote-check/tut-i-divi_games/web/sdk/divigames.js';
const URL = process.env.DG_URL || 'https://games.divi.domains/chain';
const WANT = Number(process.env.HANDS || 2);
const DIR = process.env.HOME + '/tut-i/build-blackjack';
const chain = new Chain(URL);
// One fresh testnet key. The WIF goes to a 0600 file, never to the terminal.
const wifPath = `${DIR}/player.wif`;
let key;
if (fs.existsSync(wifPath)) key = Key.fromWif(fs.readFileSync(wifPath, 'utf8').trim());
else { key = Key.generate('testnet'); fs.writeFileSync(wifPath, key.toWif() + '\n', { mode: 0o600, flag: 'wx' }); }
console.log('chain ', URL);
console.log('player', key.address);
const bj = (op) => ({ type: 'game', game: 'blackjack', op });
const gold = async (a) => (await chain.account(a)).balances?.GOLD ?? '0';
const height = async () => (await chain.info()).height;
// Sign, submit, wait for the receipt (blocks are ~60 s apart), print it.
async function run(action, label) {
const txid = await chain.send(key, action);
const r = await chain.waitForReceipt(txid, { timeoutMs: 240000, intervalMs: 2000 });
console.log(`${label}: txid ${txid} height ${r.height} ok ${r.ok}${r.error ? ` error "${r.error}"` : ''}`);
for (const ev of r.events) console.log(` event ${ev.kind} ${JSON.stringify(ev.data)}`);
return { txid, receipt: r };
}
// Every blackjack event about one hand, scanned from the blocks since `from`.
async function handEvents(from, id) {
const out = [];
let h = from;
for (;;) {
const res = await fetch(`${URL}/v1/blocks?from=${h}&limit=100`);
const blocks = (await res.json()).blocks ?? [];
if (!blocks.length) break;
for (const b of blocks) for (const tx of b.txs ?? []) for (const ev of tx.events ?? []) {
if (!ev.kind.startsWith('blackjack.')) continue;
if (ev.data.hand === id || (Array.isArray(ev.data.hands) && ev.data.hands.includes(id)))
out.push({ height: b.height, txid: tx.txid, kind: ev.kind, data: ev.data });
}
h = blocks[blocks.length - 1].height + 1;
}
return out;
}
// Poll the hand view until the house has dealt (status leaves "waiting" / pending clears).
async function waitForHouse(id) {
const start = await height();
for (;;) {
const hand = await chain.game('blackjack', { hand: id });
if (hand.status !== 'waiting' && !hand.pending) return hand;
const now = await height();
if (now - start >= 4) throw new Error(`house did not act on ${id} in 4 blocks (status ${hand.status}, pending ${hand.pending})`);
await new Promise((r) => setTimeout(r, 5000));
}
}
const total = (cards) => { let s = 0, a = 0; for (const c of cards) { const r = c % 13; if (r === 0) { s += 11; a++; } else s += r >= 9 ? 10 : r + 1; } while (s > 21 && a) { s -= 10; a--; } return s; };
if (BigInt(await gold(key.address)) < 1000n) await run({ type: 'faucet', token: 'GOLD' }, 'faucet');
console.log('GOLD before', await gold(key.address));
console.log('house', JSON.stringify(await chain.game('blackjack', { view: 'house' })));
const done = [];
let acted = false;
for (let n = 1; done.length < WANT || !acted; n++) {
const seed = `tut-${Date.now().toString(36)}-${n}`;
const from = await height();
const { txid } = await run(bj({ kind: 'bet', amount: '100', seed }), `bet #${n}`);
const id = txid.slice(0, 16);
let hand = await waitForHouse(id);
console.log(`hand ${id} dealt: player ${JSON.stringify(hand.player_cards)} (${total(hand.player_cards)}) dealer up ${JSON.stringify(hand.dealer_cards)} status ${hand.status}`);
while (hand.status === 'playing') {
const t = total(hand.player_cards);
const kind = t >= 17 ? 'stand' : 'hit';
acted = true;
await run(bj({ kind, hand: id }), `${kind} #${n} (total ${t})`);
hand = await waitForHouse(id);
console.log(`hand ${id} now: player ${JSON.stringify(hand.player_cards)} (${total(hand.player_cards)}) dealer ${JSON.stringify(hand.dealer_cards)} status ${hand.status}`);
}
const evs = await handEvents(from, id);
fs.writeFileSync(`${DIR}/hand-${id}.json`, JSON.stringify(hand, null, 2) + '\n');
fs.writeFileSync(`${DIR}/events-${id}.json`, JSON.stringify(evs, null, 2) + '\n');
console.log(`hand ${id} ${hand.status} result ${hand.result} payout ${hand.payout}`);
if (hand.status === 'finished') done.push(id);
}
console.log('GOLD after', await gold(key.address));
console.log('done', JSON.stringify(done));
This is the script exactly as it ran, and handEvents in it is wrong. /v1/blocks returns a bare JSON array of blocks, each with a txids list, not {blocks: [{txs: […]}]}, so the function found nothing and both events-<id>.json files hold []. Nothing else in the run depends on it. To collect a hand's events, walk each block's txids and fetch the receipts from /v1/tx/<txid>, which is what the next section does by hand.
Three things worth noticing in the script. waitForReceipt is given 240 s; the SDK default of 30 s is fine on this chain but not if the house is slow. The house's reply is not in your receipt: your bet receipt only carries blackjack.bet; the cards arrive in the sequencer's own house_reveal transaction, so the script polls the ?hand= view until status changes. And it never calls subscribe(), because Node 20 has no WebSocket.
$ DG_URL=https://games.divi.domains/chain HANDS=2 node bj-play.mjs
chain https://games.divi.domains/chain
player y7odmsaHZPrHa2jgJao4v6XAvi8qw7vU5T
faucet: txid b746632a0daeec459b040d7b4d95460b14dbb8959ba646e614cd14890c457e1b height 1423 ok true
event faucet {"amount":"100000","to":"y7odmsaHZPrHa2jgJao4v6XAvi8qw7vU5T","token":"GOLD"}
GOLD before 100000
house {"anchor":"17f19ec9eac33b2195d7c75c0c5b4a9a000840a3b405874069858e764e7bff08","balance":"100000","epoch":1,"exhausted":false,"free_bankroll":"100000","last":"17f19ec9…7bff08","n":10000,"pending":[],"pos":0,"reserved":"0","staked":"0"}
bet #1: txid a81df90b771832010a87ff57bb803edf5bc488b65d6ee1018ddc0485b302339e height 1424 ok true
event blackjack.bet {"amount":"100","hand":"a81df90b77183201","player":"y7odmsaHZPrHa2jgJao4v6XAvi8qw7vU5T","seed":"tut-mvijg8ai-1"}
hand a81df90b77183201 dealt: player [11,39] (21) dealer up [5,43,17,23] status finished
hand a81df90b77183201 finished result blackjack payout 250
bet #2: txid 9c65cb8250d293997a1a6cdd04c9c495170842569c6507c059a72e37644914c7 height 1426 ok true
event blackjack.bet {"amount":"100","hand":"9c65cb8250d29399","player":"y7odmsaHZPrHa2jgJao4v6XAvi8qw7vU5T","seed":"tut-mvijgdzm-2"}
hand 9c65cb8250d29399 dealt: player [17,10] (15) dealer up [1] status playing
hit #2 (total 15): txid 84e0c3da03b41073cfae53f56175dbccecdaa128eeb8aa6eb5f9287bb55aecfa height 1428 ok true
event blackjack.action {"hand":"9c65cb8250d29399","kind":"hit"}
hand 9c65cb8250d29399 now: player [17,10,32] (22) dealer [1] status finished
hand 9c65cb8250d29399 finished result bust payout 0
GOLD after 100050
done ["a81df90b77183201","9c65cb8250d29399"]
Hand one was a natural: queen of spades (11) and ace of clubs (39) against a dealer six; the dealer still had to finish from the same link and busted with 26, and the 1.00 GOLD bet paid 2.50 (payout 250 = stake + 3/2 stake). Hand two was five of hearts and jack of spades (15) against a dealer deuce; we hit, drew the seven of diamonds and bust. Net: +150 base units, from 100000 to 100050 after two 100 stakes. The whole session took 6 blocks, 1423 to 1429, about 20 seconds.
Where the cards came from: the house's reveal transactions
Each player transaction landed in one block and the house answered in the next one. The bet at height 1424 was dealt by house_reveal at 1425; the hit at 1428 by the reveal at 1429. The reveal's receipt carries every blackjack.dealt event, the finished event and the revealed event with the link:
$ curl -s https://games.divi.domains/chain/v1/tx/f41c976eb9f8f3fe4d7447f86b07f1beeb001608319ba0f8e8a3ffca3c6c06e5
{"body":"{\"v\":1,\"chain\":\"divi-games-testnet-1\",\"from\":\"xw9aaoN8FBrQcesu8F29ZjL6irKxyXpRRZ\",\"nonce\":3,
\"action\":{\"game\":\"blackjack\",\"op\":{\"kind\":\"house_reveal\",\"preimage\":\"85b0794ba7ad00ca46426d858b653bc703c485a7cb1c09b009e582c02dd0c2a3\"},\"type\":\"game\"}}",
"receipt":{"error":null,"events":[
{"data":{"card":11,"hand":"a81df90b77183201","index":0,"pos":1,"who":"player"},"kind":"blackjack.dealt"},
{"data":{"card":5,"hand":"a81df90b77183201","index":1,"pos":1,"who":"dealer"},"kind":"blackjack.dealt"},
{"data":{"card":39,"hand":"a81df90b77183201","index":2,"pos":1,"who":"player"},"kind":"blackjack.dealt"},
{"data":{"card":43,"hand":"a81df90b77183201","index":3,"pos":1,"who":"dealer"},"kind":"blackjack.dealt"},
{"data":{"card":17,"hand":"a81df90b77183201","index":4,"pos":1,"who":"dealer"},"kind":"blackjack.dealt"},
{"data":{"card":23,"hand":"a81df90b77183201","index":5,"pos":1,"who":"dealer"},"kind":"blackjack.dealt"},
{"data":{"dealer_total":26,"hand":"a81df90b77183201","payout":"250","player_total":21,"result":"blackjack"},"kind":"blackjack.finished"},
{"data":{"hands":["a81df90b77183201"],"pos":1,"preimage":"85b0794ba7ad00ca46426d858b653bc703c485a7cb1c09b009e582c02dd0c2a3"},"kind":"blackjack.revealed"}],
"height":1425,"index":0,"ok":true,"txid":"f41c976eb9f8f3fe4d7447f86b07f1beeb001608319ba0f8e8a3ffca3c6c06e5"}, …}
The second hand took two reveals: 91b977f6…6fe5ad at 1427 (pos 2: cards 17, 1, 10) and b419f80a…005b19 at 1429 (pos 3: card 32, then finished {result:"bust", player_total:22, dealer_total:2}). Notice the dealer total of 2 in that event: the dealer never drew a second card, because a bust settles without the dealer playing.
The finished hand, with every preimage
$ curl -s 'https://games.divi.domains/chain/v1/games/blackjack?hand=9c65cb8250d29399'
{
"anchor": "17f19ec9eac33b2195d7c75c0c5b4a9a000840a3b405874069858e764e7bff08",
"bet": "100",
"created_height": 1426,
"dealer_cards": [1],
"dealt": 4295099394,
"doubled": false,
"draws": [
{"card": 17, "index": 0, "pos": 2, "preimage": "5316094bcd178f386894016ab98fb5ed8dc125cd001bdd2d710e203fa0593644", "who": "player"},
{"card": 1, "index": 1, "pos": 2, "preimage": "5316094bcd178f386894016ab98fb5ed8dc125cd001bdd2d710e203fa0593644", "who": "dealer"},
{"card": 10, "index": 2, "pos": 2, "preimage": "5316094bcd178f386894016ab98fb5ed8dc125cd001bdd2d710e203fa0593644", "who": "player"},
{"card": 32, "index": 3, "pos": 3, "preimage": "b19782db7e11b16cc5886477eea5c560427f5d1fb3a95a2efb83b82453a6d49d", "who": "player"}
],
"epoch": 1,
"id": "9c65cb8250d29399",
"next_index": 4,
"payout": "0",
"player": "y7odmsaHZPrHa2jgJao4v6XAvi8qw7vU5T",
"player_cards": [17, 10, 32],
"result": "bust",
"seed": "tut-mvijgdzm-2",
"status": "finished",
"updated_height": 1429,
"waiting_for": null
}
Two different links, pos 2 and 3, because the hand took two house steps. dealt is the bit set as a number: 2^1 + 2^10 + 2^17 + 2^32 = 4295099394. The first hand (?hand=a81df90b77183201) has six draws, all with pos 1 and the link 85b0794b…0c2a3, since the natural let the dealer finish from one reveal.
7Recompute the cards yourself
Nothing on the hand view has to be trusted. With only node:crypto you can check two things for every draw: that the preimage hashed pos times gives the anchor the house committed before it saw your seed, and that the formula from step 2 gives exactly the card the chain recorded. This script is a standalone port of web/play/blackjack/verify.js (the repo also tests that file with node --test tests/sdk/blackjack-verify.test.mjs, which passed 4 of 4 for us).
// verify.mjs: recompute a blackjack hand from the chain's own data, with nothing but node:crypto.
// curl -s 'https://games.divi.domains/chain/v1/games/blackjack?hand=<id>' > hand.json
// node verify.mjs hand.json
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
const sha256 = (b) => createHash('sha256').update(b).digest();
const RANKS = ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K'];
const SUITS = ['S', 'H', 'D', 'C'];
const name = (c) => RANKS[c % 13] + SUITS[Math.floor(c / 13)];
/** card = undealt[ u32_be(SHA256(p_k || seed || hand_id || u32_be(index))[0..4]) mod undealt.len() ] */
function drawCard(preimage, seed, handId, index, dealt) {
const idx = Buffer.alloc(4);
idx.writeUInt32BE(index);
const d = sha256(Buffer.concat([preimage, Buffer.from(seed, 'utf8'), Buffer.from(handId, 'utf8'), idx]));
const undealt = [];
for (let c = 0; c < 52; c++) if (!dealt.includes(c)) undealt.push(c);
return undealt[d.readUInt32BE(0) % undealt.length];
}
function total(cards) {
let sum = 0, aces = 0;
for (const c of cards) { const r = c % 13; if (r === 0) { sum += 11; aces++; } else sum += r >= 9 ? 10 : r + 1; }
while (sum > 21 && aces > 0) { sum -= 10; aces--; }
return sum;
}
const hand = JSON.parse(readFileSync(process.argv[2], 'utf8'));
const anchor = Buffer.from(hand.anchor, 'hex');
let ok = true;
const dealt = [];
const mine = { player: [], dealer: [] };
for (const d of hand.draws) {
const p = Buffer.from(d.preimage, 'hex');
// 1. The link hashes `pos` times down to the anchor the house committed before it saw the seed.
let x = p;
for (let i = 0; i < d.pos; i++) x = sha256(x);
const chainOk = x.equals(anchor);
// 2. The card is the one the formula gives for this preimage, seed, hand id and index.
const card = drawCard(p, hand.seed, hand.id, d.index, dealt);
const cardOk = card === d.card && d.index === dealt.length;
dealt.push(d.card);
mine[d.who].push(d.card);
ok &&= chainOk && cardOk;
console.log(`#${d.index} ${d.who.padEnd(6)} pos ${d.pos} p_k=${d.preimage.slice(0, 16)}… chain ${chainOk ? 'ok' : 'BAD'} card ${card} (${name(card)}) ${cardOk ? '== on-chain' : '!= on-chain ' + d.card}`);
}
const same = JSON.stringify(mine.player) === JSON.stringify(hand.player_cards) && JSON.stringify(mine.dealer) === JSON.stringify(hand.dealer_cards);
console.log(`player ${mine.player.map(name).join(' ')} = ${total(mine.player)} dealer ${mine.dealer.map(name).join(' ')} = ${total(mine.dealer)} result ${hand.result} payout ${hand.payout}`);
console.log(ok && same ? 'VERIFIED: every card follows from the committed chain, your seed and the hand id' : 'MISMATCH');
process.exit(ok && same ? 0 : 1);
$ curl -s 'https://games.divi.domains/chain/v1/games/blackjack?hand=a81df90b77183201' > hand-a81df90b77183201.json $ node verify.mjs hand-a81df90b77183201.json #0 player pos 1 p_k=85b0794ba7ad00ca… chain ok card 11 (QS) == on-chain #1 dealer pos 1 p_k=85b0794ba7ad00ca… chain ok card 5 (6S) == on-chain #2 player pos 1 p_k=85b0794ba7ad00ca… chain ok card 39 (AC) == on-chain #3 dealer pos 1 p_k=85b0794ba7ad00ca… chain ok card 43 (5C) == on-chain #4 dealer pos 1 p_k=85b0794ba7ad00ca… chain ok card 17 (5H) == on-chain #5 dealer pos 1 p_k=85b0794ba7ad00ca… chain ok card 23 (JH) == on-chain player QS AC = 21 dealer 6S 5C 5H JH = 26 result blackjack payout 250 VERIFIED: every card follows from the committed chain, your seed and the hand id $ node verify.mjs hand-9c65cb8250d29399.json #0 player pos 2 p_k=5316094bcd178f38… chain ok card 17 (5H) == on-chain #1 dealer pos 2 p_k=5316094bcd178f38… chain ok card 1 (2S) == on-chain #2 player pos 2 p_k=5316094bcd178f38… chain ok card 10 (JS) == on-chain #3 player pos 3 p_k=b19782db7e11b16c… chain ok card 32 (7D) == on-chain player 5H JS 7D = 22 dealer 2S = 2 result bust payout 0 VERIFIED: every card follows from the committed chain, your seed and the hand id
"chain ok" on the second hand means SHA256(SHA256(5316094b…)) and SHA256(SHA256(SHA256(b19782db…))) both equal the anchor 17f19ec9…7bff08. The house could not have swapped links, and given those links it had no say in the cards. Change one character of the seed in the JSON and the script prints MISMATCH.
It proves the house dealt by the rules of a chain it fixed in advance. It does not stop the house from going quiet; that is what refund after 150 blocks is for. And a player who sees a bad first three cards cannot abandon the hand for free: the stake is escrowed and expire hands it to the bankroll after 3,600 idle blocks.
8Run the module's tests
The module's 21 tests cover the pinned vector, every error path, naturals, doubles, timeouts, chain rotation and GOLD conservation. They run with the rest of the workspace; the filter picks the blackjack module. On our build box (the same source as the live chain, release profile):
$ cargo test --release -p dg-games blackjack running 21 tests test blackjack::tests::card_derivation_matches_the_pinned_vector ... ok test blackjack::tests::bet_failures ... ok test blackjack::tests::bet_needs_a_chain_links_and_bankroll ... ok test blackjack::tests::a_new_chain_orphans_old_hands_and_they_refund ... ok test blackjack::tests::a_double_that_times_out_refunds_both_stakes ... ok test blackjack::tests::exhausted_chain_can_be_replaced ... ok test blackjack::tests::fund_validates_and_adds_to_the_bankroll ... ok test blackjack::tests::commit_validates_and_cannot_replace_a_chain_in_use ... ok test blackjack::tests::idle_players_forfeit_to_the_bankroll ... ok test blackjack::tests::only_the_sequencer_may_commit_or_reveal ... ok test blackjack::tests::hitting_to_a_bust_loses_without_the_dealer_playing ... ok test blackjack::tests::double_stakes_twice_and_settles_on_one_card ... ok test blackjack::tests::refund_after_house_timeout ... ok test blackjack::tests::totals_count_aces_softly ... ok test blackjack::tests::reveal_rejects_bad_links_replays_and_idle_reveals ... ok test blackjack::tests::player_actions_are_checked ... ok test blackjack::tests::a_hand_is_recomputable_from_chain_data_alone ... ok test blackjack::tests::views ... ok test blackjack::tests::natural_pays_three_to_two ... ok test blackjack::tests::standing_wins_loses_and_pushes ... ok test blackjack::tests::many_hands_conserve_gold_and_release_every_reservation ... ok test result: ok. 21 passed; 0 failed; 0 ignored; 0 measured; 28 filtered out; finished in 0.13s
Read a_hand_is_recomputable_from_chain_data_alone next to the verifier: it is the same check, in Rust, against a hand the test harness played.
9What the operator runs
There is no separate dealer program. The house is a task inside divi-gamechain run, started by main.rs next to the API and the block producer. On start it loads or creates <data_dir>/house.secret, 64 hex characters, created with mode 0600; if the file is readable by other users the task refuses to start and logs … is readable by other users (mode …); chmod 600 it. The node keeps running without a house, so check the log for blackjack house not started if hands sit at waiting.
The task wakes on every block, reads ?view=house (so a restart resumes where the chain is, with no local position file), and decides one thing:
/// The next action the house should take, or `None`. `Err` means the secret no longer matches the chain on-chain.
fn decide(secret: &Secret, v: &HouseView, wallet: &Wallet, next_faucet: &mut u64) -> Result<Option<Value>, &'static str> {
let (n, pos) = (v.n.unwrap_or(0), v.pos.unwrap_or(0));
let too_short = v.anchor.is_some() && n - pos < MIN_LINKS;
if v.anchor.is_none() || v.exhausted || (v.pending.is_empty() && too_short) {
let epoch = v.epoch + 1;
let anchor = hex::encode(secret.link(epoch, CHAIN_LEN, 0));
return Ok(Some(json!({ "type": "game", "game": ID, "op": { "kind": "house_commit", "anchor": anchor, "n": CHAIN_LEN } })));
}
if !v.pending.is_empty() {
let preimage = secret.link(v.epoch, n, pos + 1);
if hex::encode(sha256(&preimage)) != v.last.as_deref().unwrap_or_default() {
return Err("house.secret does not match the committed chain");
}
return Ok(Some(json!({ "type": "game", "game": ID, "op": { "kind": "house_reveal", "preimage": hex::encode(preimage) } })));
}
let free: u128 = v.free_bankroll.parse().unwrap_or(0);
if free < LOW_BANKROLL {
if wallet.gold > 0 {
return Ok(Some(json!({ "type": "game", "game": ID, "op": { "kind": "fund", "amount": wallet.gold.to_string() } })));
}
if wallet.height >= *next_faucet {
*next_faucet = wallet.height + FAUCET_COOLDOWN;
return Ok(Some(json!({ "type": "faucet", "token": GOLD })));
}
}
Ok(None)
}
- No anchor, chain exhausted, or fewer than 8 links left with nothing pending: commit a new 10,000-link chain for the next epoch.
- Hands pending: reveal the next link. If the secret on disk no longer matches
laston-chain, the task logshouse.secret does not match the committed chainon every block and submits nothing (Task::tickturns the error into awarn!and returns); it will not guess. - Free bankroll under 400.00 GOLD:
fundwhatever GOLD the sequencer holds, else claim the faucet (once per 1,800 blocks).
All of these are signed with the sequencer key through Node::submit_as_sequencer; the module accepts house_commit and house_reveal only from ctx.sequencer().
A new secret cannot continue the old chain, and the module will not let the house start another while this one is in use. house_commit answers the current chain is still in use unless the chain is exhausted, or nothing is pending and it is still at pos 0 or has fewer than 8 links left; decide applies the same test on its side. So if you delete house.secret and restart against a live 10,000-link chain at, say, pos 3, the task warns on every block while hands are pending and does nothing at all once they are gone. No fresh epoch comes on its own. Players are not stuck: a hand the house never dealt can be refunded after 150 blocks with reason: "house_timeout", exactly as when the house is simply down. The house, though, is dead for this epoch, and the only way back is the backup copy of the secret, so keep one (mode 0600, off the box).
reason: "chain_changed" comes from the legitimate case: the house committed a new epoch because the old chain was exhausted or nearly so with nothing pending. A hand from the old epoch that was still in play is refused on its player's next action (the house changed its chain; refund this hand) and gets its stake back through refund; one that was pending is refunded by the house's next reveal. Either way the player gets the full stake back; the house never keeps a bet it did not deal.
To run your own house, follow Run the gamechain; the house starts by itself, with its secret in the node's data directory.
If something goes wrong
| The receipt says | What it means |
|---|---|
| bet must be 100 to 10000 base units | Amounts are GOLD base units as strings: "100" is 1.00 GOLD. |
| `seed` must be 1 to 64 characters | Send a non-empty seed; it only needs to be yours, not secret. |
| you already have an open hand | One hand per address. Finish it, or refund it if the house timed out. ?player=<addr> shows the active id. |
| the house has not committed a chain yet | The house task has not run on this node. Check the node log for blackjack house not started. |
| the house is out of chain links; try again after it commits a new chain | Fewer than 8 links remain. The house commits a new epoch on its next tick; bet again a block later. |
| the house bankroll is too low for this bet | Free bankroll is under 2x your bet. Bet smaller, or wait for the house to fund. |
| the hand is waiting for the house | You sent hit/stand while waiting_for is set. Poll ?hand= until status is playing. |
| nothing to hit / you can only double on your first two cards | Your total is 21 or more, or you already drew a third card. |
| the house still has time to deal this hand | refund before 150 blocks have passed since the hand last waited. |
| the house changed its chain; refund this hand | Your hand is bound to an older epoch. Send refund; it succeeds at once. |
| only the house (the sequencer) may do this | house_commit, house_reveal from a player key. Only the node's house task sends these. |
| preimage does not hash to the previous link | The house tried to reveal a link that is not the next one; its secret does not match the chain on-chain. |
My hand sits at waiting for minutes. On the live chain the house answered in the next block every time we tried. If it does not, the house task is down: after 150 blocks (about 5 minutes) send {"kind":"refund","hand":"<id>"} and your stake comes back.
Still stuck? Ask the Divi chatbot, or see all tutorials.