Skip to Content
RecipesDeepBook Market Maker (Sui)

DeepBook Market Maker (Sui) with WaaP CLI

What are we cooking?

A Node.js agent that quotes both sides of DeepBook’s DEEP/SUI pool on Sui mainnet. Every tick it reads the order book, reconciles its orders, fills, pending sends and budgets against the chain, and decides what to quote. When something needs to change, it builds one transaction that cancels what is out of place, places post-only orders and sweeps settled fills back into its account. It then hands the unsigned bytes to waap-cli send-tx.

The account runs in WaaP Standard mode, where the signing key is held inside WaaP’s enclave. The agent process never has a key, and there is no private key in its environment or its logs.

The built-in strategy has not demonstrated profitability. The built-in maker keeps its inventory in a band, sizes orders to what it can back, and quotes only when the spread covers the estimated cost of a quoting cycle under the cost assumptions you configure. That check is not a profit forecast: nothing in the recipe predicts whether or how often your orders fill. Choose your own objective, sizes and loss limits before you run it with real inventory.

Measured on mainnet (28 September 2026, 65 minutes): one complete maker cycle with outside takers, +0.0040 SUI of spread against 0.0206 SUI of gas, −0.0166 SUI net. Details and the other runs are under Results.

Why DeepBook is a good fit for an agent

  • An order book needs makers. DeepBook is a central limit order book, so every trade on it crosses someone’s resting order. Quoting both sides all day is repetitive work that suits a loop.
  • The DEEP/SUI pool charges no trading fee. It is whitelisted: maker fee 0, taker fee 0, no DEEP staking required. The agent reads this from the chain every run with poolTradeParams('DEEP_SUI'), so a fee change is picked up.
  • One transaction per requote. Cancels, places and the settlement sweep all fit in one programmable transaction block, so each requote is one signature and one gas payment.
  • Your fills are public events. Every OrderFilled event names the balance managers on both sides, so the agent can find its own fills on chain without trusting its own bookkeeping.

What the built-in maker does

The maker quotes passive, post-only orders around the mid price. It keeps inventory inside a band, sizes each order to what it can back, and quotes only when the spread covers the estimated cost of a quoting cycle under the cost assumptions you configure. The code is lib/strategy.ts, a pure function with its tests beside it.

  • Inventory band. The base share of the manager’s value, f = base × mid / (base × mid + quote), stays between BAND_LOW and BAND_HIGH (default 20 to 80 %, target 50 %). Each order is sized so that f stays inside the band if that order fills completely. The opposite side is not assumed to fill. At the upper bound the maker stops bidding; at the lower bound it stops offering. Inside the band the quotes are skewed by up to SKEW_MAX_BPS, toward the side that brings f back to target. Price moves can still carry f outside the band; the band bounds what the maker’s own orders add.
  • Backed sizes. Each side is sized to the smallest of ORDER_SIZE, the band cap, and what the transaction can actually spend: free balance plus what cancelling its own orders on that side releases. Proceeds settled from a fill count as owned, but they cannot back an order until they are swept. A partly filled order, such as 17 DEEP left of 20, is kept rather than replaced. Below the pool minimum on both sides, the maker says so (inventory_limited) and says what deposit would fix it.
  • Cost gate. Gas on Sui is charged per transaction, not per DEEP, so a small order has to earn a wide spread. The maker widens its spread to what covers the estimated cost of one cycle (a bid fill plus an ask fill of the same size) at that size. The estimate is the sum of the transaction shapes the cycle sends (two refills, COST_REPLACEMENTS_PER_CYCLE replacements, failed sends, and amortised setup and cleanup), each costed from a fixed table of receipts from earlier mainnet runs (lib/costs.ts) at the quantile you set in COST_QUANTILE. If the spread needed is above MAX_SPREAD_BPS, or more than TOUCH_MULTIPLE × the current touch spread (with its own orders excluded), the maker pauses rather than widening out of reach. A pause for cost cancels everything. A pause caused only by the touch rule places nothing new but keeps resting orders that still pass the keep rules (a held order can still be filled); once paused on that rule, the maker resumes placing only after LIQUIDITY_MIN_PAUSE_MIN and with its spread inside the rule by LIQUIDITY_RESUME_MARGIN. The gate is re-checked on the final rounded prices. Passing it means the spread covers the estimated cost of one cycle if both legs fill. Actual gas can be higher than the estimate: in the 28 September run, configured for 2 replacements per cycle, the maker sent 15 requotes over its one completed cycle.
  • Dwell and tolerance, with risk overrides. A drifted order is replaced only after it has passed REQUOTE_TOLERANCE_BPS and lived MIN_DWELL_SEC. Risk overrides the dwell and cancels at once. Risk means a band breach, a size above the cap, an order near expiry, a pause or a halt.
  • Post-only, cancel-taker, finite expiry. Orders are POST_ONLY (a crossing quote aborts instead of taking), use CANCEL_TAKER so the agent never matches its own resting order, and expire after ORDER_TTL_MIN. Expiry limits how long a crashed process’s orders can trade. It does not withdraw anything; cleanup does that.
  • Limits, then cleanup. The loop halts, then cleans up, when it reaches any of these: the gas cap (GAS_CAP_SUI); inventory drawdown (MAX_DRAWDOWN_SUI), which measures manager value at mid against the run’s start and is not total P&L; run length (MAX_RUN_MIN); MAX_TICKS; repeated failures; or an unexpected taker fill. Turnover over TURNOVER_WINDOW_HOURS pauses new orders. The maker never liquidates, swaps or buys inventory.

