dexbot 1.2.1 → 1.2.3

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 (76) hide show
  1. package/dist/modules/bitshares-native/lru_cache.d.ts +9 -0
  2. package/dist/modules/bitshares-native/lru_cache.d.ts.map +1 -1
  3. package/dist/modules/bitshares-native/lru_cache.js +12 -0
  4. package/dist/modules/bitshares-native/lru_cache.js.map +1 -1
  5. package/dist/modules/bitshares-native/tx/builder.d.ts +4 -0
  6. package/dist/modules/bitshares-native/tx/builder.d.ts.map +1 -1
  7. package/dist/modules/bitshares-native/tx/builder.js +19 -0
  8. package/dist/modules/bitshares-native/tx/builder.js.map +1 -1
  9. package/dist/modules/bitshares-native/tx/tx_cache.d.ts +7 -0
  10. package/dist/modules/bitshares-native/tx/tx_cache.d.ts.map +1 -1
  11. package/dist/modules/bitshares-native/tx/tx_cache.js +11 -0
  12. package/dist/modules/bitshares-native/tx/tx_cache.js.map +1 -1
  13. package/dist/modules/bitshares_client.d.ts.map +1 -1
  14. package/dist/modules/bitshares_client.js +5 -0
  15. package/dist/modules/bitshares_client.js.map +1 -1
  16. package/dist/modules/constants.d.ts +1 -0
  17. package/dist/modules/constants.d.ts.map +1 -1
  18. package/dist/modules/constants.js +1 -0
  19. package/dist/modules/constants.js.map +1 -1
  20. package/dist/modules/dexbot_class.d.ts +22 -8
  21. package/dist/modules/dexbot_class.d.ts.map +1 -1
  22. package/dist/modules/dexbot_class.js +266 -146
  23. package/dist/modules/dexbot_class.js.map +1 -1
  24. package/dist/modules/dexbot_fill_runtime.d.ts.map +1 -1
  25. package/dist/modules/dexbot_fill_runtime.js +3 -0
  26. package/dist/modules/dexbot_fill_runtime.js.map +1 -1
  27. package/dist/modules/dexbot_maintenance_runtime.d.ts.map +1 -1
  28. package/dist/modules/dexbot_maintenance_runtime.js +9 -0
  29. package/dist/modules/dexbot_maintenance_runtime.js.map +1 -1
  30. package/dist/modules/node_manager.d.ts +1 -0
  31. package/dist/modules/node_manager.d.ts.map +1 -1
  32. package/dist/modules/node_manager.js +11 -1
  33. package/dist/modules/node_manager.js.map +1 -1
  34. package/dist/modules/order/accounting.d.ts.map +1 -1
  35. package/dist/modules/order/accounting.js +4 -1
  36. package/dist/modules/order/accounting.js.map +1 -1
  37. package/dist/modules/order/grid.d.ts +1 -0
  38. package/dist/modules/order/grid.d.ts.map +1 -1
  39. package/dist/modules/order/grid.js +124 -5
  40. package/dist/modules/order/grid.js.map +1 -1
  41. package/dist/modules/order/index.d.ts +3 -8
  42. package/dist/modules/order/index.d.ts.map +1 -1
  43. package/dist/modules/order/index.js +3 -10
  44. package/dist/modules/order/index.js.map +1 -1
  45. package/dist/modules/order/manager.d.ts +2 -0
  46. package/dist/modules/order/manager.d.ts.map +1 -1
  47. package/dist/modules/order/manager.js +5 -0
  48. package/dist/modules/order/manager.js.map +1 -1
  49. package/dist/modules/order/strategy.js +1 -1
  50. package/dist/modules/order/strategy.js.map +1 -1
  51. package/dist/modules/order/sync_engine.d.ts.map +1 -1
  52. package/dist/modules/order/sync_engine.js +40 -17
  53. package/dist/modules/order/sync_engine.js.map +1 -1
  54. package/dist/modules/order/utils/math.d.ts +28 -1
  55. package/dist/modules/order/utils/math.d.ts.map +1 -1
  56. package/dist/modules/order/utils/math.js +40 -2
  57. package/dist/modules/order/utils/math.js.map +1 -1
  58. package/dist/modules/order/utils/order.d.ts.map +1 -1
  59. package/dist/modules/order/utils/order.js +6 -3
  60. package/dist/modules/order/utils/order.js.map +1 -1
  61. package/dist/modules/utils/math_utils.d.ts +3 -6
  62. package/dist/modules/utils/math_utils.d.ts.map +1 -1
  63. package/dist/modules/utils/math_utils.js +1 -14
  64. package/dist/modules/utils/math_utils.js.map +1 -1
  65. package/dist/{modules/order → scripts}/runner.d.ts +15 -9
  66. package/dist/scripts/runner.d.ts.map +1 -0
  67. package/dist/{modules/order → scripts}/runner.js +24 -45
  68. package/dist/scripts/runner.js.map +1 -0
  69. package/package.json +1 -2
  70. package/scripts/README.md +13 -0
  71. package/dist/modules/cli_whitelist_args.d.ts +0 -6
  72. package/dist/modules/cli_whitelist_args.d.ts.map +0 -1
  73. package/dist/modules/cli_whitelist_args.js +0 -13
  74. package/dist/modules/cli_whitelist_args.js.map +0 -1
  75. package/dist/modules/order/runner.d.ts.map +0 -1
  76. package/dist/modules/order/runner.js.map +0 -1
