Tutorial · Divi Games

Anchors and the tDIVI Bridge

How the gamechain writes its block hash and state root into Divi testnet, how to decode and check an anchor yourself, and how testnet DIVI crosses the bridge in both directions. Every command below was run for real.

About 25 minutes · Checked 2026-10-21

1What anchors and the bridge are

The gamechain is a sidechain with one sequencer (see What is a sidechain). Two mechanisms tie it to Divi testnet in the write direction. Every block already carries a read checkpoint, the Divi block six below the tip, in its header; that is the other direction and is covered in Run the gamechain.

Anchor payload
77 bytes
Default anchor interval
300 blocks
Deposit confirmations
6
Default anchor fee
0.01 tDIVI
Custodial means custodial

The bridge wallet is a plain Divi testnet key the operator holds. The node only ever mints; it never spends from the bridge wallet. Payouts are a human step. That is fine for testnet coins and is documented as such in ARCHITECTURE.md §4.3; the trustless path is the HTLC swap in §6.2.

On 2026-10-21 the live chain at games.divi.domains had no anchors yet and an empty withdrawal queue, because anchoring stays off until the sequencer's Divi address holds testnet DIVI. So this tutorial runs everything on your own node, where you hold every key. If you have not got one, Run the gamechain first; the node below listens on 127.0.0.1:53910.

2Turn both on in gamechain.toml

Both features are off by default. Each needs a Divi testnet key:

$ divi-gamechain keygen --data-dir ~/tut-i/keys/bridge.d
yE49TKgQQ6jU9Bp1yXhp56YTw23votzcWk
$ ls -l ~/tut-i/keys/bridge.d
-rw------- 1 bshuler bshuler 53 Oct 22 00:32 sequencer.key   # a WIF; the file is 0600, leave it so

Then add the two tables to the node's config. This is the file our node ran with, except that every is 10 instead of 300 so an anchor lands every 20 seconds instead of every 10 minutes:

# gamechain.toml (the parts this tutorial needs)
data_dir = "/home/bshuler/tut-i/gamechain"
listen = "127.0.0.1:53910"
divi_rpc = "https://services.divi.domains/api/testnet/rpc/"

[anchor]
enabled = true
every = 10            # default 300
fee_sat = 1000000     # flat 0.01 tDIVI per anchor
poll_ms = 5000

[bridge]
enabled = true
address = "yE49TKgQQ6jU9Bp1yXhp56YTw23votzcWk"
confirmations = 6
poll_ms = 30000
lookback = 2000       # rescan window, in Divi blocks

Restart the node and the log says so:

INFO divi_gamechain::bridge: bridge: enabled address=yE49TKgQQ6jU9Bp1yXhp56YTw23votzcWk confirmations=6
INFO divi_gamechain::anchor: anchor: enabled address="yKKVyUcekidmNGP3VHCFbS1zdagwm29D5i" every=10
INFO divi_gamechain::anchor: anchor: broadcast height=290 divi_txid=14180ce7dd6dd1271d0b8ec5db8a058f617e561f1b54b22b337bc09f8bcf8125
Why the public RPC

The bridge scans with getaddressdeltas and the anchor funds itself with getaddressutxos. Both need an address index, which the public testnet RPC at services.divi.domains/api/testnet/rpc/ has. A plain IronDivi node does not, so point divi_rpc at the public endpoint even if you run your own node for the wallet, as we did.

Note the first anchor went to height 290, not 300. The target is the newest multiple of every at or below the head that is not yet anchored, so a node that was already at height 294 when anchoring came on anchors 290 at once rather than waiting.

3Read an anchor from both chains

The anchor leaves a trace on both sides. On the gamechain, the sequencer submits a system/anchor_recorded action once the Divi transaction is broadcast, and the anchors view lists them newest first:

$ curl -s 'http://127.0.0.1:53910/v1/games/system?view=anchors&limit=1'
{"anchors":[{"block_hash":"d813f213cb5093d5a1a567566c4b7c4901e5567c61fd0b879a9b38c377c49937",
  "divi_txid":"14180ce7dd6dd1271d0b8ec5db8a058f617e561f1b54b22b337bc09f8bcf8125",
  "height":290,"recorded_at":295,
  "state_root":"e7df36b77c435f1edcd9ba4a0a94ee767b245add4292de1248069edb589efcc3"}]}

