dexbot 1.4.6 → 1.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/README.md +1 -0
  2. package/dist/credential-daemon.d.ts.map +1 -1
  3. package/dist/credential-daemon.js +416 -92
  4. package/dist/credential-daemon.js.map +1 -1
  5. package/dist/dexbot.d.ts.map +1 -1
  6. package/dist/dexbot.js +61 -27
  7. package/dist/dexbot.js.map +1 -1
  8. package/dist/modules/account_bots.d.ts.map +1 -1
  9. package/dist/modules/account_bots.js +14 -24
  10. package/dist/modules/account_bots.js.map +1 -1
  11. package/dist/modules/bitshares-native/transport.d.ts.map +1 -1
  12. package/dist/modules/bitshares-native/transport.js +21 -0
  13. package/dist/modules/bitshares-native/transport.js.map +1 -1
  14. package/dist/modules/broadcast_failure.d.ts +21 -0
  15. package/dist/modules/broadcast_failure.d.ts.map +1 -0
  16. package/dist/modules/broadcast_failure.js +95 -0
  17. package/dist/modules/broadcast_failure.js.map +1 -0
  18. package/dist/modules/chain_keys.d.ts +2 -2
  19. package/dist/modules/chain_keys.d.ts.map +1 -1
  20. package/dist/modules/chain_keys.js +30 -56
  21. package/dist/modules/chain_keys.js.map +1 -1
  22. package/dist/modules/chain_orders.d.ts +79 -1
  23. package/dist/modules/chain_orders.d.ts.map +1 -1
  24. package/dist/modules/chain_orders.js +154 -10
  25. package/dist/modules/chain_orders.js.map +1 -1
  26. package/dist/modules/constants.d.ts +1 -0
  27. package/dist/modules/constants.d.ts.map +1 -1
  28. package/dist/modules/constants.js +21 -7
  29. package/dist/modules/constants.js.map +1 -1
  30. package/dist/modules/daemon_node_health.d.ts +33 -0
  31. package/dist/modules/daemon_node_health.d.ts.map +1 -0
  32. package/dist/modules/daemon_node_health.js +120 -0
  33. package/dist/modules/daemon_node_health.js.map +1 -0
  34. package/dist/modules/dexbot_class.d.ts +12 -3
  35. package/dist/modules/dexbot_class.d.ts.map +1 -1
  36. package/dist/modules/dexbot_class.js +19 -5
  37. package/dist/modules/dexbot_class.js.map +1 -1
  38. package/dist/modules/dexbot_cow_runtime.d.ts +46 -1
  39. package/dist/modules/dexbot_cow_runtime.d.ts.map +1 -1
  40. package/dist/modules/dexbot_cow_runtime.js +754 -64
  41. package/dist/modules/dexbot_cow_runtime.js.map +1 -1
  42. package/dist/modules/dexbot_credential_client.d.ts +15 -0
  43. package/dist/modules/dexbot_credential_client.d.ts.map +1 -1
  44. package/dist/modules/dexbot_credential_client.js +114 -141
  45. package/dist/modules/dexbot_credential_client.js.map +1 -1
  46. package/dist/modules/dexbot_fill_runtime.d.ts.map +1 -1
  47. package/dist/modules/dexbot_fill_runtime.js +45 -15
  48. package/dist/modules/dexbot_fill_runtime.js.map +1 -1
  49. package/dist/modules/dexbot_maintenance_runtime.d.ts +5 -1
  50. package/dist/modules/dexbot_maintenance_runtime.d.ts.map +1 -1
  51. package/dist/modules/dexbot_maintenance_runtime.js +238 -85
  52. package/dist/modules/dexbot_maintenance_runtime.js.map +1 -1
  53. package/dist/modules/dexbot_startup_runtime.d.ts.map +1 -1
  54. package/dist/modules/dexbot_startup_runtime.js +95 -33
  55. package/dist/modules/dexbot_startup_runtime.js.map +1 -1
  56. package/dist/modules/dexbot_state_recovery.d.ts.map +1 -1
  57. package/dist/modules/dexbot_state_recovery.js +24 -2
  58. package/dist/modules/dexbot_state_recovery.js.map +1 -1
  59. package/dist/modules/key_store.d.ts.map +1 -1
  60. package/dist/modules/key_store.js +5 -4
  61. package/dist/modules/key_store.js.map +1 -1
  62. package/dist/modules/launcher/credential_daemon.d.ts.map +1 -1
  63. package/dist/modules/launcher/credential_daemon.js +15 -3
  64. package/dist/modules/launcher/credential_daemon.js.map +1 -1
  65. package/dist/modules/node_failure_ledger.d.ts +39 -0
  66. package/dist/modules/node_failure_ledger.d.ts.map +1 -0
  67. package/dist/modules/node_failure_ledger.js +88 -0
  68. package/dist/modules/node_failure_ledger.js.map +1 -0
  69. package/dist/modules/node_health_cache.d.ts +2 -1
  70. package/dist/modules/node_health_cache.d.ts.map +1 -1
  71. package/dist/modules/node_health_cache.js +25 -0
  72. package/dist/modules/node_health_cache.js.map +1 -1
  73. package/dist/modules/node_manager.d.ts +3 -0
  74. package/dist/modules/node_manager.d.ts.map +1 -1
  75. package/dist/modules/node_manager.js +41 -10
  76. package/dist/modules/node_manager.js.map +1 -1
  77. package/dist/modules/order/accounting.d.ts +26 -20
  78. package/dist/modules/order/accounting.d.ts.map +1 -1
  79. package/dist/modules/order/accounting.js +281 -139
  80. package/dist/modules/order/accounting.js.map +1 -1
  81. package/dist/modules/order/grid.d.ts.map +1 -1
  82. package/dist/modules/order/grid.js +66 -14
  83. package/dist/modules/order/grid.js.map +1 -1
  84. package/dist/modules/order/grid_reconcile.d.ts.map +1 -1
  85. package/dist/modules/order/grid_reconcile.js +136 -56
  86. package/dist/modules/order/grid_reconcile.js.map +1 -1
  87. package/dist/modules/order/grid_reconcile_internal.d.ts +6 -5
  88. package/dist/modules/order/grid_reconcile_internal.d.ts.map +1 -1
  89. package/dist/modules/order/grid_reconcile_internal.js +388 -61
  90. package/dist/modules/order/grid_reconcile_internal.js.map +1 -1
  91. package/dist/modules/order/manager.d.ts +98 -4
  92. package/dist/modules/order/manager.d.ts.map +1 -1
  93. package/dist/modules/order/manager.js +379 -92
  94. package/dist/modules/order/manager.js.map +1 -1
  95. package/dist/modules/order/strategy.d.ts.map +1 -1
  96. package/dist/modules/order/strategy.js +24 -1
  97. package/dist/modules/order/strategy.js.map +1 -1
  98. package/dist/modules/order/sync_engine.d.ts +16 -3
  99. package/dist/modules/order/sync_engine.d.ts.map +1 -1
  100. package/dist/modules/order/sync_engine.js +341 -253
  101. package/dist/modules/order/sync_engine.js.map +1 -1
  102. package/dist/modules/order/utils/math.d.ts +79 -1
  103. package/dist/modules/order/utils/math.d.ts.map +1 -1
  104. package/dist/modules/order/utils/math.js +18 -0
  105. package/dist/modules/order/utils/math.js.map +1 -1
  106. package/dist/modules/order/utils/order.d.ts +44 -1
  107. package/dist/modules/order/utils/order.d.ts.map +1 -1
  108. package/dist/modules/order/utils/order.js +91 -5
  109. package/dist/modules/order/utils/order.js.map +1 -1
  110. package/dist/modules/order/utils/system.d.ts +5 -1
  111. package/dist/modules/order/utils/system.d.ts.map +1 -1
  112. package/dist/modules/order/utils/system.js +7 -5
  113. package/dist/modules/order/utils/system.js.map +1 -1
  114. package/dist/modules/order/utils/withPoolRef.d.ts.map +1 -1
  115. package/dist/modules/order/utils/withPoolRef.js +39 -4
  116. package/dist/modules/order/utils/withPoolRef.js.map +1 -1
  117. package/dist/modules/socket_json_client.d.ts +14 -0
  118. package/dist/modules/socket_json_client.d.ts.map +1 -0
  119. package/dist/modules/socket_json_client.js +100 -0
  120. package/dist/modules/socket_json_client.js.map +1 -0
  121. package/package.json +2 -1
  122. package/scripts/clear-all.sh +4 -4
  123. package/scripts/clear-logs.sh +4 -4