Worked example: why size matters more than spread

Figures are from lib/costs.ts. Net gas per shape comes from receipts of three earlier mainnet runs of this recipe. They are historical, not today’s prices. The loop counts every receipt against its gas cap, but it does not update these figures.

Order sizeCycle cost, median shapes, 1 replacementSpread neededCycle cost, p90 shapes, 3 replacements (default)Spread needed
10 DEEP0.0049 SUI255 bps0.0103 SUI539 bps
20 DEEP0.0049 SUI128 bps0.0103 SUI269 bps
50 DEEP0.0049 SUI51 bps0.0103 SUI108 bps
100 DEEP0.0049 SUI26 bps0.0103 SUI54 bps

At mid 0.0191. The spread needed is 1e4 × cycle cost ÷ (size × mid); maker fees are 0 on DEEP/SUI.

The DEEP/SUI touch spread averaged 47 bps over the 27 September run and 91 bps over the 25 September run, ranging from 5 to 512 bps. At 20 DEEP the default gate asks for about 270 bps, so the maker quotes only when the book is wide. At 50 to 100 DEEP it can sit near the touch.

Capital rule of thumb. The band decides how much capital a size needs. To keep a full q-DEEP fill inside a 20 to 80 % band from a 50 % start, the manager needs about 3.3 × q × mid SUI of value. That is 1.3 SUI for 20 DEEP, 3.2 SUI for 50 DEEP and 6.4 SUI for 100 DEEP, before a gas and cleanup reserve.

If only one side fills and the price then moves against the position, the band stops further same-side orders. The drawdown limit then ends the run: cancel → settle → withdraw, with the remaining inventory returned to the wallet and reported at mid. It is not sold.

lib/strategy.test.ts covers these cases with scripted fills (fills are never inferred from prices): a flat book, a trending book, a jumping book, a bid-only fill followed by a falling price, a SUI-only start, partial fills, fee changes and a stale book.

Key Components

  • WaaP CLI: signs and broadcasts Sui transactions. In WaaP Standard mode the key is held inside WaaP’s enclave, so there is no raw private key in your .env.
  • @mysten/deepbook-v3: the DeepBook client. Book reads, placeLimitOrder, cancelLiveOrder, withdrawSettledAmounts, and the BalanceManager calls.
  • @mysten/sui: Sui JSON-RPC client and Transaction builder.
  • A BalanceManager: the shared object DeepBook trades from. The agent creates it once and keeps its id in state.json; a new one per run leaves orphaned shared objects on chain.
  • lib/strategy.ts: the built-in maker described above, a pure function you can replace.

What you need

  • Node.js 20+
  • WaaP CLI 2.2.0, the version this recipe was tested with (npm i -g @human.tech/waap-cli@2.2.0)
  • A WaaP account, created with waap-cli signup
  • SUI on the address waap-cli whoami prints: inventory for the book, plus a gas and cleanup reserve. DEEP is optional; see step 4 of Going live

Gas: most of it comes back. Placing an order pays a storage deposit, and cancelling or filling it returns most of that deposit. On 25 September a transaction that placed orders showed about 0.07 SUI of gross storage and cost about 0.001 SUI net once the rebate came back.

The gas budget is set for you (waap-cli 2.2.0). In Standard mode waap-cli send-tx takes the transaction kind, and WaaP’s preparation step picks the gas budget, just above the estimated cost. If state changes between preparation and execution, the real cost can pass that budget and the transaction fails on chain with InsufficientGas. In the previous version’s runs that happened to 4 of 134 requotes on 25 September and 2 of 11 on 27 September, at about 0.00018 SUI each; the next tick succeeded. The loop’s gas budget counts these failures. See Known issues.


Project Setup

1. Get the code and run a bounded dry run

Dry run is the default: nothing is signed unless AGENT_DRY_RUN is exactly 0. The dry run needs no WaaP account and no waap-cli.

npx gitpick holonym-foundation/waap-examples/tree/0015b7c0effa93e8f90d050b084ae36f150d18df/deepbook-maker cd deepbook-maker npm ci cp .env.example .env # nothing secret in it npm test # unit tests, no network MAX_TICKS=3 POLL_MS=10000 ./node_modules/.bin/tsx agent.ts

The dry run reads the live book and logs a quote_plan or quote_paused line each tick, with the cost gate, band and sizes behind it. .env.example sets DRY_RUN_BASE_INVENTORY / DRY_RUN_QUOTE_INVENTORY so a first dry run has something to plan with. Without a waap-cli session it uses the zero address for logging only. After three ticks the loop exits. Nothing is signed or sent.

The SDKs are pinned (@mysten/deepbook-v3 2.4.1, @mysten/sui 2.31.0). Method names in @mysten/deepbook-v3 have moved between releases; SDK-NOTES.md in the repo lists every place the pinned SDK differs from the docs.

2. Install WaaP CLI 2.2.0

npm i -g @human.tech/waap-cli@2.2.0 # the version this recipe was tested with

