Tutorial · Divi Games

The Trading Game

How the gamechain's order book and its trading seasons work, read from the code and run for real, so you can write a bot that competes.

About 20 minutes · Checked 2026-10-21

1The order book in one page

The gamechain (see what a Divi sidechain is) has a market module: a limit-order book between any two of its tokens. You send it signed transactions of the form {"type":"market","op":{"kind":"…"}} and read it back with plain GET requests. It lives in crates/dg-market/src/market.rs of the divi_games repository; everything below is quoted from that file and from a run on a gamechain node.

kindFieldsWho
placebase, quote, side (buy / sell), price, amount (base units of the base token)anyone
cancelorder_idthe order's owner
season_createseason, start_height, end_height, start_gold, start_divithe sequencer only
join_seasonseasonanyone, once per address, inside the season's heights

Price: quote base units per whole base token, times 108

Every amount on the chain is an integer of base units, written as a decimal string. GOLD and the season gold tokens have 2 decimals ("100" is 1.00 GOLD); DIVI-like tokens have 8 ("100000000" is 1 DIVI). A price says how many base units of the quote token one whole base token costs, and that number is then multiplied by 108 so prices can carry fractions of a base unit. The cost of a fill is, from the code:

crates/dg-market/src/market.rs/// `floor(amount * price / (10^decimals * 10^8))`, or an error on overflow.
pub fn quote_for(amount: u128, price: u128, base_decimals: u8) -> Result<u128, ActionError> {
    let overflow = || ActionError::invalid("amount * price overflows");
    let scale = 10u128.checked_pow(base_decimals as u32).and_then(|s| s.checked_mul(PRICE_SCALE)).ok_or_else(overflow)?;
    Ok(amount.checked_mul(price).ok_or_else(overflow)? / scale)
}

One worked conversion. You want to sell DIVI-style tokens (8 decimals) for a gold token (2 decimals) at 1.25 gold per DIVI. 1.25 gold is 125 base units, so the price string is 125 × 108 = "12500000000". A fill of 2 DIVI is amount = 200000000, and it costs

200000000 × 12500000000 / (108 × 108) = 250 base units = 2.50 gold.

In step 5 the chain reports exactly "quote_amount":"250" for that trade. The smallest legal price is 1 (10-8 of a quote base unit per whole base token) and the largest is 264−1. A fill whose quote amount rounds to zero is refused, and a leftover so small that it cannot be priced is refunded as "dust" rather than left on the book.

Escrow

When you place an order the funds leave your account and sit at the module account module:market until the order fills or you cancel. A sell escrows amount of the base token; a buy escrows floor(amount × price / scale) of the quote token. You can watch the escrow with GET /v1/account/module:market, exactly like any other account.

Maker-price fills and the ordering rule

Matching happens the moment an order is placed, against whatever is already resting on the other side. A trade executes at the maker's price (the resting order), not the taker's. If you bid 1.30 and the best ask is 1.25, you pay 1.25 and the difference comes straight back to you (step 5 shows "refund_quote":"10" for exactly this). Resting orders are walked in price-time priority: best price first, then the earlier block, then the lower sequence number seq, which is a counter the module increments for every order ever placed. The book keys make this literal:

crates/dg-market/src/market.rsfn book_key(o: &Order) -> String {
    let price = if o.side == Side::Sell { o.price.0 } else { u64::MAX as u128 - o.price.0 };
    format!("{}{}/{}/{}", side_prefix(&o.base, &o.quote, o.side), pad(price), pad(o.height as u128), pad(o.seq as u128))
}

Asks are keyed by ascending price, bids by u64::MAX − price so the highest bid sorts first; both sides read "best first" in plain key order, with (price, height, seq) as the tie-break. The order id is the first 16 hex characters of the transaction id that placed it.

Reading the book

Every view is a GET on /v1/games/market and needs no key. The names below are the exact strings the module's view() matches on:

QueryReturns
?view=book&base=&quote=&depth={base, quote, bids, asks, last}; each level is {price, amount, orders}, best first; depth 1–100, default 20
?view=orders&owner=that address's open orders, oldest first
?view=order&id=one order, open or closed
?view=trades&base=&quote=&limit=newest first; default 50, at most 200
?view=season&n=the season, its pair, mark_price, members and the top-100 leaderboard
?view=seasonsevery season ever created
(no view){"max_open_orders":100,"module":"market","price_scale":"100000000"}

2Placing and matching: the code

Here is place, trimmed to its spine. It validates, checks the season rules for the pair, counts your open orders, escrows, emits order_placed, then matches, and whatever is left either rests on the book or is closed with a refund:

crates/dg-market/src/market.rs, fn place (trimmed)let bm = token_meta(ctx.read_raw(&format!("token/{base}")), base)?;
let qm = token_meta(ctx.read_raw(&format!("token/{quote}")), quote)?;
check_pair(ctx, base, &bm, quote, &qm)?;
if ctx.scan(&format!("open/{sender}/"), None, MAX_OPEN_ORDERS).len() >= MAX_OPEN_ORDERS {
    return Err(ActionError::Conflict(format!("at most {MAX_OPEN_ORDERS} open orders per address")));
}
let notional = quote_for(amount, price, bm.decimals)?;
if notional == 0 {
    return Err(ActionError::invalid("order is worth less than one base unit of the quote token"));
}

let id = ctx.txid().get(..16).ok_or_else(|| ActionError::Internal("short txid".into()))?.to_string();
let seq = next_seq(ctx, "oseq");
let mut order = Order { id, owner: sender.to_string(), base: base.to_string(), quote: quote.to_string(), side,
    price: Amount(price), amount: Amount(amount), remaining: Amount(amount),
    locked: Amount(if side == Side::Buy { notional } else { 0 }), status: "open".into(), height: ctx.height(), seq };
match side {
    Side::Buy => ctx.escrow_in(sender, quote, notional)?,
    Side::Sell => ctx.escrow_in(sender, base, amount)?,
}
ctx.emit("order_placed", json!({ "id": order.id, "owner": sender, "base": base, "quote": quote,
    "side": side, "price": order.price, "amount": order.amount }));