@@ -51,7 +51,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
51
51
  return (mod && mod.__esModule) ? mod : { "default": mod };
52
52
  };
53
53
  Object.defineProperty(exports, "__esModule", { value: true });
54
- exports.OrderManager = void 0;
54
+ exports.COWRebalanceEngine = exports.OrderManager = void 0;
55
55
  // ===============================================================================
56
56
  // SECTION 1: EXTERNAL DEPENDENCIES
57
57
  // ===============================================================================
@@ -68,11 +68,77 @@ const Format = __importStar(require("./format"));
68
68
  const constants_1 = require("../constants");
69
69
  const math_1 = require("./utils/math");
70
70
  const validate_1 = require("./utils/validate");
71
+ const order_1 = require("./utils/order");
71
72
  const errors_1 = require("../utils/errors");
72
73
  const { toFiniteNumber } = Format;
73
74
  // ===============================================================================
74
75
  // SECTION 2: COW REBALANCE ENGINE
75
76
  // ===============================================================================
77
+ /**
78
+ * Parse a grid slot id ("slot-123") to its rail index. Slot ids are assigned
79
+ * in ascending price order at grid generation (grid.ts), so the index is
80
+ * strictly price-monotonic and can be compared exactly where float prices
81
+ * would risk rounding ambiguity (adjacent levels can round to the same
82
+ * price). Returns null when the id is not a grid slot id (e.g. orphan fills
83
+ * with chain-derived ids) so callers can fall back to price comparison.
84
+ */
85
+ function parseSlotIndex(id) {
86
+ if (typeof id !== 'string')
87
+ return null;
88
+ const match = /^slot-(\d+)$/.exec(id);
89
+ if (!match)
90
+ return null;
91
+ const idx = parseInt(match[1], 10);
92
+ return Number.isFinite(idx) ? idx : null;
93
+ }
94
+ /**
95
+ * Stale-placement veto: whether a placement action crosses the plan's own
96
+ * boundary and would fill immediately on stale market data.
97
+ * - BUY create/update targeting a slot ABOVE the boundary is dropped; SELL
98
+ * create/update targeting a slot BELOW the boundary is dropped. Strict
99
+ * comparison: a placement AT the boundary is kept (the boundary slot is
100
+ * the spread, which reconcile never targets).
101
+ * - The reference is the PLAN'S OWN targetBoundary — and ONLY that: the
102
+ * plan's slots derive from the same target grid, so a stale plan's
103
+ * placements cross its own boundary while legitimate re-placements never
104
+ * do (the boundary is capped, the fills are not). Fill-based veto lines
105
+ * are deliberately NOT used — after a burst the fills sit beyond the
106
+ * plan's legitimate re-place levels (e.g. SELL fills 5,6,7 with cap=1
107
+ * moves the boundary only to slot 6), so ANY fill comparison can veto
108
+ * valid placements, including the strategy's own rotations (a partial
109
+ * SELL rotated into a slot below the last BUY fill after the boundary
110
+ * crawled left). The guard is therefore "inert" for boundary-consistent
111
+ * engine output by construction.
112
+ * - Slot ids are compared within the plan's own grid generation
113
+ * (targetBoundary and target slots are both from the current plan), so a
114
+ * recenter between the fill capture and the plan cannot skew the
115
+ * comparison. Both CREATE and UPDATE actions are guarded: the
116
+ * cancel/create optimization can convert the same logical placement into
117
+ * an UPDATE op (target slot = newGridId), which the CREATE-only check
118
+ * would have missed.
119
+ * - When no plan boundary is available (hand-built plans), nothing is
120
+ * vetoed — fill evidence is not a reliable veto line. Non-placement
121
+ * actions and non-grid-slot targets are never vetoed.
122
+ * @param {Object} action - COW action
123
+ * @param {number} planBoundary - The plan's target boundary slot index
124
+ * @returns {string|null} Drop reason for the log, or null to keep the action
125
+ */
126
+ function stalePlacementDropReason(action, planBoundary) {
127
+ const isPlacement = action?.type === constants_1.COW_ACTIONS.CREATE || action?.type === constants_1.COW_ACTIONS.UPDATE;
128
+ if (!isPlacement)
129
+ return null;
130
+ const targetSlot = parseSlotIndex(action?.newGridId ?? action?.id);
131
+ if (targetSlot === null)
132
+ return null;
133
+ const kindWord = action.type === constants_1.COW_ACTIONS.UPDATE ? 'update' : 'create';
134
+ if (action?.order?.type === constants_1.ORDER_TYPES.BUY && targetSlot > planBoundary) {
135
+ return `BUY ${kindWord} for slot ${action.id}: slot ${targetSlot} > plan boundary ${planBoundary}`;
136
+ }
137
+ if (action?.order?.type === constants_1.ORDER_TYPES.SELL && targetSlot < planBoundary) {
138
+ return `SELL ${kindWord} for slot ${action.id}: slot ${targetSlot} < plan boundary ${planBoundary}`;
139
+ }
140
+ return null;
141
+ }
76
142
  //