Configuration

.env.example holds every variable with its default and reason. There is nothing secret in it. The ones this page refers to:

VariableDefaultWhat it does
AGENT_DRY_RUN1Dry run unless set to exactly 0. In a dry run the agent builds the bytes and never calls waap-cli
SUI_RPC / SUI_RPC_FALLBACKSpublicnode / Suiscan on mainnetJSON-RPC endpoint, and endpoints to rotate to on a node failure. Set SUI_RPC_FALLBACKS to an empty value to disable rotation. Not Mysten’s public fullnode, which has retired methods the SDK still calls (-32601 Method not found)
ORDER_SIZE20Largest order per side, in DEEP. Lot 1 DEEP, pool minimum 10 DEEP
SPREAD_BPS / MAX_SPREAD_BPS20 / 400Narrowest full spread; the cost gate widens it up to the maximum, above which the maker pauses
TOUCH_MULTIPLE4The maker’s spread may be at most this multiple of the touch spread (own orders excluded). A declared heuristic, not a fill model
HOLD_ON_LIQUIDITY_PAUSE / LIQUIDITY_RESUME_MARGIN / LIQUIDITY_MIN_PAUSE_MIN1 / 0.25 / 10On a pause caused only by the touch rule, keep resting orders that pass the keep rules (0 cancels them). Once paused, resume placing only with the spread at most 75 % of the rule’s limit and after 10 minutes
FILL_SCAN_PAGES80Pages of 50 fill events read per tick. DeepBook emitted about 4,700 an hour across all pools on 28 September, so 80 pages covers about 50 minutes of history when a restart backfills
TARGET_BASE_FRACTION, BAND_LOW, BAND_HIGH0.5, 0.2, 0.8The inventory band
SKEW_MAX_BPS50How far the quote centre moves off mid toward the side that restores the target
REQUOTE_TOLERANCE_BPS / MIN_DWELL_SEC50 / 300A drifted order is replaced only past the tolerance and after the dwell
ORDER_TTL_MIN60On-chain order expiry
COST_REPLACEMENTS_PER_CYCLE, COST_QUANTILE3, p90Cost model assumptions for the gate
GAS_CAP_SUI / CLEANUP_GAS_ALLOWANCE_SUI0.15 / 0.05Gas cap for the run, and extra room only cleanup may use
MAX_DRAWDOWN_SUI0.05Inventory drawdown at mid, gas excluded, that ends the run
MAX_TURNOVER_SUI / TURNOVER_WINDOW_HOURS20 / 24Filled notional in the window that pauses new orders
MAX_RUN_MIN / MAX_TICKSnoneRun length and tick count; either ends the run through cleanup
POLL_MS60000Milliseconds between ticks
DEEPBOOK_BALANCE_MANAGER_IDfrom state.jsonMust agree with the state file; needed for a remote stop

Going live

1. Log in and fund

# Create a dedicated agent account (the password is read from stdin, not from argv) waap-cli signup --email youremail+deepbook-maker@example.com --password-stdin # Or log in to an existing one waap-cli login --email youremail+deepbook-maker@example.com --password-stdin # Print the Sui address waap-cli whoami --json

Fund that address with the base and quote you intend to quote, plus a gas and cleanup reserve. The capital rule of thumb above gives the size.

Always use the --chain form sui:mainnet. The deprecated --chain-id alias is not supported for Sui.

2. Set your daily spend limit

waap-cli policy set --daily-spend-limit <usd>

The limit is a threshold that asks for your approval, not a hard cap. If you need a hard boundary, use a separate account funded with only what should be on the book.

3. Create the BalanceManager

AGENT_DRY_RUN=0 MAX_TICKS=1 ./node_modules/.bin/tsx agent.ts

One live tick creates the manager, writes its id to state.json and quotes nothing. A successful setup prints balance_manager_created, and state.json must then contain a non-empty balanceManagerId. The run exits nonzero if the send is refused, its outcome is unknown, or the transaction is submitted but the manager id cannot yet be read. npm run deposit needs this manager, so create it first.

If setup exits nonzero, run AGENT_DRY_RUN=0 npm run recover and inspect the operation before retrying. For an unknown outcome, attach the digest or declare it not executed only after the required explorer check. A submitted transaction with a successful receipt stays pending until its manager id is persisted; rerun the same setup command once the object changes are readable to recover the id without sending a second create. If the submitted transaction has a readable failed receipt, setup charges its gas and clears the pending operation; fix the reported failure, then rerun setup to create the manager. Check balance_manager_created and state.json.balanceManagerId again before depositing.

4. Deposit

AGENT_DRY_RUN=0 DEPOSIT_SUI=0.6 DEPOSIT_DEEP=30 npm run deposit

SUI only also works: the agent bids until fills bring base into the band.

If you deposit while resuming an open run, the amounts are saved before signing and added to the drawdown baseline only after a successful receipt. Failed deposits add no transfer. A deposit awaiting its receipt pauses quoting, including when its digest was attached through recovery.

5. Run the loop, directly

AGENT_DRY_RUN=0 MAX_RUN_MIN=60 ./node_modules/.bin/tsx agent.ts

Do not launch a long run through npm run: on Linux, npm does not pass SIGTERM on, so stopping npm can leave the agent quoting.

