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. |
| 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).
Problem Fixed: Early batch builders ran a single fund check keyed off the first order's side, so a mixed BUY/SELL batch could trip a false "insufficient funds" warning even when each side had ample capital — the BUY check was applied to SELL orders (or vice versa).
Solution: Per-asset validation using a signed-delta water-mark. The current validator tracks the peak running requirement per asset (not a side lump sum), so BUY and SELL ops in the same batch are validated 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_class.ts — _validateOperationFunds() (line 3093), called from the COW batch broadcast path at line 4163.
// 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.
Previously, fills were processed one-at-a-time (~3s per broadcast). A burst of 29 fills in the Feb 7 market crash took ~90 seconds, during which:
- Market prices moved significantly
- Orders became stale (filled on-chain but not yet synced)
- Orphan fills were created (fill events for orders no longer on-chain)
- Fund tracking diverged from blockchain reality
Impact: The extended 90s window meant the bot couldn't react to market moves, creating a cascading failure.
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_class.ts then chunks the queue into capped batches and calls modules/order/manager.ts::processFilledOrders (line 1149) once per chunk to run the full rebalance pipeline.
Batch Sizing Algorithm: A single cap-based batch size (FILL_PROCESSING.MAX_FILL_BATCH_SIZE): a queue depth of 4 or fewer is processed as one unified batch; deeper queues are chunked into repeated batches of 4 (the last chunk may be smaller).
Configuration (modules/constants.ts):
FILL_PROCESSING: {
MAX_FILL_BATCH_SIZE: 4 // Hard cap on batch size
}Per-Batch Execution:
- Peek & Pop: Check
_incomingFillQueue, pop up to N fills (batch size) - Single Accounting Pass: Call
processFillAccounting()once for all N fills- All proceeds credited directly to
chainFree(viaadjustTotalBalance) - All proceeds immediately available to next rebalance cycle (not split across cycles)
- All 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).
The grid regenerates when accumulated proceeds create a significant funding imbalance. This is detected using the Available Funds Ratio:
ratio = (availableFunds / allocatedCapital) * 100
IF ratio >= GRID_REGENERATION_PERCENTAGE (default: 3%):
→ Trigger grid regeneration
How It Works:
- 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
Problem: One-shot _recoveryAttempted boolean flag meant permanent lockup if recovery failed once.
New Behavior: Count+time-based retry system with periodic reset.
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 was capped by available funds, the accounting system needed to track what portion of the ideal grid went unallocated. This required careful per-slot tracking to distinguish between:
- Fully allocated slots: received their ideal size (no remainder)
- Fund-capped slots: received less than ideal because available funds ran out mid-allocation
Without per-slot tracking, the remainder was computed from totals, which overstated it when some slots were fully allocated and others were capped.
Old Behavior (Incorrect):
// Compute unallocated remainder from ideal sizes
const remainder = totalIdealSizes - totalAllocatedSizes;
// Problem: If actual allocation capped at 80% due to insufficient funds,
// this uses 100% ideal in calculation → remainder overstatedNew Behavior (Correct):
// 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 based on ChainFree + Committed.
Safety Check:
If the calculated ideal grid requires more capital than is available, 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 530), orphan-fill guard in the fill drain loop (line ~1633), and pruning pass after each cycle (line ~1919).
During Feb 7 market crash, stale-order batch failures cascaded into double-crediting:
Scenario:
- Batch operation scheduled with 12 orders
- Order X is on-chain, included in batch
- Between sync and broadcast, order X fills on market (stale order)
- Batch execution fails: "Limit order X does not exist"
- Error handler: Clean up grid slot X, release funds to
chainFree - Meanwhile, fill event arrives: "Order X was filled at price Y for amount Z"
- Orphan-fill handler: Credits proceeds to
chainFreeAGAIN - Result: Double-credit of proceeds, inflated
chainTotal, fund drift
In Crash Numbers: 7 orphan fills × ~700 BTS = ~4,600 BTS inflated → cascaded to 47,842 BTS total drift.
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
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) -
Settlement (
deductBtsFees):- Check
Funds.btsFeesOwed. - If sufficient
chainFreeavailable: deduct full amount atomically. - If insufficient: defer settlement and retry when funds become available.
- Check
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 303). The fee cache itself is populated by modules/order/utils/system.ts::initializeFeeCache() (line 568).
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 onlyProblem Fixed: BUY side fee calculations incorrectly applied fees to base asset instead of quote asset.
Solution: Corrected fee accounting with proper asset assignment.
| 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.
Problem: Floating-point arithmetic accumulates rounding errors over many calculations. After dozens of order size calculations, price derivations, and fund allocations, float values drift from their true blockchain integer representations, causing mismatches between internal state and on-chain reality.
Solution: Centralized quantization utilities that eliminate float accumulation by round-tripping through blockchain integer representation.
Location: modules/order/utils/math.ts (line 260)
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
Previously, five separate quantization implementations existed:
dexbot_class.ts- Manual rounding logicorder.ts- Custom precision handlingstrategy.ts- Divergent rounding approachchain_orders.ts- Different quantization patternexport.ts- Isolated float conversions
After Consolidation:
✅ Single source of truth (math.ts)
✅ Consistent precision handling across all modules
✅ Reduced regression risk (tested once, used everywhere)
✅ Eliminated subtle float accumulation bugs
✅ All 34+ test suites pass with zero regressions
| 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 486) after every blockchain sync cycle. When a violation is detected, the system logs a CRITICAL error and attempts automatic recovery via manager.accountant.recalculateFunds() (modules/order/accounting.ts line 352, delegated from modules/order/manager.ts line 802) — 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:
AsyncLockprevents concurrent updates to the same order. - 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.
Technical Reference for DEXBot2 v1.2.0 release