77
143
  // COPY-ON-WRITE (COW) PATTERN FOR SAFE REBALANCING
78
144
  //
@@ -145,6 +211,28 @@ class COWRebalanceEngine {
145
211
  return (0, validate_1.buildAbortedResult)(reconcileResult.reason);
146
212
  }
147
213
  const optimizedActions = (0, validate_1.optimizeRebalanceActions)(reconcileResult.actions, masterGrid);
214
+ // Stale-placement guard: drop placements crossing the plan's own
215
+ // boundary (see stalePlacementDropReason) — defer them to the next
216
+ // cycle instead of filling immediately.
217
+ const planBoundary = (targetBoundary !== null && targetBoundary !== undefined && Number.isFinite(Number(targetBoundary)))
218
+ ? Number(targetBoundary)
219
+ : null;
220
+ if (planBoundary !== null) {
221
+ const before = optimizedActions.length;
222
+ const guarded = optimizedActions.filter((a) => {
223
+ const dropReason = stalePlacementDropReason(a, planBoundary);
224
+ if (dropReason) {
225
+ this.logger?.log(`[COW] Dropping stale-slot ${dropReason}`, 'warn');
226
+ return false;
227
+ }
228
+ return true;
229
+ });
230
+ if (guarded.length < before) {
231
+ this.logger?.log(`[COW] Stale-placement guard removed ${before - guarded.length} placement(s); ${guarded.length} action(s) remain`, 'warn');
232
+ }
233
+ optimizedActions.length = 0;
234
+ optimizedActions.push(...guarded);
235
+ }
148
236
  (0, validate_1.projectTargetToWorkingGrid)(workingGrid, targetGrid, { actions: optimizedActions });
149
237
  const precisions = {
150
238
  buyPrecision: this.assets?.assetB?.precision,
@@ -175,6 +263,7 @@ class COWRebalanceEngine {
175
263
  });
176
264
  }
177
265
  }
266
+ exports.COWRebalanceEngine = COWRebalanceEngine;
178
267
  // ===============================================================================
179
268
  // SECTION 3: ORDER MANAGER CLASS
180
269
  // ===============================================================================
