1Your account is an address
Divi Games has no accounts table with passwords in it. The thing the gamechain keeps balances and nonces for is a Divi pay-to-public-key-hash address: the same kind of address a Divi wallet shows you. Whoever holds the private key behind the address can play as it, and nobody else can.
An address is made from a public key in three steps: hash the 33-byte compressed public key with SHA-256 then RIPEMD-160 (hash160), put a one-byte network version in front, and encode the result in Base58Check.
x or yD)The public gamechain is divi-games-testnet-1, so it only accepts testnet addresses. A D address is rejected with signer address is on the wrong network (you will see the node's wording in step 6).
You never log in. Each request you send already carries a signature over its own contents, so the node authenticates every single action on its own. There is no session, no cookie and no token to steal. Lose the key and the account is gone; copy the key and someone else has it.
2What gets signed
Divi Games reuses the message signing that every Divi wallet already has (the signmessage / verifymessage RPCs). The signer never signs the raw text. It signs this 32-byte digest:
hash = SHA256( SHA256( varstr("DarkNet Signed Message:\n") || varstr(message) ) )
varstr(s) is the Bitcoin-style string encoding: a compact-size length prefix followed by the UTF-8 bytes. The prefix counts bytes, not characters. The magic string DarkNet Signed Message:\n (with the newline) is Divi's; Bitcoin's is different, so a Bitcoin wallet's signature over the same text will never verify on Divi.
The signature itself is 65 bytes, sent as Base64:
| Byte | What it is |
|---|---|
| 0 | Header: 27 + recid + 4. recid (0–3) says which of the possible public keys made the signature; the + 4 means the key is compressed. Divi wallets always use compressed keys, so the header is 31, 32, 33 or 34. |
| 1–32 | r |
| 33–64 | s, normalised to the low half of the curve order ("low-s") |
The signature is deterministic (RFC 6979): signing the same message with the same key gives the same bytes every time. That is why the gamechain can use a transaction's body as the only input to its txid, as you will see in step 5.
3Three ways to sign
Any of these produces a signature the games accept. They all compute the same hash and output the same 65-byte format.
The IronDivi browser extension
A page asks the extension to sign through the injected provider window.irondivi (older builds also expose it as window.pdivi). The user sees a confirmation popup with the message text and clicks Approve.
// In a web page. The provider is only there if the extension is installed. const divi = window.irondivi; const { address } = await divi.connect(); // user approves the site once const sig = await divi.signMessage('hello from the page'); // sig is the same Base64 string signmessage would give for the first address of the wallet
signMessage throws Not connected. Call connect() first. unless connect() has succeeded for this site. And although it takes an optional second argument address, the current provider ignores it and always signs with the wallet's first address (addressIndex: 0). Use the address that connect() returned. We checked this against the provider source (src/injected/provider.ts), not against ARCHITECTURE.md, which shows only signMessage(message).
The full provider API, with transaction signing, is in Browser extension API. No WalletConnect or Reown anywhere: Divi uses its own injected provider.
The command line: divi-cli or irondivi-cli
Both the C++ node and IronDivi expose the classic RPC pair. The address must be one your wallet holds the key for. This is a real run against an IronDivi testnet node (it was still syncing, which does not matter: signing is local):
$ irondivi-cli --conf ~/tut-i/irondivi/irondivi.conf --testnet getnewaddress y88nGdSSs2CWE13K6aZSNqRbVfw7EB5pEY $ irondivi-cli --conf ~/tut-i/irondivi/irondivi.conf --testnet signmessage y88nGdSSs2CWE13K6aZSNqRbVfw7EB5pEY "hello from irondivi-cli" H9mNaZMcmI7YHd5Ehp8vLjZ7XUfhyM2fI9i6yulGdKeucxTFvHrvxFGP4NvmkQ8syLSIPNwpLMrbHXBmic47ezM= $ irondivi-cli --conf ~/tut-i/irondivi/irondivi.conf --testnet verifymessage y88nGdSSs2CWE13K6aZSNqRbVfw7EB5pEY H9mNaZMcmI7YHd5Ehp8vLjZ7XUfhyM2fI9i6yulGdKeucxTFvHrvxFGP4NvmkQ8syLSIPNwpLMrbHXBmic47ezM= "hello from irondivi-cli" true
With the C++ node the commands are divi-cli -testnet signmessage <address> "<message>" and divi-cli -testnet verifymessage <address> <signature> "<message>". The Sign and verify a message tutorial walks through them.
The JavaScript SDK: Key.signMessage
divigames.js carries a small key class so a page or a Node script can sign without any wallet. It is what the play pages at games.divi.domains use for guest players.
import { Key } from './sdk/divigames.js';
const key = Key.generate('testnet'); // or Key.fromWif(wif)
key.address; // 'y...'
key.signMessage('hello from divigames.js'); // Base64, 65 bytes
Where to get the SDK, how it is laid out and how to put a game on your own page is The JavaScript SDK. The next step uses it to show the whole round trip.
4How a signature is checked
The verifier never needs your public key in advance. From the hash and the 65-byte signature it recovers the public key (the header's recid says which candidate), hashes it with hash160, and compares the result with the 20 bytes inside the address. Equal means the signature came from that address's key.
This script makes a testnet key (saved to a 0600 file that is never printed), signs a message, and checks the signature three ways: with the SDK's own verifyMessage, with Divi Core's verifymessage on the public testnet RPC, and finally the SDK checks the signature irondivi-cli made above. The scripts in this tutorial sit next to a copy of sdk/ and vendor/ from the games repo; Node 20 or newer.
// sign.mjs: make (or load) a testnet key, sign a message, verify it three ways. // node sign.mjs import fs from 'node:fs'; import { Key, verifyMessage, recoverMessageSigner, messageHash, bytesToHex, hash160 } from './sdk/divigames.js'; const RPC = 'https://services.divi.domains/api/testnet/rpc/'; const KEYFILE = 'player.wif'; // Load the key from a 0600 file, or make a new one. The WIF is never printed. let key; if (fs.existsSync(KEYFILE)) { key = Key.fromWif(fs.readFileSync(KEYFILE, 'utf8')); } else { key = Key.generate('testnet'); fs.writeFileSync(KEYFILE, key.toWif(), { mode: 0o600 }); } console.log('address ', key.address); console.log('public key', bytesToHex(key.publicKey)); const msg = 'hello from divigames.js'; const sig = key.signMessage(msg); console.log('message ', JSON.stringify(msg)); console.log('hash ', bytesToHex(messageHash(msg))); console.log('signature ', sig); const raw = Buffer.from(sig, 'base64'); console.log('header byte', raw[0], '= 27 + recid', (raw[0] - 27) & 3, '+ 4 (compressed)'); // 1. The SDK's own check: recover the public key, hash it, compare with the address. const recovered = recoverMessageSigner(msg, sig); console.log('recovered pubkey matches', bytesToHex(recovered) === bytesToHex(key.publicKey)); console.log('hash160 of it ', bytesToHex(hash160(recovered))); console.log('verifyMessage(genuine) ', verifyMessage(key.address, msg, sig)); console.log('verifyMessage(tampered) ', verifyMessage(key.address, msg + '!', sig)); // 2. Ask Divi Core on the public testnet RPC. async function verifymessage(address, signature, message) { const res = await fetch(RPC, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '1.0', id: 1, method: 'verifymessage', params: [address, signature, message] }), }); return (await res.json()).result; } console.log('RPC verifymessage(genuine) ', await verifymessage(key.address, sig, msg)); console.log('RPC verifymessage(tampered)', await verifymessage(key.address, sig, msg + '!')); // 3. A signature made by irondivi-cli (`signmessage y88n... "hello from irondivi-cli"`) checks out in the SDK too. const cliAddr = 'y88nGdSSs2CWE13K6aZSNqRbVfw7EB5pEY'; const cliSig = 'H9mNaZMcmI7YHd5Ehp8vLjZ7XUfhyM2fI9i6yulGdKeucxTFvHrvxFGP4NvmkQ8syLSIPNwpLMrbHXBmic47ezM='; console.log('SDK verifies irondivi-cli signature', verifyMessage(cliAddr, 'hello from irondivi-cli', cliSig)); console.log('RPC verifies irondivi-cli signature', await verifymessage(cliAddr, cliSig, 'hello from irondivi-cli'));
What it printed:
$ node sign.mjs address yFdxP45jC2kma8snwQMNWFBqZnfQQ6kWGg public key 03ba246333bfc83f3197af465a580bf30da59a4f56bbbcb0bbce2c7f12f1628dfb message "hello from divigames.js" hash 3367a99d55b1d1d761c304ba0d5b8bcfddb5a9832fdc3969e4cc6aaae6732425 signature IETg6/wqTeBN1cgZWowKg2vw9KBRg/p29EvHCx2Z6xvQYkvOcOWePBBypcT/zQUnnqJcr4isdbfntDPM3VUFQp4= header byte 32 = 27 + recid 1 + 4 (compressed) recovered pubkey matches true hash160 of it cca782a90b24e5c69bf8126ab6202c48fbccb19e verifyMessage(genuine) true verifyMessage(tampered) false RPC verifymessage(genuine) true RPC verifymessage(tampered) false SDK verifies irondivi-cli signature true RPC verifies irondivi-cli signature true
Three independent implementations (the SDK in JavaScript, Divi Core in C++, IronDivi in Rust) agree on every signature. Changing one character of the message flips both verifiers to false, because the recovered key no longer hashes to the address.
You can run the RPC check yourself without any script; the public testnet node answers plain JSON-RPC over HTTPS:
$ curl -s https://services.divi.domains/api/testnet/rpc/ -H 'content-type: application/json' \
-d '{"jsonrpc":"1.0","id":1,"method":"verifymessage","params":["yFdxP45jC2kma8snwQMNWFBqZnfQQ6kWGg","IETg6/wqTeBN1cgZWowKg2vw9KBRg/p29EvHCx2Z6xvQYkvOcOWePBBypcT/zQUnnqJcr4isdbfntDPM3VUFQp4=","hello from divigames.js"]}'
{"error":null,"id":1,"result":true}
5A game transaction, signed by hand
A gamechain transaction is just a message signature over a JSON string. The envelope you POST is {"body": "<json string>", "sig": "<base64>"}, where body is:
| Field | Value |
|---|---|
| v | 1 |
| chain | "divi-games-testnet-1". A body for another chain is rejected; a signature cannot be replayed on a different gamechain. |
| from | Your testnet address. The signature must recover to it. |
| nonce | Your account's next nonce: 0 for a fresh address, then up by one for every transaction that gets into a block, successful or not. Stops replays. |
| action | An object with a type: faucet, transfer, game and so on. |
| memo | Optional, at most 140 characters. |
The node signs and hashes the exact bytes you sent. It never parses and re-serialises the body, so key order and whitespace are whatever you produced; just sign the same string you send. The transaction id is hex(SHA256(body)): the body alone, not the signature, and no magic prefix. The body may be at most 32 KiB.
This script builds a faucet claim for the key from step 4 without using the SDK's Chain helper, so every byte is visible. It reads the nonce from GET /v1/account/<address>, signs, computes the txid, POSTs, waits for the receipt and reads the account again.
// faucet.mjs: build a gamechain tx by hand, sign it, submit it, read the receipt. // node faucet.mjs import fs from 'node:fs'; import { createHash } from 'node:crypto'; import { Key, messageHash, bytesToHex } from './sdk/divigames.js'; const CHAIN = 'https://games.divi.domains/chain'; const key = Key.fromWif(fs.readFileSync('player.wif', 'utf8')); // 1. The account's next nonce. const before = await (await fetch(`${CHAIN}/v1/account/${key.address}`)).json(); console.log('account before', JSON.stringify(before)); // 2. The body is a JSON *string*. These exact bytes get signed; the node never re-serialises them. const body = JSON.stringify({ v: 1, chain: 'divi-games-testnet-1', from: key.address, nonce: before.next_nonce, action: { type: 'faucet', token: 'GOLD' }, }); console.log('body', body); // 3. Sign the body like any other message. txid = sha256 of the body bytes. const sig = key.signMessage(body); const txid = createHash('sha256').update(body, 'utf8').digest('hex'); console.log('message hash', bytesToHex(messageHash(body))); console.log('sig ', sig); console.log('txid', txid); fs.writeFileSync('last-tx.json', JSON.stringify({ body, sig })); // 4. Submit the envelope {body, sig}. const res = await fetch(`${CHAIN}/v1/tx`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ body, sig }), }); console.log('POST /v1/tx ->', res.status, await res.text()); // 5. Wait for the receipt, then look at the account. for (;;) { const t = await (await fetch(`${CHAIN}/v1/tx/${txid}`)).json(); if (t.receipt) { console.log('receipt', JSON.stringify(t.receipt)); break; } await new Promise((r) => setTimeout(r, 500)); } const after = await (await fetch(`${CHAIN}/v1/account/${key.address}`)).json(); console.log('account after ', JSON.stringify(after));
The real run, on the public gamechain:
$ node faucet.mjs
account before {"address":"yFdxP45jC2kma8snwQMNWFBqZnfQQ6kWGg","balances":{},"next_nonce":0,"nonce":0}
body {"v":1,"chain":"divi-games-testnet-1","from":"yFdxP45jC2kma8snwQMNWFBqZnfQQ6kWGg","nonce":0,"action":{"type":"faucet","token":"GOLD"}}
message hash 4ab51701ed969655b5ad48162d90564fe7c2f4d34c2fd218191b726c5ed85fac
sig H3qETc788nPiNGAhejO/SMAQj6krfIg9zv+DV5T23L96XuaHt8/9sqyAh1PyX59NWKu4lBH8DFpWHb/9tUHltaY=
txid e93743a519e068875c732a692d8dfa7657215460c61fbe80f129b621ffb61cde
POST /v1/tx -> 200 {"txid":"e93743a519e068875c732a692d8dfa7657215460c61fbe80f129b621ffb61cde"}
receipt {"error":null,"events":[{"data":{"amount":"100000","to":"yFdxP45jC2kma8snwQMNWFBqZnfQQ6kWGg","token":"GOLD"},"kind":"faucet"}],"height":1430,"index":0,"ok":true,"txid":"e93743a519e068875c732a692d8dfa7657215460c61fbe80f129b621ffb61cde"}
account after {"address":"yFdxP45jC2kma8snwQMNWFBqZnfQQ6kWGg","balances":{"GOLD":"100000"},"next_nonce":1,"nonce":1}
Read it top to bottom: the node accepted the envelope (200 with the same txid we computed locally), put it in block 1430 as transaction 0, the faucet paid 100000 base units (1000.00 GOLD, two decimals), and the account's nonce moved from 0 to 1. The transaction is still there: /chain/v1/tx/e93743a5….
nonce counts transactions already in blocks. next_nonce also counts yours waiting in the mempool, so it is the one to put in your next body if you send several in a row. The SDK's Chain.send() does exactly this for you; this tutorial avoids it on purpose.
6What the node rejects, and what it says
Four ways a transaction goes wrong, sent to the live node right after the faucet claim. Each one is a correctly formed envelope with exactly one thing off.
// errors.mjs: what the live node says to a wrong nonce, a tampered body, a wrong chain id, and a replay. // node errors.mjs import fs from 'node:fs'; import { Key } from './sdk/divigames.js'; const CHAIN = 'https://games.divi.domains/chain'; const key = Key.fromWif(fs.readFileSync('player.wif', 'utf8')); const { next_nonce } = await (await fetch(`${CHAIN}/v1/account/${key.address}`)).json(); async function submit(label, body, sig) { const res = await fetch(`${CHAIN}/v1/tx`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ body, sig }) }); console.log(`${label}: HTTP ${res.status} ${await res.text()}`); } const mk = (nonce, chain = 'divi-games-testnet-1') => JSON.stringify({ v: 1, chain, from: key.address, nonce, action: { type: 'transfer', to: key.address, token: 'GOLD', amount: '1' } }); // a. Correct signature, but the nonce is 5 too high. let body = mk(next_nonce + 5); await submit('wrong nonce ', body, key.signMessage(body)); // b. Signed one body, sent another (amount changed after signing). body = mk(next_nonce); const sig = key.signMessage(body); await submit('tampered body ', body.replace('"amount":"1"', '"amount":"100000"'), sig); // c. Correct signature over a body for another chain id. body = mk(next_nonce, 'divi-games-mainnet-1'); await submit('wrong chain id', body, key.signMessage(body)); // d. Replay: the faucet tx from faucet.mjs, submitted again. const last = JSON.parse(fs.readFileSync('last-tx.json', 'utf8')); await submit('replayed tx ', last.body, last.sig);
$ node errors.mjs
wrong nonce : HTTP 400 {"error":"nonce: expected 1, got 6"}
tampered body : HTTP 400 {"error":"bad tx: invalid signature"}
wrong chain id: HTTP 400 {"error":"bad tx: invalid transaction: wrong chain id"}
replayed tx : HTTP 409 {"error":"duplicate tx"}
The order of checks on the node is fixed, and it decides which message you get when more than one thing is wrong:
- Shape (
bad tx: invalid transaction: …, HTTP 400): body over 32 KiB, not JSON,vnot 1,wrong chain id,signer address is on the wrong network, missingaction.type, memo over 140 characters. - Signature (
bad tx: invalid signature, HTTP 400): the key recovered from the signature does not hash tofrom. Tampering with one byte of the body lands here, because the signature was over different bytes. - Duplicate (
duplicate tx, HTTP 409): the same txid is already in a block or in the mempool. Since the txid is the hash of the body, resending the same body is always a duplicate. - Nonce (
nonce: expected N, got M, HTTP 400): not the account's next nonce. - Mempool full (HTTP 503): more than 10,000 waiting transactions. We did not reproduce this one on the public node; it comes from the sequencer source.
Everything that passes these checks gets a receipt in the next block (one every 2 seconds while there is something to include). A receipt can still be ok: false with an error string when the action itself fails, for example faucet cooling down; next claim at height N if you claim GOLD twice within 1800 blocks. The nonce is consumed either way.
7Linking an address on the site
The site at games.divi.domains identifies people through Telegram and lets each of them attach up to 10 addresses. To prove you hold an address, the site shows you one sentence and asks for a signature over it, made with any of the three methods in step 3:
games.divi.domains wants to link <address> to telegram:<your telegram id> nonce:<challenge number>
The nonce is a fresh challenge id from the site's database. It is valid for one hour and for one use, so a signature someone copies from you cannot be sent again. The site runs the same recovery check as the gamechain and answers That signature does not match the address and message. on failure.
// link.mjs: the sentence games.divi.domains asks you to sign to link an address, and how the site checks it. // node link.mjs import fs from 'node:fs'; import { Key, verifyMessage } from './sdk/divigames.js'; const key = Key.fromWif(fs.readFileSync('player.wif', 'utf8')); const telegramId = 123456789; // your Telegram user id const nonce = 42; // the site's challenge number, shown with the sentence const sentence = `games.divi.domains wants to link ${key.address} to telegram:${telegramId} nonce:${nonce}`; console.log('sentence ', sentence); const sig = key.signMessage(sentence); console.log('signature', sig); // The site runs the same check (dg_core::crypto::verify_message), then marks the nonce used. console.log('site accepts ', verifyMessage(key.address, sentence, sig)); console.log('old nonce replayed ', verifyMessage(key.address, sentence.replace('nonce:42', 'nonce:43'), sig));
$ node link.mjs sentence games.divi.domains wants to link yFdxP45jC2kma8snwQMNWFBqZnfQQ6kWGg to telegram:123456789 nonce:42 signature IBy1ds4zv5KD8ZpNx+GByEPCwqzi5PeP9v8Bjel4hVG1Nbi/fYqIbDE1a8FqNSVq8J9qAnOjBMl+ADm5+PIaFZU= site accepts true old nonce replayed false
The Telegram id and nonce here are made up so the sentence can be shown; the site fills in the real ones when you click Link an address. The check is the same.
This is the whole pattern. Whether it is a game move, a faucet claim or a profile link, "signing in with Divi" is always: the service writes down exactly what it wants you to agree to, you sign it, and the service recovers your public key and compares it with your address.
If something goes wrong
| You see | What it means |
|---|---|
verifymessage returns false for a signature you know is right | The message is not byte-for-byte the same: a trailing newline, a different quote style, or a shell that changed \n. Compare messageHash on both sides. |
unsupported signature header from the SDK | Header byte outside 31–34: the signature is from an uncompressed key or not a Divi/Bitcoin-style signature at all. All Divi wallets sign with compressed keys. |
Not connected. Call connect() first. | The extension has not approved your site. Call await window.irondivi.connect() and use the address it returns. |
bad tx: invalid signature | The signature is over different bytes than the body you sent, or made with a key for a different address than from. |
nonce: expected N, got M | Read next_nonce from /v1/account/<address> right before signing, and send transactions from one address one at a time (or track the nonce yourself). |
duplicate tx (409) | You resent the same body. If you meant a new transaction, the nonce (and so the body and txid) must differ. |
signer address is on the wrong network | A mainnet D address on the testnet gamechain. Make a testnet key: Key.generate('testnet'). |
Related: Sign and verify a message (the wallet side), Browser extension API, The JavaScript SDK, Run the gamechain and Build tic-tac-toe.
Still stuck? Ask the Divi chatbot, or see all tutorials.