let end = match_order(ctx, &mut order, bm.decimals)?;
match end {
    End::Filled => close(ctx, &mut order, "filled", "filled")?,
    End::Dust => close(ctx, &mut order, "closed", "dust")?,
    End::FillLimit => close(ctx, &mut order, "closed", "fill_limit")?,
    End::Rest => {
        // Give back what price improvement left over, so a resting buy holds exactly what it needs.
        if side == Side::Buy {
            let needed = quote_for(order.remaining.0, price, bm.decimals)?;
            let excess = order.locked.0.saturating_sub(needed);
            if excess > 0 {
                ctx.escrow_out(sender, quote, excess)?;
                order.locked = Amount(needed);
            }
        }
        ctx.put(&book_key(&order), order.id.clone().into_bytes());
        ctx.put(&open_key(sender, &order.id), b"1".to_vec());
        ctx.put_json(&order_key(&order.id), &order);
    }
}

And the matching loop. It scans the opposite side of the book in key order (so best price first), stops at the first order that no longer crosses, refuses to trade with your own order, caps the number of fills, pays both sides of every fill at the maker's price and records the trade:

crates/dg-market/src/market.rs, fn match_order (trimmed)let prefix = side_prefix(&taker.base, &taker.quote, if taker.side == Side::Buy { Side::Sell } else { Side::Buy });
let mut cursor: Option<String> = None;
let mut fills = 0usize;
loop {
    if taker.remaining.0 == 0 { return Ok(End::Filled); }
    let rows = ctx.scan(&prefix, cursor.as_deref(), CHUNK);
    if rows.is_empty() { return Ok(End::Rest); }
    for (key, value) in rows {
        cursor = Some(key);
        let id = String::from_utf8(value)…;
        let mut maker: Order = ctx.get_json(&order_key(&id))?…;
        let crosses = match taker.side {
            Side::Buy => maker.price.0 <= taker.price.0,
            Side::Sell => maker.price.0 >= taker.price.0,
        };
        if !crosses { return Ok(End::Rest); }
        if maker.owner == taker.owner {
            return Err(ActionError::Conflict("order would trade with your own resting order".into()));
        }
        if fills >= MAX_FILLS { return Ok(End::FillLimit); }
        let fill = taker.remaining.0.min(maker.remaining.0);
        let quote_amount = quote_for(fill, maker.price.0, base_decimals)?;
        if quote_amount == 0 {
            if fills == 0 { return Err(ActionError::invalid("fill is too small: its quote amount rounds to zero")); }
            return Ok(End::Dust);
        }
        fills += 1;

        let (buyer, seller) = if taker.side == Side::Buy { (&mut *taker, &mut maker) } else { (&mut maker, &mut *taker) };
        buyer.locked = Amount(buyer.locked.0.checked_sub(quote_amount)…);
        ctx.escrow_out(&buyer.owner, &base, fill)?;
        ctx.escrow_out(&seller.owner, &quote, quote_amount)?;
        taker.remaining = Amount(taker.remaining.0 - fill);
        maker.remaining = Amount(maker.remaining.0 - fill);

        let tseq = next_seq(ctx, "tseq");
        let trade = Trade { seq: tseq, base, quote, price: maker.price, amount: Amount(fill), quote_amount: Amount(quote_amount),
            maker: maker.owner.clone(), taker: taker.owner.clone(), maker_order: maker.id.clone(), taker_order: taker.id.clone(),
            taker_side: taker.side, height: ctx.height() };
        ctx.put_json(&trade_key(&base, &quote, tseq), &trade);
        ctx.put_json(&last_key(&base, &quote), &json!({ "price": maker.price, "height": trade.height, "seq": tseq }));
        ctx.emit("trade", to_json(&trade)?);

        if maker.remaining.0 == 0 {
            close(ctx, &mut maker, "filled", "filled")?;
        } else if quote_for(maker.remaining.0, maker.price.0, base_decimals)? == 0 {
            close(ctx, &mut maker, "closed", "dust")?;
        } else {
            ctx.put_json(&order_key(&maker.id), &maker);
        }
        if taker.remaining.0 == 0 { return Ok(End::Filled); }
        if quote_for(taker.remaining.0, taker.price.0, base_decimals)? == 0 { return Ok(End::Dust); }
    }
}

Two things a bot writer should take from this. First, everything happens inside the taker's transaction: the trade, the maker's close, the payouts. The maker's own receipt only ever shows its order_placed; to learn that your resting order filled you must watch block events (the WebSocket at /v1/ws) or re-read ?view=orders. Second, a place that fails anywhere (self-trade, too many open orders, a dust fill) fails as a whole: the escrow is rolled back, nothing rests, but the transaction is still mined and your nonce is used up.

close is the one exit for every order: it refunds whatever is still held (the unfilled base of a sell, the still-locked quote of a buy), removes the book and open-list keys, and emits order_closed:

crates/dg-market/src/market.rs, fn close (trimmed)match o.side {
    Side::Sell if status != "filled" => refund_base = o.remaining.0,
    Side::Buy => refund_quote = o.locked.0,
    Side::Sell => {}
}
…
ctx.emit("order_closed", json!({ "id": o.id, "owner": o.owner, "status": status, "reason": reason,
    "remaining": o.remaining, "refund_base": Amount(refund_base), "refund_quote": Amount(refund_quote) }));

status is one of open, filled, cancelled or closed; reason adds filled, cancelled, dust or fill_limit.

3The limits and their exact error strings

The constants at the top of the module:

crates/dg-market/src/market.rs/// Prices carry eight extra decimals.
pub const PRICE_SCALE: u128 = 100_000_000;
/// Resting orders one placement may match.
pub const MAX_FILLS: usize = 50;
pub const MAX_OPEN_ORDERS: usize = 100;

A failed action's text lands in the receipt's error field with a prefix from the error type (invalid:, not allowed:, not found:, conflict:, or insufficient TOKEN: need N, have M). These are the strings your bot will see, copied from the source:

RuleReceipt error or event
One placement sweeps at most 50 resting orders. The taker's remainder is refunded, never left crossing the book.no error: order_closed with "status":"closed","reason":"fill_limit"
At most 100 open orders per addressconflict: at most 100 open orders per address
An order that would trade with your own resting order (checked only when it actually crosses yours)conflict: order would trade with your own resting order
Notional below one quote base unitinvalid: order is worth less than one base unit of the quote token
First fill rounds to zero quote unitsinvalid: fill is too small: its quote amount rounds to zero
Price 0, or above 264−1invalid: `price` must be between 1 and 18446744073709551615
Cancel by someone elsenot allowed: only the owner can cancel an order
Cancel an order that is not openconflict: order <id> is already filled (or cancelled / closed)
Cancel an unknown idnot found: order <id>
Season pair outside its heightsconflict: season N trades from height A to B
A season token against anything elseinvalid: season tokens trade only as S<n>DIVI against S<n>GOLD of the same season
Not enough balance to escrowinsufficient S1DIVI: need 300000000, have 0 (from the core)

Three of these are exercised for real in step 5: the self-trade, joining twice, and trading after the season's end.

4Seasons

A season is the trading game proper. The sequencer creates it, and that creates two fresh tokens issued by module:market: S<n>GOLD with 2 decimals and S<n>DIVI with 8. Anyone who joins is minted the same starting balance of each, once. The only pair those tokens can trade in is S<n>DIVI against S<n>GOLD, and only between start_height and end_height. Outside the season they are worth nothing; nothing real is at stake.

crates/dg-market/src/market.rs, fn season_create (trimmed)if sender != ctx.sequencer() {
    return Err(ActionError::unauthorized("only the sequencer creates seasons"));
}
let n = op_u64(op, "season")?;
if n == 0 { return Err(ActionError::invalid("`season` starts at 1")); }
let (start_height, end_height) = (op_u64(op, "start_height")?, op_u64(op, "end_height")?);
if end_height <= start_height {
    return Err(ActionError::invalid("`end_height` must be after `start_height`"));
}
let (start_gold, start_divi) = (op_amount(op, "start_gold")?, op_amount(op, "start_divi")?);
if start_gold == 0 || start_divi == 0 { return Err(ActionError::invalid("starting balances must be positive")); }
if ctx.get(&season_key(n)).is_some() {
    return Err(ActionError::Conflict(format!("season {n} already exists")));
}
let (gold, divi) = (format!("S{n}GOLD"), format!("S{n}DIVI"));
ctx.create_token(&gold, &format!("Season {n} Gold"), GOLD_DECIMALS, None)?;
ctx.create_token(&divi, &format!("Season {n} Divi"), DIVI_DECIMALS, None)?;
crates/dg-market/src/market.rs, fn join_season (trimmed)if h < season.start_height {
    return Err(ActionError::Conflict(format!("season {n} starts at height {}", season.start_height)));
}
if h > season.end_height {
    return Err(ActionError::Conflict(format!("season {n} ended at height {}", season.end_height)));
}
let key = member_key(n, sender);
if ctx.get(&key).is_some() {
    return Err(ActionError::Conflict(format!("{sender} already joined season {n}")));
}
ctx.mint(sender, &season.gold, season.start_gold.0)?;
ctx.mint(sender, &season.divi, season.start_divi.0)?;

The pair rule is enforced in check_pair, which place calls before anything else. A season symbol only counts if its issuer really is module:market, so nobody can fake one:

crates/dg-market/src/market.rs, fn check_pair (trimmed)match (season_of(base, bm), season_of(quote, qm)) {
    (None, None) => Ok(None),
    (Some((n, false)), Some((m, true))) if n == m => {
        let season: Season = ctx.get_json(&season_key(n))?…;
        let h = ctx.height();
        if h < season.start_height || h > season.end_height {
            return Err(ActionError::Conflict(format!("season {n} trades from height {} to {}", season.start_height, season.end_height)));
        }
        Ok(Some(n))
    }
    _ => Err(ActionError::invalid("season tokens trade only as S<n>DIVI against S<n>GOLD of the same season")),
}

cancel never calls check_pair, which is why cancels keep working after the end: you can always get your escrow back.

The leaderboard formula

?view=season&n= walks every member and values them at the last trade price of the season pair. Funds locked in your own resting orders count as yours (a resting buy's locked gold, a resting sell's remaining DIVI):

crates/dg-market/src/market.rs, fn season_view (trimmed)let (mut gold, mut divi) = (state.balance_of(&addr, &season.gold), state.balance_of(&addr, &season.divi));
for o in open_orders(state, &addr)? {
    if o.base == season.divi && o.quote == season.gold {
        match o.side {
            Side::Buy => gold += o.locked.0,
            Side::Sell => divi += o.remaining.0,
        }
    }
}
let marked = match mark {
    Some(p) => quote_for(divi, p, DIVI_DECIMALS).unwrap_or(u128::MAX - gold),
    None => 0,
};
rows.push((gold.saturating_add(marked), addr, gold, divi));
…
rows.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));

So value = gold + floor(divi × mark_price / 1016) in gold base units, ties broken by address. Before the first trade mark_price is null and DIVI counts for nothing: the standings are gold-only until someone trades. The course notes the consequence: a single tiny trade at an odd price moves everyone's mark. Bots learn that.

Heights, not minutes

A season is measured in blocks. The sequencer makes a block every block_ms (2 s) while the mempool has transactions, and otherwise one empty heartbeat block a minute. A 150-block season is five minutes on a busy chain and two and a half hours on a quiet one. Read height from /v1/info; never convert heights to wall-clock time.

5Run a season for real

The public chain's seasons are created by the public sequencer, which you do not control, so this part runs on our own gamechain node (set up in run the gamechain), reached at http://127.0.0.1:53910, where we hold the sequencer's key. Everything below is two short Node 20 scripts using the JavaScript SDK; copy web/sdk/ and web/vendor/ from the repository next to it. The sequencer's WIF is read from a mode-0600 file and never printed, the same way sign in with Divi handles keys.

season.mjs// season.mjs: one short trading season on your own gamechain, end to end.
// Run with Node 20+ next to a copy of web/sdk and web/vendor:  node season.mjs
import fs from 'node:fs';
import { Chain, Key } from './web/sdk/divigames.js';

const chain = new Chain('http://127.0.0.1:53910');
const DIR = process.env.HOME + '/tut-i/trading-game';
const KEYS = process.env.HOME + '/tut-i/keys';