recorded_at is the gamechain block that carries the record; the anchor itself describes block 290. On Divi, the transaction has one nulldata output and one change output back to the sequencer:

$ curl -s https://services.divi.domains/api/testnet/rpc/ -H 'content-type: application/json' \
    -d '{"jsonrpc":"1.0","id":"a","method":"getrawtransaction","params":["14180ce7dd6dd1271d0b8ec5db8a058f617e561f1b54b22b337bc09f8bcf8125",1]}'
… "size":280, "confirmations":3,
"vin":[{"txid":"d10bd06b…","vout":1}],
"vout":[
  {"value":0.0,"n":0,"scriptPubKey":{"type":"nulldata","hex":"6a4c4d44474348012201000000000000d813f213…589efcc3"}},
  {"value":4999.98,"n":1,"scriptPubKey":{"addresses":["yKKVyUcekidmNGP3VHCFbS1zdagwm29D5i"]}}]

Take the hex apart and the format in crates/dg-system/src/divi_tx.rs falls out:

BytesHexMeaning
16aOP_RETURN
24c 4dOP_PUSHDATA1, 77 bytes follow (anything over 75 needs PUSHDATA1)
444 47 43 48"DGCH"
101version 1
822 01 00 00 00 00 00 00height 290, u64 little-endian (0x122 = 290)
32d813f213…c49937block hash of gamechain block 290
32e7df36b7…9efcc3state root after block 290

Here is a script that does the comparison end to end: newest anchor from the gamechain, its transaction from Divi, decode, and compare with /v1/blocks/<height>. Save it as check-anchor.py:

# check-anchor.py: read the newest anchor from the gamechain, fetch its Divi transaction from the public
# testnet RPC, decode the OP_RETURN and compare it with the gamechain block it claims to anchor.
# usage: python3 check-anchor.py [gamechain url]
import json, sys, urllib.request
CHAIN = sys.argv[1] if len(sys.argv) > 1 else 'http://127.0.0.1:53910'
RPC = 'https://services.divi.domains/api/testnet/rpc/'

def get(url):
    return json.load(urllib.request.urlopen(url))
def rpc(method, *params):
    req = urllib.request.Request(RPC, json.dumps({'jsonrpc': '1.0', 'id': 'a', 'method': method, 'params': list(params)}).encode(),
                                 {'content-type': 'application/json'})
    return json.load(urllib.request.urlopen(req))['result']

a = get(f'{CHAIN}/v1/games/system?view=anchors&limit=1')['anchors'][0]
print('anchor   ', json.dumps(a))
tx = rpc('getrawtransaction', a['divi_txid'], 1)
print('divi tx  ', a['divi_txid'], 'confirmations', tx.get('confirmations', 0))
data = next(v for v in tx['vout'] if v['scriptPubKey']['type'] == 'nulldata')['scriptPubKey']['hex']
raw = bytes.fromhex(data)
assert raw[:3] == b'\x6a\x4c\x4d', 'OP_RETURN OP_PUSHDATA1 77'
p = raw[3:]
assert p[:4] == b'DGCH' and p[4] == 1 and len(p) == 77
height = int.from_bytes(p[5:13], 'little'); block_hash = p[13:45].hex(); state_root = p[45:77].hex()
print('payload  ', 'height', height, 'block_hash', block_hash, 'state_root', state_root)
b = get(f'{CHAIN}/v1/blocks/{height}')
print('chain    ', 'height', b['header']['height'], 'hash', b['hash'], 'state_root', b['header']['state_root'])
ok = height == a['height'] == b['header']['height'] and block_hash == a['block_hash'] == b['hash'] and state_root == a['state_root'] == b['header']['state_root']
print('MATCH' if ok else 'MISMATCH')

By the time we ran it the node had anchored twice more, so it checked height 310:

$ python3 check-anchor.py
anchor    {"block_hash": "d98f5364d4e29f482bd7fa47384b7dcbdf64e5815f9160853e868a95dcb7fc7e", "divi_txid": "05871329ef7d75c06baa4b254c3be3868ce10cd751dc23130947fb8b99762fa2", "height": 310, "recorded_at": 311, "state_root": "ae7093207432a6a2c1b60db80dab72f42ed1c5578de2398d4fc47a82a8cacfbd"}
divi tx   05871329ef7d75c06baa4b254c3be3868ce10cd751dc23130947fb8b99762fa2 confirmations 5
payload   height 310 block_hash d98f5364d4e29f482bd7fa47384b7dcbdf64e5815f9160853e868a95dcb7fc7e state_root ae7093207432a6a2c1b60db80dab72f42ed1c5578de2398d4fc47a82a8cacfbd
chain     height 310 hash d98f5364d4e29f482bd7fa47384b7dcbdf64e5815f9160853e868a95dcb7fc7e state_root ae7093207432a6a2c1b60db80dab72f42ed1c5578de2398d4fc47a82a8cacfbd
MATCH
How the anchor pays for itself

The public RPC has no wallet, so the node builds the transaction itself: it lists the sequencer's unspent outputs, spends the largest first and only as many as needed, pays the flat fee_sat, and sends change back to itself if the change is at least 0.001 tDIVI (DUST_SAT). Each anchor costs exactly the fee: our sequencer's change output went from 4,999.98 after the first anchor to 4,999.96 after the third.

4Replay the chain and match the state root

An anchor is only worth something if a third party can recompute the state root it claims. divi-gamechain replay re-applies every transaction in the database from genesis and prints the final root. Run it on a copy of the data directory, with anchoring and the bridge off so the copy does not start broadcasting:

$ mkdir ~/tut-i/replay-copy
$ sqlite3 ~/tut-i/gamechain/chain.db ".backup ~/tut-i/replay-copy/chain.db"
$ cp ~/tut-i/gamechain/sequencer.key ~/tut-i/gamechain/house.secret ~/tut-i/replay-copy/
$ sed 's#data_dir = .*#data_dir = "/home/bshuler/tut-i/replay-copy"#; s/enabled = true/enabled = false/' \
      ~/tut-i/gamechain/gamechain.toml > ~/tut-i/replay-copy/replay.toml
$ time divi-gamechain --config ~/tut-i/replay-copy/replay.toml replay
ok: 315 blocks, 11 txs, 20 state keys, state root 99980e3b59b27c88de38e9369a611d9464e9790bcc60a66ce93813ca34c66c81

real    0m0.157s

315 blocks means heights 0 to 314, so the root to compare against is the head block's:

$ curl -s http://127.0.0.1:53910/v1/blocks/314 | python3 -c "import sys,json;d=json.load(sys.stdin);print(d['header']['state_root'])"
99980e3b59b27c88de38e9369a611d9464e9790bcc60a66ce93813ca34c66c81

Same root. Put the two halves together and the proof chain is: the Divi block (which you can verify with any testnet node) holds the anchor; the anchor names a gamechain block hash and state root; a replay of the public block data reproduces that root. The sequencer could stop producing blocks, but it could not change what happened below the anchored height without the mismatch showing.

5Deposit: Divi testnet in, TDIVI out

A deposit is an ordinary payment to the bridge address, nothing else. Which gamechain account gets the TDIVI is decided by the address that funded the deposit's first input, because that is the only thing the node can read off the transaction. So pay from a wallet whose address you also want to use on the gamechain. Ours was y9Z4n2f5qDdbhSoyqoJDdCNZA1eVnNhyDB, holding 20,000 tDIVI in one output. We built the payment by hand so the first input was unambiguous; sendtoaddress from a one-address wallet does the same job.

# rpc.sh wraps curl to the local IronDivi wallet; credentials come from its conf, never the command line
$ ./rpc.sh createrawtransaction '[{"txid":"d10bd06b…","vout":0}]' \
    '{"y9Z4n2f5qDdbhSoyqoJDdCNZA1eVnNhyDB":19899.99,"yE49TKgQQ6jU9Bp1yXhp56YTw23votzcWk":100}'
$ ./rpc.sh signrawtransaction <hex>          # "complete": true
$ ./rpc.sh sendrawtransaction <signed hex>
{"result":"87cbff6e7259324a8c6b72cb01114ecaceeb986ba69119c6791f12059547f501"}

