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.
- An anchor is a 77-byte
OP_RETURNthe sequencer writes into Divi testnet soon after a block holds a player transaction, usually within a minute or two, and every[anchor] everyblocks (default 300) on a chain with no moves. It holds the gamechain height, block hash and state root. Once it is in a Divi block, nobody, the sequencer included, can quietly rewrite history below that height without the anchor disagreeing with a replay. - The bridge is a custodial two-way lane for testnet DIVI. Send tDIVI to the bridge address and the sequencer mints the same amount of
TDIVI(8 decimals) on the gamechain. It goes to the address you write in anOP_RETURNmemo on the deposit, or, with no memo, to the address that paid it. BurnTDIVIwith a withdraw action and the operator pays you back from the bridge wallet by hand, then records the payout on-chain.
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:
- The anchor is paid from the sequencer's own address, the one in
<data_dir>/sequencer.key. Fund it with a few testnet DIVI from the faucet. Our sequencer is yKKVyUcekidmNGP3VHCFbS1zdagwm29D5i and held 5,000 tDIVI. - The bridge needs an address whose key you keep. The quickest way to make one is the node's own key generator pointed at a throw-away data directory; it prints the address and never the 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
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:
| Bytes | Hex | Meaning |
|---|---|---|
| 1 | 6a | OP_RETURN |
| 2 | 4c 4d | OP_PUSHDATA1, 77 bytes follow (anything over 75 needs PUSHDATA1) |
| 4 | 44 47 43 48 | "DGCH" |
| 1 | 01 | version 1 |
| 8 | 22 01 00 00 00 00 00 00 | height 290, u64 little-endian (0x122 = 290) |
| 32 | d813f213…c49937 | block hash of gamechain block 290 |
| 32 | e7df36b7…9efcc3 | state 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
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. If the transaction has an
OP_RETURNwhose text is a valid Divi testnet address, and not the bridge's own, the mint goes there. Spaces around the text are ignored. - The funder. Otherwise the mint goes to the address that funded the deposit's first input, if that is a plain pay-to-pubkey-hash address.
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.
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.
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:
node tools/bridge/payout.mjs <id>reads withdrawalidfrom the live chain and builds the Divi testnet payment itself: the bridge's coins in (fromgetaddressutxoson the public RPC, which has no wallet), the amount to the withdrawal's address, change back to the bridge address, 0.001 fee. It reads the bridge key from the operator's password manager once and keeps it only in memory, and it refuses if that key does not matchbridge_addressin/v1/infoor the withdrawal is already paid. Without--sendit only prints the txid and the signed hex, to check withdecoderawtransaction; with--sendit broadcasts and prints the next command.sudo cat /var/lib/divi-games/chain/sequencer.key | node tools/bridge/mark-paid.mjs <id> <divi txid>, on the gamechain host, sendsbridge/paid. The sequencer key comes in on standard input, never on the command line.
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 view | Who | What it does | Refused when |
|---|---|---|---|
bridge/mint {divi_txid, vout, to, amount} | sequencer | mints TDIVI, records the outpoint, emits bridge.minted | outpoint already minted (Conflict) |
bridge/withdraw {amount, divi_address} | anyone, but not a session key | burns TDIVI, queues withdrawal id, emits bridge.burn and bridge.withdraw_queued | balance too low |
bridge/paid {id, divi_txid} | sequencer | marks it paid, removes it from the queue, emits bridge.paid | unknown id, or already paid (Conflict) |
bridge?outpoint=<txid>:<vout> | view | the mint for that deposit | not found: outpoint … has not been minted |
bridge?id=<n> · bridge?view=queue&limit= | view | one withdrawal · the unpaid ones | |
system/anchor_recorded {height, block_hash, state_root, divi_txid} | sequencer | records an anchor, emits system.anchor_recorded | height already anchored (Conflict) |
system?view=anchors&limit= | view | anchors, 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
| Symptom | Cause | Fix |
|---|---|---|
bridge: enabled but divi_rpc is empty; the bridge is off, or bridge: [bridge] address is not a Divi testnet address; the bridge is off | no Divi RPC, or a bad [bridge] address; /v1/info then has no bridge_address | set divi_rpc and a testnet address, restart, look for bridge: enabled |
| Deposit minted to the wrong account | no memo, so it went to the address that funded the first input, often a wallet change address | always 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 off | no Divi RPC in the config | set divi_rpc to the public testnet endpoint |
Anchoring enabled, no broadcast lines | the sequencer address has no unspent tDIVI, or the RPC has no address index | fund the address shown in the anchor: enabled line; use the public RPC |
| Anchor tx not found on your own node | you looked it up before it reached your node's mempool | ask the public RPC, which broadcast it |
| Deposit never mints, no log line | fewer than 6 confirmations, or older than lookback blocks | wait; for an old deposit raise lookback and restart |
deposit has no mintable recipient | first input came from the bridge address, or there was no valid memo and the first input was not a plain P2PKH output | nothing to do for change; refund a real deposit by hand |
withdraw receipt ok: false | not enough TDIVI, an amount that is not a positive whole number of base units, or a divi_address that is not a testnet address | check /v1/account; amounts are base units |
paid refused | signed with a key that is not the sequencer's, or the id is already paid | sign with <data_dir>/sequencer.key; check ?id= |
| Replay root differs from the head block | the copy was taken while a block was being written, or the chain has advanced since | back 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.