// The sequencer's key is read from its 0600 file and never printed.
const sequencer = Key.fromWif(fs.readFileSync(`${KEYS}/player.wif`, 'utf8').trim());
// Two fresh player keys, written 0600 on first run and reused after.
function player(name) {
  const file = `${DIR}/${name}.wif`;
  if (fs.existsSync(file)) return Key.fromWif(fs.readFileSync(file, 'utf8').trim());
  const k = Key.generate('testnet');
  fs.writeFileSync(file, k.toWif() + '\n', { mode: 0o600 });
  return k;
}
const A = player('alice');
const B = player('bob');
console.log('sequencer', sequencer.address);
console.log('alice    ', A.address);
console.log('bob      ', B.address);

const market = (op) => ({ type: 'market', op });
const j = (v) => JSON.stringify(v);

// Sign and send one action, wait for the receipt, print txid, height, ok/error and every event.
async function send(k, action, label) {
  const txid = await chain.send(k, action);
  const r = await chain.waitForReceipt(txid, { timeoutMs: 60000 });
  console.log(`${label}: txid ${txid} height ${r.height} ok ${r.ok}${r.error ? ` error "${r.error}"` : ''}`);
  for (const ev of r.events) console.log(`   event ${ev.kind} ${j(ev.data)}`);
  return { txid, receipt: r };
}
const view = async (label, q) => console.log(`${label}: ${j(await chain.game('market', q))}`);
const height = async () => (await chain.info()).height;
async function waitFor(h) {
  while ((await height()) < h) await new Promise((r) => setTimeout(r, 1000));
}
const bal = async (who, sym) => (await chain.account(who)).balances[sym] ?? '0';

// 1. The next free season number, then season_create as the sequencer.
const { seasons } = await chain.game('market', { view: 'seasons' });
const n = seasons.reduce((m, s) => Math.max(m, s.season), 0) + 1;
const GOLD = `S${n}GOLD`, DIVI = `S${n}DIVI`;
const h0 = await height();
const start = h0 + 8, end = start + 150;        // 150 blocks of 2 s: a five-minute season
console.log(`height ${h0}: creating season ${n}, heights ${start}..${end}`);
await send(sequencer, market({ kind: 'season_create', season: n, start_height: start, end_height: end,
  start_gold: '100000', start_divi: '10000000000' }), 'season_create');   // 1000.00 GOLD, 100 DIVI each
await view('seasons', { view: 'seasons' });

// 2. Too early: the window has not opened yet.
await send(A, market({ kind: 'join_season', season: n }), 'join alice (too early)');
await waitFor(start);
console.log(`height ${await height()}: the season is open`);

// 3. Both players join; Bob tries a second time.
await send(A, market({ kind: 'join_season', season: n }), 'join alice');
await send(B, market({ kind: 'join_season', season: n }), 'join bob');
await send(B, market({ kind: 'join_season', season: n }), 'join bob again');
await view('leaderboard before any trade', { view: 'season', n });

// 4. Alice sells 3 DIVI at 1.25 GOLD each. Price 1.25 GOLD = 125 base units, times 10^8.
const { txid: sellTxid, receipt: sold } = await send(A, market({ kind: 'place', base: DIVI, quote: GOLD,
  side: 'sell', price: '12500000000', amount: '300000000' }), 'alice sell 3 @ 1.25');
const sellId = sold.events[0].data.id;
console.log(`order id ${sellId} == first 16 hex of the txid: ${sellTxid.slice(0, 16) === sellId}`);
console.log(`alice ${DIVI} ${await bal(A.address, DIVI)}  escrow ${DIVI} ${await bal('module:market', DIVI)}`);
await view('book after the sell', { view: 'book', base: DIVI, quote: GOLD });

// 5. Alice tries to buy against her own order.
console.log(`alice nonce ${(await chain.account(A.address)).nonce}, ${GOLD} ${await bal(A.address, GOLD)}`);
await send(A, market({ kind: 'place', base: DIVI, quote: GOLD, side: 'buy', price: '12500000000', amount: '100000000' }),
  'alice buy against herself');
console.log(`alice nonce ${(await chain.account(A.address)).nonce}, ${GOLD} ${await bal(A.address, GOLD)}`);

// 6. Bob buys 2 DIVI, bidding 1.30. It fills at Alice's 1.25: the maker's price.
await send(B, market({ kind: 'place', base: DIVI, quote: GOLD, side: 'buy', price: '13000000000', amount: '200000000' }),
  'bob buy 2 @ 1.30');
console.log(`alice ${GOLD} ${await bal(A.address, GOLD)} ${DIVI} ${await bal(A.address, DIVI)}`);
console.log(`bob   ${GOLD} ${await bal(B.address, GOLD)} ${DIVI} ${await bal(B.address, DIVI)}`);
await view('book after the trade', { view: 'book', base: DIVI, quote: GOLD });
await view('trades', { view: 'trades', base: DIVI, quote: GOLD });
await view('order', { view: 'order', id: sellId });
await view('alice open orders', { view: 'orders', owner: A.address });
await view('leaderboard after the trade', { view: 'season', n });
fs.writeFileSync(`${DIR}/season.json`, j({ n, start, end, sellId }));

What it printed on 2026-10-21, trimmed only for width. First the season is created and the too-early join is refused:

sequencer yKKVyUcekidmNGP3VHCFbS1zdagwm29D5i
alice     y1oxRLg1PB3xoSsggGbS4iEHLVQ9p5wiVk
bob       y33ESXoQ7HmJtDJhK8A6N5ygACmYJggWPW
height 309: creating season 1, heights 317..467
season_create: txid 345bc9c726fefae3d01aa4f74bda138dba22e13a37d9ea5a1a7873babbada90b height 310 ok true
   event token_created {"decimals":2,"issuer":"module:market","max_supply":null,"name":"Season 1 Gold","symbol":"S1GOLD"}
   event token_created {"decimals":8,"issuer":"module:market","max_supply":null,"name":"Season 1 Divi","symbol":"S1DIVI"}
   event market.season_created {"created_height":310,"divi":"S1DIVI","end_height":467,"gold":"S1GOLD","season":1,
     "start_divi":"10000000000","start_gold":"100000","start_height":317}
seasons: {"seasons":[{"created_height":310,"divi":"S1DIVI","end_height":467,"gold":"S1GOLD","season":1,
     "start_divi":"10000000000","start_gold":"100000","start_height":317}]}
