1What the SDK is
The gamechain's HTTP API is plain JSON, and signing is Divi's ordinary signmessage. The SDK wraps both so a page or a Node script needs about ten lines to act on chain. It lives in the games repo at web/sdk/divigames.js (546 lines) and is served by the site at https://games.divi.domains/sdk/divigames.js.
Its only dependencies are two audited noble libraries, copied into web/vendor/ as plain ES modules so a browser can import them without a bundler, an import map or a CDN:
Path (relative to web/) | Package | Used for |
|---|---|---|
| vendor/noble-secp256k1/index.js | @noble/secp256k1 2.3.0, unchanged | Used for: keys, signing, public-key recovery |
| vendor/noble-hashes/sha2.js | @noble/hashes 1.8.0 | Used for: SHA-256 |
| vendor/noble-hashes/legacy.js | @noble/hashes 1.8.0 | Used for: RIPEMD-160 (addresses) |
| vendor/noble-hashes/hmac.js | @noble/hashes 1.8.0 | Used for: HMAC for deterministic RFC 6979 signatures |
| vendor/noble-hashes/_md.js, _u64.js, utils.js, crypto.js | @noble/hashes 1.8.0 internals | Used for: pulled in by the three above |
Two edits from upstream, both listed with file hashes in web/vendor/README.md: utils.js imports ./crypto.js instead of the bare specifier @noble/hashes/crypto, and the sourceMappingURL comments are gone because the map files are not shipped. The SDK imports the vendor files by relative path (../vendor/…), so sdk/ and vendor/ must sit next to each other wherever you copy them.
2Every export
Everything the module exports, in file order. Amounts are strings of integer base units (GOLD has 2 decimals, TDIVI 8). Line numbers refer to web/sdk/divigames.js at the commit checked.
| Export | What it does |
|---|---|
| CHAIN_ID | 'divi-games-testnet-1', the default chain id for txBody and Chain. |
| MESSAGE_MAGIC | 'DarkNet Signed Message:\n', the prefix of every Divi message signature. |
| NETWORKS | { testnet: { pubkey: 139, secret: 239 }, mainnet: { pubkey: 30, secret: 212 } }: address and WIF version bytes. |
| bytesToHex(bytes) | Uint8Array to lower-case hex. |
| sha256(data) | Re-exported from noble-hashes. Uint8Array in, Uint8Array out. |
| sha256d(data) | SHA256(SHA256(data)). |
| hash160(data) | RIPEMD160(SHA256(data)), the 20 bytes inside a P2PKH address. |
| base58checkEncode(payload) | Base58 of payload || first 4 bytes of sha256d(payload). |
| base58checkDecode(text) | The payload without its checksum. Throws on a bad character or checksum. |
| addressFromPublicKey(publicKey, network = 'testnet') | P2PKH address of a 33-byte compressed public key. |
| decodeAddress(address) | { network, hash160 }. Throws not a Divi address: … if it is not one. |
| isAddress(address, network?) | True for a valid Divi address (on that network, if given). Never throws. |
| messageHash(message) | The 32-byte digest signmessage signs; the length prefix counts UTF-8 bytes. |
| class Key | A secp256k1 private key and its address. new Key(secret32, network?); Key.generate(network = 'testnet'); Key.fromWif(wif) (network from the WIF's version byte); key.toWif() (compressed flag, testnet WIFs start with c); key.publicKey (33 bytes); key.address; key.network; key.signMessage(message) → Base64 of header || r || s, header 27 + recid + 4, deterministic, low-s. |
| recoverMessageSigner(message, sigB64) | The compressed public key that made the signature (33 bytes). Throws signature must be 65 bytes or unsupported signature header (header outside 31–34). |
| verifyMessage(address, message, sigB64) | Divi verifymessage: true when the recovered key hashes to the address. Never throws. |
| txBody(from, { nonce, action, memo?, chain = CHAIN_ID }) | The exact JSON string of a tx body ({v:1, chain, from, nonce, action, memo?}). Throws on a bad nonce, an action without a string type, or a memo over 140 characters. |
| txidOf(body) | hex(SHA256(body)). |
| signTx(key, fields) | { body, sig, txid }: txBody + key.signMessage(body) + txidOf. |
| formatAmount(baseUnits, decimals) | formatAmount("1250", 2) === "12.5"; trailing zeros trimmed; negative ok. |
| parseAmount(display, decimals) | parseAmount("12.5", 2) === "1250". Throws on anything that is not a plain decimal with at most decimals places. |
| class Chain | The API client. Below. |
class Chain
| Member | What it does |
|---|---|
| new Chain(baseUrl?, { chain? }) | baseUrl defaults to <this page's origin>/chain in a browser and http://127.0.0.1:7410 elsewhere; a trailing slash is stripped. chain is the chain id send() puts in bodies (default CHAIN_ID). |
| info() | GET /v1/info: chain id, network, height, head hash, Divi checkpoint, modules, tokens. |
| account(address) | GET /v1/account/:addr → { nonce, next_nonce, balances }. next_nonce counts your transactions still in the mempool. |
| tokens() | GET /v1/tokens. |
| submit({ body, sig }) | POST /v1/tx → { txid }. Throws an Error whose message is the node's error text and whose status is the HTTP status (400, 409, 503). |
| tx(txid) | GET /v1/tx/:txid → { body, sig, receipt }, receipt null while pending; resolves to null on 404. |
| waitForReceipt(txid, { timeoutMs = 30000, intervalMs = 500 }) | Polls tx() until there is a receipt and returns it. Check receipt.ok yourself. Throws tx … not in a block after 30s on timeout. |
| game(name, query?) | GET /v1/games/:name?…, a game module's view, for example game('tictactoe', { id }) or { open: 1 }. |
| send(signer, action, { memo? }) | Reads next_nonce, builds the body, calls signer.signMessage(body) (sync or async, so a wallet works too), submits, returns the txid. signer is anything with address and signMessage. |
| subscribe(onBlock, { onStatus? }) | Opens the /v1/ws WebSocket and calls onBlock({ type: 'block', header, receipts }) per block; reconnects by itself with backoff up to 15 s; onStatus(true|false) on connect and drop. Returns an unsubscribe function. Blocks that arrive while disconnected are missed, so refresh your view in onStatus(true). Needs a global WebSocket: browsers, Node 22+, or Node 20 with --experimental-websocket. |
3Getting it onto your page
Copy web/sdk/ and web/vendor/ from the games repo into your site, side by side, and import by relative path:
<script type="module">
import { Key, Chain, formatAmount } from './sdk/divigames.js';
</script>
The site does publish the same files (/sdk/divigames.js, 18,961 bytes, text/javascript, and the four vendor imports all answer 200), but you cannot import them from another origin. The gamechain API under /chain/ sends CORS headers; the static /sdk and /vendor directories do not. From a page on http://127.0.0.1:53930:
$ curl -s -D - -o /dev/null -H 'Origin: http://127.0.0.1:53930' https://games.divi.domains/chain/v1/info | grep -i 'access-control'
access-control-allow-origin: *
$ curl -s -D - -o /dev/null -X OPTIONS -H 'Origin: http://127.0.0.1:53930' -H 'Access-Control-Request-Method: POST' -H 'Access-Control-Request-Headers: content-type' https://games.divi.domains/chain/v1/tx | grep -i 'access-control'
access-control-allow-methods: *
access-control-allow-headers: *
access-control-allow-origin: *
$ curl -s -D - -o /dev/null -H 'Origin: http://127.0.0.1:53930' https://games.divi.domains/sdk/divigames.js | grep -i 'access-control'
(nothing)
A browser proves it. This page, served from 127.0.0.1:53930, fetches the API and then tries to import the SDK from the site:
<!doctype html><meta charset="utf-8"><title>CORS check</title>
<pre id="out"></pre>
<script type="module">
const out = (s) => { document.getElementById('out').textContent += s + '\n'; };
// a. The chain API sends access-control-allow-origin: * so a cross-origin fetch works.
try { const i = await (await fetch('https://games.divi.domains/chain/v1/info')).json(); out('fetch /chain/v1/info: ok, height ' + i.height); }
catch (e) { out('fetch /chain/v1/info: FAILED ' + e.message); }
// b. The SDK file is served without CORS headers, so importing it from another origin fails.
try { const m = await import('https://games.divi.domains/sdk/divigames.js'); out('import /sdk/divigames.js: ok, ' + Object.keys(m).length + ' exports'); }
catch (e) { out('import /sdk/divigames.js: FAILED ' + e.message); }
</script>
$ google-chrome --headless=new --virtual-time-budget=30000 --dump-dom http://127.0.0.1:53930/cors.html | grep -A2 'id="out"' <pre id="out">fetch /chain/v1/info: ok, height 1715 import /sdk/divigames.js: FAILED Failed to fetch dynamically imported module: https://games.divi.domains/sdk/divigames.js
So: the gamechain is open to any origin (nginx proxies /chain/ to the node, which answers every origin with *, and /chain/v1/ws is proxied for WebSockets), while the SDK is a file you host yourself. ARCHITECTURE.md says the SDK is "served at /sdk/divigames.js"; that is true, but only pages on games.divi.domains itself can import it from there.
4A complete page: guest key, GOLD, a table
One static HTML file. On load it makes or loads a guest key in localStorage, shows the address and GOLD balance, and subscribes to blocks. Two buttons send a faucet claim and a free tic-tac-toe create; the board is drawn from the tictactoe.* events as they arrive. ?auto=1 presses both buttons in order, which is how the headless test below drives it. This is the file as served and run, byte for byte:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>My tic-tac-toe table</title>
<style>
body { font: 16px/1.5 system-ui, sans-serif; max-width: 520px; margin: 24px auto; padding: 0 16px; color: #eee; background: #111; }
code, pre { font-family: ui-monospace, monospace; font-size: 0.9em; overflow-wrap: anywhere; }
pre { background: #222; padding: 10px; border-radius: 8px; white-space: pre-wrap; }
button { font: inherit; padding: 8px 14px; margin: 4px 6px 4px 0; border-radius: 8px; border: 0; background: #e94560; color: #fff; }
button:disabled { opacity: .5; }
#board { display: grid; grid-template-columns: repeat(3, 64px); gap: 4px; margin: 12px 0; }
#board div { width: 64px; height: 64px; display: grid; place-items: center; font-size: 2rem; background: #333; border-radius: 6px; }
</style>
</head>
<body>
<h1>My tic-tac-toe table</h1>
<p>Address <code id="address">…</code></p>
<p>GOLD <strong id="gold">…</strong> · chain <span id="status">connecting…</span></p>
<button id="claim">Claim 1000 GOLD</button>
<button id="create">Create a free table</button>
<p>Table <code id="game">none yet</code></p>
<div id="board"></div>
<pre id="log"></pre>
<script type="module">
import { Key, Chain, formatAmount } from './sdk/divigames.js';
// 1. A guest key, kept in this browser's localStorage. The WIF never leaves the browser.
const WIF_KEY = 'divigames-demo-wif';
let key;
try { key = Key.fromWif(localStorage.getItem(WIF_KEY)); }
catch { key = Key.generate('testnet'); localStorage.setItem(WIF_KEY, key.toWif()); }
document.getElementById('address').textContent = key.address;
// 2. The public gamechain. (On games.divi.domains itself, `new Chain()` would default to <origin>/chain.)
const chain = new Chain('https://games.divi.domains/chain');
const $ = (id) => document.getElementById(id);
const log = (...a) => { $('log').textContent += a.join(' ') + '\n'; };
async function showBalance() {
const acct = await chain.account(key.address);
$('gold').textContent = formatAmount(acct.balances.GOLD ?? '0', 2);
}
// 3. Send an action, wait for its receipt, show both.
async function run(label, action) {
try {
const txid = await chain.send(key, action);
log(`${label}: txid ${txid}`);
const r = await chain.waitForReceipt(txid);
log(`${label}: block ${r.height} ok=${r.ok}${r.error ? ' error=' + r.error : ''}`);
return r;
} catch (e) {
log(`${label}: rejected (${e.status ?? '-'}) ${e.message}`);
}
}
// 4. The board: 9 cells from a 9-character string like "x.o......".
let gameId = null;
function renderBoard(board) {
$('board').replaceChildren(...[...board].map((c) => Object.assign(document.createElement('div'), { textContent: c === '.' ? '' : c })));
}
$('claim').onclick = () => run('faucet', { type: 'faucet', token: 'GOLD' });
$('create').onclick = async () => {
const r = await run('create', { type: 'game', game: 'tictactoe', op: { kind: 'create' } });
if (r?.ok) { gameId = r.txid.slice(0, 16); $('game').textContent = gameId; }
};
// 5. Live updates: every block's receipts arrive over the WebSocket. Refresh what they touch.
chain.subscribe((block) => {
for (const r of block.receipts) for (const ev of r.events) {
if (ev.kind === 'faucet' && ev.data.to === key.address) showBalance();
if (ev.kind === 'tictactoe.created' && ev.data.x === key.address) renderBoard('.........');
if (ev.kind === 'tictactoe.moved' && ev.data.id === gameId) renderBoard(ev.data.board);
if (ev.kind === 'tictactoe.finished' && ev.data.id === gameId) log(`game ${gameId} finished: ${ev.data.result}`);
}
}, { onStatus: (up) => { $('status').textContent = up ? 'live' : 'reconnecting…'; } });
await showBalance();
renderBoard('.........');
// For the headless test run: ?auto=1 clicks both buttons in order.
if (new URLSearchParams(location.search).get('auto')) {
await run('faucet', { type: 'faucet', token: 'GOLD' });
await $('create').onclick();
}
</script>
</body>
</html>
Things worth noticing:
- The key stays in the browser.
Key.fromWif(null)throws, so the first visit generates one and saves its WIF; later visits load it. It is a guest key: clearing site data loses it, and anyone with that browser profile can play as it. new Chain('https://games.divi.domains/chain')because the page is not on games.divi.domains. On the site itselfnew Chain()would do.chain.send()thenchain.waitForReceipt()is the whole write path. The receipt'sokanderrortell you whether the action did what you asked; a rejected submission throws before that, with the node's text and HTTP status.- The game id is the first 16 hex characters of the create txid (
r.txid.slice(0, 16)), which is why the receipt is enough to know the table's id. - Events, not polling.
subscribedelivers every block's receipts; the handler picks the events about this address or this table and updates the balance or the board.
?auto=1 ran in headless Chrome: a fresh key got 1000 GOLD in block 1718 and opened table 61ae6ea4466e4639 in block 1719.5Serve it and test it headlessly
The page needs an http: origin (an ES module from file: cannot fetch its imports). On the test machine (hp3, Linux, Node 20) the directory ~/tut-i/divigames-js/ holds index.html, cors.html, sdk/ and vendor/, served by Python's built-in server on localhost only:
#!/bin/sh # Serve ~/tut-i/divigames-js on 127.0.0.1:53930; PID in http.pid. Stop with: kill $(cat ~/tut-i/divigames-js/http.pid) cd ~/tut-i/divigames-js || exit 1 nohup python3 -m http.server 53930 --bind 127.0.0.1 > http.log 2>&1 & echo $! > http.pid echo "pid $(cat http.pid)"
$ sh start.sh
pid 3418559
$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:53930/index.html
200
From the Mac, an ssh tunnel makes that port local, and headless Chrome loads the page, waits for the fetches and receipts (--virtual-time-budget fast-forwards timers but still waits for network), then dumps the DOM and takes the screenshot above in the same run:
$ ssh -f -N -L 53930:127.0.0.1:53930 hp3
$ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new --disable-gpu --hide-scrollbars \
--user-data-dir=/tmp/chrome-profile-92c --window-size=500,700 --virtual-time-budget=120000 \
--screenshot=page.png --dump-dom "http://127.0.0.1:53930/index.html?auto=1" > page-dom.html
$ sed -n '/<body>/,/<script type="module">/p' page-dom.html
<body>
<h1>My tic-tac-toe table</h1>
<p>Address <code id="address">yGAKP63t2R5gKw8R3VDTsn9ZovpZcN5TWY</code></p>
<p>GOLD <strong id="gold">1000</strong> · chain <span id="status">live</span></p>
<button id="claim">Claim 1000 GOLD</button>
<button id="create">Create a free table</button>
<p>Table <code id="game">61ae6ea4466e4639</code></p>
<div id="board"><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div></div>
<pre id="log">faucet: txid 1ca9d38eeb88d4161166c48bf94b64de0285017dc71b582d9ae025f4ed4adae9
faucet: block 1718 ok=true
create: txid 61ae6ea4466e463986bf8cd769fab63fdac97bb79dabcabe8bf65dca5f78e3c1
create: block 1719 ok=true
</pre>
The table the browser made is on the public chain for anyone to join:
$ curl -s 'https://games.divi.domains/chain/v1/games/tictactoe?id=61ae6ea4466e4639'
{"board":".........","created_height":1719,"id":"61ae6ea4466e4639","last_move_height":1719,"moves":0,"o":null,"opponent":null,"reason":null,"result":null,"stake":"0","status":"open","turn":"x","winner":null,"x":"yGAKP63t2R5gKw8R3VDTsn9ZovpZcN5TWY"}
The key lives in that profile's localStorage. Running the page twice with the same profile reuses the key, so the second faucet claim comes back ok=false error=conflict: faucet cooling down; next claim at height … and the page shows it in the log. That is correct behaviour, just not the screenshot you want.
6The same from Node
The module runs unchanged in Node 20+ (fetch is built in; globalThis.crypto is what the vendored hashes use). Only subscribe needs a global WebSocket, which Node 22 has and Node 20 gets with --experimental-websocket. This script does what the page does, then plays a two-player game so there are real joined and moved events, then walks through the error cases of step 7. Keys are read from 0600 files and never printed.
// demo.mjs: the same flow as the page, from Node, plus a two-player game and the error cases.
// node --experimental-websocket demo.mjs (Node 20; Node 22+ has WebSocket built in)
import fs from 'node:fs';
import { Key, Chain, formatAmount, signTx } from './sdk/divigames.js';
const chain = new Chain('https://games.divi.domains/chain');
const load = (file) => {
if (fs.existsSync(file)) return Key.fromWif(fs.readFileSync(file, 'utf8'));
const k = Key.generate('testnet');
fs.writeFileSync(file, k.toWif(), { mode: 0o600 });
return k;
};
const alice = load('player.wif');
const bob = load('opponent.wif');
console.log('alice', alice.address);
console.log('bob ', bob.address);
const info = await chain.info();
console.log('chain', info.chain_id, 'height', info.height);
// 1. Watch blocks. Every receipt's events pass through here.
const seen = [];
const unsubscribe = chain.subscribe((block) => {
for (const r of block.receipts) for (const ev of r.events) seen.push(`block ${r.height}: ${ev.kind} ${JSON.stringify(ev.data)}`);
}, { onStatus: (up) => console.log('ws', up ? 'connected' : 'reconnecting') });
// 2. send() reads next_nonce, signs, submits; waitForReceipt() polls /v1/tx/<txid>.
async function run(label, signer, action) {
const txid = await chain.send(signer, action);
const r = await chain.waitForReceipt(txid);
console.log(`${label}: txid ${txid} block ${r.height} ok=${r.ok}${r.error ? ' error=' + r.error : ''}`);
return r;
}
await run('alice faucet', alice, { type: 'faucet', token: 'GOLD' });
const a = await chain.account(alice.address);
console.log('alice GOLD', formatAmount(a.balances.GOLD ?? '0', 2), 'next_nonce', a.next_nonce);
// 3. A free table, a join and two moves.
const created = await run('alice create', alice, { type: 'game', game: 'tictactoe', op: { kind: 'create' } });
const id = created.txid.slice(0, 16);
await run('bob join ', bob, { type: 'game', game: 'tictactoe', op: { kind: 'join', id } });
await run('alice move 4', alice, { type: 'game', game: 'tictactoe', op: { kind: 'move', id, cell: 4 } });
await run('bob move 0 ', bob, { type: 'game', game: 'tictactoe', op: { kind: 'move', id, cell: 0 } });
const view = await chain.game('tictactoe', { id });
console.log('view', JSON.stringify({ id: view.id, board: view.board, turn: view.turn, status: view.status }));
// 4. Errors.
// a. A receipt with ok:false: the faucet again, inside the cooldown. The nonce is still used up.
await run('alice faucet again', alice, { type: 'faucet', token: 'GOLD' });
// b. 409: submitting the same signed tx twice.
const { next_nonce } = await chain.account(alice.address);
const tx = signTx(alice, { nonce: next_nonce, action: { type: 'transfer', to: bob.address, token: 'GOLD', amount: '100' } });
console.log('transfer txid', (await chain.submit(tx)).txid);
try { await chain.submit(tx); } catch (e) { console.log('submit again ->', e.status, e.message); }
await chain.waitForReceipt(tx.txid);
// c. Two txs signed with the same nonce (what two unsynchronised send() calls can produce): the second is rejected.
const n = (await chain.account(alice.address)).next_nonce;
const t1 = signTx(alice, { nonce: n, action: { type: 'transfer', to: bob.address, token: 'GOLD', amount: '1' } });
const t2 = signTx(alice, { nonce: n, action: { type: 'transfer', to: bob.address, token: 'GOLD', amount: '2' } });
console.log('same nonce, first ->', (await chain.submit(t1)).txid);
try { await chain.submit(t2); } catch (e) { console.log('same nonce, second ->', e.status, e.message); }
await chain.waitForReceipt(t1.txid);
// d. Track the nonce yourself when sending several at once: signTx with next_nonce, +1 each.
let nonce = (await chain.account(alice.address)).next_nonce;
const txids = [];
for (const amount of ['3', '4']) {
const t = signTx(alice, { nonce: nonce++, action: { type: 'transfer', to: bob.address, token: 'GOLD', amount } });
txids.push((await chain.submit(t)).txid);
}
for (const t of txids) { const r = await chain.waitForReceipt(t); console.log('batched', t.slice(0, 16), 'block', r.height, 'ok', r.ok); }
await new Promise((r) => setTimeout(r, 2500));
unsubscribe();
console.log('--- events seen over the WebSocket ---');
for (const s of seen) if (s.includes(alice.address) || s.includes(id)) console.log(s);
The run, against the public chain:
$ node --experimental-websocket demo.mjs
alice y5pRAU9bYf47wnwCBvnNhnDrKihmtuQTQB
bob xzK5WF971Lw84zLyKkpgxKgt6hf5BCa1B2
chain divi-games-testnet-1 height 1705
ws connected
alice faucet: txid c628bd3cf5a1d85a0eeaed38a795310048200c94d239ff2d3927370730eb425d block 1706 ok=true
alice GOLD 1000 next_nonce 1
alice create: txid e5e2e91a93b319e305fb104ea40114b3cadd33f1fe663ec98569038fe76b306e block 1707 ok=true
bob join : txid 5fbb55688e6204d39b1c104460d2089fec174864684270a4563a43d5d14a8641 block 1708 ok=true
alice move 4: txid 711494886bb642223f4bc65e0754f2bc14fd80442c3666acc2b7f774ddb2007f block 1709 ok=true
bob move 0 : txid ec096ba6dbf54fcafea3bb314f5e60171ffe3225a78c82a94aad2a3773b49b01 block 1710 ok=true
view {"id":"e5e2e91a93b319e3","board":"o...x....","turn":"x","status":"playing"}
alice faucet again: txid 030f3dc8a9dc036a97edc53250c8f301b2f4b18325773903dcfb0e06a306705b block 1711 ok=false error=conflict: faucet cooling down; next claim at height 3506
transfer txid 50ff85a27f62dfcb392304bb046debd1647b24470f0c012692d62fdb43e30072
submit again -> 409 duplicate tx
same nonce, first -> eb4017f3860427b4bbcda265c883fc065f9170d0091ebdc27d74792eba660a45
same nonce, second -> 400 nonce: expected 6, got 5
batched 762b22b3f5f5634d block 1714 ok true
batched 62d590eb8328507c block 1714 ok true
--- events seen over the WebSocket ---
block 1706: faucet {"amount":"100000","to":"y5pRAU9bYf47wnwCBvnNhnDrKihmtuQTQB","token":"GOLD"}
block 1707: tictactoe.created {"id":"e5e2e91a93b319e3","opponent":null,"stake":"0","x":"y5pRAU9bYf47wnwCBvnNhnDrKihmtuQTQB"}
block 1708: tictactoe.joined {"id":"e5e2e91a93b319e3","o":"xzK5WF971Lw84zLyKkpgxKgt6hf5BCa1B2"}
block 1709: tictactoe.moved {"board":"....x....","by":"y5pRAU9bYf47wnwCBvnNhnDrKihmtuQTQB","cell":4,"id":"e5e2e91a93b319e3","turn":"o"}
block 1710: tictactoe.moved {"board":"o...x....","by":"xzK5WF971Lw84zLyKkpgxKgt6hf5BCa1B2","cell":0,"id":"e5e2e91a93b319e3","turn":"x"}
block 1712: transfer {"amount":"100","from":"y5pRAU9bYf47wnwCBvnNhnDrKihmtuQTQB","to":"xzK5WF971Lw84zLyKkpgxKgt6hf5BCa1B2","token":"GOLD"}
block 1713: transfer {"amount":"1","from":"y5pRAU9bYf47wnwCBvnNhnDrKihmtuQTQB","to":"xzK5WF971Lw84zLyKkpgxKgt6hf5BCa1B2","token":"GOLD"}
block 1714: transfer {"amount":"3","from":"y5pRAU9bYf47wnwCBvnNhnDrKihmtuQTQB","to":"xzK5WF971Lw84zLyKkpgxKgt6hf5BCa1B2","token":"GOLD"}
block 1714: transfer {"amount":"4","from":"y5pRAU9bYf47wnwCBvnNhnDrKihmtuQTQB","to":"xzK5WF971Lw84zLyKkpgxKgt6hf5BCa1B2","token":"GOLD"}
Each tictactoe.moved event carries the whole board, so a page never needs to reconstruct state from single moves: draw ev.data.board and you are current. The two batched transfers (nonces 6 and 7) landed in the same block, 1714, which is what tracking the nonce yourself buys you.
send() only needs signer.address and signer.signMessage(body), and it awaits the result. So an object wrapping the IronDivi extension, { address, signMessage: (m) => window.irondivi.signMessage(m) }, plugs in where a Key would, after connect(). How the signature is made and checked is in Sign in with your Divi address.
7Errors and nonces
Three layers can say no, and the SDK surfaces each differently:
| Where | How it shows up | Seen above |
|---|---|---|
| Submission rejected (HTTP 400) | submit() / send() throws; e.status === 400, e.message is the node's text: bad tx: …, nonce: expected N, got M. | Seen above: 400 nonce: expected 6, got 5 |
| Duplicate (HTTP 409) | Throws with e.status === 409, duplicate tx. The same body has the same txid, and it is already in the mempool or a block. Treat it as "already sent": look up the receipt with tx(txid) instead of signing again. | Seen above: 409 duplicate tx |
| Mempool full (HTTP 503) | Throws with e.status === 503 once 10,000 transactions are waiting. Back off and retry; your nonce is unchanged because nothing was queued. | Seen above: not reproduced on purpose (source: sequencer.rs MEMPOOL_MAX, api.rs maps it to 503) |
Receipt with ok: false | The transaction was valid and is in a block, but the action failed: receipt.error says why. The nonce is consumed. waitForReceipt does not throw for this; check ok. | Seen above: ok=false error=conflict: faucet cooling down; next claim at height 3506 |
Nonces
send() reads next_nonce every time, which is right when you send one transaction and wait for its receipt before the next. Two send() calls started together can both read the same number; the demo shows what the node says to the second one (nonce: expected 6, got 5). To send several at once, read next_nonce once and sign with signTx(key, { nonce: n++, action }), as the "batched" part of the script does. next_nonce already includes your transactions waiting in the mempool, so it is safe to read it again right after a submit.
A faucet claim per address every 1,800 blocks, a free table, a join, two moves: nothing in this tutorial needs tDIVI or costs anything.
If something goes wrong
| You see | What it means |
|---|---|
Failed to fetch dynamically imported module or a CORS error for /sdk/divigames.js | You imported the SDK from games.divi.domains on a page that is not on games.divi.domains. Copy sdk/ and vendor/ next to your page. |
Import works for divigames.js but fails for ../vendor/noble-secp256k1/index.js | vendor/ is not next to sdk/. Keep the two directories side by side. |
Page opened from file:// shows nothing | Module imports and fetch need an http: origin. Serve the directory (python3 -m http.server). |
ReferenceError: WebSocket is not defined in Node | subscribe() on Node 20: run with node --experimental-websocket, or use Node 22+. |
tx … not in a block after 30s | waitForReceipt timed out. Blocks come every 2 s when there is anything to include, so this means the node or the tunnel is down. Check info(). |
ok=false error=conflict: faucet cooling down; next claim at height N | One claim per address per 1,800 blocks. Use the GOLD you have, or a new guest key. |
not a Divi WIF private key, not base58: … or base58check: too short | Key.fromWif got something that is not a Divi WIF: wrong version byte or length, a stray character, or an empty string. The page catches all of these and generates a key. |
The second send() fails with nonce: expected N, got M | You sent two at once. Await the first, or track the nonce yourself with signTx. |
Related: Sign in with your Divi address (what the signature is), Build tic-tac-toe (the game module behind the table), Run the gamechain (point new Chain('http://127.0.0.1:7410') at your own node), Deploy your game and Browser extension API.
Still stuck? Ask the Divi chatbot, or see all tutorials.