Output 1 of that transaction is the deposit: 100 tDIVI to the bridge. Now wait. The node polls every 30 seconds and needs 6 confirmations, which took about ten minutes from broadcast: the deposit confirmed at 00:37:49 UTC and the mint was submitted at 00:42:38. The scan logic in bins/divi-gamechain/src/bridge.rs is simple: every pass it asks getaddressdeltas for the bridge address from tip − lookback up to tip + 1 − confirmations, skips every outpoint the chain already shows as minted, looks up the funder, and submits a mint as the sequencer. Because the chain and not memory decides what was minted, a restart loses nothing.

INFO divi_gamechain::bridge: bridge: mint submitted outpoint=87cbff6e…f501:1 to=y9Z4n2f5qDdbhSoyqoJDdCNZA1eVnNhyDB amount=10000000000

Query the outpoint and the account:

$ curl -s 'http://127.0.0.1:53910/v1/games/bridge?outpoint=87cbff6e7259324a8c6b72cb01114ecaceeb986ba69119c6791f12059547f501:1'
{"amount":"10000000000","divi_txid":"87cbff6e…f501","height":302,"to":"y9Z4n2f5qDdbhSoyqoJDdCNZA1eVnNhyDB","vout":1}
$ curl -s http://127.0.0.1:53910/v1/account/y9Z4n2f5qDdbhSoyqoJDdCNZA1eVnNhyDB
{"address":"y9Z4n2f5qDdbhSoyqoJDdCNZA1eVnNhyDB","balances":{"TDIVI":"10000000000"},"next_nonce":0,"nonce":0}

100 tDIVI became 10000000000 base units of TDIVI: 8 decimals, the same as Divi satoshis, so no rounding ever happens at the bridge. The mint landed in gamechain block 302. Query the same outpoint again later and you get the same answer; submit a second mint for it and the chain refuses with outpoint … is already minted.

Two deposits that never mint

If the first input of the deposit was spent from the bridge address itself (that is what payout change looks like), or from anything that is not a plain pay-to-pubkey-hash output, the node logs bridge: deposit has no mintable funder; not minting and skips it for good. The operator refunds those by hand. Coinbase and stake rewards fall in the same bucket: they have no spent outpoint at all.

6Withdraw: burn TDIVI, queue a payout

Withdrawing is a normal gamechain action signed by the account that holds the TDIVI. It burns the amount at once and appends a withdrawal to the queue; nothing moves on Divi until the operator acts. This script uses the SDK, copied next to it as sdk/ and vendor/, and reads the signing key from a 0600 file:

// withdraw.mjs: burn 40 TDIVI on the gamechain and queue a payout to a Divi testnet address.
// Run with Node 20+:  node withdraw.mjs
// Needs the SDK next to it: copy web/sdk/ and web/vendor/ from the divi_games repo.
import fs from 'node:fs';
import { Chain, Key, formatAmount } from './sdk/divigames.js';

const chain = new Chain(process.env.CHAIN_URL ?? 'http://127.0.0.1:53910');
// The depositor's key, read from a 0600 file; it is never printed.
const me = Key.fromWif(fs.readFileSync(process.env.HOME + '/tut-i/keys/wallet.d/sequencer.key', 'utf8'));
console.log('address', me.address);

const tdivi = async () => (await chain.account(me.address)).balances.TDIVI ?? '0';
console.log('TDIVI before', formatAmount(await tdivi(), 8));

// 40 tDIVI = 4,000,000,000 base units (TDIVI has 8 decimals, like Divi satoshis)
const txid = await chain.send(me, { type: 'bridge', op: { kind: 'withdraw', amount: '4000000000', divi_address: me.address } });
const r = await chain.waitForReceipt(txid, { timeoutMs: 60000 });
console.log(`withdraw: 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)}`);
console.log('TDIVI after ', formatAmount(await tdivi(), 8));
console.log('queue', JSON.stringify(await chain.game('bridge', { view: 'queue' })));
$ node withdraw.mjs
address y9Z4n2f5qDdbhSoyqoJDdCNZA1eVnNhyDB
TDIVI before 100
withdraw: txid e6339025a7e7e11da9f5ada97abcef769a8e3d686494704da77ec587f10f0824 height 309 ok true
   event bridge.burn {"amount":"4000000000","from":"y9Z4n2f5qDdbhSoyqoJDdCNZA1eVnNhyDB","token":"TDIVI"}
   event bridge.withdraw_queued {"amount":"4000000000","divi_address":"y9Z4n2f5qDdbhSoyqoJDdCNZA1eVnNhyDB","from":"y9Z4n2f5qDdbhSoyqoJDdCNZA1eVnNhyDB","height":309,"id":0,"paid":false,"paid_txid":null}