@@ -267,6 +356,7 @@ class OrderManager {
267
356
  _accountTotalsPromise;
268
357
  _accountTotalsResolve;
269
358
  _isFetchingTotals;
359
+ accountTotalsStale;
270
360
  ordersNeedingPriceCorrection;
271
361
  shadowOrderIds;
272
362
  processedFillTracker;
@@ -280,6 +370,7 @@ class OrderManager {
280
370
  _gridSidesUpdated;
281
371
  _pauseFundRecalc;
282
372
  _pauseFundRecalcWatchdog;
373
+ _fillBatchInFlight;
283
374
  _pauseRecalcLogging;
284
375
  _pauseRecalcLoggingWatchdog;
285
376
  _throwOnIllegalState;
@@ -291,6 +382,7 @@ class OrderManager {
291
382
  _pendingBroadcasts;
292
383
  _committedOrderIds;
293
384
  _committedOrderIdsBuiltAt;
385
+ _orderIdAssignedAt;
294
386
  _gapSlots;
295
387
  _gridDirtyAt;
296
388
  _orphanFillsCreditedAt;
@@ -338,7 +430,9 @@ class OrderManager {
338
430
  buy: { armed: true, lastTriggeredAt: 0 },
339
431
  sell: { armed: true, lastTriggeredAt: 0 }
340
432
  };
341
- this.resetFunds();
433
+ // Sync reset — _fundLock is not created until later in the constructor,
434
+ // and no concurrent access exists during construction.
435
+ this.accountant.resetFunds();
342
436
  this.btsBalance = { free: 0, total: 0, locked: 0 };
343
437
  this.targetSpreadCount = 0;
344
438
  this.currentSpreadCount = 0;
@@ -347,6 +441,7 @@ class OrderManager {
347
441
  this._accountTotalsPromise = null;
348
442
  this._accountTotalsResolve = null;
349
443
  this._isFetchingTotals = false;
444
+ this.accountTotalsStale = false;
350
445
  this.ordersNeedingPriceCorrection = [];
351
446
  this.shadowOrderIds = new Map();
352
447
  this.processedFillTracker = new Map();
@@ -372,6 +467,13 @@ class OrderManager {
372
467
  this._gridSidesUpdated = new Set();
373
468
  this._pauseFundRecalc = 0;
374
469
  this._pauseFundRecalcWatchdog = null;
470
+ // Depth counter for fill batches currently mid-accounting. While non-zero,
471
+ // _verifyFundInvariants must not run: the batch's balance refresh already
472
+ // reflects the just-filled orders on-chain, but the grid still holds them
473
+ // as committed until the batch's grid mutation lands, so a check in that
474
+ // window would report a spurious Total != Free + Committed by exactly the
475
+ // fills' size. The check runs once the batch settles instead.
476
+ this._fillBatchInFlight = 0;
375
477
  this._pauseRecalcLogging = false;
376
478
  this._pauseRecalcLoggingWatchdog = null;
377
479
  this._throwOnIllegalState = false;
@@ -383,6 +485,7 @@ class OrderManager {
383
485
  this._pendingBroadcasts = new Map();
384
486
  this._committedOrderIds = new Set();
385
487
  this._committedOrderIdsBuiltAt = 0;
488
+ this._orderIdAssignedAt = new Map();
386
489
  this._gapSlots = 0;
387
490
  this._gridDirtyAt = null;
388
491
  this._orphanFillsCreditedAt = null;
@@ -420,6 +523,54 @@ class OrderManager {
420
523
  ? constants_1.REBALANCE_STATES.REBALANCING
421
524
  : constants_1.REBALANCE_STATES.NORMAL;
422
525
  }
526
+ /**
527
+ * Push a working grid onto the rebalance stack (LIFO), set the state to
528
+ * REBALANCING, and — when a result object is provided — mark it as pushed
529
+ * so pop sites can release exactly the entry this push created. This is
530
+ * the only place a grid may be pushed; all releases go through
531
+ * _popWorkingGridRef (marker-guarded, early-return sites) or
532
+ * _releaseWorkingGridRef (identity-checked, commit sites).
533
+ * @param {Object} workingGrid - The working grid to push
534
+ * @param {Object} [result] - Result object to carry the push marker on
535
+ */
536
+ _pushWorkingGridRef(workingGrid, result = null) {
537
+ this._currentWorkingGridStack.push(workingGrid);
538
+ this._rebalanceState = constants_1.REBALANCE_STATES.REBALANCING;
539
+ if (result) {
540
+ result._workingGridPushed = true;
541
+ }
542
+ return result;
543
+ }
544
+ /**
545
+ * Pop the stack entry owned by a result, exactly once (marker-guarded).
546
+ * Results that were never pushed (aborted plans, no-trigger
547
+ * processFilledOrders outputs, updateOrdersOnChainPlan cowResults,
548
+ * reconcileGridOrders null results) leave the stack untouched — an
549
+ * unmatched pop could steal a nested grid's entry. Clears the marker so
550
+ * a later throw in the same frame cannot pop a second time.
551
+ * @param {Object} [result] - Result object carrying the push marker
552
+ */
553
+ _popWorkingGridRef(result = null) {
554
+ if (result && result._workingGridPushed === true) {
555
+ this._clearWorkingGridRef();
556
+ result._workingGridPushed = false;
557
+ }
558
+ }
559
+ /**
560
+ * Release the stack entry for a grid after its commit settles, exactly
561
+ * once per commit invocation. Identity-checked: plan-path grids
562
+ * (updateOrdersOnChainPlan, local-only divergence commits) were never
563
+ * pushed, so popping unconditionally would steal a NESTED grid's stack
564
+ * entry. Pushed grids are always at the top at commit time (LIFO), so
565
+ * the check is a no-op for them.
566
+ * @param {Object} workingGrid - The committed working grid
567
+ */
568
+ _releaseWorkingGridRef(workingGrid) {
569
+ const stack = this._currentWorkingGridStack;
570
+ if (stack.length > 0 && stack[stack.length - 1] === workingGrid) {
571
+ this._clearWorkingGridRef();
572
+ }
573
+ }
423
574
  _setRebalanceState(state) {
424
575
  this._rebalanceState = state;
425
576
  this.logger?.log(`[COW] Rebalance state: ${state}`, 'debug');
@@ -536,20 +687,29 @@ class OrderManager {
536
687
  }
537
688
  }
538
689
  /**
539
- * @returns {void}
690
+ * Reset funds structure to zeroed values. Acquires _fundLock so the
691
+ * wholesale funds replacement cannot race with concurrent fund mutations
692
+ * (deductBtsFees, tryDeductFromChainFree, updateOptimisticFreeBalance).
693
+ * @returns {Promise<void>}
540
694
  */
541
- resetFunds() {
542
- return this.accountant.resetFunds();
695
+ async resetFunds() {
696
+ return await this._fundLock.acquire(async () => {
697
+ return this.accountant.resetFunds();
698
+ });
543
699
  }
544
700
  async _deductFromChainFree(orderType, size, operation) {
545
701
  if (!this.accountant)
546
702
  return;
547
- return await this.accountant.tryDeductFromChainFree(orderType, size, operation);
703
+ return await this._fundLock.acquire(async () => {
704
+ return await this.accountant.tryDeductFromChainFree(orderType, size, operation);
705
+ });
548
706
  }
549
707
  async _addToChainFree(orderType, size, operation) {
550
708
  if (!this.accountant)
551
709
  return;
552
- return await this.accountant.addToChainFree(orderType, size, operation);
710
+ return await this._fundLock.acquire(async () => {
711
+ return await this.accountant.addToChainFree(orderType, size, operation);
712
+ });
553
713
  }
554
714
  _getGridTotal(side) {
555
715
  return (this.funds?.committed?.grid?.[side] || 0) + (this.funds?.virtual?.[side] || 0);
@@ -573,7 +733,7 @@ class OrderManager {
573
733
  const size = toFiniteNumber(order.size);
574
734
  if (size <= 0)
575
735
  continue;
576
- const isBuy = order.type === constants_1.ORDER_TYPES.BUY || (order.type === constants_1.ORDER_TYPES.SPREAD && order.price < this.config.startPrice);
736
+ const isBuy = order.type === constants_1.ORDER_TYPES.BUY || (order.type === constants_1.ORDER_TYPES.SPREAD && (0, order_1.resolveSpreadOrderSide)(order.price, this.config.startPrice) === constants_1.ORDER_TYPES.BUY);
577
737
  if (isBuy)
578
738
  committedBuy += size;
579
739
  else
@@ -622,6 +782,66 @@ class OrderManager {
622
782
  this.accountId = accountId;
623
783
  await this._fetchAccountBalancesAndSetTotals();
624
784
  }
785
+ /**
786
+ * Refresh accountTotals from chain when the cached snapshot is stale.
787
+ *
788
+ * Optimistic deductions (tryDeductFromChainFree) refuse to operate on
789
+ * snapshots older than MAX_ACCOUNT_TOTALS_AGE_MS and schedule a recovery
790
+ * instead, leaving the order placed but unaccounted. By refreshing once
791
+ * up front, the caller's accounting batch runs against a fresh snapshot
792
+ * so deductions apply normally and no skip/recovery cycle is triggered.
793
+ *
794
+ * @returns {Promise<{ok: boolean, reason?: string}>} {ok: true} when the
795
+ * snapshot is (now) fresh; {ok: false, reason} when the refresh failed and
796
+ * the snapshot is still stale — the caller must not proceed with accounting.
797
+ */
798
+ async refreshAccountTotalsIfStale(options = {}) {
799
+ const force = options?.force === true;
800
+ const lastFetched = this.accountTotals?._lastFetchedAt || 0;
801
+ if (!force && Date.now() - lastFetched <= constants_1.TIMING.MAX_ACCOUNT_TOTALS_AGE_MS) {
802
+ return { ok: true };
803
+ }
804
+ const fetchedBefore = lastFetched;
805
+ // Apply the standard blockchain-op policy (30s timeout, 3 attempts, then
806
+ // node failover) so a transiently bad node does not force the caller to
807
+ // defer the whole fill batch to the next cycle. Only a genuinely
808
+ // unreachable chain leaves the snapshot stale.
809
+ try {
810
+ await (0, system_1.withBlockchainRetry)(() => this.fetchAccountTotals(this.accountId), 'refreshAccountTotalsIfStale', { logger: this.logger });
811
+ }
812
+ catch (err) {
813
+ this.logger?.log?.(`[SYNC] refreshAccountTotalsIfStale fetch failed: ${(0, errors_1.getErrorMessage)(err)}`, 'warn');
814
+ }
815
+ const fetchedAfter = this.accountTotals?._lastFetchedAt || 0;
816
+ if (fetchedAfter <= fetchedBefore) {
817
+ this.logger?.log?.(`[SYNC] refreshAccountTotalsIfStale: accountTotals still stale after refresh (age=${Math.max(0, Date.now() - fetchedAfter)}ms).`, 'warn');
818
+ return { ok: false, reason: 'refresh-failed' };
819
+ }
820
+ return { ok: true };
821
+ }
822
+ /**
823
+ * Re-anchor accountTotals to authoritative on-chain values after a fill
824
+ * batch has applied its optimistic accounting and grid mutation.
825
+ *
826
+ * A fill batch's up-front stale-totals refresh returns POST-fill balances
827
+ * (the fills already settled on-chain), then processFillAccounting applies
828
+ * the same pays/receives again — a double-count on the totals. Force-fetching
829
+ * here overwrites the optimistic values with the authoritative post-fill
830
+ * state so the batch ends exactly consistent with chain and the invariant
831
+ * holds at the final consolidated recalculation.
832
+ *
833
+ * @param {string} [label] - Context label for log messages
834
+ * @returns {Promise<boolean>} true when the re-anchor refreshed successfully
835
+ */
836
+ async reanchorAccountTotals(label = 'fill-batch') {
837
+ const result = await this.refreshAccountTotalsIfStale({ force: true });
838
+ if (!result.ok) {
839
+ this.logger?.log?.(`[ACCOUNTING] ${label}: accountTotals re-anchor failed (${result.reason}); marking totals stale.`, 'warn');
840
+ this.accountTotalsStale = true;
841
+ return false;
842
+ }
843
+ return true;
844
+ }
625
845
  async _fetchAccountBalancesAndSetTotals() {
626
846
  return await this.sync.fetchAccountBalancesAndSetTotals();
627
847
  }
@@ -637,7 +857,7 @@ class OrderManager {
637
857
  async _setAccountTotals(totals) {
638
858
  this.accountTotals = { ...(this.accountTotals || {}), ...totals, _lastFetchedAt: Date.now() };
639
859
  if (!this.funds)
640
- this.resetFunds();
860
+ await this.resetFunds();
641
861
  await this._recalculateFunds();
642
862
  if ((0, math_1.hasValidAccountTotals)(this.accountTotals, true) && typeof this._accountTotalsResolve === 'function') {
643
863
  try {
@@ -733,7 +953,7 @@ class OrderManager {
733
953
  this.logger?.log?.(`[MANAGER] pauseFundRecalc safety watchdog: resetting depth from ${this._pauseFundRecalc} to 0`, 'warn');
734
954
  this._pauseFundRecalc = 0;
735
955
  this._pauseFundRecalcWatchdog = null;
736
- this._recalculateFunds().catch((err) => {
956
+ this.recalculateFunds().catch((err) => {
737
957
  this.logger?.log?.(`[MANAGER] Watchdog recalc failed: ${(0, errors_1.getErrorMessage)(err)}`, 'error');
738
958
  });
739
959
  }
@@ -944,6 +1164,24 @@ class OrderManager {
944
1164
  }
945
1165
  const updatedOrder = (0, system_1.deepFreeze)({ ...nextOrder });
946
1166
  const id = order.id;
1167
+ // Track when an orderId is first assigned to a slot. A freshly assigned
1168
+ // orderId (create/adopt broadcast still in flight, or not yet visible to
1169
+ // a lagging/truncated chain read) must not be treated as "absent from
1170
+ // chain" by phantom cleanup — that would virtualize a real live order
1171
+ // and re-create a duplicate. Absence decisions defer while the stamp is
1172
+ // younger than SYNC_LOCK_TIMEOUT_MS.
1173
+ if (updatedOrder.orderId && (!oldOrder?.orderId || oldOrder.orderId !== updatedOrder.orderId)) {
1174
+ // Prune stamps older than the guard window — they can never be
1175
+ // consulted again (the consumer defers only while the stamp is
1176
+ // younger than SYNC_LOCK_TIMEOUT_MS), so keeping them would leak one
1177
+ // entry per orderId on a perpetual-turnover bot.
1178
+ for (const [orderId, assignedAt] of this._orderIdAssignedAt) {
1179
+ if (Date.now() - assignedAt >= constants_1.TIMING.SYNC_LOCK_TIMEOUT_MS) {
1180
+ this._orderIdAssignedAt.delete(orderId);
1181
+ }
1182
+ }
1183
+ this._orderIdAssignedAt.set(updatedOrder.orderId, Date.now());
1184
+ }
947
1185
  const newMap = (0, system_1.cloneMap)(this.orders);
948
1186
  newMap.set(id, updatedOrder);
949
1187
  this.orders = Object.freeze(newMap);
@@ -1088,6 +1326,11 @@ class OrderManager {
1088
1326
  const shouldRebalance = triggerFills.length > 0;
1089
1327
  if (shouldRebalance) {
1090
1328
  const rebalanceResult = await this.performSafeRebalance(orders, excl);
1329
+ // Carry the fill set + exclusions on the result so the COW batch
1330
+ // executor can re-plan once from fresh master if the plan goes
1331
+ // stale between planning and broadcast.
1332
+ rebalanceResult.fills = orders;
1333
+ rebalanceResult.excludeIds = excl || new Set();
1091
1334
  return rebalanceResult;
1092
1335
  }
1093
1336
  const workingGrid = new working_grid_1.WorkingGrid(this.orders, { baseVersion: this._gridVersion });
@@ -1349,10 +1592,13 @@ class OrderManager {
1349
1592
  excludeIds
1350
1593
  });
1351
1594
  if (result.aborted) {
1352
- this._clearWorkingGridRef();
1595
+ // Nothing was pushed for an aborted result — the push below is
1596
+ // skipped — so there is no working-grid ref to clear here. Popping
1597
+ // would steal an unrelated stack entry (double-pop) when combined
1598
+ // with the caller-side no-action handling in _executeBatchIfNeeded.
1353
1599
  return result;
1354
1600
  }
1355
- this._currentWorkingGridStack.push(result.workingGrid);
1601
+ this._pushWorkingGridRef(result.workingGrid, result);
1356
1602
  return result;
1357
1603
  }
1358
1604
  _reconcileGridCOW(targetGrid, targetBoundary, workingGrid) {
@@ -1410,100 +1656,128 @@ class OrderManager {
1410
1656
  return (0, validate_1.buildAbortedResult)(reason);
1411
1657
  }
1412
1658
  async _commitWorkingGrid(workingGrid, _workingIndexes, workingBoundary, options = {}) {
1413
- const { skipRecalc } = this._normalizeCommitOptions(options);
1414
1659
  const startTime = Date.now();
1415
- const stats = workingGrid.getMemoryStats();
1416
1660
  let committed = false;
1417
1661
  let comparePrecisions;
1662
+ let skipRecalc = false;
1663
+ // Exactly-once stack-release contract: every settle path (return or
1664
+ // throw) releases the working-grid stack entry exactly one time via
1665
+ // _releaseWorkingGridRef (identity-checked, so plan-path grids that
1666
+ // were never pushed leave the stack untouched). When the caller passes
1667
+ // its result object (options.result), the push marker is cleared too —
1668
+ // the batch executor's catch block then cannot pop a second time for
1669
+ // the same grid.
1670
+ let popped = false;
1671
+ const releaseStackEntry = () => {
1672
+ if (popped)
1673
+ return;
1674
+ popped = true;
1675
+ this._releaseWorkingGridRef(workingGrid);
1676
+ if (options?.result) {
1677
+ options.result._workingGridPushed = false;
1678
+ }
1679
+ };
1418
1680
  try {
1419
- comparePrecisions = this._getCowComparePrecisions();
1420
- }
1421
- catch (precisionErr) {
1422
- this.logger.log(`[COW] ${(0, errors_1.getErrorMessage)(precisionErr)}`, 'error');
1423
- this._clearWorkingGridRef();
1424
- return false;
1425
- }
1426
- const preCommitGuard = (0, validate_1.evaluateCommit)(workingGrid, {
1427
- hasLock: false,
1428
- currentVersion: this._gridVersion,
1429
- masterGrid: this.orders,
1430
- comparePrecisions
1431
- });
1432
- if (!preCommitGuard.canCommit) {
1433
- this.logger.log(`[COW] ${preCommitGuard.reason}`, preCommitGuard.level || 'warn');
1434
- this._clearWorkingGridRef();
1435
- return false;
1436
- }
1437
- await this._gridLock.acquire(async () => {
1438
- const lockCommitGuard = (0, validate_1.evaluateCommit)(workingGrid, {
1439
- hasLock: true,
1681
+ const { skipRecalc: skipRecalcOpt } = this._normalizeCommitOptions(options);
1682
+ skipRecalc = skipRecalcOpt;
1683
+ const stats = workingGrid.getMemoryStats();
1684
+ try {
1685
+ comparePrecisions = this._getCowComparePrecisions();
1686
+ }
1687
+ catch (precisionErr) {
1688
+ this.logger.log(`[COW] ${(0, errors_1.getErrorMessage)(precisionErr)}`, 'error');
1689
+ releaseStackEntry();
1690
+ return false;
1691
+ }
1692
+ const preCommitGuard = (0, validate_1.evaluateCommit)(workingGrid, {
1693
+ hasLock: false,
1440
1694
  currentVersion: this._gridVersion,
1441
1695
  masterGrid: this.orders,
1442
1696
  comparePrecisions
1443
1697
  });
1444
- if (!lockCommitGuard.canCommit) {
1445
- this.logger.log(`[COW] ${lockCommitGuard.reason}`, lockCommitGuard.level || 'warn');
1446
- this._clearWorkingGridRef();
1447
- return;
1698
+ if (!preCommitGuard.canCommit) {
1699
+ this.logger.log(`[COW] ${preCommitGuard.reason}`, preCommitGuard.level || 'warn');
1700
+ releaseStackEntry();
1701
+ return false;
1448
1702
  }
1449
- this.logger.log(`[COW] Committing working grid: ${stats.size} orders, ${stats.modified} modified`, 'debug');
1450
- const finalMap = workingGrid.toMap();
1451
- // RC-4: Deep-freeze all modified orders before committing to master state
1452
- // Ensures COW immutability invariants are maintained for all grid entries.
1453
- for (const [, order] of finalMap.entries()) {
1454
- if (order && !Object.isFrozen(order)) {
1455
- (0, system_1.deepFreeze)(order);
1703
+ await this._gridLock.acquire(async () => {
1704
+ const lockCommitGuard = (0, validate_1.evaluateCommit)(workingGrid, {
1705
+ hasLock: true,
1706
+ currentVersion: this._gridVersion,
1707
+ masterGrid: this.orders,
1708
+ comparePrecisions
1709
+ });
1710
+ if (!lockCommitGuard.canCommit) {
1711
+ this.logger.log(`[COW] ${lockCommitGuard.reason}`, lockCommitGuard.level || 'warn');
1712
+ // Working grid ref is cleared exactly once below via the
1713
+ // !committed path (the callback returns without committing).
1714
+ return;
1456
1715
  }
1716
+ this.logger.log(`[COW] Committing working grid: ${stats.size} orders, ${stats.modified} modified`, 'debug');
1717
+ const finalMap = workingGrid.toMap();
1718
+ // RC-4: Deep-freeze all modified orders before committing to master state
1719
+ // Ensures COW immutability invariants are maintained for all grid entries.
1720
+ for (const [, order] of finalMap.entries()) {
1721
+ if (order && !Object.isFrozen(order)) {
1722
+ (0, system_1.deepFreeze)(order);
1723
+ }
1724
+ }
1725
+ this.orders = Object.freeze(finalMap);
1726
+ this._setBoundary(workingBoundary);
1727
+ this._gridVersion++;
1728
+ committed = true;
1729
+ // Track orderIds from successful COW commits for recovery sync protection.
1730
+ // Build a new set from the committed finalMap and atomically swap to
1731
+ // avoid the intermediate empty state that clear() creates — a crash
1732
+ // during clear()+repopulate would lose all committed IDs.
1733
+ const newCommittedIds = new Set();
1734
+ for (const [, order] of finalMap.entries()) {
1735
+ if (order.orderId)
1736
+ newCommittedIds.add(order.orderId);
1737
+ }
1738
+ this._committedOrderIds = newCommittedIds;
1739
+ this._committedOrderIdsBuiltAt = Date.now();
1740
+ // Index caches invalidated by _gridVersion bump above — lazy getters recompute from this.orders
1741
+ });
1742
+ if (!committed) {
1743
+ releaseStackEntry();
1744
+ return false;
1457
1745
  }
1458
- this.orders = Object.freeze(finalMap);
1459
- this._setBoundary(workingBoundary);
1460
- this._gridVersion++;
1461
- committed = true;
1462
- // Track orderIds from successful COW commits for recovery sync protection.
1463
- // Build a new set from the committed finalMap and atomically swap to
1464
- // avoid the intermediate empty state that clear() creates — a crash
1465
- // during clear()+repopulate would lose all committed IDs.
1466
- const newCommittedIds = new Set();
1467
- for (const [, order] of finalMap.entries()) {
1468
- if (order.orderId)
1469
- newCommittedIds.add(order.orderId);
1470
- }
1471
- this._committedOrderIds = newCommittedIds;
1472
- this._committedOrderIdsBuiltAt = Date.now();
1473
- // Index caches invalidated by _gridVersion bump above — lazy getters recompute from this.orders
1474
- });
1475
- if (!committed) {
1476
- this._clearWorkingGridRef();
1477
- return false;
1478
- }
1479
- try {
1480
- if (!skipRecalc) {
1481
- // If the last accounting failure was due to stale accountTotals,
1482
- // the commit proceeded without locking funds. Refresh accountTotals
1483
- // before recalculateFunds() to avoid a false-positive drift recovery.
1484
- if (this._lastAccountingFailure?.reason === 'stale') {
1485
- this.logger.log('[COW] Stale accountTotals during commit; refreshing before recalculateFunds to avoid false recovery', 'warn');
1486
- await this.fetchAccountTotals(this.accountId);
1746
+ try {
1747
+ if (!skipRecalc) {
1748
+ // If the last accounting failure was due to stale accountTotals,
1749
+ // the commit proceeded without locking funds. Refresh accountTotals
1750
+ // before recalculateFunds() to avoid a false-positive drift recovery.
1751
+ if (this._lastAccountingFailure?.reason === 'stale') {
1752
+ this.logger.log('[COW] Stale accountTotals during commit; refreshing before recalculateFunds to avoid false recovery', 'warn');
1753
+ await this.fetchAccountTotals(this.accountId);
1754
+ }
1755
+ await this.recalculateFunds();
1756
+ }
1757
+ const duration = Date.now() - startTime;
1758
+ this.logger.log(`[COW] Grid committed in ${duration}ms`, 'debug');
1759
+ if (stats.size > constants_1.COW_PERFORMANCE.GRID_MEMORY_WARNING) {
1760
+ this.logger.log(`[COW] Warning: Large grid size (${stats.size} orders). Peak memory: ~${Math.round(stats.estimatedBytes / 1024)}KB`, 'warn');
1487
1761
  }
1488
- await this.recalculateFunds();
1489
1762
  }
1490
- const duration = Date.now() - startTime;
1491
- this.logger.log(`[COW] Grid committed in ${duration}ms`, 'debug');
1492
- if (stats.size > constants_1.COW_PERFORMANCE.GRID_MEMORY_WARNING) {
1493
- this.logger.log(`[COW] Warning: Large grid size (${stats.size} orders). Peak memory: ~${Math.round(stats.estimatedBytes / 1024)}KB`, 'warn');
1763
+ catch (recalcErr) {
1764
+ this.logger.log(`[COW] Fund recalculation failed post-commit: ${(0, errors_1.getErrorMessage)(recalcErr)}`, 'error');
1765
+ this._recoveryState = { ...this._recoveryState, lastFailureAt: Date.now() };
1766
+ // Re-throw to signal callers that the commit is incomplete
1767
+ // (grid state committed, but fund state is stale).
1768
+ releaseStackEntry();
1769
+ throw recalcErr;
1494
1770
  }
1771
+ releaseStackEntry();
1772
+ return true;
1495
1773
  }
1496
- catch (recalcErr) {
1497
- this.logger.log(`[COW] Fund recalculation failed post-commit: ${(0, errors_1.getErrorMessage)(recalcErr)}`, 'error');
1498
- this._recoveryState = { ...this._recoveryState, lastFailureAt: Date.now() };
1499
- // Re-throw to signal callers that the commit is incomplete
1500
- // (grid state committed, but fund state is stale).
1501
- throw recalcErr;
1774
+ catch (err) {
1775
+ // Unhandled commit failure (e.g. _gridLock acquisition timeout or
1776
+ // an in-lock commit error): nothing was committed, but the stack
1777
+ // entry must still be released exactly once.
1778
+ releaseStackEntry();
1779
+ throw err;
1502
1780
  }
1503
- finally {
1504
- this._clearWorkingGridRef();
1505
- }
1506
- return true;
1507
1781
  }
1508
1782
  /**
1509
1783
  * @param {Object} [options]
@@ -1544,7 +1818,7 @@ class OrderManager {
1544
1818
  * swapping the live map and exposing it to concurrent readers.
1545
1819
  * @returns {Promise<import('./types').PersistenceValidationResult>}
1546
1820
  */
1547
- async persistGrid(snapshotOrders, recentFillKeys) {
1821
+ async persistGrid(snapshotOrders, recentFillKeys, fundSnapshot) {
1548
1822
  if (this._gridPersistenceSuspendedReason) {
1549
1823
  this.logger.log(`[PERSISTENCE-GATE] Skipping grid persistence while suspended: ${this._gridPersistenceSuspendedReason}`, 'warn');
1550
1824
  return { isValid: true, skipped: true, suspended: true, reason: this._gridPersistenceSuspendedReason };
@@ -1554,7 +1828,20 @@ class OrderManager {
1554
1828
  this.logger.log(`[PERSISTENCE-GATE] Skipping persistence of corrupted state: ${validation.reason}`, 'warn');
1555
1829
  return validation;
1556
1830
  }
1557
- const persisted = await (0, system_1.persistGridSnapshot)(this, this.accountOrders, snapshotOrders, recentFillKeys);
1831
+ // Capture fund state under _fundLock so the persisted snapshot is
1832
+ // internally consistent even when fills/fee settlement mutate
1833
+ // accountTotals concurrently (same TOCTOU fixed for recalculateGrid
1834
+ // at grid.ts:~1047). An explicit fundSnapshot (recalculateGrid) is
1835
+ // kept as an override — the caller captured it under the same lock
1836
+ // right after resetFunds.
1837
+ let effectiveFundSnapshot = fundSnapshot;
1838
+ if (!effectiveFundSnapshot) {
1839
+ effectiveFundSnapshot = await this._fundLock.acquire(async () => ({
1840
+ btsFeesOwed: this.funds.btsFeesOwed,
1841
+ accountTotals: this.accountTotals,
1842
+ }));
1843
+ }
1844
+ const persisted = await (0, system_1.persistGridSnapshot)(this, this.accountOrders, snapshotOrders, recentFillKeys, effectiveFundSnapshot);
1558
1845
  if (persisted === false) {
1559
1846
  this.logger.log(`[PERSIST] Grid persistence FAILED (disk full / permissions / write error)`, 'error');
1560
1847
  // Keep the dirty flag set so the next flush retries.