join alice (too early): txid 89d6726641d7450b1ada0f24099f4c9f3b3c8a15b1ea5a21b6636624129c709d height 311 ok false
     error "conflict: season 1 starts at height 317"

Then both join, Bob's second join is refused, and the leaderboard before any trade has mark_price null, so each player's value is just their 1000.00 gold:

height 317: the season is open
join alice: txid bb13a5c3b8b8eaa132c58ecda8c35e097f2393dc544d9fbe46bc2553877508e4 height 318 ok true
   event market.mint {"amount":"100000","to":"y1oxRLg1PB3xoSsggGbS4iEHLVQ9p5wiVk","token":"S1GOLD"}
   event market.mint {"amount":"10000000000","to":"y1oxRLg1PB3xoSsggGbS4iEHLVQ9p5wiVk","token":"S1DIVI"}
   event market.season_joined {"address":"y1oxRLg1PB3xoSsggGbS4iEHLVQ9p5wiVk","divi":"10000000000","gold":"100000","season":1}
join bob: txid e5f020601fd4fa81deb617a860ee6c52f70f6598c104aa5c3a57b3eea7da2073 height 319 ok true
   event market.mint {"amount":"100000","to":"y33ESXoQ7HmJtDJhK8A6N5ygACmYJggWPW","token":"S1GOLD"}
   event market.mint {"amount":"10000000000","to":"y33ESXoQ7HmJtDJhK8A6N5ygACmYJggWPW","token":"S1DIVI"}
   event market.season_joined {"address":"y33ESXoQ7HmJtDJhK8A6N5ygACmYJggWPW","divi":"10000000000","gold":"100000","season":1}
join bob again: txid 6bd0a044075c5619aa35f325875bfe9b845fb529898bb8912abcf3e4b70acd7c height 320 ok false
     error "conflict: y33ESXoQ7HmJtDJhK8A6N5ygACmYJggWPW already joined season 1"
leaderboard before any trade: {"last":null,"leaderboard":[
     {"address":"y1oxRLg1PB3xoSsggGbS4iEHLVQ9p5wiVk","divi":"10000000000","gold":"100000","rank":1,"value":"100000"},
     {"address":"y33ESXoQ7HmJtDJhK8A6N5ygACmYJggWPW","divi":"10000000000","gold":"100000","rank":2,"value":"100000"}],
     "mark_price":null,"members":2,"pair":{"base":"S1DIVI","quote":"S1GOLD"},"season":{…}}

Alice's sell rests on the book. Her 3 S1DIVI moved to module:market, and the order id is the first 16 hex characters of her txid. Her attempt to buy against her own order is refused, her gold is untouched, and her nonce still went from 3 to 4:

alice sell 3 @ 1.25: txid 01698c2f327cf4b4a1709bd956d4f552f816f2648d38c7a96b0081357a5c22b7 height 321 ok true
   event market.order_placed {"amount":"300000000","base":"S1DIVI","id":"01698c2f327cf4b4","owner":"y1oxRLg1PB3xoSsggGbS4iEHLVQ9p5wiVk",
     "price":"12500000000","quote":"S1GOLD","side":"sell"}
order id 01698c2f327cf4b4 == first 16 hex of the txid: true
alice S1DIVI 9700000000  escrow S1DIVI 300000000
book after the sell: {"asks":[{"amount":"300000000","orders":1,"price":"12500000000"}],"base":"S1DIVI","bids":[],"last":null,"quote":"S1GOLD"}
alice nonce 3, S1GOLD 100000
alice buy against herself: txid c8420d935abfb705ffba218a8e4dabd351a50fb8791f0affa0af0747707e2d33 height 322 ok false
     error "conflict: order would trade with your own resting order"
alice nonce 4, S1GOLD 100000

Bob bids 1.30 for 2 DIVI. Bob is the taker, so his receipt carries all three events: his order_placed, the trade at Alice's price of 1.25 with quote_amount 250 (the worked conversion from step 1), and his own order_closed as filled with refund_quote 10: he escrowed 260 at 1.30, paid 250 at 1.25, and got the 0.10 gold back. Alice's order stays open with 1 DIVI remaining:

bob buy 2 @ 1.30: txid 7060f20bf0b1efc02c4fd78e7a8e2523c76682bf5b1f52077f6815baea7e74e1 height 323 ok true
   event market.order_placed {"amount":"200000000","base":"S1DIVI","id":"7060f20bf0b1efc0","owner":"y33ESXoQ7HmJtDJhK8A6N5ygACmYJggWPW",
     "price":"13000000000","quote":"S1GOLD","side":"buy"}
   event market.trade {"amount":"200000000","base":"S1DIVI","height":323,"maker":"y1oxRLg1PB3xoSsggGbS4iEHLVQ9p5wiVk",
     "maker_order":"01698c2f327cf4b4","price":"12500000000","quote":"S1GOLD","quote_amount":"250","seq":0,
     "taker":"y33ESXoQ7HmJtDJhK8A6N5ygACmYJggWPW","taker_order":"7060f20bf0b1efc0","taker_side":"buy"}
   event market.order_closed {"id":"7060f20bf0b1efc0","owner":"y33ESXoQ7HmJtDJhK8A6N5ygACmYJggWPW","reason":"filled",
     "refund_base":"0","refund_quote":"10","remaining":"0","status":"filled"}
alice S1GOLD 100250 S1DIVI 9700000000
bob   S1GOLD 99750 S1DIVI 10200000000
book after the trade: {"asks":[{"amount":"100000000","orders":1,"price":"12500000000"}],"base":"S1DIVI","bids":[],
     "last":{"height":323,"price":"12500000000","seq":0},"quote":"S1GOLD"}
trades: {"base":"S1DIVI","quote":"S1GOLD","trades":[{"amount":"200000000","base":"S1DIVI","height":323,
     "maker":"y1oxRLg1PB3xoSsggGbS4iEHLVQ9p5wiVk","maker_order":"01698c2f327cf4b4","price":"12500000000","quote":"S1GOLD",
     "quote_amount":"250","seq":0,"taker":"y33ESXoQ7HmJtDJhK8A6N5ygACmYJggWPW","taker_order":"7060f20bf0b1efc0","taker_side":"buy"}]}