6. Stop

Use any one of these:

  • Ctrl-C or SIGTERM the agent. It finishes the tick, stops quoting, then runs cancel → settle → withdraw and checks the chain for no open order, no settled balance and an empty manager. The loop runs the same cleanup on every handled exit: MAX_TICKS, MAX_RUN_MIN, the gas and drawdown limits, repeated failures and an unexpected taker fill.
  • AGENT_DRY_RUN=0 npm run stop. On the same machine as a live loop, this signals the loop and waits for its cleanup, then verifies. After a crash, it runs the cleanup itself; add -- --break-stale-lock if the dead loop left its lock.
  • From another machine, first make sure the original loop is stopped: the lock is a local file, so a stop on another machine can neither see nor signal it. Then log in to the same WaaP account and run DEEPBOOK_BALANCE_MANAGER_ID=0x… AGENT_DRY_RUN=0 npm run stop. The manager id must be supplied, because that machine has no state.json.

7. Check that cleanup succeeded

Cleanup can fail, for example on a refused or unknown send, a failed receipt, or residuals still on chain. The process then exits unsuccessfully, and orders or balances can remain in the manager until you recover.

What to look for
Successcleanup_confirmed (with the digest and residuals) or cleanup_verified_clean (the manager was already empty), then process_exit with cleanupOk: true. For npm run stop: stop_done with ok: true and exit code 0
Failurecleanup_failed with a stage and an instruction, or cleanup_skipped if the state file could not be written; then process_exit with cleanupOk: false, or stop_done with ok: false and exit code 1
RecoverAGENT_DRY_RUN=0 npm run stop. Cleanup is idempotent, so it is safe to rerun. If a send’s outcome is unknown, AGENT_DRY_RUN=0 npm run recover lists it for an explorer check (without AGENT_DRY_RUN=0 it reads the dry-run state and shows nothing pending). Orders expire on their own within ORDER_TTL_MIN

If cleanup reports stage: pending, an earlier operation is still unresolved even though the manager is empty now. It returns failure and keeps the run and its budgets open. Use AGENT_DRY_RUN=0 npm run recover to inspect and resolve the operation, then rerun AGENT_DRY_RUN=0 npm run stop to verify the manager again. A delayed deposit can put funds back after an earlier empty-balance check.

For a normal run, read cleanupOk alongside the exit code: an exit on a limit (gas cap, drawdown, taker fill, repeated failures) returns 1 even when cleanup succeeded. For first-time manager setup, also require balance_manager_created and a saved balanceManagerId before depositing.

./stop-check.sh rehearses steps 5 and 6 as a dry run: launch, duplicate refused, stop handoff, cleanup, no survivor, lock released. It runs with a replaced environment, a temporary HOME and a stand-in waap-cli that refuses to sign. It fails if anything reached a signer.


The Recipe Workflow

The snippets on this page are excerpts from the full source . Each block names its file and function; some are marked as condensed.

1. The signing spine

// lib/waap.ts, buildKindBytes (excerpt): https://github.com/holonym-foundation/waap-examples/blob/0015b7c0effa93e8f90d050b084ae36f150d18df/deepbook-maker/lib/waap.ts export async function buildKindBytes( tx: Transaction, kind: string, extra: Record<string, unknown> = {}, ): Promise<string | null> { try { // `tx.build` is an RPC read: it resolves every input object through the node, so // a dead endpoint shows up here as `tx_build_failed` and a silently skipped tick. // `sui` is read inside the closure so a rotation mid-build is picked up. const bytes = await withRpc('tx_build', () => tx.build({ client: sui, onlyTransactionKind: true })) const b64 = toBase64(bytes) log('event', 'tx_built', { kind, bytesLen: bytes.length, b64Len: b64.length, ...extra }) return b64 } catch (err) { log('error', 'tx_build_failed', { kind, error: String(err).slice(0, 300), ...extra }) return null } }

onlyTransactionKind: true drops the sender and the gas payment. The CLI fills both in from the WaaP wallet, so the agent never needs to know which coins it owns. It also means the agent does not choose the gas budget: WaaP’s preparation step does (see Known issues).

Every send in the recipe, from the loop, the deposit and the stop, goes through one function with one dry-run guard:

// lib/waap.ts, signAndSendTx (condensed): https://github.com/holonym-foundation/waap-examples/blob/0015b7c0effa93e8f90d050b084ae36f150d18df/deepbook-maker/lib/waap.ts export async function signAndSendTx(b64TxBytes: string, kind: string): Promise<string | null> { if (DRY_RUN) { log('info', 'dry_run_skip', { kind, bytesLen: b64TxBytes.length, sendTxCalls }) return null } sendTxCalls++ const res = await execa( 'waap-cli', ['send-tx', '--tx', b64TxBytes, '--tx-format', 'base64', '--chain', `sui:${NETWORK}`, '--json'], { timeout: 120_000 }, ) // a nonzero exit is kept (it is what a policy rejection looks like) and rethrown // waap-cli can print `{"event":"error"}` and still exit 0, so the stream decides. const digest = digestFromCliStdout(res.stdout) if (!digest) throw new Error('waap-cli returned no transaction digest') return digest }

On the command line the same call is:

