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-23

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
Anchor after a move
1–2 min
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.

The live chain at games.divi.domains has anchored since 2026-10-22: on 2026-10-23 its anchors view listed seven, every 300 blocks from height 2400 to 4200, and each divi_txid is a real testnet transaction with a 77-byte OP_RETURN starting DGCH. Since 2026-10-24 it anchors soon after each move: that day a two-bot game ended at sidechain block 5583, and the anchor for block 5584 was in a Divi block about two minutes later. Its bridge has been on since 2026-10-23 (see below). This tutorial still 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.

The live chain's /v1/info has carried "bridge_address":"y7SwXAHzGzAb2hpn5Ueb8kwuVAQuBwqHF3" since 2026-10-23. A node puts bridge_address in /v1/info only when the bridge is really running, and the game pages show their tDIVI bridge panel only when that field is there and a wallet is connected. Its first round trip, on 2026-10-23, was a 15 tDIVI deposit in Divi testnet transaction 928cce3b…b47b, minted at gamechain height 4523, then withdrawal 0 for 5 TDIVI, paid in b1a0b002…48a1. Step 7 shows how the operator pays it.

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 even with no moves the node anchors every 10 blocks:

# gamechain.toml (the parts this tutorial needs)
data_dir = "/home/bshuler/tut-i/gamechain"
listen = "127.0.0.1:53910"
divi_rpc = "https://app.irondivi.com/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 app.irondivi.com/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 294. This run was on 2026-10-22, when the target was the newest multiple of every at or below the head. Since 2026-10-24 the target is the head itself: the sequencer anchors as soon as a block after the last anchor holds a transaction other than its own system records, or once every blocks have passed without one. It sends the next anchor only after the previous one is in a Divi block, because getaddressutxos lists confirmed outputs only, so there is at most one anchor per Divi block, about one a minute. Today this node would anchor 294.

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://app.irondivi.com/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://app.irondivi.com/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. The node picks the gamechain account that gets the TDIVI in this order:

The memo is the safe way. Wallets that make a new change address for every payment often spend from an address you never see, and with no memo that address would get the TDIVI. The Divi game pages always put your address in the memo for that reason; see the memo run below. This first run shows the fallback: no memo, so we paid from the address we wanted 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, picks the recipient (memo, then 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), the deposit never mints, whatever its memo says. If there is no usable memo and the first input is not a plain pay-to-pubkey-hash output, it never mints either. A memo that is not text, not an address, a mainnet address or the bridge's own address counts as no memo. In both cases the node logs bridge: deposit has no mintable recipient; not minting and skips it for good. The operator refunds those by hand. Coinbase and stake rewards with no memo fall in the same bucket: they have no spent outpoint at all.

The memo run: mint to the address in the OP_RETURN

This deposit was made on a throw-away test chain with confirmations = 1. The deposit itself is a real public testnet transaction, so you can check it with the public RPC. It paid 10 tDIVI to that chain's bridge, yCY5yTXnnZtWEQaem3ubgPD1qz2sA5LcGz, from y1dG8MJqS4hKXrKJycypAzcpkfcv8zRfwj, and put the player's address in an OP_RETURN:

$ curl -s https://app.irondivi.com/api/testnet/rpc/ -H 'Content-Type: application/json' \
    -d '{"jsonrpc":"1.0","id":"t","method":"getrawtransaction","params":["d2c9349de35d70ab59c2b2151bf0e3b93a0e6ac5cc8ba5146c7c7bb5e1e6332a",1]}' \
  | python3 -c "
import sys,json
for o in json.load(sys.stdin)['result']['vout']:
    s=o['scriptPubKey']; print(o['n'], o['value'], s['type'], (s.get('addresses') or [s['hex']])[0])"
0 10.0 pubkeyhash yCY5yTXnnZtWEQaem3ubgPD1qz2sA5LcGz
1 0.0 nulldata 6a22794b36373559576378776955457a576f723345713759546a6e655146513377687242
2 34.9899748 pubkeyhash y1dG8MJqS4hKXrKJycypAzcpkfcv8zRfwj
$ echo 794b36373559576378776955457a576f723345713759546a6e655146513377687242 | xxd -r -p; echo
yK675YWcxwiUEzWor3Eq7YTjneQFQ3whrB

Output 0 is the deposit and output 2 is the sender's change. In output 1, 6a is OP_RETURN, 22 pushes 34 bytes, and those bytes are the address as plain text. The funder is y1dG8…, but the memo wins. The chain log:

INFO divi_gamechain::bridge: bridge: mint submitted outpoint=d2c9349d…:0 to=yK675YWcxwiUEzWor3Eq7YTjneQFQ3whrB amount=1000000000

10 tDIVI became 1000000000 base units for yK675…. The player then withdrew 2.5 TDIVI from the game page, signed with their wallet, which left the account at "TDIVI":"750000000","nonce":1.

To attach a memo yourself: the IronDivi Web Wallet takes it as op_return= in a send link (/testnet/wallet/?to=<bridge>&amount=10&op_return=<your address>), and the Telegram wallet bot takes /send <bridge> <amount> <your address>. The game pages build both for you once you connect a wallet; see Sign in with Divi.

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 recipient and skips it, whatever an OP_RETURN on it says. That is the rule from step 5 doing its job; without it every payout would mint its own change.

How the live chain is paid

On games.divi.domains the same two steps are done with two scripts in the divi_games repository, tools/bridge/. They differ from the ones above in where the keys live:

Withdrawal 0 on the live chain went this way on 2026-10-23: 5 TDIVI to xxEudsyrt2uAyCGTxPtYTuVwJ83otnax1c, paid in Divi testnet transaction b1a0b002e3d785cd80ff14fef96f7be4e83e50c4ac37a9a19e915473084048a1 (5 to the player, 9.999 back to the bridge), recorded in gamechain transaction 72afa3f2…4454. Anyone can check it:

$ curl -s 'https://games.divi.domains/chain/v1/games/bridge?id=0'
{"amount":"500000000","divi_address":"xxEudsyrt2uAyCGTxPtYTuVwJ83otnax1c","from":"y3LXPRGKQKBoFkhbk3ZB3qh9pcbc3kx3fi","height":4525,"id":0,"paid":true,"paid_txid":"b1a0b002e3d785cd80ff14fef96f7be4e83e50c4ac37a9a19e915473084048a1"}

Record a payment straight after it is sent. Until bridge/paid lands the withdrawal is still in the queue, and the chain cannot see Divi to know it was paid; only the second bridge/paid is refused.

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}anyone, but not a session keyburns 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}. A session key, the short-lived key a game page plays with (see Sign in with Divi), may only send game, faucet, session and session_end; a withdraw from one fails with not allowed: session keys can only play games. Withdraw from the owner's own key.

If something goes wrong

SymptomCauseFix
bridge: enabled but divi_rpc is empty; the bridge is off, or bridge: [bridge] address is not a Divi testnet address; the bridge is offno Divi RPC, or a bad [bridge] address; /v1/info then has no bridge_addressset divi_rpc and a testnet address, restart, look for bridge: enabled
Deposit minted to the wrong accountno memo, so it went to the address that funded the first input, often a wallet change addressalways put your address in the OP_RETURN; if you hold that address's key, send the TDIVI on with a transfer
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 recipientfirst input came from the bridge address, or there was no valid memo and the first input was not a plain 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.