Tutorial · Divi Games

Sign in with your Divi address

There is no username and no password. Your account is a Divi address, every move is a signed message, and the games check the signature by recovering your public key. Here is exactly how, with real signatures and a real transaction.

About 12 minutes · Checked 2026-10-21

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.

Testnet version byte
139
Testnet addresses start with
x or y
Mainnet version byte
30 (D)

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).

What "signing in" means here

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:

ByteWhat it is
0Header: 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–32r
33–64s, 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
Two things the docs gloss over

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:

FieldValue
v1
chain"divi-games-testnet-1". A body for another chain is rejected; a signature cannot be replayed on a different gamechain.
fromYour testnet address. The signature must recover to it.
nonceYour 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.
actionAn object with a type: faucet, transfer, game and so on.
memoOptional, at most 140 characters.
The body is a string, and the bytes matter

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….

next_nonce vs nonce

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:

  1. Shape (bad tx: invalid transaction: …, HTTP 400): body over 32 KiB, not JSON, v not 1, wrong chain id, signer address is on the wrong network, missing action.type, memo over 140 characters.
  2. Signature (bad tx: invalid signature, HTTP 400): the key recovered from the signature does not hash to from. Tampering with one byte of the body lands here, because the signature was over different bytes.
  3. 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.
  4. Nonce (nonce: expected N, got M, HTTP 400): not the account's next nonce.
  5. 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.

If something goes wrong

You seeWhat it means
verifymessage returns false for a signature you know is rightThe 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 SDKHeader 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 signatureThe 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 MRead 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 networkA 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.