waap-cli send-tx --tx <base64 transaction kind> --tx-format base64 --chain sui:mainnet --json

waap-cli streams NDJSON: progress events, then one {"event":"result", ...} carrying the digest, or an {"event":"error", ...}. digestFromCliStdout returns the result’s digest and nothing else.

2. Intent is saved before anything is signed

signAndSendTx is never called directly by the loop. sendWithIntent in lib/ops.ts first checks the gas budget, then writes the intent (kind, gas reservation, client order ids) to the state file, and only then signs:

// lib/ops.ts, sendWithIntent (condensed): https://github.com/holonym-foundation/waap-examples/blob/0015b7c0effa93e8f90d050b084ae36f150d18df/deepbook-maker/lib/ops.ts if (!canReserve(state.budget, state.pending, GAS_CAPS, RESERVE_PER_TX_MIST, args.kind)) throw new GasBudgetError(/* … */) state.pending.push(op) // status 'intent', with its reservation and client order ids ctx.save() try { const digest = await signAndSendTx(args.b64, args.kind) op.status = 'submitted'; op.digest = digest; ctx.save() return { status: 'submitted', digest, op } } catch (err) { if (classifySendFailure(error) === 'not_submitted') { /* refused before signing: release the reservation */ } op.status = 'unknown'; ctx.save() // blocks quoting until chain evidence resolves it; never resent return { status: 'unknown', error, op } }

A send whose result is unknown blocks quoting and is never resent. It resolves only on transaction evidence; an empty book is not evidence. When the evidence cannot be found automatically, AGENT_DRY_RUN=0 npm run recover records what an operator found on an explorer. Cleanup is idempotent and stays allowed.

3. One BalanceManager, created once

When state.json has no manager id, a live tick builds the create transaction, sends it through sendWithIntent, reads the new object’s id out of the transaction, and writes it to state.json. It quotes nothing on that tick. That is what step 3 of Going live runs.

// agent.ts, tickBody, the no-manager branch (condensed): https://github.com/holonym-foundation/waap-examples/blob/0015b7c0effa93e8f90d050b084ae36f150d18df/deepbook-maker/agent.ts const tx = new Transaction() tx.add(db().balanceManager.createAndShareBalanceManager()) const b64 = await buildKindBytes(tx, 'create_manager', { tick: n }) const out = await sendWithIntent(ctx, { kind: 'create_manager', b64, runId, proc: 'loop' }) if (out.status === 'submitted') { const id = findCreatedBalanceManagerId(await fetchTx(out.digest)) if (id) { s.balanceManagerId = id; ctx.save() } } if (!DRY_RUN) return

The state file is bound to owner, network, pool and live or dry mode, and written atomically. If state.json and DEEPBOOK_BALANCE_MANAGER_ID disagree, every script refuses.

The condensed branch above leaves out its recovery guard. A submitted create stays pending when its receipt succeeds but the manager id is not yet readable. A readable failed receipt charges its gas and clears the pending create, so setup can be retried after the cause is fixed. The loop reports setup failure until the id is saved.

4. The maker, which is the part you replace

planStrategy() in lib/strategy.ts takes the book, the pool’s tick, lot and minimum, the fee rate, the manager’s free and locked balances, the resting orders, and any halt or pause. It returns a mode (quote, pause, halt or inventory_limited), the cancels and places, and a note per side saying why. It has no network and no clock of its own.

The cost gate at its centre is two functions in lib/costs.ts:

// lib/costs.ts, cycleCost and requiredSpreadBps (excerpt): https://github.com/holonym-foundation/waap-examples/blob/0015b7c0effa93e8f90d050b084ae36f150d18df/deepbook-maker/lib/costs.ts export function cycleCost(a: CycleAssumptions, table: Record<Shape, ShapeCost> = SHAPE_COSTS): CycleCost { const c = (s: Shape) => shapeCost(s, a.quantile, table) const sends = 2 + a.replacementsPerCycle const parts = { refills: 2 * c('place_one'), replacements: a.replacementsPerCycle * c(a.replaceShape), failures: a.failRate * sends * c('failed'), setupCleanupAmortised: (c('create_manager') + c('deposit') + c('place_two') + c('cleanup')) / Math.max(1, a.cyclesToAmortize), } return { totalSui: Object.values(parts).reduce((x, y) => x + y, 0), parts, assumptions: a } } /** Full spread (bps of mid) at which a cycle of `q` base covers `costSui` and both-side maker fees. */ export function requiredSpreadBps(q: number, mid: number, costSui: number, makerFeeRate: number): number { if (!(q > 0) || !(mid > 0)) return Number.POSITIVE_INFINITY return (1e4 * costSui) / (q * mid) + 2 * makerFeeRate * 1e4 }

Each tick’s decision is logged as quote_plan, quote_paused or inventory_limited, with the band fraction, the spread and the gate’s edge, cost and required spread.

5. Starting with SUI only

You do not need DEEP to start. With a SUI-only manager the base share is 0, below the band, so the maker places only bids, and only when a bid moves the share back toward target. Once fills bring base into the band, it quotes both sides without a restart. If neither side can be backed at the pool minimum, the maker logs inventory_limited with the deposit that would fix it, and after INVENTORY_LIMITED_EXIT_TICKS such ticks it ends the run through cleanup.

