The accounting system is designed around a Single Source of Truth principle with Optimistic Execution. It prevents double-spending while maximizing capital efficiency by treating pending proceeds as immediately available ("Optimistic ChainFree").
Naming convention: Capitalized prose names below (e.g.
ChainFree,Virtual,Committed) refer to the abstract fund component. The corresponding code identifier is shown in the Code Reference column — most are scoped per side, e.g.ChainFreeresolves toaccountTotals.buyFreeon the buy side andaccountTotals.sellFreeon the sell side.
| Component | Code Reference | Definition & Ownership |
|---|---|---|
| ChainFree | accountTotals.buyFree |
Liquid Capital. The unallocated balance on the blockchain. Balanced: Deducted pre-emptively on fills to offset state release. |
| Virtual | funds.virtual |
Planned Capital. Sum of sizes for orders in VIRTUAL state. Purpose: Prevents ChainFree from being re-spent on overlapping grid layers. |
| Committed (Chain) | funds.committed.chain |
Locked Capital. Sum of sizes for ACTIVE + PARTIAL orders (including those without orderId yet). Source: Real-time grid state + on-chain orders. |
| Committed (Grid) | funds.committed.grid |
Strategy Capital. Alias for committed.chain in the current engine. |
| Allocated | funds.allocated.{buy,sell} |
Capital Budget. The bot's configured share of total capital per side. Source: applyBotFundsAllocation() applies botFunds config (percentage or absolute) against chainTotal (free + committed). Purpose: All order sizing ( getSideBudget, _getSizingContext) reads from Allocated, not raw ChainFree, ensuring the bot never exceeds its configured capital share. |
| FeesOwed | funds.btsFeesOwed |
Liability. Accumulated blockchain fees (BTS) that must be settled. |
| FeesReservation | btsFeesReservation |
Safety Buffer. Reserved BTS to ensure future grid operations (creation/cancellation) don't fail. |
This formula determines the bot's spending power. It is calculated atomically in math.ts::calculateAvailableFundsValue.
Critical Invariants:
- Virtual represents Plan. Orders remain in
Virtualonly while they are truly uncommitted. As soon as they move toACTIVE, they move toCommitted(Chain), even if the blockchain transaction is still in flight. This maintains theTotal = Free + Committedinvariant. - Available Funds = True Spending Power. This formula is the single source of truth for how much capital can be deployed immediately.
- Non-BTS pair reservation. When neither asset is BTS, the formula adds an extra proportional deduction: any BTS fee-budget deficit (formula budget minus
funds.btsBalance.free) is split acrossbuyFree+sellFreeproportional to each side's free balance, and the side's share is subtracted fromAvailable. SeecalculateAvailableFundsValue()inmodules/order/utils/math.ts(lines 407–425).
The raw Available from §1.2 is the bot's immediate spending power, but order sizing does not use it directly. A separate allocation pipeline applies the botFunds cap to produce funds.allocated, which is the budget source for all grid operations.
Pipeline:
accountTotals.buyFree / sellFree (raw chain free)
+ funds.committed.chain (+ what's locked in orders)
→ computeChainFundTotals()
→ chainTotalBuy / chainTotalSell (total capital per side)
chainTotal × botFunds% (apply botFunds cap)
→ resolveConfigValueWithRegistry()
→ funds.allocated.{buy,sell} (bot's capital budget)
funds.allocated → getSideBudget() (budget for target grid sizing)
funds.allocated → _getSizingContext() (budget for spread correction)
Key points:
botFundspercentage applies to total capital (free + locked in orders), not just free. A bot at"50%"gets half of everything, not half of what's currently idle.funds.allocatedis the ceiling for each side. Existing orders already consume part of it; the remaining free portion is available for new placements.- The downstream
applyBotFundsAllocation()(manager.ts:949) also capsfunds.availableto<= allocatedas a safety net, but the primary budget chokepoint isgetSideBudget/_getSizingContextreadingallocateddirectly (v1.2.6).
Mixed BUY/SELL batches are validated per asset using a signed-delta peak running requirement (not a side lump sum), so BUY and SELL ops in the same batch are checked independently against their own free balance.
- BUY orders sell quote asset (assetB), so they are validated against
accountTotals.buyFree— the unallocated assetB available for limit orders. - SELL orders sell base asset (assetA), so they are validated against
accountTotals.sellFree— the unallocated assetA available for limit orders.
File: modules/dexbot_cow_runtime.ts — validateOperationFunds() (line 1488), called from the COW batch broadcast path at line 2908. modules/dexbot_class.ts exposes a thin wrapper _validateOperationFunds() (line 1122).
// Per-asset peak requirement vs. quantized chain-free snapshot.
// Updates consume a signed delta (size delta); creates consume the full amount.
for (const op of operations) {
// ... resolve sellAssetId / sellAmountInt from op_data ...
netRequiredFunds[sellAssetId] += signedDelta; // net after releases
runningRequiredFunds[sellAssetId] += signedDelta; // running watermark
peakRequiredFunds[sellAssetId] = max(peak, running); // high-water mark
}
const availableFunds = {
[assetA.id]: quantizeFloat(snap.chainFreeSell, assetA.precision),
[assetB.id]: quantizeFloat(snap.chainFreeBuy, assetB.precision)
};
// Precision-aware comparison: int-cast both sides before comparing.
if (floatToBlockchainInt(peak, prec) > floatToBlockchainInt(available, prec)) {
fundViolations.push({ asset, required: peak, netRequired, available, deficit });
}- Each asset validated independently against its own free balance (
buyFreefor assetB,sellFreefor assetA). - Peak-tracking catches interleaved create+update ops whose net is affordable but whose intermediate watermark is not.
- No double-counting when BUY and SELL orders are placed in the same batch — they draw from disjoint asset pools.
- Quantized comparison (
floatToBlockchainInt) eliminates float-accumulation false positives.
For checking order types and states, use centralized helpers from modules/order/utils/order.ts:
isOrderOnChain(order)- Check if ACTIVE or PARTIALisOrderPlaced(order)- Check if safely placed (on-chain with ID)isOrderVirtual(order)- Check if VIRTUAL state
See developer_guide.md#order-state-helper-functions for complete helper function reference.
Mechanism: Fill events arrive via modules/dexbot_fill_runtime.ts (the fill-runtime module), which pushes them into bot._incomingFillQueue (declared in modules/dexbot_class.ts). The drain loop in dexbot_fill_runtime.ts then chunks the queue into capped batches and calls modules/order/manager.ts::processFilledOrders (line 1438) once per chunk to run the full rebalance pipeline.
Batch Sizing Algorithm: Batch size is derived from the grid gap-slot count (DEXBot._getGapSlotBatchSize): a queue depth at or below gapSlots is processed as one unified batch; deeper queues are chunked into repeated batches of gapSlots (the last chunk may be smaller). The same gap-slot size caps order operations per broadcast transaction (oversized op batches are split into sequential broadcasts).
Configuration: no fixed constant — both FILL_PROCESSING.MAX_FILL_BATCH_SIZE and COW_PERFORMANCE.MAX_OPS_PER_BROADCAST were removed; batch sizing follows the grid gap-slot count.
Per-Batch Execution:
- Peek & Pop: Check
_incomingFillQueue, pop up to N fills (batch size) - Replay-safe Accounting Pass: Each fill is accounted individually via
processFillAccounting(fillOp, fillKey)with replay-safe dedup (applyReplaySafeFillAccounting,dexbot_fill_runtime.ts:227)- Proceeds credited directly to
chainFree(viaadjustTotalBalance) - Same-order fill batching (sync_engine Phase 6 cumulative transition) aggregates multiple fill transitions on one order before a single rebalance
- All proceeds immediately available to next rebalance cycle (not split across cycles)
- Proceeds credited directly to
- Single Target Calculation: Call
calculateTargetGrid()once- Sizes replacement orders using combined proceeds
- Applies rotations and boundary shifts
- Batch Broadcast: Call
updateOrdersOnChainBatch()once- All new orders + cancellations in single operation
- Persist: Call
persistGrid()to save grid state - Loop: Continue with next batch (or idle if queue empty)
Result: 29 fills now processed in ~8 broadcasts (~24s) instead of 29 broadcasts (~90s).
Residual Dust Cancellation (post-1.4.12): After a sub-dust fill leaves a residual order on chain (e.g. the quote-side value truncates to 0 on bitshares-core maybe_cull_small_order), the fill runtime explicitly cancels those residuals via cancelResidualOrders() (dexbot_fill_runtime.ts:74, [RESIDUAL] tag) so a leftover of ≥1 base unit cannot be re-adopted into a grid slot by maintenance.
The grid regenerates when accumulated proceeds create a significant funding imbalance — in either direction. This is detected using the Available Funds Ratio (grow leg) plus an over-allocation leg (shrink leg), all sharing GRID_REGENERATION_PERCENTAGE (default: 3%):
GROW: ratio = (availableFunds / allocatedCapital) * 100
IF ratio >= GRID_REGENERATION_PERCENTAGE (default: 3%):
→ Trigger grid regeneration (deploy proceeds)
SHRINK: overAlloc = (gridTracked - allocatedCapital) / allocatedCapital * 100
(`gridTracked` = funds.total.grid: ACTIVE + PARTIAL + VIRTUAL planned size)
IF overAlloc >= threshold:
→ Trigger grid regeneration (resize affected orders down)
Deliberately no per-side chain-total-drop leg: a normal fill moves value across sides (pays one asset, receives the other — see recordFillBalances), so one side's total routinely drops ≥3% on ordinary fills, and the fill pipeline already re-sizes from the post-fill budget.
How It Works (grow):
- Fill occurs → proceeds added to
chainFree calculateAvailableFundsValue()computes true spending power (chainFree minus reservations)- Grid divergence check compares this ratio against allocated capital in active orders
- If ratio exceeds 3%, the grid has accumulated enough proceeds to warrant redeployment
- Grid regeneration recalculates all order sizes and applies new placements
How It Works (shrink):
- External removal (manual transfer/withdrawal) →
chainTotaldrops →funds.allocated(chainTotal ×botFunds%) drops while the grid-tracked size stays put - Divergence check flags the side when grid-tracked size exceeds the allocation by ≥ 3% (slow bleeds accumulate across ticks: the grid stays fixed while the allocation sinks)
- The same regeneration path runs:
_recalculateGridOrderSizesFromBlockchainrecomputes geometric ideals on the smaller budget and queuesUPDATEactions shrinking affected on-chain orders (delta < 0releases the difference back to free balance on chain). Fork-kept shelf orders (live non-slot-N ids below the rail) are skipped by the per-slot loop — their manual sizes survive the resize while still counting in the budget denominator. - Under-deployed grids (grid-tracked size still within the shrunken allocation) correctly do NOT trigger — their orders remain fully funded
Recovery uses a count+time-based retry system with periodic reset, so a single failed recovery attempt never locks out future retries.
State Machine:
INITIAL (count=0, time=0)
↓
RECOVERY_FAILED (count++, time=now) ← Recovery attempted but failed
↓ (wait 60s)
READY_RETRY (count < 5 and time_elapsed ≥ 60s) ← Time passed, can retry
↓
RECOVERY_ATTEMPTED (increment count) ← Attempt retry
↓ (on fail) ← Success not yet
↓ ← Loops back to RECOVERY_FAILED
↓ (on success)
RESET via resetRecoveryState() ← Recovery succeeded, reset for next episode
Configuration (modules/constants.ts):
PIPELINE_TIMING: {
RECOVERY_RETRY_INTERVAL_MS: 60000, // Min 60s between retry attempts
MAX_RECOVERY_ATTEMPTS: 5 // Max 5 retries per episode (0 = unlimited)
}Reset Points (Called by resetRecoveryState() in modules/order/accounting.ts):
- Fill-triggered: Every fill in
processFilledOrders()resets recovery state - Periodic: Blockchain fetch loop resets state every 10 minutes (even if no fills)
- Bootstrap completion: After grid initialization
Impact:
- ✅ If recovery fails, bot retries every 60s instead of requiring manual restart
- ✅ Self-heals within minutes after market settles
- ✅ No permanent lockup from single failure
When a batch fails because an on-chain order no longer exists, the cleanup releases the local slot — but a delayed orphan-fill event can still arrive and re-credit the proceeds, double-counting capital. The bot tracks stale-cleaned order IDs in _staleCleanedOrderIds and skips crediting any fill whose order is still in that map. The full mechanism, data structure, and TTL rules are documented in §3.6 Orphan-Fill Deduplication & Double-Credit Prevention.
When grid resize is capped by available funds, accounting tracks what portion of the ideal grid went unallocated via per-slot tracking:
- Fully allocated slots: receive their ideal size (no remainder)
- Fund-capped slots: receive less than ideal because available funds ran out mid-allocation
Computing the remainder from totals instead overstates it when some slots are fully allocated and others are capped.
// Track per-slot applied sizes
const appliedSizes = [];
for (const slot of slots) {
const appliedSize = min(idealSize[slot], availableFundsRemaining);
appliedSizes.push(appliedSize);
availableFundsRemaining -= appliedSize;
}
// Compute unallocated remainder from actual allocated values
const remainder = totalIdealSizes - sum(appliedSizes);
// Result: Reflects true remaining capacity for next cycleImpact:
- ✅ Remainder accurately reflects what was NOT allocated due to fund caps
- ✅ Next rebalance cycle gets correct available fund picture
- ✅ No skewed sizing decisions
The grid is a unified array ("Master Rail") of price levels, not separate Buy/Sell arrays.
Order sizes are calculated using a geometric progression to distribute risk.
Inputs:
-
$N$ : Number of orders -
$Total$ : Total budget for side -
$w$ : Weight Distribution parameter (-1to2) -
$inc$ : Increment factor (incrementPercent / 100)
Base Factor:
Raw Weight (
Orientation:
-
SELL Side: Normal indexing (
$i=0$ is market-closest). -
BUY Side: Reversed indexing (
$i=N-1$ is market-closest) to ensure heaviest weights are always near the spread.
Final Size (
The grid is divided into zones by a dynamic Boundary Index.
-
Gap Size (
$G$ ): Calculated fromtargetSpreadPercentandincrementPercent.$$G = \lceil \frac{\ln(1 + \text{targetSpread}/100)}{\ln(1 + \text{increment}/100)} \rceil - 1$$ (Min capped atMIN_SPREAD_ORDERS, usually 2. The $-1$ accounts for the naturally occurring center gap during grid centering) -
Zones:
-
BUY: Indices
$[0, \text{boundaryIdx}]$ -
SPREAD: Indices
$[\text{boundaryIdx}+1, \text{boundaryIdx}+G]$ (Total of$G+1$ actual gaps) -
SELL: Indices
$[\text{boundaryIdx}+G+1, N]$
-
BUY: Indices
The rebalancing logic (strategy.ts::calculateTargetGrid) computes the target "Crawl" state.
When a fill occurs, the boundary shifts to "follow" the price.
-
BUY Fill: Market moved down
$\to$ boundaryIdx--(Shift Left). -
SELL Fill: Market moved up
$\to$ boundaryIdx++(Shift Right).
Budgets are dynamic. The bot calculates TotalSideBudget from funds.allocated.{buy,sell} (the botFunds-capped capital per side — see §1.3). This ensures the bot never attempts to deploy more than its configured share of account capital, even when the account holds additional free balance for other bots or manual trading.
Safety Check:
If the calculated ideal grid requires more capital than available in the allocation, the increase is capped.
During fill batch rebalancing, the unallocated remainder (amount NOT allocated due to fund caps) affects available funds for the next cycle:
Remainder Calculation:
- Old: Computed from ideal sizes even when resize was capped
- New: Tracked per-slot, derived from actual allocated values
Effect on Side Capping Formula:
// In next rebalance cycle:
availableFunds = chainFree - virtual - feesOwed - feesReservation
sideIncrease = min(idealSide - currentSide, availableFunds)
// When batch capping applied in previous cycle:
// availableFunds now correctly reflects the unfulfilled allocation gapExample:
Cycle N (Batch Processing):
- Ideal grid total: 1000 BTS
- Available funds: 600 BTS
- Allocate: 600 BTS (per-slot tracking)
- Unallocated remainder: 400 BTS (1000 - 600)
Cycle N+1:
- Unallocated remainder (400 BTS) available for next allocation
- Prevents "stuck fund" situations where capital appeared allocated but wasn't
Impact:
- ✅ Accurate available fund calculations for next rebalance
- ✅ No overstated fund capping in subsequent cycles
- ✅ Smooth rebalancing when market moves expand/contract positions
Rotations move capital from "Surplus" (useless) to "Shortage" (needed).
- Identify Shortages: Empty slots inside the active window (near boundary).
- Identify Surpluses: Active orders outside the window (far edges).
-
Sort:
- Shortages: Closest to market first.
- Surpluses: Furthest from market first.
-
Execute:
For each pair (Surplus
$S$ , Shortage$T$ ):-
Atomic Transition:
-
$S$ state:ACTIVE$\to$ VIRTUAL(size 0, releases funds). -
$T$ state:VIRTUAL(size$S_{size}$ , reserves funds).
-
-
Fund Calculation:
- The released funds from
$S$ are immediately added toChainFree. - The reserved funds for
$T$ are immediately subtracted (added toVirtual).
- The released funds from
-
Atomic Transition:
Change: Prioritize furthest-from-market surpluses (lowest Buy / highest Sell) for rotations.
Reason: Improves execution robustness by using stable edge orders for rotations and leaving volatile inner surpluses to potentially catch "surplus fills" during grid shifts.
Impact:
- ✅ More stable rotation candidates (outer orders less likely to be filled mid-operation)
- ✅ Inner surpluses remain available for spontaneous fill opportunities
- ✅ Reduces unnecessary churn on volatile price action
Change: Explicitly detect and cancel "victim" dust orders when a rotation targets an occupied slot.
Reason: Maintains 1-to-1 mapping between grid slots and blockchain orders in the Edge-First system, preventing "ghost" capital on-chain.
Implementation:
// If rotation target slot has an order (victim), cancel it first
if (targetSlot.orderId) {
scheduleCancel(targetSlot);
targetSlot.state = VIRTUAL; // Prepare slot for new order
}
// Then place new order at target
targetSlot.state = ACTIVE;
targetSlot.orderId = newOrderId;Impact:
- ✅ Prevents "ghost" capital lingering on-chain
- ✅ Ensures grid slot ↔ blockchain order 1-to-1 mapping
- ✅ No orphaned capital in rotation operations
Location: modules/dexbot_class.ts — constructor, _recoverExplicitStaleOrders() (line 448); orphan-fill guard in the fill drain loop and pruning pass after each cycle now live in modules/dexbot_fill_runtime.ts (guard ~lines 628-646, pruning ~lines 934-946).
Mechanism: Track which orders were cleaned up during batch failure recovery using timestamp + grid-slot retention.
Data Structure (modules/dexbot_class.ts):
// Map of orderId → { markedAt: number, gridId: string | null }
_staleCleanedOrderIds = new Map();
// Retention window (set in the constructor):
_staleCleanupRetentionMs = Math.max(_fillDedupeWindowMs, 5 * 60 * 1000); // ≥5 minutesCleanup Process (in _recoverExplicitStaleOrders()):
1. Parse error message for stale order IDs (e.g., "Limit order 12345 does not exist")
2. For each stale ID matching a grid slot:
- Virtualize the grid slot (state → VIRTUAL, size 0)
- Record: _staleCleanedOrderIds.set(orderId, { markedAt: Date.now(), gridId })
3. For stale IDs with no matching grid slot:
- Record: _staleCleanedOrderIds.set(orderId, { markedAt: Date.now(), gridId: null })Orphan-Fill Handler Check (in the drain loop):
const entry = _staleCleanedOrderIds.get(orderId);
if (entry) {
const ageMs = Date.now() - entry.markedAt;
if (ageMs <= _staleCleanupRetentionMs) {
// Within retention: skip credit entirely (funds already freed)
continue;
}
if (entry.gridId) {
const currentOrder = manager.orders.get(entry.gridId);
if (currentOrder?.orderId && currentOrder.orderId !== orderId) {
// Slot recycled: funds already redeployed — skip credit
continue;
}
}
// Expired and slot not recycled: drop the tombstone and credit as normal orphan
_staleCleanedOrderIds.delete(orderId);
}
// Credit proceeds only if NOT protected above
await adjustTotalBalance(orderType, proceeds, `orphan-fill-${orderId}`);- Delayed Orphans: Fill events can arrive minutes after batch failure (network latency); the retention window covers the dedupe interval.
- Recycled-Slot Tombstones: Entries with a
gridIdare kept indefinitely as tombstones — a fill arriving after TTL is still skipped if the slot has been redeployed, preventing a late orphan from double-counting freed-and-redeployed capital. - Bounded Entry Pruning: Entries without a
gridId(no slot to check) are pruned onceageMs > _staleCleanupRetentionMs, so the map stays bounded. Pruning runs after each fill-processing cycle, not on a fixed timer. - ID-Based: Works with any error format (different BitShares versions have different error messages).
- Explicit Logging:
[ORPHAN-FILL] Skipping double-credit/slot recycled/Pruned N expiredmessages create an audit trail.
The available funds are verified at allocation time:
- Proceeds are only added to
chainFreewhen confirmed on blockchain - Stale-cleaned orders don't consume allocation funds
- Next cycle sees accurate available funds for sizing decisions
- ✅ Eliminates double-counting root cause that fed 47,842 BTS drift
- ✅ Handles network-latent orphan events (not just immediate fills)
- ✅ No fund corruption from delayed fill events after batch failure
- ✅ Production stability after market crashes and stale order cascades
When a grid is regenerated or resized, existing partial orders (partially filled orders) may remain on-chain. Rather than employing complex merge/split mechanics, the system uses a direct consolidation approach focused on fund efficiency and spreading simplicity.
A partial order is classified as Dust if:
Dust orders are too small to be efficient on-chain and are marked for consolidation into the grid rebuild cycle.
When the strategy engine encounters partial orders during rebalancing:
Direct Approach (Simplified):
- Identify unhealthy partials: Detect any partial orders below the 5% dust threshold on each side
- Mark for consolidation: Flag partials as needing attention in the next rebalance cycle
- Fund-driven grid rebuild: Rather than complex slot-by-slot merge/split logic, the entire grid is regenerated based on current total funds (including proceeds from fills)
- Natural redistribution: The rebuilt grid automatically sizes all orders (including those replacing consolidation candidates) using the Ideal Grid sizing formula
- Spread maintenance: The target spread gap remains constant at
targetSpreadPercent—no dynamically inflated corrections
Why This Works:
- Simpler code path: No merge vs. split decision logic
- Fund-safe: Rebuild uses only available funds; orders that can't be sized are skipped
- Constant spread: The spread gap size stays fixed, improving predictability
- Minimal blockchain interaction: Grid regeneration happens once per consolidation event (not per partial)
When consolidating partials:
- Proceeds become available: Fill proceeds from the partial are added to
chainFree - Grid regenerates once: A single rebalance cycle recalculates all order sizes based on total funds
- Partial slot replaced naturally: The new ideal grid may place a fresh order at the partial's price, or skip it if insufficient funds
- No special "doubling" flags: All slots are treated uniformly—no side-specific bonuses or penalties
Boundary Behavior:
- The boundary index shifts with each fill (as before) to follow market movement
- Grid slots are reassigned based on the new boundary and available funds
- No additional spread-widening corrections triggered by partial consolidation
Fund Consumption: Only the net sizing operations consume funds. Since partials are absorbed into the grid rebuild, fund impact is purely from the new order placements in the regenerated grid.
The bot manages two types of fees: Blockchain Fees (BTS) and Market Fees (Asset deduction).
BitShares charges fees for limit_order_create and limit_order_cancel.
-
Reservation (
BTS_RESERVATION_MULTIPLIERinconstants.ts::FEE_PARAMETERS):$$Reserve = N_{active} \times BTS_RESERVATION_MULTIPLIER$$ (Default: 5× per order — covers create, rotate (cancel+place), update, and cancel over the order's lifetime. $N_{active}$ counts window orders plus thereserveOrdersedge ladder — reserves rest live on-chain and pay fees like window orders) -
Settlement (
deductBtsFees):- Check
Funds.btsFeesOwed. - If sufficient
chainFreeavailable: deduct full amount atomically. - If insufficient: defer settlement and retry when funds become available.
- Check
-
Adoption fee parity: COW chain-adoption paths charge fees exactly like the normal open-orders loop.
adoptPlacedBatchFromChain(refused-commit and poll-confirmed paths) and the startup uncertain-create adoption apply the create/cancel/update fees via_applySync. -
Safe fee lookup:
processBatchResultsusesgetAssetFeesSafe('BTS')with zero-fee fallbacks — the throwing variant can no longer hard-fail a whole batch after a successful commit (modules/dexbot_cow_runtime.ts).
These are deducted from the proceeds of a fill.
- Maker (Limit Orders): Typically lower fee (e.g., 0.1%).
- Rebate: On BitShares, Makers often get a fee rebate on cancellation (vesting).
- Taker (Market Orders): Typically higher fee.
- Calculation (
processFilledOrders):GrossProceeds = Size * Price NetProceeds = GrossProceeds - (GrossProceeds * FeePercent)
For BTS fees, the system returns a structured object (not a simple number) with multiple fields for accounting precision.
Location: modules/order/utils/math.ts::getAssetFees() (line 312). The fee cache itself is populated by modules/order/utils/system.ts::initializeFeeCache() (line 665).
getAssetFees('BTS', amount, isMaker=true)
// Returns (maker example, amount=45000, orderCreationFee=500, MAKER_REFUND_PERCENT=0.9):
{
netProceeds: 45450, // amount + refund = 45000 + 450
total: 45450, // aliased to netProceeds for downstream use
refund: 450, // orderCreationFee * MAKER_REFUND_PERCENT = 500 * 0.9
isMaker: true // Flag: is this a maker fee?
}For Makers (isMaker = true, gets MAKER_REFUND_PERCENT of orderCreationFee back):
netProceeds = assetAmount + (orderCreationFee * MAKER_REFUND_PERCENT)
// Example: 45,000 asset + (500 fee * 0.9 refund) = 45,450
For Takers (isMaker = false, no rebate):
netProceeds = assetAmount
// Example: 45,000 asset (no refund) = 45,000
Without an amount, non-BTS assets return a small percent descriptor. With an amount, they return the same { netProceeds, total, ... } shape as BTS (but with feeAmount instead of refund):
getAssetFees('IOB.XRP', 1000)
// Returns (assumes 0.1% maker market fee, maxMarketFee not binding):
// { netProceeds: 999, total: 999, feeAmount: 1, feePercent: 0.1, isMaker: true }
getAssetFees('USD') // no amount → percent descriptor only
// Returns: { marketFee: <cached>, takerFee: <cached>, percent: <resolved> }Code can safely detect the shape:
// With an amount, both BTS and non-BTS return an object carrying netProceeds.
if (typeof feeInfo === 'object' && feeInfo !== null) {
const proceeds = feeInfo.netProceeds; // works for BTS and non-BTS
} else {
// Percent-descriptor path (no amount supplied): use feeInfo.percent
const feePercent = feeInfo.percent ?? 0;
}
// Legacy fields on the BTS no-amount descriptor (still present):
const createFee = feeInfo.createFee; // BTS only| Side | Asset | Calculation | Notes |
|---|---|---|---|
| BUY | Quote (assetB) | Fee deducted from buyFree |
Buyers pay in quote currency |
| SELL | Base (assetA) | Fee deducted from sellFree |
Sellers pay in base currency |
Trading pair: XRP (base) / USD (quote)
BUY Order Fills:
- Receives: 1000 XRP
- Pays: 45,000 USD
- Fee: 500 USD (0.1% of 45,500 total)
- Net proceeds: 45,000 USD (quoted asset reduced by fee)
SELL Order Fills:
- Receives: 45,000 USD
- Pays: 1000 XRP
- Fee: 1 XRP (0.1% of 1000 total)
- Net proceeds: 999 XRP (base asset reduced by fee)
For BUY orders that are makers:
// Market fill amount: 45,500 USD worth
// Maker fee: 500 USD (0.1%)
// Maker refund: 90% of 500 = 450 USD back
// Net proceeds to chainFree:
// - Deposit: 45,500 USD (market received)
// - Fee paid: -500 USD
// - Refund received: +450 USD
// - Final: 45,450 USD credited to buyFreeImpact: Ensures internal ledgers match blockchain totals exactly, preventing accounting drift from fee variances.
Floating-point arithmetic drifts from true blockchain integer representations over many order-size calculations, price derivations, and fund allocations. Quantization eliminates this accumulation by round-tripping every value through its blockchain integer form.
Location: modules/order/utils/math.ts (line 265)
Converts float → blockchain int → float to "snap" values to precision boundaries.
/**
* Quantize a float value by round-tripping through blockchain integer representation.
* Converts float → blockchain int (satoshi-level precision) → float.
* Eliminates floating-point accumulation errors.
*
* @param {number} value - Float value to quantize (e.g., 45.123456789)
* @param {number} precision - Asset precision (e.g., 8 for satoshis)
* @returns {number} Quantized float value (e.g., 45.12345679)
*/
function quantizeFloat(value, precision) {
return blockchainToFloat(floatToBlockchainInt(value, precision), precision);
}
// Example:
// Input: 45.123456789 (accumulated float error)
// Step 1: Float → Int: 45.123456789 * 10^8 = 4512345678.9 → rounds to 4512345679
// Step 2: Int → Float: 4512345679 / 10^8 = 45.12345679 (corrected!)Use Cases:
- After fund allocation calculations (prevent 0.000000001 drift)
- When rounding order sizes to blockchain precision
- Before storing prices for comparison operations
- After grid divergence calculations
Converts int → float → int to ensure the integer aligns with precision boundaries.
/**
* Normalize an integer value by round-tripping through float representation.
* Converts int → float (readable format) → blockchain int.
* Ensures the integer aligns with precision boundaries.
* Used for precision-aware comparisons.
*
* @param {number} value - Integer value (e.g., 4512345679)
* @param {number} precision - Asset precision
* @returns {number} Normalized integer value
*/
function normalizeInt(value, precision) {
return floatToBlockchainInt(blockchainToFloat(value, precision), precision);
}
// Example: Ensure consistency in size comparisons
const currentSizeInt = 4512345679;
const idealSizeInt = 4512345679;
const normalized = normalizeInt(currentSizeInt, 8);
// Returns normalized value for consistent == comparisonsUse Cases:
- Ensuring order sizes align to blockchain satoshi boundaries
- Normalizing fund totals before invariant checks
- Preparing sizes for blockchain transaction encoding
Quantization has a single source of truth: quantizeFloat() in modules/order/utils/math.ts.
✅ Consistent precision handling across all modules ✅ Reduced regression risk (tested once, used everywhere) ✅ No subtle float accumulation bugs from divergent rounding paths
| Scenario | Function | Example |
|---|---|---|
| Calculate order size | quantizeFloat() |
quantizeFloat(45.123456789, 8) → Snap to satoshi |
| Compare sizes | normalizeInt() |
Ensure both sides use same integer representation |
| Fund allocation | quantizeFloat() |
After geometric distribution, eliminate drift |
| Price derivation | quantizeFloat() |
Pool/market price calculations prone to float errors |
| Validate blockchain match | normalizeInt() |
Check: normalizeInt(internal) === normalizeInt(chain) |
The corrected fund validation in _validateOperationFunds() uses quantized values:
// Check: Does required amount fit in available balance?
const availableBalance = snap.chainFreeSell; // Quantized by accounting
const requiredAmount = quantizeFloat(totalRequired, precision); // Quantize for comparison
if (requiredAmount > availableBalance) {
// Reject batch before broadcasting
return { valid: false, reason: 'Insufficient funds' };
}This prevents the bug where available = chainFree + required created a tautology (required > chainFree + required always false). Quantized comparisons now accurately reflect blockchain constraints.
The Accountant enforces strict mathematical invariants to detect bugs or manual interference. Invariants are checked by _verifyFundInvariants() (modules/order/accounting.ts line 501) after every blockchain sync cycle. The verification reads from a snapshot captured under _fundLock — actualBuy/actualSell are captured at snapshot time, not read live outside the lock, closing a TOCTOU window. When a violation is detected, the system logs a CRITICAL error and attempts automatic recovery via manager.accountant.recalculateFunds() (modules/order/accounting.ts line 346, delegated from modules/order/manager.ts lines 981–990) — resetting internal state to match on-chain reality. If the grid lock is held (mid-rebalance), recovery is deferred until the lock is released. The bot continues operating throughout; it does not halt on invariant violations.
Total funds on chain must equal free plus committed.
This is the primary drift detector. A mismatch means the bot's internal ledger has diverged from blockchain reality — typically caused by a missed fill event, a double-credited orphan, or a fee deducted from the wrong side. Recovery resets accountTotals from the live blockchain balances.
Grid commitment cannot exceed total wealth.
A violation here means the grid has allocated more capital than actually exists on-chain. This can happen if an order was cancelled externally (outside the bot) or if a fill was processed but the commitment was never released. Recovery rebuilds committed totals by walking the current grid state.
To prevent "Time-of-Check to Time-of-Use" errors:
- Locking:
AsyncLock(re-entrant) prevents concurrent updates to the same order. Nestedacquire()from the same execution context runs the callback directly instead of queueing. - Atomic Deduct:
tryDeductFromChainFreechecks and subtracts in a single synchronous step. - Bootstrapping: Fills arriving during startup (
isBootstrapping=true) are queued until the grid is fully reconciled (GRID_RECONCILE.md).
Stale accountTotals does not abort COW commit. Transient staleness (e.g., the periodic balance fetch overlaps with a COW commit) logs a WARN and schedules recovery instead of throwing ACCOUNTING_COMMITMENT_FAILED. Totals are also refreshed after bootstrap to prevent a spurious full recovery on the first maintenance cycle.
Recovery verifies the fetch before trusting balances. State recovery checks that the chain read actually refreshed (_lastFetchedAt advanced) and defers the attempt otherwise, so drift is never re-measured against stale/optimistic numbers. When drift persists after sync, _recalibrateTrackedFundsFromChain rebuilds the tracked commitment from the fresh chain read (matched slots forced to chain size, fully-consumed and stale-absent slots virtualized under the sync orphan pass's recent-commit lag guards; free balances never derived from totals), then re-runs recalculateFunds and re-checks drift.
Fee-deduction failure logs at error. When getAssetFees throws during fill processing (e.g., network blip), _deductFeesFromProceeds skips the deduction and logs at error with explicit "fund tracking will over-credit" language so operators can detect the drift source in production logs.
TOCTOU protection in processFillAccounting. _buildBtsDeferredRefundAdjustment reads btsFeeState from mgr.orders while the order lock is held — the lock is acquired before accounting runs, and the POST-RESET and BOOTSTRAP tracked-fill accounting paths follow the same locking pattern.
Technical Reference for DEXBot2 v1.6.2 release