order: {"amount":"300000000","base":"S1DIVI","height":321,"id":"01698c2f327cf4b4","locked":"0","owner":"y1oxRLg1PB3xoSsggGbS4iEHLVQ9p5wiVk",
     "price":"12500000000","quote":"S1GOLD","remaining":"100000000","seq":0,"side":"sell","status":"open"}
alice open orders: {"orders":[{…same order…}],"owner":"y1oxRLg1PB3xoSsggGbS4iEHLVQ9p5wiVk"}
leaderboard after the trade: {"last":{"height":323,"price":"12500000000","seq":0},"leaderboard":[
     {"address":"y1oxRLg1PB3xoSsggGbS4iEHLVQ9p5wiVk","divi":"9800000000","gold":"100250","rank":1,"value":"112500"},
     {"address":"y33ESXoQ7HmJtDJhK8A6N5ygACmYJggWPW","divi":"10200000000","gold":"99750","rank":2,"value":"112500"}],
     "mark_price":"12500000000","members":2,"pair":{"base":"S1DIVI","quote":"S1GOLD"},"season":{…}}

Check the leaderboard by hand. Alice: 100250 gold + (9700000000 in her account + 100000000 still resting) × 12500000000 / 1016 = 100250 + 12250 = 112500. Bob: 99750 + 10200000000 × 12500000000 / 1016 = 99750 + 12750 = 112500. They are equal because the only trade so far happened at the mark price; Alice ranks first only because her address sorts first. Note that the leaderboard's divi for Alice (9800000000) includes the 1 DIVI locked in her open sell.

After the end: no trades, but cancels still refund

A second script, run after the bot of step 6 had quoted its ladder, first prints the book (shown in step 6), then pushes the chain past end_height and tries the two things a bot does after a season closes. The lane node was otherwise idle, so it was only making a heartbeat block a minute; seasons are counted in blocks, not time, so to reach height 467 within minutes the script sends a cheap failing transaction (a cancel of an order that does not exist, from a throwaway key) every two seconds, each of which is mined and moves the height by one:

after.mjs (trimmed; same helpers as season.mjs)const { n, end, sellId } = JSON.parse(fs.readFileSync(`${DIR}/season.json`, 'utf8'));
const A = Key.fromWif(fs.readFileSync(`${DIR}/alice.wif`, 'utf8').trim());
await view('book with the bot quoting', { view: 'book', base: DIVI, quote: GOLD });

let h = await height();
console.log(`height ${h}, season ${n} ends at ${end}`);
if (h <= end) {
  const ticker = Key.generate('testnet');
  let sent = 0;
  const first = await send(ticker, { type: 'market', op: { kind: 'cancel', order_id: 'nope' } }, 'tick');
  sent++;
  while ((await height()) <= end) {
    await chain.send(ticker, { type: 'market', op: { kind: 'cancel', order_id: 'nope' } });
    sent++;
    await new Promise((r) => setTimeout(r, 2000));
  }
  console.log(`sent ${sent} ticks; height now ${await height()} (first tick error "${first.error}")`);
}

// After the end: no more trades on the pair, but a cancel still refunds.
await send(A, { type: 'market', op: { kind: 'place', base: DIVI, quote: GOLD, side: 'sell', price: '12500000000', amount: '100000000' } },
  'alice sell after the end');
console.log(`alice ${DIVI} ${await bal(A.address, DIVI)} before the cancel`);
await send(A, { type: 'market', op: { kind: 'cancel', order_id: sellId } }, 'alice cancel');
console.log(`alice ${DIVI} ${await bal(A.address, DIVI)} after the cancel`);
await send(A, { type: 'market', op: { kind: 'cancel', order_id: sellId } }, 'alice cancel again');
await view('final book', { view: 'book', base: DIVI, quote: GOLD });
await view('final leaderboard', { view: 'season', n });

Output. The post-end sell is refused with the pair-window error; the cancel refunds Alice's remaining 1 S1DIVI (refund_base 100000000) and her balance goes from 97 back to 98; cancelling again is refused because the order is already cancelled:

height 325, season 1 ends at 467
tick: txid 54b8cc5b4b35d39647e9ddc812e0fa56b4822bdd04d7637bb54a8f56cb0bb271 height 326 ok false error "not found: order nope"
sent 143 ticks; height now 468 (first tick error "not found: order nope")
alice sell after the end: txid 21ac07c927a314e82c8f58344d57bbdff261c7610dd5aa31a70e5c119735d944 height 469 ok false
     error "conflict: season 1 trades from height 317 to 467"
alice S1DIVI 9700000000 before the cancel
alice cancel: txid 46ba8b378ebe998d02d7f707e119365181108edde4c757c359991266091f93cc height 470 ok true
   event market.order_closed {"id":"01698c2f327cf4b4","owner":"y1oxRLg1PB3xoSsggGbS4iEHLVQ9p5wiVk","reason":"cancelled",
     "refund_base":"100000000","refund_quote":"0","remaining":"100000000","status":"cancelled"}
alice S1DIVI 9800000000 after the cancel
alice cancel again: txid 6bc597f704f2d65e98a087e914f6cdd7879aa8693b05f8644b27208058139ded height 471 ok false
     error "conflict: order 01698c2f327cf4b4 is already cancelled"
final book: {"asks":[{"amount":"100000000","orders":1,"price":"12625000000"},{"amount":"100000000","orders":1,"price":"12750000000"},
     {"amount":"100000000","orders":1,"price":"12875000000"}],"base":"S1DIVI",
     "bids":[{"amount":"100000000","orders":1,"price":"12375000000"},{"amount":"100000000","orders":1,"price":"12250000000"},
     {"amount":"100000000","orders":1,"price":"12125000000"}],"last":{"height":323,"price":"12500000000","seq":0},"quote":"S1GOLD"}
final leaderboard: {"last":{"height":323,"price":"12500000000","seq":0},"leaderboard":[
     {"address":"y1oxRLg1PB3xoSsggGbS4iEHLVQ9p5wiVk","divi":"9800000000","gold":"100250","rank":1,"value":"112500"},
     {"address":"y33ESXoQ7HmJtDJhK8A6N5ygACmYJggWPW","divi":"10200000000","gold":"99750","rank":2,"value":"112500"},
     {"address":"y4Xu8LZbdyamXagqAdS8exwTsLyjW8ziyG","divi":"10000000000","gold":"100000","rank":3,"value":"112500"}],
     "mark_price":"12500000000","members":3,"pair":{"base":"S1DIVI","quote":"S1GOLD"},"season":{…}}