6. One transaction per requote

// agent.ts, tickBody, the requote (excerpt): https://github.com/holonym-foundation/waap-examples/blob/0015b7c0effa93e8f90d050b084ae36f150d18df/deepbook-maker/agent.ts const tx = new Transaction() for (const id of cancels) tx.add(db().deepBook.cancelLiveOrder(POOL_KEY, MANAGER_KEY, id)) const placedAt = now() const clientOrderIds: string[] = [] for (const o of plan.place) { const cid = makeClientOrderId(placedAt, ++s.opSeq) clientOrderIds.push(cid) addPlaceOrder(tx, db(), { poolKey: POOL_KEY, managerKey: MANAGER_KEY, clientOrderId: cid, price: o.price, quantity: o.quantity, isBid: o.isBid, expirationMs: placedAt + STRATEGY.orderTtlMs, payWithDeep: STRATEGY.payWithDeep }) } tx.add(db().deepBook.withdrawSettledAmounts(POOL_KEY, MANAGER_KEY)) const b64 = await buildKindBytes(tx, 'requote', { tick: n, runId, cancels: cancels.length, places: plan.place.length }) if (!b64) return const out = await sendWithIntent(ctx, { kind: 'requote', b64, clientOrderIds, runId, proc: 'loop' })

addPlaceOrder in lib/ptb.ts sets what the SDK leaves open. The SDK’s defaults are an unrestricted order type, self-matching allowed and no expiry, so a quote that turned marketable between the book read and execution would trade as taker:

// lib/ptb.ts, addPlaceOrder (excerpt): https://github.com/holonym-foundation/waap-examples/blob/0015b7c0effa93e8f90d050b084ae36f150d18df/deepbook-maker/lib/ptb.ts tx.add( db.deepBook.placeLimitOrder({ poolKey: a.poolKey, balanceManagerKey: a.managerKey, clientOrderId: a.clientOrderId, price: a.price, quantity: a.quantity, isBid: a.isBid, expiration: a.expirationMs, orderType: ORDER_TYPE_POST_ONLY, selfMatchingOption: SELF_MATCH_CANCEL_TAKER, payWithDeep: a.payWithDeep, }), )

cancelLiveOrder, not cancelOrder: an order can fill between the tick that read the book and the tick that cancels it, and cancelOrder aborts the whole transaction on an id the manager no longer holds. A tick with nothing to change submits nothing.

7. Reconcile against the chain, every tick

A maker usually learns about its fills from someone else’s transaction: the taker’s. So the agent’s own view of what is resting goes stale the moment someone crosses it. Each tick starts by asking the chain:

// condensed from agent.ts readChainOrders() and lib/quotes.ts reconcileResting(): https://github.com/holonym-foundation/waap-examples/blob/0015b7c0effa93e8f90d050b084ae36f150d18df/deepbook-maker/lib/quotes.ts const onChain = (await db.accountOpenOrders(POOL_KEY, MANAGER_KEY)).map(String); // Orders in state that are no longer on chain were filled, cancelled or expired: drop them. // Orders on chain that state does not know about: adopt them, decoding side and price from the id. // Orders partly filled: resize from getAccountOrderDetails.

A failed read skips the reconcile and logs a warning rather than wiping the resting set.

8. Reading your own fills

DeepBook emits an OrderFilled event per match, across every pool. lib/fills.ts anchors a cursor at the run’s start, walks OrderFilled events forward from it, and keeps the ones on this pool that name this manager. lib/events.ts labels each one with its role: maker, taker (a quote that crossed, which post-only should prevent and which halts the run) or self, with the fee and the asset it was paid in. Fills, the cursor and the budgets are saved in one write. If a later page of a scan fails, its dedup changes are discarded so the retry still returns fills from earlier pages. On restart, fills are backfilled from the saved cursor before quoting resumes.

npm run fills walks the same events for this manager up to confirmed cleanup and prints spread against gas, with fees and taker or self fills.

9. The loop and the cleanup

// agent.ts, main (condensed): https://github.com/holonym-foundation/waap-examples/blob/0015b7c0effa93e8f90d050b084ae36f150d18df/deepbook-maker/agent.ts for (const sig of ['SIGTERM', 'SIGINT'] as const) { process.on(sig, () => { stopping ??= `signal_${sig}` }) // finish this tick, then clean up } while (!stopping) { try { await tick() consecutiveErrors = 0 } catch (err) { // state unwritable or gas cap reached: stop at once; otherwise count the failure if (++consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { stopping = 'too_many_consecutive_errors'; break } } ticks++ if (haltReason) stopping = haltReason // gas, drawdown, run length, taker fill, … else if (MAX_TICKS && ticks >= MAX_TICKS) stopping = 'max_ticks' if (stopping) break // wait POLL_MS, waking each second to notice a signal } await finish(stopping!, clean ? 0 : 1) // quotes_stopped → runCleanup → process_exit

finish and npm run stop share one cleanup, runCleanup in lib/ops.ts. It builds cancel → settle → withdraw in one transaction:

// lib/ptb.ts, addCleanup (excerpt): https://github.com/holonym-foundation/waap-examples/blob/0015b7c0effa93e8f90d050b084ae36f150d18df/deepbook-maker/lib/ptb.ts if (a.poolAccount !== false) { tx.add(db.deepBook.cancelAllOrders(a.poolKey, a.managerKey)) tx.add(db.deepBook.withdrawSettledAmounts(a.poolKey, a.managerKey)) } for (const coin of [...new Set(a.coins)]) tx.add(db.balanceManager.withdrawAllFromManager(a.managerKey, coin, a.recipient))

withdrawSettledAmounts is there because cancelAllOrders settles only through the orders it cancels. If every order had already filled, there is nothing to cancel, and the proceeds would stay in the pool account where withdrawAllFromManager cannot see them. After the receipt, runCleanup reads the chain until it shows no open order, no settled balance and an empty manager. It logs cleanup_confirmed with the digest and the timestamped residuals only when no operation remains pending. Otherwise it logs cleanup_failed with stage: pending and requires recovery followed by another stop. If the manager is already empty and nothing is pending, it logs cleanup_verified_clean and sends nothing.

A crash cannot run cleanup. Order expiry (ORDER_TTL_MIN) bounds how long its orders can trade, the state file keeps what it had sent, and npm run stop finishes the job. If the state file cannot be written, the loop stops quoting and skips cleanup, because cleanup has to save its intent first; it tells you to fix the disk and run npm run stop.


Safety model

ConcernWhat the recipe does
Two processes on one account and poolAn atomic lock (open wx) keyed on account, network and pool, under LOCK_DIR. A live holder refuses the second process. A dead one is taken over only with --break-stale-lock. Single host: a local file cannot stop a second machine
Which managerState and DEEPBOOK_BALANCE_MANAGER_ID must agree. If they disagree, every script refuses. Missing state next to a configured manager is refused unless ADOPT_MANAGER=1, which starts a new run with fresh budgets
StateVersioned and identity-bound: owner, network, pool, and live or dry mode. Written atomically. A corrupt or unwritable file stops the loop; it is never silently reset
A send whose result is unknownIntent, gas reservation and client order ids are saved before signing. An unknown outcome blocks quoting and is never resent
RestartFills are backfilled from the saved cursor before quoting resumes. Failed scans leave dedup unchanged so retries do not lose fills. FILL_SKIP_GAP=1 skips the backfill and marks the run’s turnover and P&L incomplete
Deposits during an open runAmounts are saved before signing. Successful receipts adjust the drawdown baseline once, including after recovery; failed receipts add no transfer. Quoting waits for pending deposit receipts
GasConsumed gas only goes up: rebates never add capacity. Reservations are held until each receipt is counted. Cleanup has its own allowance beyond the cap

Results

Sui mainnet, DEEP/SUI, WaaP Standard mode, waap-cli 2.2.0, one account and one BalanceManager throughout. Net gas is computation plus storage minus storage rebates, summed over every transaction the run sent, deposit and cleanup included.

RunCodeLengthFillsSpreadNet gasSpread − gas
28 Sep, 14:51 UTCrevised maker, 065e90265 min1 bid + 1 ask, 50 DEEP each+0.0040 SUI0.0206 SUI−0.0166 SUI
28 Sep, 09:39 UTCrevised maker, cecc96a50 min1 bid, 50 DEEPnone (no sell)0.0015 SUI−0.0015 SUI, plus 50 DEEP held
27 Sepprevious version, f6bfee368 min (quoting stopped at tick 26)4−0.0007 SUI0.0110 SUI−0.0117 SUI, plus 17 DEEP held
25 Sepprevious version6 h 55 m40 (20 + 20)−0.0316 SUI0.1301 SUI−0.1617 SUI
  • The 65-minute run completed one cycle: our bid was taken at 0.01873 (397dDA4Y…) and our ask at 0.01881 (3HqcEsfr…), both by an outside sender with our orders resting as maker. 17 transactions (the deposit, 15 requotes and the cleanup), none failed. 12 of its 15 requotes were the maker pausing and resuming as the touch spread crossed the TOUCH_MULTIPLE limit, which is where most of the gas went. Four ticks could not read the chain while nothing was resting. Cleanup was confirmed on chain (CfxyhRfj…).
  • The 50-minute run had one send whose waap-cli call exited with an error; its cause is not known. The loop held it as unknown and sent nothing more, and the account’s history showed it was not executed. Cleanup was confirmed on chain.
  • The previous version quoted a fixed spread at a fixed size, with no band or cost gate. Its runs measured the gas per transaction shape that the cost table now uses.
  • Wallet over the 28 September afternoon, 14:10 to 16:05 UTC: 2.896094463 → 2.881038567 SUI, DEEP unchanged at 68, a change of −0.015055896 SUI. That is the 65-minute run’s −0.016614636 plus +0.001558740 from an earlier attempt that was stopped before it quoted, whose cleanup rebate exceeded its deposit gas.

Check it yourself. The validation record  has every run’s digests, ledgers and grade. Its evidence manifest  lists each wallet transaction in that window, and SUI_RPC=https://rpc-mainnet.suiscan.xyz node evidence/check-ledger.mjs re-derives the 28 September totals from public chain data. The script’s default endpoint no longer serves that history, so keep the SUI_RPC override.