@@ -134,7 +134,6 @@ class DEXBot {
134
134
  _currentCycleId;
135
135
  _autoCancelOrphanCycleMarker;
136
136
  _autoCancelOrphanSubCount;
137
- _orphanFillsCreditedAt;
138
137
  _consecutiveConsumeFailures;
139
138
  _consumeFailureFirstAt;
140
139
  _reconnectUnregister;
@@ -226,7 +225,6 @@ class DEXBot {
226
225
  this._currentCycleId = 0;
227
226
  this._autoCancelOrphanCycleMarker = null;
228
227
  this._autoCancelOrphanSubCount = 0;
229
- this._orphanFillsCreditedAt = null;
230
228
  // Per-session guard: ghost order IDs successfully cancelled
231
229
  // (avoids spamming the chain with repeated cancel attempts for the same
232
230
  // orphan residual on every fill cycle).
@@ -598,6 +596,42 @@ class DEXBot {
598
596
  }
599
597
  this.manager.logger.log(`[RECOVERY] Grid reloaded from persisted snapshot: ${this.manager.orders.size} orders, ` +
600
598
  `${chainOpenOrders.length} on-chain orders synced`, 'info');
599
+ // Gap 2: Check for unmatched chain orders after reload + sync.
600
+ // If the sync resolved all unmatched entries, _lastUnmatchedChainOrders
601
+ // was cleared by the sync engine. If any remain, the persisted snapshot
602
+ // produced an inconsistent grid — reject so the structural resync
603
+ // falls through to requestGridReset (full rebuild from chain).
604
+ const remainingUnmatched = Array.isArray(this.manager?._lastUnmatchedChainOrders)
605
+ ? this.manager._lastUnmatchedChainOrders
606
+ : [];
607
+ if (remainingUnmatched.length > 0) {
608
+ const sample = remainingUnmatched.slice(0, 3)
609
+ .map(o => this._formatUnmatchedChainOrderForLog(o))
610
+ .join(' | ');
611
+ this.manager.logger.log(`[RECOVERY] Persisted grid reloaded but ${remainingUnmatched.length} unmatched chain order(s) ` +
612
+ `remain${sample ? ` (${sample})` : ''}. Rejecting — full grid reset required.`, 'warn');
613
+ return { success: false, reason: `grid inconsistent after reload: ${remainingUnmatched.length} unmatched remain` };
614
+ }
615
+ // If the reloaded grid is still bloated, reject recovery so the
616
+ // caller falls through to a full grid reset (requestGridReset).
617
+ // Without this check, a bloated snapshot gets accepted as "success"
618
+ // and the structural-resync loop loads the same broken state forever.
619
+ //
620
+ // NOTE: loadGrid() already fires requestStructuralGridResync when it
621
+ // detects bloat internally, so the inner async resync may be in flight
622
+ // by the time this outer check runs. That's fine — the structural-resync
623
+ // gate (_structuralGridResyncRunning / _structuralGridResyncTimer) dedup's
624
+ // concurrent requests. This outer check exists so the synchronous return
625
+ // value is honest about the state; the inner resync is a safety net.
626
+ const { isGridBloated } = require('./order/grid');
627
+ const ordersArr = Array.from(this.manager.orders.values());
628
+ const bloatPostRecovery = isGridBloated(this.manager, ordersArr);
629
+ if (bloatPostRecovery.bloated) {
630
+ const d = bloatPostRecovery.details;
631
+ this.manager.logger.log(`[RECOVERY] Persisted grid reloaded but still bloated ` +
632
+ `(${d.gridSize} slots, max ${d.maxAllowed}). Rejecting — full grid reset required.`, 'warn');
633
+ return { success: false, reason: 'grid still bloated after reload' };
634
+ }
601
635
  return { success: true };
602
636
  }
603
637
  catch (err) {
@@ -1070,24 +1104,30 @@ class DEXBot {
1070
1104
  if (trackedFillKey && !this._isNewFillKey(trackedFillKey, processedFillKeys, '[POST-RESET]', fillOp.order_id)) {
1071
1105
  continue;
1072
1106
  }
1073
- const accountingResult = await this._applyReplaySafeTrackedFillAccounting(fill, fillOp, {
1074
- context: 'POST-RESET',
1075
- logger: { log: this._log.bind(this) },
1076
- replayMessage: (op) => `[POST-RESET] Replay detected for ${op.order_id}; skipping duplicate rebalance`
1077
- });
1078
- if (accountingResult.status === 'missing_key') {
1079
- requiresOpenOrdersSync = true;
1080
- continue;
1081
- }
1082
- if (accountingResult.status !== 'applied') {
1083
- continue;
1107
+ this.manager.lockOrders([gridOrder.id]);
1108
+ try {
1109
+ const accountingResult = await this._applyReplaySafeTrackedFillAccounting(fill, fillOp, {
1110
+ context: 'POST-RESET',
1111
+ logger: { log: this._log.bind(this) },
1112
+ replayMessage: (op) => `[POST-RESET] Replay detected for ${op.order_id}; skipping duplicate rebalance`
1113
+ });
1114
+ if (accountingResult.status === 'missing_key') {
1115
+ requiresOpenOrdersSync = true;
1116
+ continue;
1117
+ }
1118
+ if (accountingResult.status !== 'applied') {
1119
+ continue;
1120
+ }
1121
+ // Process this fill through the full rebalance pipeline
1122
+ // This will shift the boundary and place a new order on the filled slot
1123
+ const result = await this._processFillsWithBatching([gridOrder], new Set(), `[POST-RESET] fill ${gridOrder.id}`);
1124
+ if (result.aborted) {
1125
+ this._warn('[POST-RESET] Aborted batch due to illegal state; skipping grid persistence this cycle');
1126
+ continue;
1127
+ }
1084
1128
  }
1085
- // Process this fill through the full rebalance pipeline
1086
- // This will shift the boundary and place a new order on the filled slot
1087
- const result = await this._processFillsWithBatching([gridOrder], new Set(), `[POST-RESET] fill ${gridOrder.id}`);
1088
- if (result.aborted) {
1089
- this._warn('[POST-RESET] Aborted batch due to illegal state; skipping grid persistence this cycle');
1090
- continue;
1129
+ finally {
1130
+ this.manager.unlockOrders([gridOrder.id]);
1091
1131
  }
1092
1132
  }
1093
1133
  if (requiresOpenOrdersSync) {
@@ -1566,10 +1606,12 @@ class DEXBot {
1566
1606
  // fill cycle. It is re-set when orphan fills are credited,
1567
1607
  // and consumed (set to null) on the next fund-invariant check
1568
1608
  // in accounting.ts, which widens tolerance by 5x while set.
1609
+ // Written to this.manager (not this) because accounting.ts
1610
+ // reads from the OrderManager reference (mgr).
1569
1611
  // Also cleared by _performStateRecovery after a fresh chain
1570
1612
  // fetch. The timestamp value itself is not compared against a
1571
1613
  // window — it acts as a consume-on-read boolean.
1572
- this._orphanFillsCreditedAt = null;
1614
+ this.manager._orphanFillsCreditedAt = null;
1573
1615
  while (this._incomingFillQueue.length > 0) {
1574
1616
  const batchStartTime = Date.now();
1575
1617
  // Track max queue depth
@@ -1634,9 +1676,9 @@ class DEXBot {
1634
1676
  requiresOpenOrdersSync = true;
1635
1677
  }
1636
1678
  // Record orphan fill credit timestamp for fund invariant
1637
- // tolerance widening. Accounting checks recency via
1638
- // _checkOrphanFillRecency() instead of a cross-module flag.
1639
- this._orphanFillsCreditedAt = Date.now();
1679
+ // tolerance widening. Written to this.manager because
1680
+ // accounting.ts reads from the OrderManager (mgr).
1681
+ this.manager._orphanFillsCreditedAt = Date.now();
1640
1682
  // Don't add to validFills - we can't do rebalancing without a grid slot
1641
1683
  // But the funds are now credited, preventing fund invariant violation
1642
1684
  continue;
@@ -1644,7 +1686,7 @@ class DEXBot {
1644
1686
  // Process both maker and taker fills for our grid orders
1645
1687
  // Grid validation ensures we only process fills belonging to our account
1646
1688
  // Taker fills are included because the bot may execute market orders or act as taker
1647
- const roleStr = fillOp.is_maker ? 'maker' : 'taker';
1689
+ const roleStr = fillOp.is_maker !== false ? 'maker' : 'taker';
1648
1690
  this.manager.logger.log(`Processing ${roleStr} fill for order ${fillOp.order_id}`, 'debug');
1649
1691
  const fillKey = buildFillKey(fill);
1650
1692
  if (!fillKey) {
@@ -1751,6 +1793,12 @@ class DEXBot {
1751
1793
  const sortedBlocks = [...fillsByBlock.keys()].sort((a, b) => a - b);
1752
1794
  const accumulatedOrders = [];
1753
1795
  let anyRequiresSync = false;
1796
+ // Capture whether any fill was skipped during validation
1797
+ // (e.g. missing history ID) and set the sync flag. The
1798
+ // per-block reset below would lose this state since those
1799
+ // fills were filtered out of validFills and never enter
1800
+ // any block group.
1801
+ const initialRequiresSync = requiresOpenOrdersSync;
1754
1802
  for (const blockNum of sortedBlocks) {
1755
1803
  // Reset requiresOpenOrdersSync per block group so one
1756
1804
  // block's history-id gap doesn't force the next block
@@ -1762,12 +1810,24 @@ class DEXBot {
1762
1810
  if (requiresOpenOrdersSync)
1763
1811
  anyRequiresSync = true;
1764
1812
  }
1765
- requiresOpenOrdersSync = anyRequiresSync;
1813
+ // Preserve both per-block sync flags and the initial flag
1814
+ // from filtered-out fills (missing history ID).
1815
+ requiresOpenOrdersSync = anyRequiresSync || initialRequiresSync;
1766
1816
  if (fillsWithoutBlock.length > 0) {
1767
1817
  this.manager.logger.log(`[FILL-BLOCK] Processing ${fillsWithoutBlock.length} fill(s) without block info`, 'debug');
1768
1818
  const noBlockResult = await processValidFills(fillsWithoutBlock);
1769
1819
  accumulatedOrders.push(...noBlockResult);
1770
1820
  }
1821
+ // If a fill was filtered out during validation (e.g. missing
1822
+ // history ID) and no block group triggered the open-orders
1823
+ // sync, run it now so the grid state is reconciled.
1824
+ if (requiresOpenOrdersSync && !anyRequiresSync) {
1825
+ this.manager.logger.log('[FILL-BLOCK] Running open-orders sync for fills with missing history identifiers', 'warn');
1826
+ const fallbackOrders = await processValidFills([]);
1827
+ accumulatedOrders.push(...fallbackOrders);
1828
+ // Flag consumed — variable goes out of scope on
1829
+ // next while iteration.
1830
+ }
1771
1831
  allFilledOrders = accumulatedOrders;
1772
1832
  // 4. Handle Price Corrections
1773
1833
  if (ordersNeedingCorrection.length > 0) {
@@ -2002,20 +2062,26 @@ class DEXBot {
2002
2062
  if (trackedFillKey && !this._isNewFillKey(trackedFillKey, processedFillKeys, '[BOOTSTRAP]', fillOp.order_id)) {
2003
2063
  continue;
2004
2064
  }
2005
- const accountingResult = await this._applyReplaySafeTrackedFillAccounting(fill, fillOp, {
2006
- context: 'BOOTSTRAP',
2007
- replayMessage: (op) => `[BOOTSTRAP] Replay detected for ${op.order_id}; skipping duplicate bootstrap rebalance`
2008
- });
2009
- if (accountingResult.status === 'missing_key') {
2010
- requiresOpenOrdersSync = true;
2011
- continue;
2065
+ this.manager.lockOrders([gridOrder.id]);
2066
+ try {
2067
+ const accountingResult = await this._applyReplaySafeTrackedFillAccounting(fill, fillOp, {
2068
+ context: 'BOOTSTRAP',
2069
+ replayMessage: (op) => `[BOOTSTRAP] Replay detected for ${op.order_id}; skipping duplicate bootstrap rebalance`
2070
+ });
2071
+ if (accountingResult.status === 'missing_key') {
2072
+ requiresOpenOrdersSync = true;
2073
+ continue;
2074
+ }
2075
+ if (accountingResult.status !== 'applied') {
2076
+ continue;
2077
+ }
2078
+ validFills.push({ ...fill, gridOrder });
2079
+ const fillType = gridOrder.type === ORDER_TYPES.BUY ? 'BUY' : 'SELL';
2080
+ this._log(`[BOOTSTRAP] Fill detected: ${fillType} order (${fillOp.is_maker !== false ? 'maker' : 'taker'})`);
2012
2081
  }
2013
- if (accountingResult.status !== 'applied') {
2014
- continue;
2082
+ finally {
2083
+ this.manager.unlockOrders([gridOrder.id]);
2015
2084
  }
2016
- validFills.push({ ...fill, gridOrder });
2017
- const fillType = gridOrder.type === ORDER_TYPES.BUY ? 'BUY' : 'SELL';
2018
- this._log(`[BOOTSTRAP] Fill detected: ${fillType} order (${fillOp.is_maker ? 'maker' : 'taker'})`);
2019
2085
  }
2020
2086
  if (requiresOpenOrdersSync) {
2021
2087
  this._log('[BOOTSTRAP] Falling back to open-orders sync for fill(s) missing replay-safe history identifiers', 'warn');
@@ -2764,6 +2830,45 @@ class DEXBot {
2764
2830
  this.manager.logger.log(`[COW][UNCERTAIN] Discarded planned CREATEs (no chain match after ${recheckRound} recheck(s)): ${discarded
2765
2831
  .map(d => d.slotId)
2766
2832
  .join(', ')}`, 'warn');
2833
+ // Restore target grid sizes for discarded CREATEs so the slots are
2834
+ // not left as virtual/0 after recovery. Without this, the slot stays
2835
+ // at size 0 (set by the fill handler) and is never reactivated until
2836
+ // a fresh fill cycle triggers another rebalance that happens to succeed.
2837
+ // entry.order is the pending-broadcast target order (captured at broadcast
2838
+ // time); its .id matches entry.slotId — the lookup below keys on slotId.
2839
+ for (const entry of discarded) {
2840
+ if (entry.order && entry.slotId) {
2841
+ const current = this.manager.orders.get(entry.slotId);
2842
+ if (current && current.state === ORDER_STATES.VIRTUAL && !current.orderId) {
2843
+ try {
2844
+ await this.manager._applyOrderUpdate({ ...entry.order, state: ORDER_STATES.VIRTUAL, orderId: null }, 'uncertain-recovery-restore-size', { skipAccounting: true, fee: 0 });
2845
+ this.manager.logger.log(`[COW][UNCERTAIN] Restored target size for discarded CREATE slot ${entry.slotId} (size: ${entry.order.size})`, 'info');
2846
+ }
2847
+ catch (restoreErr) {
2848
+ this.manager.logger.log(`[COW][UNCERTAIN] Failed to restore size for slot ${entry.slotId}: ${restoreErr?.message || restoreErr}`, 'warn');
2849
+ }
2850
+ }
2851
+ }
2852
+ }
2853
+ }
2854
+ // ---- Structural resync safeguard after skipAccounting restore ----
2855
+ // The discarded CREATE restore above used skipAccounting: true to avoid
2856
+ // double-counting when the structural resync recalculates accounting
2857
+ // from scratch. Ensure one is scheduled — if already in-flight (timer
2858
+ // or running), the existing resync will handle the accounting fix.
2859
+ if (discarded.length > 0 && typeof this.manager?.requestStructuralGridResync === 'function') {
2860
+ const alreadyScheduled = this._structuralGridResyncRunning || this._structuralGridResyncTimer;
2861
+ if (!alreadyScheduled) {
2862
+ this.manager.logger.log(`[COW][UNCERTAIN] Scheduling structural resync after discarded CREATE restore ` +
2863
+ `(skipAccounting used — resync needed for fund recalculation)`, 'info');
2864
+ this.manager.requestStructuralGridResync('cow-uncertain-accounting-repair', { discardedCount: discarded.length }).catch((err) => {
2865
+ this.manager.logger.log(`[COW][UNCERTAIN] Failed to schedule accounting repair resync: ${err.message}`, 'error');
2866
+ });
2867
+ }
2868
+ else {
2869
+ this.manager.logger.log(`[COW][UNCERTAIN] Structural resync already ${this._structuralGridResyncRunning ? 'running' : 'scheduled'}; ` +
2870
+ `it will repair accounting after discarded CREATE restore`, 'debug');
2871
+ }
2767
2872
  }
2768
2873
  this._clearPendingBroadcasts();
2769
2874
  // Post-recovery safety net: if there are still unmatched chain
@@ -2831,18 +2936,22 @@ class DEXBot {
2831
2936
  return { executed: false, hadRotation, uncertain: true, adopted, discarded };
2832
2937
  }
2833
2938
  /**
2834
- * Auto-cancel a single unmatched chain order from the recovery snapshot.
2939
+ * Auto-cancel a price-drift orphan from the unmatched-order snapshot.
2940
+ *
2941
+ * Only cancels entries with reason === 'price-drift-orphan' — these are
2942
+ * surplus orders that drifted away from their slot price and have no
2943
+ * adoptable grid slot. All other unmatched orders (duplicate-price-level,
2944
+ * already-matched-slot, etc.) are adoptable positions that the structural
2945
+ * resync will integrate into the grid; cancelling them destroys capital.
2835
2946
  *
2836
2947
  * This is the post-recovery safety net: if, after
2837
- * _reconcileAfterUncertainBroadcast runs, there are still chain orders
2838
- * the bot doesn't recognize (e.g. from a network partition, or from a
2839
- * daemon timeout that we couldn't even fingerprint), we cancel ONE of
2840
- * them per call. Per-cycle cap = 1 — the next cycle will pick up the
2841
- * next unmatched order if more remain.
2948
+ * _reconcileAfterUncertainBroadcast runs, there are still price-drift
2949
+ * orphans, cancel ONE per cycle. Per-cycle cap = 1 (or 5 in recovery mode)
2950
+ * — the next cycle will pick up the next orphan if more remain.
2842
2951
  *
2843
2952
  * Safety conditions (ALL must hold):
2844
2953
  * 1. _pendingBroadcasts is empty (no in-flight recovery)
2845
- * 2. _lastUnmatchedChainOrders is non-empty
2954
+ * 2. _lastUnmatchedChainOrders contains at least one price-drift-orphan
2846
2955
  * 3. The current cycle has not already auto-cancelled an orphan
2847
2956
  * (tracked via this._autoCancelOrphanCycleMarker)
2848
2957
  *
@@ -2878,17 +2987,27 @@ class DEXBot {
2878
2987
  if (unmatched.length === 0) {
2879
2988
  return { cancelled: false, reason: 'no-unmatched' };
2880
2989
  }
2881
- const priceDriftOrphan = unmatched.find(u => u && u.reason === 'price-drift-orphan');
2882
- const target = priceDriftOrphan || unmatched[0];
2883
- const orderId = target?.id || target?.orderId || target?.chainOrderId;
2990
+ // Check for fingerprinted entries first — these came from a pending
2991
+ // broadcast (missing-create-result) and must be handled by the recovery
2992
+ // path, not by auto-cancel. The first fingerprinted entry is checked
2993
+ // regardless of its reason field.
2994
+ const fingerprinted = unmatched.find(u => u && u.fingerprint);
2995
+ if (fingerprinted) {
2996
+ return { cancelled: false, reason: 'fingerprinted-handle-via-recovery' };
2997
+ }
2998
+ // Only cancel price-drift orphans — these are surplus orders that
2999
+ // drifted away from their assigned slot price and have no adoptable
3000
+ // grid slot. All other unmatched orders (duplicate-price-level,
3001
+ // already-matched-slot, etc.) are adoptable positions that the structural
3002
+ // resync will integrate into the grid — cancelling them destroys capital.
3003
+ const target = unmatched.find(u => u && u.reason === 'price-drift-orphan');
3004
+ if (!target) {
3005
+ return { cancelled: false, reason: 'no-price-drift-orphan', message: 'no price-drift orphan to cancel; other unmatched orders are adoptable' };
3006
+ }
3007
+ const orderId = target.id || target.orderId || target.chainOrderId;
2884
3008
  if (!orderId) {
2885
3009
  return { cancelled: false, reason: 'no-orderId' };
2886
3010
  }
2887
- if (target?.fingerprint) {
2888
- // Fingerprinted unmatched orders came from a pending broadcast.
2889
- // The recovery path is the right place to handle them, not here.
2890
- return { cancelled: false, reason: 'fingerprinted-handle-via-recovery' };
2891
- }
2892
3011
  if (!chainOrders?.cancelOrder) {
2893
3012
  return { cancelled: false, reason: 'cancelOrder-unavailable' };
2894
3013
  }
@@ -3746,111 +3865,112 @@ class DEXBot {
3746
3865
  ? Array.from(this.manager._pendingBroadcasts.values())
3747
3866
  : [];
3748
3867
  if (hasCreateActions && (unmatchedChainOrders.length > 0 || pendingBroadcasts.length > 0)) {
3749
- // ---- FIX 3: Absorb unmatched orders instead of rejecting ----
3750
- // When unmatched chain orders exist, try to auto-cancel them
3751
- // inline to unblock the CREATE batch. Only fall through to
3752
- // full rejection + structural resync if cancellation fails.
3753
- let cancelledUnmatched = 0;
3754
- if (unmatchedChainOrders.length > 0 && pendingBroadcasts.length === 0) {
3755
- // Bounded-parallel cancellation: process unmatched orders in
3756
- // concurrent batches (3 at a time) to avoid serialising 19+
3757
- // individual cancel txs (~0.5s each = 10s batch hold).
3758
- const CANCEL_CONCURRENCY = 3;
3759
- const cancelable = unmatchedChainOrders.filter(u => {
3760
- const oid = u?.id || u?.orderId || u?.chainOrderId;
3761
- return Boolean(oid) && !u?.fingerprint;
3762
- });
3763
- for (let i = 0; i < cancelable.length; i += CANCEL_CONCURRENCY) {
3764
- const batch = cancelable.slice(i, i + CANCEL_CONCURRENCY);
3765
- const results = await Promise.allSettled(batch.map(async (unmatched) => {
3766
- const orderId = unmatched.id || unmatched.orderId || unmatched.chainOrderId;
3767
- this.manager.logger.log(`[COW] Auto-cancelling unmatched chain order ${orderId} ` +
3768
- `(${this._formatUnmatchedChainOrderForLog(unmatched)}) to unblock CREATE batch.`, 'warn');
3769
- await chainOrders.cancelOrder(this.account, this.privateKey, orderId);
3770
- if (typeof chainOrders.recordOwnCancel === 'function') {
3771
- chainOrders.recordOwnCancel(orderId);
3772
- }
3773
- }));
3774
- for (const r of results) {
3775
- if (r.status === 'fulfilled')
3776
- cancelledUnmatched++;
3777
- else
3778
- this.manager.logger.log(`[COW] Failed to cancel unmatched chain order: ${r.reason?.message || r.reason}`, 'warn');
3779
- }
3780
- }
3781
- if (cancelable.length > 0 && cancelledUnmatched >= cancelable.length) {
3782
- this.manager.logger.log(`[COW] Cancelled all ${cancelledUnmatched} unmatched chain order(s); proceeding with CREATE batch.`, 'warn');
3783
- // Optimistic clear: the cancel txs are sent but not yet
3784
- // confirmed. If a cancel silently fails on chain, the
3785
- // next sync cycle will re-detect the unmatched order and
3786
- // re-enter this guard — that's safe because the cancel
3787
- // is idempotent and the re-detection is non-blocking.
3788
- this.manager._lastUnmatchedChainOrders = [];
3789
- }
3790
- else if (cancelable.length > 0) {
3791
- this.manager.logger.log(`[COW] Cancelled ${cancelledUnmatched}/${cancelable.length} unmatched chain order(s); ` +
3792
- `remaining must be handled by structural reconciliation.`, 'warn');
3793
- }
3794
- }
3795
- // Re-check after auto-cancel attempt
3796
- const remainingUnmatched = Array.isArray(this.manager?._lastUnmatchedChainOrders)
3797
- ? this.manager._lastUnmatchedChainOrders
3798
- : [];
3799
- if (remainingUnmatched.length > 0 || pendingBroadcasts.length > 0) {
3800
- const blockers = [];
3801
- if (remainingUnmatched.length > 0)
3802
- blockers.push(`${remainingUnmatched.length} unmatched chain order(s)`);
3803
- if (pendingBroadcasts.length > 0)
3804
- blockers.push(`${pendingBroadcasts.length} pending broadcast(s)`);
3805
- const reasonText = blockers.join(' and ');
3806
- if (pendingBroadcasts.length > 0) {
3807
- this.manager.logger.log(`[COW] Rejecting CREATE batch: ${reasonText} from a prior uncertain ` +
3808
- `broadcast. Running recovery before placing replacement orders.`, 'error');
3809
- }
3810
- else {
3811
- const sample = remainingUnmatched
3812
- .slice(0, 3)
3813
- .map(order => this._formatUnmatchedChainOrderForLog(order))
3814
- .join(' | ');
3815
- this.manager.logger.log(`[COW] Rejecting CREATE batch: ${reasonText} ` +
3816
- `are not represented in the grid${sample ? ` (${sample})` : ''}. ` +
3817
- `Run structural reconciliation before placing replacement orders.`, 'error');
3818
- }
3868
+ // ---- Handle pending broadcasts first ----
3869
+ if (pendingBroadcasts.length > 0) {
3870
+ this.manager.logger.log(`[COW] Rejecting CREATE batch: ${pendingBroadcasts.length} pending broadcast(s) from a prior uncertain ` +
3871
+ `broadcast. Running recovery before placing replacement orders.`, 'error');
3819
3872
  if (typeof this.manager.requestStructuralGridResync === 'function') {
3820
3873
  if (this.manager._recoveryState)
3821
3874
  this.manager._recoveryState.structuralResyncRequested = true;
3822
- await this.manager.requestStructuralGridResync(pendingBroadcasts.length > 0
3823
- ? 'pending broadcasts before COW create'
3824
- : 'unmatched chain orders before COW create', pendingBroadcasts.length > 0
3825
- ? { pendingBroadcasts: pendingBroadcasts.map(p => p.slotId) }
3826
- : { unmatchedChainOrders: remainingUnmatched });
3875
+ await this.manager.requestStructuralGridResync('pending broadcasts before COW create', { pendingBroadcasts: pendingBroadcasts.map(p => p.slotId) });
3827
3876
  }
3828
- // If we have pending broadcasts, drive the recovery now so the
3829
- // next planning cycle has a clean state.
3830
- if (pendingBroadcasts.length > 0) {
3831
- try {
3832
- await this._reconcileAfterUncertainBroadcast(new BroadcastUncertainError('rejected CREATE batch had pending broadcasts', {
3833
- operations: pendingBroadcasts.map(p => p.order),
3834
- accountName: this.account,
3835
- batchId: this._currentBatchId || null,
3836
- payload: null,
3837
- timeoutMs: null
3838
- }), []);
3839
- }
3840
- catch (recoverErr) {
3841
- this.manager.logger.log(`[COW] Recovery from pending broadcasts failed: ${recoverErr?.message || recoverErr}`, 'error');
3842
- }
3877
+ try {
3878
+ await this._reconcileAfterUncertainBroadcast(new BroadcastUncertainError('rejected CREATE batch had pending broadcasts', {
3879
+ operations: pendingBroadcasts.map(p => p.order),
3880
+ accountName: this.account,
3881
+ batchId: this._currentBatchId || null,
3882
+ payload: null,
3883
+ timeoutMs: null
3884
+ }), []);
3885
+ }
3886
+ catch (recoverErr) {
3887
+ this.manager.logger.log(`[COW] Recovery from pending broadcasts failed: ${recoverErr?.message || recoverErr}`, 'error');
3843
3888
  }
3844
3889
  return {
3845
3890
  executed: false,
3846
3891
  aborted: true,
3847
- reason: pendingBroadcasts.length > 0 ? 'PENDING_BROADCASTS' : 'UNMATCHED_CHAIN_ORDERS',
3848
- unmatchedChainOrders: pendingBroadcasts.length > 0 ? [] : remainingUnmatched,
3849
- pendingBroadcasts: pendingBroadcasts.map(p => p.slotId),
3892
+ reason: 'PENDING_BROADCASTS',
3850
3893
  hadRotation: false
3851
3894
  };
3852
3895
  }
3853
- // All unmatched were cancelled — fall through to continue batch
3896
+ // ---- Unmatched orders: adopt instead of cancel ----
3897
+ // Unmatched orders are legitimate on-chain positions the grid
3898
+ // hasn't adopted yet. Cancelling them destroys capital and
3899
+ // creates gaps in the order book. Instead, re-sync to adopt
3900
+ // them into the grid, then reject (the sync invalidated the
3901
+ // working grid, so the existing COW plan is stale).
3902
+ // NOTE: syncFromOpenOrders may be a partial no-op under certain
3903
+ // lock conditions (e.g. _fillProcessingLock + !isReentrant).
3904
+ // Structural resync scheduled below handles adoption regardless.
3905
+ const unmatchedSample = unmatchedChainOrders
3906
+ .slice(0, 3)
3907
+ .map(o => this._formatUnmatchedChainOrderForLog(o))
3908
+ .join(' | ');
3909
+ this.manager.logger.log(`[COW] ${unmatchedChainOrders.length} unmatched chain order(s) blocking CREATES ` +
3910
+ (unmatchedSample ? `(${unmatchedSample})` : '') +
3911
+ ` — adopting via sync instead of cancelling`, 'info');
3912
+ try {
3913
+ const accountRef = this.account;
3914
+ const freshSnapshot = await chainOrders.readOpenOrders(accountRef);
3915
+ if (freshSnapshot && freshSnapshot.length > 0) {
3916
+ const syncResult = await this.manager.syncFromOpenOrders(freshSnapshot, {
3917
+ skipAccounting: true,
3918
+ });
3919
+ // Only overwrite _lastUnmatchedChainOrders when sync actually
3920
+ // processed orders (filled + updated + corrected > 0).
3921
+ // Force-release and lock-contention early-exit paths return
3922
+ // empty arrays without touching _lastUnmatchedChainOrders —
3923
+ // overwriting with [] would drop stale unmatched entries.
3924
+ if (syncResult && Array.isArray(syncResult.unmatchedChainOrders)) {
3925
+ const processed = (syncResult.filledOrders?.length || 0) +
3926
+ (syncResult.updatedOrders?.length || 0) +
3927
+ (syncResult.ordersNeedingCorrection?.length || 0);
3928
+ if (processed > 0) {
3929
+ this.manager._lastUnmatchedChainOrders = syncResult.unmatchedChainOrders;
3930
+ this.manager.logger.log(`[COW] Adopted chain order(s) via sync: ${processed} processed, ` +
3931
+ `${syncResult.unmatchedChainOrders.length} still unmatched`, 'info');
3932
+ }
3933
+ else {
3934
+ // processed === 0 with unmatched still present means the
3935
+ // sync could not adopt the unmatched chain orders. This is
3936
+ // normal when called re-entrantly (inside _fillProcessingLock):
3937
+ // the sync engine runs inline and either timed out (unlikely
3938
+ // for a re-entrant call) or all chain orders were already
3939
+ // matched — leaving only stale unmatched entries.
3940
+ const syncUnmatchedCount = syncResult.unmatchedChainOrders.length;
3941
+ this.manager.logger.log(`[COW] Sync returned without processing (processed=0, ` +
3942
+ `unmatched=${syncUnmatchedCount} in result, ` +
3943
+ `_lastUnmatchedChainOrders=${unmatchedChainOrders.length}). ` +
3944
+ `Structural resync will handle adoption.`, syncUnmatchedCount > 0 ? 'warn' : 'debug');
3945
+ // If the sync result itself has unmatched entries that
3946
+ // differ from _lastUnmatchedChainOrders, adopt them now
3947
+ // so the stale tracker is more accurate for the resync.
3948
+ if (syncUnmatchedCount > 0 && syncUnmatchedCount !== unmatchedChainOrders.length) {
3949
+ this.manager._lastUnmatchedChainOrders = syncResult.unmatchedChainOrders.map((o) => ({ ...o }));
3950
+ this.manager.logger.log(`[COW] Updated _lastUnmatchedChainOrders from sync result: ` +
3951
+ `${unmatchedChainOrders.length} → ${syncUnmatchedCount}`, 'debug');
3952
+ }
3953
+ }
3954
+ }
3955
+ }
3956
+ }
3957
+ catch (syncErr) {
3958
+ this.manager.logger.log(`[COW] Failed to sync/unmatched orders: ${syncErr?.message || syncErr}`, 'warn');
3959
+ }
3960
+ // Working grid is stale after master grid mutation from sync.
3961
+ // Request structural resync to rebuild the grid on the next cycle.
3962
+ if (typeof this.manager.requestStructuralGridResync === 'function') {
3963
+ if (this.manager._recoveryState)
3964
+ this.manager._recoveryState.structuralResyncRequested = true;
3965
+ await this.manager.requestStructuralGridResync('unmatched chain orders before COW create', { unmatchedChainOrders: unmatchedChainOrders });
3966
+ }
3967
+ this.manager.logger.log(`[COW] Rejecting CREATE batch after sync: working grid invalidated by master mutation`, 'info');
3968
+ return {
3969
+ executed: false,
3970
+ aborted: true,
3971
+ reason: 'UNMATCHED_CHAIN_ORDERS',
3972
+ hadRotation: false
3973
+ };
3854
3974
  }
3855
3975
  const { assetA, assetB } = this.manager.assets;
3856
3976
  const operations = [];