TDIVI after  60
queue {"queue":[{"amount":"4000000000","divi_address":"y9Z4n2f5qDdbhSoyqoJDdCNZA1eVnNhyDB","height":309,"id":0,"paid":false,"paid_txid":null,…}]}

The withdrawal got id 0 from a counter the module keeps in state (next_id), and it sits in the queue with paid: false. The 40 TDIVI are gone from the account for good; there is no cancel.

7Pay the queue and record it

Two steps, both by the operator. First the Divi payment from the bridge wallet. We had imported the bridge key into the local IronDivi wallet (importprivkey from the key file, then a rescan), so it can sign for the deposit output. The payout spends that output explicitly: 40 to the player, 59.99 back to the bridge, 0.01 fee.

#!/bin/sh
# pay.sh: the operator pays a queued withdrawal from the bridge wallet (the node's own wallet
# holds the bridge key). Spends the deposit output explicitly so nothing else in the wallet is touched.
# usage: pay.sh <payout address> <amount> <change amount>
set -e
TO=$1; AMT=$2; CHANGE=$3
B=yE49TKgQQ6jU9Bp1yXhp56YTw23votzcWk
RPC=$HOME/tut-i/rpc.sh
RAW=$($RPC createrawtransaction "[{\"txid\":\"87cbff6e7259324a8c6b72cb01114ecaceeb986ba69119c6791f12059547f501\",\"vout\":1}]" "{\"$TO\":$AMT,\"$B\":$CHANGE}" | python3 -c 'import sys,json;print(json.load(sys.stdin)["result"])')
SIGNED=$($RPC signrawtransaction "$RAW" | python3 -c 'import sys,json;d=json.load(sys.stdin)["result"];assert d["complete"];print(d["hex"])')
$RPC sendrawtransaction "$SIGNED"
$ ./pay.sh y9Z4n2f5qDdbhSoyqoJDdCNZA1eVnNhyDB 40 59.99
{"jsonrpc":"2.0","id":"t","result":"5efc74f8ce61dc021b63f862f962d76a96ef1fe0bdfe737c95f0265886afdb98"}

Then tell the chain. bridge/paid is sequencer-only, so this script signs with the node's own key, the same file the node reads, and checks that address against /v1/info before sending:

// paid.mjs: the operator marks a queued withdrawal as paid. Sequencer key only.
// Run with Node 20+:  node paid.mjs <withdrawal id> <divi txid of the payout>
import fs from 'node:fs';
import { Chain, Key } from './sdk/divigames.js';

const [id, diviTxid] = process.argv.slice(2);
const chain = new Chain(process.env.CHAIN_URL ?? 'http://127.0.0.1:53910');
// The sequencer's key: the same file the node reads (<data_dir>/sequencer.key).
const seq = Key.fromWif(fs.readFileSync(process.env.HOME + '/tut-i/keys/player.wif', 'utf8'));
console.log('sequencer', seq.address, '==', (await chain.info()).sequencer);

const txid = await chain.send(seq, { type: 'bridge', op: { kind: 'paid', id: Number(id), divi_txid: diviTxid } });
const r = await chain.waitForReceipt(txid, { timeoutMs: 60000 });
console.log(`paid: 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)}`);
console.log('withdrawal', JSON.stringify(await chain.game('bridge', { id })));
console.log('queue', JSON.stringify(await chain.game('bridge', { view: 'queue' })));
$ node paid.mjs 0 5efc74f8ce61dc021b63f862f962d76a96ef1fe0bdfe737c95f0265886afdb98
sequencer yKKVyUcekidmNGP3VHCFbS1zdagwm29D5i == yKKVyUcekidmNGP3VHCFbS1zdagwm29D5i
paid: txid 5e78dbafa7b638a6075d437f82b9e0a582c26992b26097836e836e2903ad704e height 314 ok true
   event bridge.paid {"amount":"4000000000","divi_address":"y9Z4n2f5qDdbhSoyqoJDdCNZA1eVnNhyDB","from":"y9Z4n2f5qDdbhSoyqoJDdCNZA1eVnNhyDB","height":309,"id":0,"paid":true,"paid_txid":"5efc74f8ce61dc021b63f862f962d76a96ef1fe0bdfe737c95f0265886afdb98"}