The final book is the bot's six resting orders: nobody traded with it, so it ends the season with the same 1000.00 gold and 100 DIVI it was minted (its divi on the leaderboard counts the 3 DIVI locked in its asks). All three members tie at 112500 because every DIVI is still marked at the only trade, 1.25. The bot's orders stay on the book after the end; it, too, can cancel them whenever it likes.

6The market-maker bot

The repository ships the house bot twice with the same strategy: bots/rust (dg-bots market-maker, a clap command-line tool with a pure strategy.rs) and bots/js/market-maker.mjs, which has no dependencies beyond web/sdk/divigames.js and runs on Node 20. We use the JavaScript one. It quotes both sides of a season's book around a reference price (the last trade, or --reference-price until there is one), as a symmetric ladder:

bots/js/market-maker.mjs/** Symmetric ladder: level i bids at ref*(1-(spread/2+i*step)) and asks at the mirror price. */
export function ladder(reference, { spreadBps = 200, stepBps = 100, levels = 3, levelSize = 100_000_000n } = {}) {
  const out = [];
  if (reference <= 0n || levelSize <= 0n) return out;
  for (let i = 0n; i < BigInt(levels); i++) {
    const off = BigInt(spreadBps) / 2n + i * BigInt(stepBps);
    if (off < BPS) {
      const price = clamp((reference * (BPS - off)) / BPS);
      if (price) out.push({ side: 'buy', price, amount: levelSize });
    }
    const ask = clamp((reference * (BPS + off)) / BPS);
    if (ask) out.push({ side: 'sell', price: ask, amount: levelSize });
  }
  return out;
}
FlagDefaultMeaning
--node (DG_NODE)http://127.0.0.1:7410gamechain node
--key-file (DG_BOT_KEY_FILE)requiredWIF key file; created 0600 on first run, refused if group- or world-readable, never taken on the command line
--seasonrequiredseason number to trade
--reference-price1.00gold per whole DIVI until the first trade; afterwards the last trade price
--spread-bps200total gap between best bid and best ask (2%)
--step-bps100extra distance per further level
--levels3levels per side
--level-size100000000base units per level (one DIVI)

Each round it fits the ladder to what it can fund, counting what its own resting orders already lock, and then cancels and places only the orders that differ. A quiet book costs no transactions. It signs each transaction itself, tracking the nonce locally instead of asking the node before every one:

bots/js/market-maker.mjs/** One round: free balances plus what resting orders already lock are the budget. */
export function plan(cfg, reference, freeGold, freeDivi, open, baseDecimals = DIVI_DECIMALS) {
  let gold = freeGold;
  let divi = freeDivi;
  for (const o of open) {
    if (o.side === 'buy') gold += o.locked;
    else divi += o.remaining;
  }
  return diffOrders(open, fitBudget(ladder(reference, cfg), gold, divi, baseDecimals));
}

async function round(chain, key, season, cfg, fallback) {
  const me = key.address;
  const acct = await chain.account(me);
  const bal = (sym) => BigInt(acct.balances?.[sym] ?? 0);
  const orders = await chain.game('market', { view: 'orders', owner: me });
  const open = orders.orders.map((o) => parseOpen(o, season.base, season.quote)).filter(Boolean);
  const [gold, divi] = [bal(season.quote), bal(season.base)];
  if (gold === 0n && divi === 0n && open.length === 0) {
    await chain.send(key, joinAction(season.n));
    console.error(`joined season ${season.n}`);
    return;
  }
  const book = await chain.game('market', { view: 'book', base: season.base, quote: season.quote, depth: 1 });
  const reference = book.last?.price ? BigInt(book.last.price) : fallback;
  const { cancel, place } = plan(cfg, reference, gold, divi, open);
  if (!cancel.length && !place.length) return;
  console.error(`ref ${reference}: cancel ${cancel.length} place ${place.length}`);
  let nonce = acct.next_nonce;
  for (const action of [...cancel.map(cancelAction), ...place.map((q) => placeAction(season.base, season.quote, q))]) {
    const { body, sig } = signTx(key, { nonce, action });
    await chain.submit({ body, sig });
    nonce += 1;
  }
}

The main loop polls /v1/info once a second and runs a round on every new height, but only while season.start <= height <= season.end:

bots/js/market-maker.mjslet last = null;
for (;;) {
  try {
    const { height } = await chain.info();
    if (height !== last) {
      last = height;
      if (height >= season.start && height <= season.end) await round(chain, key, season, cfg, fallback);
    }
  } catch (e) {
    console.error(`round failed: ${e.message}`);
  }
  await new Promise((r) => setTimeout(r, 1000));
}

Its tests

package.json declares no dependencies, so there is nothing to install. We copied bots/js/ next to our web/ copy (the bot imports ../../web/sdk/divigames.js) and ran its tests with Node's built-in runner:

$ cd ~/tut-i/trading-game/bots/js && node --test
TAP version 13
# Subtest: quote arithmetic matches the market module
ok 1 - quote arithmetic matches the market module
# Subtest: prices parse
ok 2 - prices parse
# Subtest: ladder is symmetric and widens
ok 3 - ladder is symmetric and widens
# Subtest: ladder edges
ok 4 - ladder edges
# Subtest: budget keeps inner levels
ok 5 - budget keeps inner levels
# Subtest: diff leaves matching orders alone
ok 6 - diff leaves matching orders alone
# Subtest: plan counts funds locked in resting orders
ok 7 - plan counts funds locked in resting orders
# Subtest: actions and view parsing have the on-chain shape
ok 8 - actions and view parsing have the on-chain shape
# Subtest: key file is created 0600, reused, and refused when loose
ok 9 - key file is created 0600, reused, and refused when loose
1..9
# tests 9
# pass 9
# fail 0

(The per-test duration_ms lines are cut.) The first test pins the bot's arithmetic to the module's: quoteFor(250000000n, 120n × 108) must be 300, and one base unit at a price of 1.00 must round to 0.

Running it against our season

We ran the bot for 60 seconds against season 1 while it was open (between the trade of step 5 and the end-of-season script). timeout kills it so nothing stays running. The reference price is 1.25, the last trade; the flag only matters until there is one:

$ cd ~/tut-i/trading-game && timeout 60 node bots/js/market-maker.mjs --node http://127.0.0.1:53910 \
    --key-file ~/tut-i/trading-game/maker.key --season 1 --reference-price 1.25
bot address y4Xu8LZbdyamXagqAdS8exwTsLyjW8ziyG trading S1DIVI/S1GOLD
joined season 1
ref 12500000000: cancel 0 place 6
$ ls -l maker.key
-rw------- 1 bshuler bshuler 53 Oct 22 00:55 maker.key

The first round found an empty account and joined (that is the market.mint of 1000.00 S1GOLD and 100 S1DIVI); the next height it placed six orders, and after that every round was silent because the ladder already matched. The book then held Alice's remaining ask and the bot's three levels a side: bids at 1.2375, 1.2250, 1.2125 and asks at 1.2625, 1.2750, 1.2875, which is 1.25 ± 1% for the spread, then +1% per level, one DIVI each:

book with the bot quoting: {"asks":[{"amount":"100000000","orders":1,"price":"12500000000"},{"amount":"100000000","orders":1,"price":"12625000000"},
     {"amount":"100000000","orders":1,"price":"12750000000"},{"amount":"100000000","orders":1,"price":"12875000000"}],"base":"S1DIVI",
     "bids":[{"amount":"100000000","orders":1,"price":"12375000000"},{"amount":"100000000","orders":1,"price":"12250000000"},
     {"amount":"100000000","orders":1,"price":"12125000000"}],"last":{"height":323,"price":"12500000000","seq":0},"quote":"S1GOLD"}

The key file was created by the bot with mode 0600, as its ninth test promises. To beat this bot you need only cross its best quote: a buy at 1.2625 fills against its first ask at the maker's price, and the next height it re-quotes one level deeper.

7The live public book

The public gamechain's API is at https://games.divi.domains/chain/v1/…. These are the three reads a bot makes first, and what they returned when we ran them:

$ curl -s https://games.divi.domains/chain/v1/games/market?view=seasons
{"seasons":[]}
$ curl -s 'https://games.divi.domains/chain/v1/games/market?view=book&base=GOLD&quote=TDIVI&depth=5'
{"asks":[],"base":"GOLD","bids":[],"last":null,"quote":"TDIVI"}
$ curl -s 'https://games.divi.domains/chain/v1/games/market?view=trades&base=GOLD&quote=TDIVI&limit=5'
{"base":"GOLD","quote":"TDIVI","trades":[]}

At the time of writing (2026-10-21, public height 1748, sequencer xw9aaoN8FBrQcesu8F29ZjL6irKxyXpRRZ, tokens GOLD, TDGOLD and TDIVI) the public gamechain has no seasons and an empty book: no season has been created yet, no order has rested on GOLD/TDIVI and no trade has ever printed. So there is nothing to show from the public order book but the shapes above; the behaviour you saw in steps 5 and 6 is the same code the public node runs.

When a season exists, ?view=seasons lists it with its gold and divi symbols and heights, and ?view=book&base=S<n>DIVI&quote=S<n>GOLD is the book to trade. Until then, the pair the courses use is GOLD against TDIVI (any two of the chain's tokens can pair; the third today is TDGOLD); GOLD comes free from the faucet action ({"type":"faucet","token":"GOLD"}), TDIVI from the bridge (anchors and the tDIVI bridge).

8Checklist for your own bot

  1. Keys. One WIF in a mode-0600 file, read at run time; never on the command line, never in the repository. The SDK's Key.generate('testnet') and Key.fromWif() are all you need, and loadOrCreateKey in the bot is a good 12 lines to copy.
  2. Find the season. ?view=seasons, then trade S<n>DIVI against S<n>GOLD and only while start_height <= height <= end_height. Join once; a second join is a wasted nonce.
  3. Prices are integers. Quote base units per whole base token, times 108, as decimal strings. Do all arithmetic in BigInt; the bot's quoteFor is the same formula as the module's quote_for.
  4. Own your nonce. Read next_nonce once per round and increment locally. A failed action still consumes a nonce; a bad nonce never enters the mempool (HTTP 400). Treat 409 as "already sent" and 503 as "mempool full, wait".
  5. Watch events, not just receipts. Fills of your resting orders appear in the taker's receipt. Subscribe to /v1/ws (the SDK's chain.subscribe) or re-read ?view=orders&owner= every block.
  6. Respect the limits. 100 open orders per address, 50 fills per placement, no crossing your own order. Cancel stale orders so your escrow is not stuck; cancels work even after the season ends.
  7. Count heights, not seconds. Blocks come every 2 s when busy and once a minute when idle.
  8. Remember the mark. The leaderboard values everyone at the last trade price, and nothing until there is one.
  9. Test on your own node first. Run the gamechain, create a season with your own sequencer key as in step 5, and point the bot at it with --node.

Related: the JavaScript SDK for everything the bot calls, sign in with Divi for how the signatures work, and build tic-tac-toe for the same send-and-receipt loop in a game.

If something goes wrong

You seeWhat it means
conflict: season N starts at height HYou joined before start_height. Wait for the height; the tx used a nonce.
conflict: season N ended at height HToo late to join. Cancels still work; trades do not.
conflict: <address> already joined season NOne join per address per season.
conflict: season N trades from height A to BA place on the season pair outside the window.
conflict: order would trade with your own resting orderCancel your own order first, or price so you do not cross it.
conflict: at most 100 open orders per addressCancel some.
not allowed: only the sequencer creates seasonsseason_create from a non-sequencer key. On the public chain only the operator can create seasons; use your own node.
order_closed … "reason":"fill_limit"Your order hit 50 resting orders; the rest was refunded. Place again if you still want it.
order_closed … "reason":"dust"What was left could not be priced to a whole quote base unit; it was refunded.
HTTP 400 on POST /v1/txBad signature, wrong chain id or wrong nonce. Re-read next_nonce.
key file … has mode 644; chmod 600The bot refuses a key file others can read.
The bot prints nothing for minutesNormal on a quiet book: it only sends when the ladder changes. Check ?view=orders&owner=.

Still stuck? Ask the Divi chatbot, or see all tutorials.