dexbot 1.4.7 → 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 +237 -84
  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 +1 -1
  78. package/dist/modules/order/accounting.d.ts.map +1 -1
  79. package/dist/modules/order/accounting.js +219 -101
  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 +130 -56
  86. package/dist/modules/order/grid_reconcile.js.map +1 -1
  87. package/dist/modules/order/grid_reconcile_internal.d.ts +2 -1
  88. package/dist/modules/order/grid_reconcile_internal.d.ts.map +1 -1
  89. package/dist/modules/order/grid_reconcile_internal.js +333 -54
  90. package/dist/modules/order/grid_reconcile_internal.js.map +1 -1
  91. package/dist/modules/order/manager.d.ts +97 -4
  92. package/dist/modules/order/manager.d.ts.map +1 -1
  93. package/dist/modules/order/manager.js +377 -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 +329 -247
  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
  // ===============================================================================
@@ -281,6 +370,7 @@ class OrderManager {
281
370
  _gridSidesUpdated;
282
371
  _pauseFundRecalc;
283
372
  _pauseFundRecalcWatchdog;
373
+ _fillBatchInFlight;
284
374
  _pauseRecalcLogging;
285
375
  _pauseRecalcLoggingWatchdog;
286
376
  _throwOnIllegalState;
@@ -292,6 +382,7 @@ class OrderManager {
292
382
  _pendingBroadcasts;
293
383
  _committedOrderIds;
294
384
  _committedOrderIdsBuiltAt;
385
+ _orderIdAssignedAt;
295
386
  _gapSlots;
296
387
  _gridDirtyAt;
297
388
  _orphanFillsCreditedAt;
@@ -339,7 +430,9 @@ class OrderManager {
339
430
  buy: { armed: true, lastTriggeredAt: 0 },
340
431
  sell: { armed: true, lastTriggeredAt: 0 }
341
432
  };
342
- 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();
343
436
  this.btsBalance = { free: 0, total: 0, locked: 0 };
344
437
  this.targetSpreadCount = 0;
345
438
  this.currentSpreadCount = 0;
@@ -374,6 +467,13 @@ class OrderManager {
374
467
  this._gridSidesUpdated = new Set();
375
468
  this._pauseFundRecalc = 0;
376
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;
377
477
  this._pauseRecalcLogging = false;
378
478
  this._pauseRecalcLoggingWatchdog = null;
379
479
  this._throwOnIllegalState = false;
@@ -385,6 +485,7 @@ class OrderManager {
385
485
  this._pendingBroadcasts = new Map();
386
486
  this._committedOrderIds = new Set();
387
487
  this._committedOrderIdsBuiltAt = 0;
488
+ this._orderIdAssignedAt = new Map();
388
489
  this._gapSlots = 0;
389
490
  this._gridDirtyAt = null;
390
491
  this._orphanFillsCreditedAt = null;
@@ -422,6 +523,54 @@ class OrderManager {
422
523
  ? constants_1.REBALANCE_STATES.REBALANCING
423
524
  : constants_1.REBALANCE_STATES.NORMAL;
424
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
+ }
425
574
  _setRebalanceState(state) {
426
575
  this._rebalanceState = state;
427
576
  this.logger?.log(`[COW] Rebalance state: ${state}`, 'debug');
@@ -538,20 +687,29 @@ class OrderManager {
538
687
  }
539
688
  }
540
689
  /**
541
- * @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>}
542
694
  */
543
- resetFunds() {
544
- return this.accountant.resetFunds();
695
+ async resetFunds() {
696
+ return await this._fundLock.acquire(async () => {
697
+ return this.accountant.resetFunds();
698
+ });
545
699
  }
546
700
  async _deductFromChainFree(orderType, size, operation) {
547
701
  if (!this.accountant)
548
702
  return;
549
- 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
+ });
550
706
  }
551
707
  async _addToChainFree(orderType, size, operation) {
552
708
  if (!this.accountant)
553
709
  return;
554
- 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
+ });
555
713
  }
556
714
  _getGridTotal(side) {
557
715
  return (this.funds?.committed?.grid?.[side] || 0) + (this.funds?.virtual?.[side] || 0);
@@ -575,7 +733,7 @@ class OrderManager {
575
733
  const size = toFiniteNumber(order.size);
576
734
  if (size <= 0)
577
735
  continue;
578
- 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);
579
737
  if (isBuy)
580
738
  committedBuy += size;
581
739
  else
@@ -624,6 +782,66 @@ class OrderManager {
624
782
  this.accountId = accountId;
625
783
  await this._fetchAccountBalancesAndSetTotals();
626
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
+ }
627
845
  async _fetchAccountBalancesAndSetTotals() {
628
846
  return await this.sync.fetchAccountBalancesAndSetTotals();
629
847
  }
@@ -639,7 +857,7 @@ class OrderManager {
639
857
  async _setAccountTotals(totals) {
640
858
  this.accountTotals = { ...(this.accountTotals || {}), ...totals, _lastFetchedAt: Date.now() };
641
859
  if (!this.funds)
642
- this.resetFunds();
860
+ await this.resetFunds();
643
861
  await this._recalculateFunds();
644
862
  if ((0, math_1.hasValidAccountTotals)(this.accountTotals, true) && typeof this._accountTotalsResolve === 'function') {
645
863
  try {
@@ -735,7 +953,7 @@ class OrderManager {
735
953
  this.logger?.log?.(`[MANAGER] pauseFundRecalc safety watchdog: resetting depth from ${this._pauseFundRecalc} to 0`, 'warn');
736
954
  this._pauseFundRecalc = 0;
737
955
  this._pauseFundRecalcWatchdog = null;
738
- this._recalculateFunds().catch((err) => {
956
+ this.recalculateFunds().catch((err) => {
739
957
  this.logger?.log?.(`[MANAGER] Watchdog recalc failed: ${(0, errors_1.getErrorMessage)(err)}`, 'error');
740
958
  });
741
959
  }
@@ -946,6 +1164,24 @@ class OrderManager {
946
1164
  }
947
1165
  const updatedOrder = (0, system_1.deepFreeze)({ ...nextOrder });
948
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
+ }
949
1185
  const newMap = (0, system_1.cloneMap)(this.orders);
950
1186
  newMap.set(id, updatedOrder);
951
1187
  this.orders = Object.freeze(newMap);
@@ -1090,6 +1326,11 @@ class OrderManager {
1090
1326
  const shouldRebalance = triggerFills.length > 0;
1091
1327
  if (shouldRebalance) {
1092
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();
1093
1334
  return rebalanceResult;
1094
1335
  }
1095
1336
  const workingGrid = new working_grid_1.WorkingGrid(this.orders, { baseVersion: this._gridVersion });
@@ -1351,10 +1592,13 @@ class OrderManager {
1351
1592
  excludeIds
1352
1593
  });
1353
1594
  if (result.aborted) {
1354
- 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.
1355
1599
  return result;
1356
1600
  }
1357
- this._currentWorkingGridStack.push(result.workingGrid);
1601
+ this._pushWorkingGridRef(result.workingGrid, result);
1358
1602
  return result;
1359
1603
  }
1360
1604
  _reconcileGridCOW(targetGrid, targetBoundary, workingGrid) {
@@ -1412,100 +1656,128 @@ class OrderManager {
1412
1656
  return (0, validate_1.buildAbortedResult)(reason);
1413
1657
  }
1414
1658
  async _commitWorkingGrid(workingGrid, _workingIndexes, workingBoundary, options = {}) {
1415
- const { skipRecalc } = this._normalizeCommitOptions(options);
1416
1659
  const startTime = Date.now();
1417
- const stats = workingGrid.getMemoryStats();
1418
1660
  let committed = false;
1419
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
+ };
1420
1680
  try {
1421
- comparePrecisions = this._getCowComparePrecisions();
1422
- }
1423
- catch (precisionErr) {
1424
- this.logger.log(`[COW] ${(0, errors_1.getErrorMessage)(precisionErr)}`, 'error');
1425
- this._clearWorkingGridRef();
1426
- return false;
1427
- }
1428
- const preCommitGuard = (0, validate_1.evaluateCommit)(workingGrid, {
1429
- hasLock: false,
1430
- currentVersion: this._gridVersion,
1431
- masterGrid: this.orders,
1432
- comparePrecisions
1433
- });
1434
- if (!preCommitGuard.canCommit) {
1435
- this.logger.log(`[COW] ${preCommitGuard.reason}`, preCommitGuard.level || 'warn');
1436
- this._clearWorkingGridRef();
1437
- return false;
1438
- }
1439
- await this._gridLock.acquire(async () => {
1440
- const lockCommitGuard = (0, validate_1.evaluateCommit)(workingGrid, {
1441
- 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,
1442
1694
  currentVersion: this._gridVersion,
1443
1695
  masterGrid: this.orders,
1444
1696
  comparePrecisions
1445
1697
  });
1446
- if (!lockCommitGuard.canCommit) {
1447
- this.logger.log(`[COW] ${lockCommitGuard.reason}`, lockCommitGuard.level || 'warn');
1448
- this._clearWorkingGridRef();
1449
- return;
1698
+ if (!preCommitGuard.canCommit) {
1699
+ this.logger.log(`[COW] ${preCommitGuard.reason}`, preCommitGuard.level || 'warn');
1700
+ releaseStackEntry();
1701
+ return false;
1450
1702
  }
1451
- this.logger.log(`[COW] Committing working grid: ${stats.size} orders, ${stats.modified} modified`, 'debug');
1452
- const finalMap = workingGrid.toMap();
1453
- // RC-4: Deep-freeze all modified orders before committing to master state
1454
- // Ensures COW immutability invariants are maintained for all grid entries.
1455
- for (const [, order] of finalMap.entries()) {
1456
- if (order && !Object.isFrozen(order)) {
1457
- (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;
1458
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;
1459
1745
  }
1460
- this.orders = Object.freeze(finalMap);
1461
- this._setBoundary(workingBoundary);
1462
- this._gridVersion++;
1463
- committed = true;
1464
- // Track orderIds from successful COW commits for recovery sync protection.
1465
- // Build a new set from the committed finalMap and atomically swap to
1466
- // avoid the intermediate empty state that clear() creates — a crash
1467
- // during clear()+repopulate would lose all committed IDs.
1468
- const newCommittedIds = new Set();
1469
- for (const [, order] of finalMap.entries()) {
1470
- if (order.orderId)
1471
- newCommittedIds.add(order.orderId);
1472
- }
1473
- this._committedOrderIds = newCommittedIds;
1474
- this._committedOrderIdsBuiltAt = Date.now();
1475
- // Index caches invalidated by _gridVersion bump above — lazy getters recompute from this.orders
1476
- });
1477
- if (!committed) {
1478
- this._clearWorkingGridRef();
1479
- return false;
1480
- }
1481
- try {
1482
- if (!skipRecalc) {
1483
- // If the last accounting failure was due to stale accountTotals,
1484
- // the commit proceeded without locking funds. Refresh accountTotals
1485
- // before recalculateFunds() to avoid a false-positive drift recovery.
1486
- if (this._lastAccountingFailure?.reason === 'stale') {
1487
- this.logger.log('[COW] Stale accountTotals during commit; refreshing before recalculateFunds to avoid false recovery', 'warn');
1488
- 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');
1489
1761
  }
1490
- await this.recalculateFunds();
1491
1762
  }
1492
- const duration = Date.now() - startTime;
1493
- this.logger.log(`[COW] Grid committed in ${duration}ms`, 'debug');
1494
- if (stats.size > constants_1.COW_PERFORMANCE.GRID_MEMORY_WARNING) {
1495
- 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;
1496
1770
  }
1771
+ releaseStackEntry();
1772
+ return true;
1497
1773
  }
1498
- catch (recalcErr) {
1499
- this.logger.log(`[COW] Fund recalculation failed post-commit: ${(0, errors_1.getErrorMessage)(recalcErr)}`, 'error');
1500
- this._recoveryState = { ...this._recoveryState, lastFailureAt: Date.now() };
1501
- // Re-throw to signal callers that the commit is incomplete
1502
- // (grid state committed, but fund state is stale).
1503
- 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;
1504
1780
  }
1505
- finally {
1506
- this._clearWorkingGridRef();
1507
- }
1508
- return true;
1509
1781
  }
1510
1782
  /**
1511
1783
  * @param {Object} [options]
@@ -1546,7 +1818,7 @@ class OrderManager {
1546
1818
  * swapping the live map and exposing it to concurrent readers.
1547
1819
  * @returns {Promise<import('./types').PersistenceValidationResult>}
1548
1820
  */
1549
- async persistGrid(snapshotOrders, recentFillKeys) {
1821
+ async persistGrid(snapshotOrders, recentFillKeys, fundSnapshot) {
1550
1822
  if (this._gridPersistenceSuspendedReason) {
1551
1823
  this.logger.log(`[PERSISTENCE-GATE] Skipping grid persistence while suspended: ${this._gridPersistenceSuspendedReason}`, 'warn');
1552
1824
  return { isValid: true, skipped: true, suspended: true, reason: this._gridPersistenceSuspendedReason };
@@ -1556,7 +1828,20 @@ class OrderManager {
1556
1828
  this.logger.log(`[PERSISTENCE-GATE] Skipping persistence of corrupted state: ${validation.reason}`, 'warn');
1557
1829
  return validation;
1558
1830
  }
1559
- 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);
1560
1845
  if (persisted === false) {
1561
1846
  this.logger.log(`[PERSIST] Grid persistence FAILED (disk full / permissions / write error)`, 'error');
1562
1847
  // Keep the dirty flag set so the next flush retries.