Changed since, not yet run live. The linked code adds pause and resume hysteresis on the touch rule (a replay of the 65-minute run’s logged book gives 8 requotes instead of 15, which would still have been about −0.006 SUI net and assumes no further fills), a larger fill-history budget on restart, and an HTTP connection pool that is replaced on endpoint failure. It also makes manager setup fail unless an id is saved and lets a readable failed creation receipt clear the pending operation. Further fixes preserve fills when a scan fails midway, keep cleanup unsuccessful while operations remain pending, and apply deposit adjustments only after successful receipts, including after recovery. These changes are covered by offline tests, not a new mainnet run.

Not verified

  • The changes in the linked code on mainnet. Pause hysteresis, the larger fill budget, the connection pool, manager-setup recovery, fill-scan retries, pending-operation cleanup and deposit accounting are tested offline only.
  • 24 hours unattended. The longest run, of the previous version, is 6 h 55 m.
  • Every transaction succeeding. The 25 and 27 September runs had requotes fail on chain with InsufficientGas on a WaaP-set gas budget with waap-cli 2.2.0.
  • Spread covering gas. Not observed in any run above. The gate checks estimated cost per cycle; whether cycles complete depends on fills.
  • How often orders fill. How often DEEP/SUI takers reach a quote at a given distance from the touch is a market fact that no replay of price prints can supply.
  • A policy ask from a live run. The one probe sent a 999 SUI deposit from a wallet holding under 1 SUI. The transaction was rejected at preparation for insufficient balance, before the Policy Engine saw it, so no approval request was sent. Account policy is described below as documented, not as observed in this run.
  • WaaP Squid Mode. Every run of this recipe used WaaP Standard mode. Running it on a Squid Mode account has not been tested.

Known issues

  • Occasional InsufficientGas (waap-cli 2.2.0). In Standard mode waap-cli send-tx takes the transaction kind, and WaaP’s preparation step sets the gas budget just above the estimate; 2.2.0 offers no way to set it. When state changes before execution, a requote can fail on chain. In runs of the previous version this happened to 4 of 134 requotes on 25 September and 2 of 11 on 27 September, at about 0.00018 SUI each; the next tick succeeded. None of the 17 transactions in the 65-minute run on 28 September failed this way. The gas budget counts these failures.
  • Fills are not predicted. The cost gate says whether a cycle would cover its estimated cost. It does not say whether one will happen.
  • Fees in the input token. On a fee-bearing pool, non-DEEP maker fees are taken as the order’s input asset (quote for a bid, base for an ask). DEEP/SUI is whitelisted at zero fee, so that path is covered only by synthetic tests.
  • RPC endpoints. Mysten’s public fullnode no longer serves the JSON-RPC methods the SDK uses. The default is publicnode, with suiscan as the fallback. Check that any endpoint you add answers before a long run.
  • The lock is per machine. It stops a second loop on the same host. It cannot stop a loop started on another machine against the same account and pool.

Security: enclave signing and account policy

Every transaction goes out through waap-cli send-tx. In WaaP Standard mode the signing key is held inside WaaP’s enclave, so the agent process never has access to a key at all. Each transaction is evaluated by the Policy Engine against your account policy before it is signed. This recipe runs on your ordinary account policy and configures no Privilege.

# The point at which the day's total requires your approval. # This is a threshold, not a cap. See the note below. waap-cli policy set --daily-spend-limit 2500
LayerWhat it does
Enclave signing (WaaP Standard mode)The key is held inside the enclave; the agent process has no key and cannot sign on its own
Daily spend limitThe point at which the day’s aggregate spend starts requiring your approval. Not a cap: an approved transaction may take the total past it
Limits in the agentBacked sizes, the inventory band, the gas cap, the drawdown limit, turnover and run length. They bound what this process does; they are code you run, not a policy WaaP enforces
No .env secretsNo raw private key in environment variables or code

Why this recipe uses no Privilege. A Privilege is advance approval for one job, scoped to a chain, targets, an amount and an expiry of at most two hours. A market maker is meant to run for days, so a Privilege would lapse mid-run. When one lapses WaaP does not refuse the transaction: it evaluates it under your ordinary account policy, which may still sign it. This recipe uses ordinary account policy and configures no Privilege.

Account policy is a threshold, not a sandbox. The daily limit is the point at which approval is required, not a cap, and staying under it does not by itself guarantee a transaction is signed. Use a separate account, funded with only what you want working on the book, when you need a hard spending boundary.

BalanceManager custody. DeepBook separates trading, deposit and withdrawal authority into capability objects (mintTradeCap, mintDepositCap, mintWithdrawalCap). Trade authority could sit with the agent’s account while withdrawal stays with a different owner. This recipe does not split them yet; the agent’s account owns the manager outright.


What’s next

  • Replace the strategy. planStrategy() is one pure function with a typed input and plan. Its input has the book, pool parameters, fees, balances and resting orders; it does not carry fill history or cost basis, so a strategy that needs those has to extend the input. Keep the transaction building, sending and cleanup as they are.
  • Choose your own size and limits. The worked example shows the trade-off: a larger order lowers the spread the gate needs and raises the capital and inventory at risk.
  • Split custody. Hand the agent a trade capability and keep withdrawal with a separate owner.
  • Alerts. Pipe cleanup_failed, send_outcome_unknown and fill lines to a Telegram channel.
Last updated on