withdrawal {"amount":"4000000000","divi_address":"y9Z4n2f5qDdbhSoyqoJDdCNZA1eVnNhyDB","from":"y9Z4n2f5qDdbhSoyqoJDdCNZA1eVnNhyDB","height":309,"id":0,"paid":true,"paid_txid":"5efc74f8…db98"}
queue {"queue":[]}

The queue is empty, the withdrawal remembers its Divi txid, and the wallet shows the round trip: the player's address holds its 19,899.99 change plus the 40 payout, the bridge holds 59.99.

The change output is a deposit the node will refuse

Output 1 of the payout is 59.99 tDIVI paid to the bridge address, funded by the bridge address. Once it has 6 confirmations the scanner sees it as a deposit, finds the funder is the bridge itself, logs no mintable funder and skips it. That is the rule from step 5 doing its job; without it every payout would mint its own change.

8The rules in one table

Everything the bridge and anchor code enforces, from crates/dg-system/src/bridge.rs, crates/dg-system/src/system.rs and the two node tasks:

Action or viewWhoWhat it doesRefused when
bridge/mint {divi_txid, vout, to, amount}sequencermints TDIVI, records the outpoint, emits bridge.mintedoutpoint already minted (Conflict)
bridge/withdraw {amount, divi_address}anyoneburns TDIVI, queues withdrawal id, emits bridge.burn and bridge.withdraw_queuedbalance too low
bridge/paid {id, divi_txid}sequencermarks it paid, removes it from the queue, emits bridge.paidunknown id, or already paid (Conflict)
bridge?outpoint=<txid>:<vout>viewthe mint for that depositnot found: outpoint … has not been minted
bridge?id=<n> · bridge?view=queue&limit=viewone withdrawal · the unpaid ones
system/anchor_recorded {height, block_hash, state_root, divi_txid}sequencerrecords an anchor, emits system.anchor_recordedheight already anchored (Conflict)
system?view=anchors&limit=viewanchors, newest first, default 20

Actions are sent as {"type":"bridge","op":{"kind":"withdraw",…}} and {"type":"system","op":{"kind":"anchor_recorded",…}}, signed like any other gamechain transaction. Config defaults, from bins/divi-gamechain/src/config.rs: anchor {enabled=false, every=300, fee_sat=1000000, poll_ms=5000}, bridge {enabled=false, address="", confirmations=6, poll_ms=30000, lookback=2000}.

If something goes wrong

SymptomCauseFix
anchor: enabled but divi_rpc is empty; anchoring is offno Divi RPC in the configset divi_rpc to the public testnet endpoint
Anchoring enabled, no broadcast linesthe sequencer address has no unspent tDIVI, or the RPC has no address indexfund the address shown in the anchor: enabled line; use the public RPC
Anchor tx not found on your own nodeyou looked it up before it reached your node's mempoolask the public RPC, which broadcast it
Deposit never mints, no log linefewer than 6 confirmations, or older than lookback blockswait; for an old deposit raise lookback and restart
deposit has no mintable funderfirst input came from the bridge address or a non-P2PKH outputnothing to do for change; refund a real deposit by hand
withdraw receipt ok: falsenot enough TDIVI, an amount that is not a positive whole number of base units, or a divi_address that is not a testnet addresscheck /v1/account; amounts are base units
paid refusedsigned with a key that is not the sequencer's, or the id is already paidsign with <data_dir>/sequencer.key; check ?id=
Replay root differs from the head blockthe copy was taken while a block was being written, or the chain has advanced sinceback up with sqlite's .backup and compare with the block the copy ends at

Done with the coins? Send leftover testnet DIVI back to the faucet address in Get Testnet DIVI.