dexbot 1.1.11 → 1.1.13

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 (78) hide show
  1. package/dist/analysis/analyze_derivatives.js +3 -3
  2. package/dist/analysis/analyze_derivatives.js.map +1 -1
  3. package/dist/analysis/analyze_dynamic_weight.js +8 -9
  4. package/dist/analysis/analyze_dynamic_weight.js.map +1 -1
  5. package/dist/analysis/analyze_kalman.js +1 -1
  6. package/dist/analysis/analyze_kalman.js.map +1 -1
  7. package/dist/analysis/analyze_regime.js +1 -1
  8. package/dist/analysis/analyze_regime.js.map +1 -1
  9. package/dist/analysis/analyze_regime_windows.js +1 -1
  10. package/dist/analysis/analyze_regime_windows.js.map +1 -1
  11. package/dist/analysis/analyze_volatility.js +1 -1
  12. package/dist/analysis/analyze_volatility.js.map +1 -1
  13. package/dist/analysis/bot_key_utils.d.ts +6 -0
  14. package/dist/analysis/bot_key_utils.d.ts.map +1 -1
  15. package/dist/analysis/bot_key_utils.js +101 -0
  16. package/dist/analysis/bot_key_utils.js.map +1 -1
  17. package/dist/analysis/tradingview/analyze_tradingview.d.ts +3 -5
  18. package/dist/analysis/tradingview/analyze_tradingview.d.ts.map +1 -1
  19. package/dist/analysis/tradingview/analyze_tradingview.js +10 -50
  20. package/dist/analysis/tradingview/analyze_tradingview.js.map +1 -1
  21. package/dist/analysis/trend_detection/dynamic_weight_chart_generator.d.ts.map +1 -1
  22. package/dist/analysis/trend_detection/dynamic_weight_chart_generator.js +3 -2
  23. package/dist/analysis/trend_detection/dynamic_weight_chart_generator.js.map +1 -1
  24. package/dist/credential-daemon.d.ts.map +1 -1
  25. package/dist/credential-daemon.js +13 -4
  26. package/dist/credential-daemon.js.map +1 -1
  27. package/dist/market_adapter/core/market_adapter_service.d.ts.map +1 -1
  28. package/dist/market_adapter/core/market_adapter_service.js +6 -0
  29. package/dist/market_adapter/core/market_adapter_service.js.map +1 -1
  30. package/dist/market_adapter/market_adapter.d.ts.map +1 -1
  31. package/dist/market_adapter/market_adapter.js +6 -3
  32. package/dist/market_adapter/market_adapter.js.map +1 -1
  33. package/dist/modules/chain_orders.d.ts +5 -1
  34. package/dist/modules/chain_orders.d.ts.map +1 -1
  35. package/dist/modules/chain_orders.js +8 -4
  36. package/dist/modules/chain_orders.js.map +1 -1
  37. package/dist/modules/constants.d.ts +2 -0
  38. package/dist/modules/constants.d.ts.map +1 -1
  39. package/dist/modules/constants.js +8 -0
  40. package/dist/modules/constants.js.map +1 -1
  41. package/dist/modules/dexbot_class.d.ts +29 -0
  42. package/dist/modules/dexbot_class.d.ts.map +1 -1
  43. package/dist/modules/dexbot_class.js +425 -91
  44. package/dist/modules/dexbot_class.js.map +1 -1
  45. package/dist/modules/dexbot_credential_client.d.ts +2 -0
  46. package/dist/modules/dexbot_credential_client.d.ts.map +1 -1
  47. package/dist/modules/dexbot_credential_client.js +69 -43
  48. package/dist/modules/dexbot_credential_client.js.map +1 -1
  49. package/dist/modules/dexbot_maintenance_runtime.d.ts.map +1 -1
  50. package/dist/modules/dexbot_maintenance_runtime.js +47 -4
  51. package/dist/modules/dexbot_maintenance_runtime.js.map +1 -1
  52. package/dist/modules/key_store.d.ts +2 -2
  53. package/dist/modules/key_store.d.ts.map +1 -1
  54. package/dist/modules/key_store.js +4 -1
  55. package/dist/modules/key_store.js.map +1 -1
  56. package/dist/modules/order/accounting.d.ts.map +1 -1
  57. package/dist/modules/order/accounting.js +21 -5
  58. package/dist/modules/order/accounting.js.map +1 -1
  59. package/dist/modules/order/grid_reconcile.d.ts.map +1 -1
  60. package/dist/modules/order/grid_reconcile.js +6 -3
  61. package/dist/modules/order/grid_reconcile.js.map +1 -1
  62. package/dist/modules/order/manager.d.ts +2 -3
  63. package/dist/modules/order/manager.d.ts.map +1 -1
  64. package/dist/modules/order/manager.js +18 -21
  65. package/dist/modules/order/manager.js.map +1 -1
  66. package/dist/modules/order/sync_engine.d.ts +11 -3
  67. package/dist/modules/order/sync_engine.d.ts.map +1 -1
  68. package/dist/modules/order/sync_engine.js +41 -5
  69. package/dist/modules/order/sync_engine.js.map +1 -1
  70. package/dist/modules/order/utils/system.d.ts +1 -0
  71. package/dist/modules/order/utils/system.d.ts.map +1 -1
  72. package/dist/modules/order/utils/system.js +90 -38
  73. package/dist/modules/order/utils/system.js.map +1 -1
  74. package/dist/modules/paths.d.ts +1 -0
  75. package/dist/modules/paths.d.ts.map +1 -1
  76. package/dist/modules/paths.js +1 -0
  77. package/dist/modules/paths.js.map +1 -1
  78. package/package.json +1 -1
@@ -71,7 +71,7 @@ const { ProcessedFillStore, PROCESSED_FILL_PERSISTENCE_MODES } = require('./orde
71
71
  const DexbotFillRuntime = require('./dexbot_fill_runtime');
72
72
  const DexbotMaintenanceRuntime = require('./dexbot_maintenance_runtime');
73
73
  const CreditRuntime = require('./credit_runtime');
74
- const { ORDER_STATES, ORDER_TYPES, REBALANCE_STATES, COW_ACTIONS, TIMING, MAINTENANCE, FILL_PROCESSING, DAEMON_CODES, } = require('./constants');
74
+ const { ORDER_STATES, ORDER_TYPES, REBALANCE_STATES, COW_ACTIONS, TIMING, PIPELINE_TIMING, MAINTENANCE, FILL_PROCESSING, DAEMON_CODES, } = require('./constants');
75
75
  const { PATHS, getRecalculateTriggerFile } = require('./paths');
76
76
  const { attemptResumePersistedGridByPriceMatch, decideStartupGridAction, reconcileGridOrders } = require('./order/grid_reconcile');
77
77
  const { AccountOrders } = require('./account_orders');
@@ -132,12 +132,15 @@ class DEXBot {
132
132
  _lastGridActivityAt;
133
133
  _currentCycleId;
134
134
  _autoCancelOrphanCycleMarker;
135
+ _autoCancelOrphanSubCount;
136
+ _orphanFillsCreditedAt;
135
137
  _consecutiveConsumeFailures;
136
138
  _consumeFailureFirstAt;
137
139
  _reconnectUnregister;
138
140
  _credentialRecoveryDeferredTimer;
139
141
  _structuralGridResyncTimer;
140
142
  _structuralGridResyncRunning;
143
+ _ghostOrderCancelAttempted;
141
144
  _dustHealthCheckTimer;
142
145
  _lastBroadcastHeartbeatAt;
143
146
  _currentBatchId;
@@ -220,6 +223,12 @@ class DEXBot {
220
223
  this._lastGridActivityAt = 0;
221
224
  this._currentCycleId = 0;
222
225
  this._autoCancelOrphanCycleMarker = null;
226
+ this._autoCancelOrphanSubCount = 0;
227
+ this._orphanFillsCreditedAt = null;
228
+ // Per-session guard: ghost order IDs successfully cancelled
229
+ // (avoids spamming the chain with repeated cancel attempts for the same
230
+ // orphan residual on every fill cycle).
231
+ this._ghostOrderCancelAttempted = null;
223
232
  // Dust cancellation uses immediate on-chain cancel — no maps or timers needed.
224
233
  this._dustHealthCheckTimer = null;
225
234
  // Fill consumer watchdog: consecutive failure tracking.
@@ -364,7 +373,7 @@ class DEXBot {
364
373
  this.manager.logger.log(`Triggering state recovery sync (${reason})...`, 'info');
365
374
  await this.manager.fetchAccountTotals(this.accountId);
366
375
  const openOrders = await chainOrders.readOpenOrders(this.accountId);
367
- await this.manager.syncFromOpenOrders(openOrders, { skipAccounting: true, fillLockAlreadyHeld: true });
376
+ await this.manager.syncFromOpenOrders(openOrders, { skipAccounting: true, fillLockAlreadyHeld: true, protectCommittedOrders: true });
368
377
  if (typeof this.manager.persistGrid === 'function') {
369
378
  await this.manager.persistGrid();
370
379
  }
@@ -538,6 +547,57 @@ class DEXBot {
538
547
  }
539
548
  return Array.from(ids);
540
549
  }
550
+ /**
551
+ * Reload the entire grid from the persisted on-disk snapshot and reconcile
552
+ * with current chain state. Mirrors the startup recovery path.
553
+ *
554
+ * LOCK SAFETY: Does NOT acquire _fillProcessingLock. Caller must either
555
+ * hold it already or accept concurrent fill-processing risk — matches
556
+ * the startup pattern at line ~1340.
557
+ *
558
+ * @returns {Promise<{success: boolean, reason?: string}>}
559
+ */
560
+ async _recoverFromPersistedGrid() {
561
+ if (!this.accountOrders || !this.manager) {
562
+ return { success: false, reason: 'accountOrders or manager unavailable' };
563
+ }
564
+ const accountRef = this.accountId || this.account?.id || this.account;
565
+ if (!accountRef) {
566
+ return { success: false, reason: 'no account reference' };
567
+ }
568
+ this.manager.logger.log('[RECOVERY] Attempting full grid reload from persisted snapshot...', 'warn');
569
+ try {
570
+ // 1. Force reload from disk
571
+ const persistedGrid = this.accountOrders.loadGrid(true);
572
+ if (!persistedGrid || persistedGrid.length === 0) {
573
+ return { success: false, reason: 'no persisted grid on disk' };
574
+ }
575
+ const boundaryIdx = this.accountOrders.loadBoundaryIdx(true);
576
+ // 2. Load into manager (same path as startup at line 1340)
577
+ await Grid.loadGrid(this.manager, persistedGrid, boundaryIdx);
578
+ // 3. Read current chain state
579
+ const chainOpenOrders = await chainOrders.readOpenOrders(accountRef);
580
+ // 4. Reconcile
581
+ if (chainOpenOrders.length > 0 && this.manager?.syncFromOpenOrders) {
582
+ await this.manager.syncFromOpenOrders(chainOpenOrders, {
583
+ skipAccounting: true,
584
+ fillLockAlreadyHeld: true,
585
+ protectCommittedOrders: true
586
+ });
587
+ }
588
+ // 5. Persist the reconciled state
589
+ if (typeof this.manager.persistGrid === 'function') {
590
+ await this.manager.persistGrid();
591
+ }
592
+ this.manager.logger.log(`[RECOVERY] Grid reloaded from persisted snapshot: ${this.manager.orders.size} orders, ` +
593
+ `${chainOpenOrders.length} on-chain orders synced`, 'info');
594
+ return { success: true };
595
+ }
596
+ catch (err) {
597
+ this.manager.logger.log(`[RECOVERY] Full grid reload from persisted snapshot failed: ${err.message}`, 'error');
598
+ return { success: false, reason: err.message };
599
+ }
600
+ }
541
601
  /**
542
602
  * Attempt to repair size-drift for specific order IDs by reading their
543
603
  * current on-chain state and correcting the local grid directly.
@@ -1147,6 +1207,12 @@ class DEXBot {
1147
1207
  await Grid.loadGrid(this.manager, persistedGrid, persistedBoundaryIdx);
1148
1208
  let startupChainOpenOrders = chainOpenOrders;
1149
1209
  const syncResult = await this.manager.syncFromOpenOrders(startupChainOpenOrders, { skipAccounting: true, fillLockAlreadyHeld: true });
1210
+ // Process price corrections queued during startup sync.
1211
+ // These are not picked up by _consumeFillQueue until a fill
1212
+ // arrives, which may never come for an idle market.
1213
+ if (syncResult.ordersNeedingCorrection?.length > 0) {
1214
+ await correctAllPriceMismatches(this.manager, this.account, this.privateKey, chainOrders);
1215
+ }
1150
1216
  if (syncResult.filledOrders && syncResult.filledOrders.length > 0) {
1151
1217
  this._log(`Startup sync: ${syncResult.filledOrders.length} grid order(s) found filled. Processing proceeds.`, 'info');
1152
1218
  const batchResult = await this._processFillsWithBatching(syncResult.filledOrders, new Set(), 'startup sync fill rebalance', { skipAccountTotalsUpdate: true });
@@ -1438,6 +1504,14 @@ class DEXBot {
1438
1504
  return;
1439
1505
  }
1440
1506
  await this.manager._fillProcessingLock.acquire(async () => {
1507
+ // Reset orphan-fill credit timestamp at the start of each
1508
+ // fill cycle. It is re-set when orphan fills are credited,
1509
+ // and consumed (set to null) on the next fund-invariant check
1510
+ // in accounting.ts, which widens tolerance by 5x while set.
1511
+ // Also cleared by _performStateRecovery after a fresh chain
1512
+ // fetch. The timestamp value itself is not compared against a
1513
+ // window — it acts as a consume-on-read boolean.
1514
+ this._orphanFillsCreditedAt = null;
1441
1515
  while (this._incomingFillQueue.length > 0) {
1442
1516
  const batchStartTime = Date.now();
1443
1517
  // Track max queue depth
@@ -1501,6 +1575,10 @@ class DEXBot {
1501
1575
  if (accountingResult.status === 'missing_key') {
1502
1576
  requiresOpenOrdersSync = true;
1503
1577
  }
1578
+ // Record orphan fill credit timestamp for fund invariant
1579
+ // tolerance widening. Accounting checks recency via
1580
+ // _checkOrphanFillRecency() instead of a cross-module flag.
1581
+ this._orphanFillsCreditedAt = Date.now();
1504
1582
  // Don't add to validFills - we can't do rebalancing without a grid slot
1505
1583
  // But the funds are now credited, preventing fund invariant violation
1506
1584
  continue;
@@ -1547,6 +1625,7 @@ class DEXBot {
1547
1625
  // 3. Sync and Collect Filled Orders
1548
1626
  let allFilledOrders = [];
1549
1627
  let ordersNeedingCorrection = [];
1628
+ const pendingGhostOrders = new Set();
1550
1629
  const fillMode = chainOrders.getFillProcessingMode();
1551
1630
  const processValidFills = async (fillsToSync) => {
1552
1631
  let resolvedOrders = [];
@@ -1568,6 +1647,8 @@ class DEXBot {
1568
1647
  resolvedOrders.push(...resultHistory.filledOrders);
1569
1648
  if (resultHistory.requiresOpenOrdersSync)
1570
1649
  requiresOpenOrdersSync = true;
1650
+ if (resultHistory.ghostOrderId)
1651
+ pendingGhostOrders.add(resultHistory.ghostOrderId);
1571
1652
  }
1572
1653
  }
1573
1654
  if (fillMode !== 'history' || requiresOpenOrdersSync) {
@@ -1587,7 +1668,49 @@ class DEXBot {
1587
1668
  };
1588
1669
  this.manager.pauseFundRecalc();
1589
1670
  try {
1590
- allFilledOrders = await processValidFills(validFills);
1671
+ // FIX 1: Block-level fill batching — group valid fills by block
1672
+ // and process each block group as a unit. This prevents slot
1673
+ // collisions when fills from the same block (original + replacement
1674
+ // orders both filled) arrive in different sync batches. Processing
1675
+ // all fills from a block together lets the sync engine see every
1676
+ // fill for overlapping slots simultaneously.
1677
+ const fillsByBlock = new Map();
1678
+ const fillsWithoutBlock = [];
1679
+ for (const fill of validFills) {
1680
+ if (fill.block_num != null) {
1681
+ const list = fillsByBlock.get(fill.block_num);
1682
+ if (list)
1683
+ list.push(fill);
1684
+ else
1685
+ fillsByBlock.set(fill.block_num, [fill]);
1686
+ }
1687
+ else {
1688
+ fillsWithoutBlock.push(fill);
1689
+ }
1690
+ }
1691
+ // Process block groups in ascending block order so the sync
1692
+ // engine sees a deterministic, chronological fill sequence.
1693
+ const sortedBlocks = [...fillsByBlock.keys()].sort((a, b) => a - b);
1694
+ const accumulatedOrders = [];
1695
+ let anyRequiresSync = false;
1696
+ for (const blockNum of sortedBlocks) {
1697
+ // Reset requiresOpenOrdersSync per block group so one
1698
+ // block's history-id gap doesn't force the next block
1699
+ // into an unnecessary open-orders snapshot re-fetch.
1700
+ requiresOpenOrdersSync = false;
1701
+ this.manager.logger.log(`[FILL-BLOCK] Processing ${fillsByBlock.get(blockNum).length} fill(s) from block ${blockNum}`, 'debug');
1702
+ const blockResult = await processValidFills(fillsByBlock.get(blockNum));
1703
+ accumulatedOrders.push(...blockResult);
1704
+ if (requiresOpenOrdersSync)
1705
+ anyRequiresSync = true;
1706
+ }
1707
+ requiresOpenOrdersSync = anyRequiresSync;
1708
+ if (fillsWithoutBlock.length > 0) {
1709
+ this.manager.logger.log(`[FILL-BLOCK] Processing ${fillsWithoutBlock.length} fill(s) without block info`, 'debug');
1710
+ const noBlockResult = await processValidFills(fillsWithoutBlock);
1711
+ accumulatedOrders.push(...noBlockResult);
1712
+ }
1713
+ allFilledOrders = accumulatedOrders;
1591
1714
  // 4. Handle Price Corrections
1592
1715
  if (ordersNeedingCorrection.length > 0) {
1593
1716
  const correctionResult = await correctAllPriceMismatches(this.manager, this.account, this.privateKey, chainOrders);
@@ -1596,6 +1719,30 @@ class DEXBot {
1596
1719
  }
1597
1720
  }
1598
1721
  finally {
1722
+ // 4b. Cancel orphaned chain orders detected by other-side rounding.
1723
+ // These orders have a tiny residual that the bot treated as fully
1724
+ // filled, but the blockchain did not close the order. We send a
1725
+ // cancel tx to clean up the zombie order on chain.
1726
+ // Runs in the finally so it still fires even if processValidFills
1727
+ // throws after collecting ghost IDs into pendingGhostOrders.
1728
+ // Each cancelOrder is individually try/caught so no per-order
1729
+ // error can prevent resumeFundRecalc from running.
1730
+ if (pendingGhostOrders.size > 0) {
1731
+ if (!this._ghostOrderCancelAttempted)
1732
+ this._ghostOrderCancelAttempted = new Set();
1733
+ for (const ghostOrderId of pendingGhostOrders) {
1734
+ if (this._ghostOrderCancelAttempted.has(ghostOrderId))
1735
+ continue;
1736
+ try {
1737
+ this.manager.logger.log(`[SYNC] Cancelling orphaned chain order ${ghostOrderId} (other-side full-fill residual)`, 'info');
1738
+ await chainOrders.cancelOrder(this.account, this.privateKey, ghostOrderId);
1739
+ this._ghostOrderCancelAttempted.add(ghostOrderId);
1740
+ }
1741
+ catch (err) {
1742
+ this.manager.logger.log(`[SYNC] Failed to cancel orphaned order ${ghostOrderId}: ${err?.message || err}`, 'warn');
1743
+ }
1744
+ }
1745
+ }
1599
1746
  await this.manager.resumeFundRecalc();
1600
1747
  }
1601
1748
  // 5. Fixed-Cap Fill Rebalance
@@ -2054,7 +2201,8 @@ class DEXBot {
2054
2201
  const openOrders = await chainOrders.readOpenOrders(accountRef);
2055
2202
  const recoveryResult = await this.manager.syncFromOpenOrders(openOrders, {
2056
2203
  skipAccounting: false,
2057
- fillLockAlreadyHeld: true
2204
+ fillLockAlreadyHeld: true,
2205
+ protectCommittedOrders: true
2058
2206
  });
2059
2207
  this._preserveMissingCreateBlockersAfterRecovery(preRecoveryMissingCreateBlockers, recoveryResult);
2060
2208
  // Persist any master grid mutations from the recovery sync. The
@@ -2442,7 +2590,7 @@ class DEXBot {
2442
2590
  return { executed: false, hadRotation: false, uncertain: true };
2443
2591
  }
2444
2592
  const adopted = [];
2445
- const discarded = [];
2593
+ let discarded = [];
2446
2594
  // 2. For each pending broadcast, look for a chain match.
2447
2595
  for (const entry of pending) {
2448
2596
  const match = this._findChainOrderForSlot(chainSnapshot, entry.slotId, {
@@ -2456,44 +2604,82 @@ class DEXBot {
2456
2604
  this.manager._pendingBroadcasts.delete(entry.fingerprint);
2457
2605
  }
2458
2606
  else {
2459
- discarded.push({ slotId: entry.slotId });
2460
- }
2461
- }
2462
- // 3. Apply the result to the working grid.
2463
- // - For adopted entries: re-run a structural sync so the manager picks up
2464
- // the chain order into the planned slot. The pre-broadcast guard
2465
- // already cleared the working grid, so we use a fresh sync.
2466
- // - For discarded entries: leave the planned slot empty; the next
2467
- // planning cycle will refill it.
2468
- // - Short-circuit the re-sync when every CREATE in the batch was
2469
- // fingerprinted AND adopted (the happy path of a slow but successful
2470
- // chain). The chain state is already known for those slots, so a full
2471
- // sync would just produce a burst of false-positive "no adoptable
2472
- // slot" warnings for any non-CREATE orders that were already in
2473
- // place before this batch.
2474
- let hadRotation = false;
2475
- const allCreatesAdopted = pending.length > 0
2476
- && pending.every(p => adopted.some(a => a.slotId === p.slotId));
2477
- const shouldRunHeavySync = !(allCreatesAdopted && discarded.length === 0);
2478
- if (shouldRunHeavySync) {
2607
+ // Preserve full entry context so the recheck below can use
2608
+ // both the fingerprint match path (against _pendingBroadcasts)
2609
+ // and the near-match path (via finalInts/orderType), not just
2610
+ // slotId alone.
2611
+ discarded.push(entry);
2612
+ }
2613
+ }
2614
+ // ---- RACE MITIGATION: block-delay recheck before discarding creates ----
2615
+ // BROADCAST_DEADLINE fires while the transaction may still be valid
2616
+ // and land in the next block. Wait for up to 3 blocks (~1 BitShares
2617
+ // block interval each) and re-check before permanently discarding.
2618
+ const UNCERTAIN_RECHECK_MAX_ATTEMPTS = PIPELINE_TIMING.RETRY_MAX_ATTEMPTS; // 3
2619
+ const UNCERTAIN_RECHECK_INTERVAL_MS = TIMING.MILLISECONDS_PER_SECOND * 3; // 3s
2620
+ let recheckRound = 0;
2621
+ while (discarded.length > 0 && recheckRound < UNCERTAIN_RECHECK_MAX_ATTEMPTS) {
2622
+ recheckRound++;
2623
+ await new Promise(r => setTimeout(r, UNCERTAIN_RECHECK_INTERVAL_MS));
2624
+ let freshSnapshot;
2479
2625
  try {
2480
- if (chainSnapshot && chainSnapshot.length > 0 && this.manager?.syncFromOpenOrders) {
2481
- await this.manager.syncFromOpenOrders(chainSnapshot, {
2482
- skipAccounting: true,
2483
- fillLockAlreadyHeld: true
2484
- });
2485
- hadRotation = true;
2626
+ freshSnapshot = await chainOrders.readOpenOrders(accountRef);
2627
+ }
2628
+ catch {
2629
+ break;
2630
+ }
2631
+ const stillDiscarded = [];
2632
+ for (const entry of discarded) {
2633
+ const match = this._findChainOrderForSlot(freshSnapshot, entry.slotId, {
2634
+ sell: entry.finalInts?.sell,
2635
+ receive: entry.finalInts?.receive,
2636
+ orderType: entry.orderType || entry.order?.type,
2637
+ fingerprint: entry.fingerprint
2638
+ });
2639
+ if (match) {
2640
+ adopted.push({ slotId: entry.slotId, chainOrderId: match.id });
2641
+ this.manager.logger.log(`[COW][UNCERTAIN] CREATE re-adopted after ${recheckRound} block(s): ` +
2642
+ `${entry.slotId}->${match.id}`, 'info');
2643
+ }
2644
+ else {
2645
+ stillDiscarded.push(entry);
2486
2646
  }
2487
2647
  }
2648
+ discarded = stillDiscarded;
2649
+ }
2650
+ // 3. Re-read chain with latest state. The initial snapshot may be
2651
+ // stale for non-CREATE ops (updates/cancels) not tracked in
2652
+ // _pendingBroadcasts — the seed cause of the slot-115 cascade.
2653
+ let latestSnapshot;
2654
+ try {
2655
+ latestSnapshot = await chainOrders.readOpenOrders(accountRef);
2656
+ }
2657
+ catch {
2658
+ latestSnapshot = chainSnapshot;
2659
+ }
2660
+ // Always sync to link adopted chain orders into grid slots.
2661
+ // Otherwise an adopted slot stays VIRTUAL and the next COW cycle
2662
+ // places a duplicate order at the same price.
2663
+ let hadRotation = false;
2664
+ if (latestSnapshot && latestSnapshot.length > 0 && this.manager?.syncFromOpenOrders) {
2665
+ try {
2666
+ await this.manager.syncFromOpenOrders(latestSnapshot, {
2667
+ skipAccounting: true,
2668
+ fillLockAlreadyHeld: true,
2669
+ protectCommittedOrders: true
2670
+ });
2671
+ hadRotation = true;
2672
+ }
2488
2673
  catch (syncErr) {
2489
2674
  this.manager.logger.log(`[COW][UNCERTAIN] syncFromOpenOrders failed during recovery: ${syncErr?.message || syncErr}`, 'error');
2675
+ // Fallback: reload from persisted snapshot + re-sync.
2676
+ this.manager.logger.log('[COW][UNCERTAIN] syncFromOpenOrders failed; falling back to full grid reload from persisted snapshot', 'warn');
2677
+ const fallbackResult = await this._recoverFromPersistedGrid();
2678
+ if (fallbackResult.success) {
2679
+ hadRotation = true;
2680
+ }
2490
2681
  }
2491
2682
  }
2492
- else {
2493
- hadRotation = true;
2494
- this.manager.logger.log(`[COW][UNCERTAIN] All ${adopted.length} fingerprinted CREATE(s) adopted; ` +
2495
- `skipping heavy re-sync to avoid false-positive unmatched warnings.`, 'debug');
2496
- }
2497
2683
  // 4. Log structured summary.
2498
2684
  const elapsedMs = Date.now() - startedAt;
2499
2685
  const heartbeatAgeMs = this._lastBroadcastHeartbeatAt
@@ -2501,14 +2687,14 @@ class DEXBot {
2501
2687
  : null;
2502
2688
  this.manager.logger.log(`[COW][UNCERTAIN] batchId=${err?.batchId || 'n/a'} ops=${opContexts.length} ` +
2503
2689
  `staleSinceMs=${err?.timeoutMs || 'n/a'} heartbeatAgeMs=${heartbeatAgeMs ?? 'n/a'} ` +
2504
- `adopted=${adopted.length} discarded=${discarded.length} elapsedMs=${elapsedMs}`, adopted.length > 0 ? 'info' : 'warn');
2690
+ `adopted=${adopted.length} discarded=${discarded.length} recheckRounds=${recheckRound} elapsedMs=${elapsedMs}`, adopted.length > 0 ? 'info' : 'warn');
2505
2691
  if (adopted.length > 0) {
2506
2692
  this.manager.logger.log(`[COW][UNCERTAIN] Adopted chain orders: ${adopted
2507
2693
  .map(a => `${a.slotId}->${a.chainOrderId}`)
2508
2694
  .join(', ')}`, 'info');
2509
2695
  }
2510
2696
  if (discarded.length > 0) {
2511
- this.manager.logger.log(`[COW][UNCERTAIN] Discarded planned CREATEs (no chain match): ${discarded
2697
+ this.manager.logger.log(`[COW][UNCERTAIN] Discarded planned CREATEs (no chain match after ${recheckRound} recheck(s)): ${discarded
2512
2698
  .map(d => d.slotId)
2513
2699
  .join(', ')}`, 'warn');
2514
2700
  }
@@ -2557,8 +2743,18 @@ class DEXBot {
2557
2743
  */
2558
2744
  async _autoCancelOneUnmatchedOrphan() {
2559
2745
  const cycleId = this._currentCycleId || 0;
2746
+ // FIX 4: Higher cap during recovery mode so orphan backlog clears faster.
2747
+ const recoveryActive = this.manager?._recoveryState?.structuralResyncRequested === true;
2748
+ const cycleCap = recoveryActive ? 5 : 1;
2560
2749
  if (this._autoCancelOrphanCycleMarker === cycleId) {
2561
- return { cancelled: false, reason: 'cap-reached-this-cycle' };
2750
+ if (this._autoCancelOrphanSubCount >= cycleCap) {
2751
+ return { cancelled: false, reason: 'cap-reached-this-cycle', subCount: this._autoCancelOrphanSubCount };
2752
+ }
2753
+ }
2754
+ else {
2755
+ // Reset sub-count on first call this cycle
2756
+ this._autoCancelOrphanCycleMarker = cycleId;
2757
+ this._autoCancelOrphanSubCount = 0;
2562
2758
  }
2563
2759
  const pending = (this.manager && this.manager._pendingBroadcasts instanceof Map)
2564
2760
  ? this.manager._pendingBroadcasts.size
@@ -2587,13 +2783,15 @@ class DEXBot {
2587
2783
  return { cancelled: false, reason: 'cancelOrder-unavailable' };
2588
2784
  }
2589
2785
  try {
2590
- this._autoCancelOrphanCycleMarker = cycleId;
2591
- this.manager.logger.log(`[COW] Auto-cancelling 1/${unmatched.length} unmatched chain order ` +
2592
- `(${this._formatUnmatchedChainOrderForLog(target)}) — per-cycle cap=1.`, 'warn');
2593
2786
  await chainOrders.cancelOrder(this.account, this.privateKey, orderId);
2594
2787
  if (typeof chainOrders.recordOwnCancel === 'function') {
2595
2788
  chainOrders.recordOwnCancel(orderId);
2596
2789
  }
2790
+ // Increment sub-count only after a confirmed successful cancel.
2791
+ // Failed cancels do not consume the per-cycle cap budget.
2792
+ this._autoCancelOrphanSubCount++;
2793
+ this.manager.logger.log(`[COW] Auto-cancelled ${this._autoCancelOrphanSubCount}/${unmatched.length} unmatched chain order ` +
2794
+ `(${this._formatUnmatchedChainOrderForLog(target)}) — per-cycle cap=${cycleCap}.`, 'warn');
2597
2795
  return { cancelled: true, orderId };
2598
2796
  }
2599
2797
  catch (err) {
@@ -2647,6 +2845,21 @@ class DEXBot {
2647
2845
  if (isRetriable) {
2648
2846
  this.manager.logger.log(`[COW] Broadcast uncertain (attempt ${attempt}/${MAX_RETRIES + 1}), retrying...`, 'warn');
2649
2847
  await this._ensureCredentialDaemonWritable('COW batch retry');
2848
+ // Reconcile before retry — stale ops would fail "not found".
2849
+ try {
2850
+ const accountRef = this.accountId || this.account?.id || this.account;
2851
+ const freshChain = await chainOrders.readOpenOrders(accountRef);
2852
+ if (freshChain.length > 0 && this.manager?.syncFromOpenOrders) {
2853
+ await this.manager.syncFromOpenOrders(freshChain, {
2854
+ skipAccounting: true,
2855
+ fillLockAlreadyHeld: true,
2856
+ protectCommittedOrders: true
2857
+ });
2858
+ }
2859
+ }
2860
+ catch (syncErr) {
2861
+ this.manager.logger.log(`[COW] Pre-retry sync failed (non-fatal): ${syncErr?.message || syncErr}`, 'warn');
2862
+ }
2650
2863
  continue;
2651
2864
  }
2652
2865
  throw err;
@@ -3409,58 +3622,111 @@ class DEXBot {
3409
3622
  ? Array.from(this.manager._pendingBroadcasts.values())
3410
3623
  : [];
3411
3624
  if (hasCreateActions && (unmatchedChainOrders.length > 0 || pendingBroadcasts.length > 0)) {
3412
- const blockers = [];
3413
- if (unmatchedChainOrders.length > 0)
3414
- blockers.push(`${unmatchedChainOrders.length} unmatched chain order(s)`);
3415
- if (pendingBroadcasts.length > 0)
3416
- blockers.push(`${pendingBroadcasts.length} pending broadcast(s)`);
3417
- const reasonText = blockers.join(' and ');
3418
- if (pendingBroadcasts.length > 0) {
3419
- this.manager.logger.log(`[COW] Rejecting CREATE batch: ${reasonText} from a prior uncertain ` +
3420
- `broadcast. Running recovery before placing replacement orders.`, 'error');
3421
- }
3422
- else {
3423
- const sample = unmatchedChainOrders
3424
- .slice(0, 3)
3425
- .map(order => this._formatUnmatchedChainOrderForLog(order))
3426
- .join(' | ');
3427
- this.manager.logger.log(`[COW] Rejecting CREATE batch: ${reasonText} ` +
3428
- `are not represented in the grid${sample ? ` (${sample})` : ''}. ` +
3429
- `Run structural reconciliation before placing replacement orders.`, 'error');
3625
+ // ---- FIX 3: Absorb unmatched orders instead of rejecting ----
3626
+ // When unmatched chain orders exist, try to auto-cancel them
3627
+ // inline to unblock the CREATE batch. Only fall through to
3628
+ // full rejection + structural resync if cancellation fails.
3629
+ let cancelledUnmatched = 0;
3630
+ if (unmatchedChainOrders.length > 0 && pendingBroadcasts.length === 0) {
3631
+ // Bounded-parallel cancellation: process unmatched orders in
3632
+ // concurrent batches (3 at a time) to avoid serialising 19+
3633
+ // individual cancel txs (~0.5s each = 10s batch hold).
3634
+ const CANCEL_CONCURRENCY = 3;
3635
+ const cancelable = unmatchedChainOrders.filter(u => {
3636
+ const oid = u?.id || u?.orderId || u?.chainOrderId;
3637
+ return Boolean(oid) && !u?.fingerprint;
3638
+ });
3639
+ for (let i = 0; i < cancelable.length; i += CANCEL_CONCURRENCY) {
3640
+ const batch = cancelable.slice(i, i + CANCEL_CONCURRENCY);
3641
+ const results = await Promise.allSettled(batch.map(async (unmatched) => {
3642
+ const orderId = unmatched.id || unmatched.orderId || unmatched.chainOrderId;
3643
+ this.manager.logger.log(`[COW] Auto-cancelling unmatched chain order ${orderId} ` +
3644
+ `(${this._formatUnmatchedChainOrderForLog(unmatched)}) to unblock CREATE batch.`, 'warn');
3645
+ await chainOrders.cancelOrder(this.account, this.privateKey, orderId);
3646
+ if (typeof chainOrders.recordOwnCancel === 'function') {
3647
+ chainOrders.recordOwnCancel(orderId);
3648
+ }
3649
+ }));
3650
+ for (const r of results) {
3651
+ if (r.status === 'fulfilled')
3652
+ cancelledUnmatched++;
3653
+ else
3654
+ this.manager.logger.log(`[COW] Failed to cancel unmatched chain order: ${r.reason?.message || r.reason}`, 'warn');
3655
+ }
3656
+ }
3657
+ if (cancelledUnmatched >= cancelable.length) {
3658
+ this.manager.logger.log(`[COW] Cancelled all ${cancelledUnmatched} unmatched chain order(s); proceeding with CREATE batch.`, 'warn');
3659
+ // Optimistic clear: the cancel txs are sent but not yet
3660
+ // confirmed. If a cancel silently fails on chain, the
3661
+ // next sync cycle will re-detect the unmatched order and
3662
+ // re-enter this guard — that's safe because the cancel
3663
+ // is idempotent and the re-detection is non-blocking.
3664
+ this.manager._lastUnmatchedChainOrders = [];
3665
+ }
3666
+ else {
3667
+ this.manager.logger.log(`[COW] Cancelled ${cancelledUnmatched}/${cancelable.length} unmatched chain order(s); ` +
3668
+ `remaining must be handled by structural reconciliation.`, 'warn');
3669
+ }
3430
3670
  }
3431
- if (typeof this.manager.requestStructuralGridResync === 'function') {
3432
- if (this.manager._recoveryState)
3433
- this.manager._recoveryState.structuralResyncRequested = true;
3434
- await this.manager.requestStructuralGridResync(pendingBroadcasts.length > 0
3435
- ? 'pending broadcasts before COW create'
3436
- : 'unmatched chain orders before COW create', pendingBroadcasts.length > 0
3437
- ? { pendingBroadcasts: pendingBroadcasts.map(p => p.slotId) }
3438
- : { unmatchedChainOrders });
3439
- }
3440
- // If we have pending broadcasts, drive the recovery now so the
3441
- // next planning cycle has a clean state.
3442
- if (pendingBroadcasts.length > 0) {
3443
- try {
3444
- await this._reconcileAfterUncertainBroadcast(new BroadcastUncertainError('rejected CREATE batch had pending broadcasts', {
3445
- operations: pendingBroadcasts.map(p => p.order),
3446
- accountName: this.account,
3447
- batchId: this._currentBatchId || null,
3448
- payload: null,
3449
- timeoutMs: null
3450
- }), [], { fillLockAlreadyHeld: true });
3671
+ // Re-check after auto-cancel attempt
3672
+ const remainingUnmatched = Array.isArray(this.manager?._lastUnmatchedChainOrders)
3673
+ ? this.manager._lastUnmatchedChainOrders
3674
+ : [];
3675
+ if (remainingUnmatched.length > 0 || pendingBroadcasts.length > 0) {
3676
+ const blockers = [];
3677
+ if (remainingUnmatched.length > 0)
3678
+ blockers.push(`${remainingUnmatched.length} unmatched chain order(s)`);
3679
+ if (pendingBroadcasts.length > 0)
3680
+ blockers.push(`${pendingBroadcasts.length} pending broadcast(s)`);
3681
+ const reasonText = blockers.join(' and ');
3682
+ if (pendingBroadcasts.length > 0) {
3683
+ this.manager.logger.log(`[COW] Rejecting CREATE batch: ${reasonText} from a prior uncertain ` +
3684
+ `broadcast. Running recovery before placing replacement orders.`, 'error');
3685
+ }
3686
+ else {
3687
+ const sample = remainingUnmatched
3688
+ .slice(0, 3)
3689
+ .map(order => this._formatUnmatchedChainOrderForLog(order))
3690
+ .join(' | ');
3691
+ this.manager.logger.log(`[COW] Rejecting CREATE batch: ${reasonText} ` +
3692
+ `are not represented in the grid${sample ? ` (${sample})` : ''}. ` +
3693
+ `Run structural reconciliation before placing replacement orders.`, 'error');
3451
3694
  }
3452
- catch (recoverErr) {
3453
- this.manager.logger.log(`[COW] Recovery from pending broadcasts failed: ${recoverErr?.message || recoverErr}`, 'error');
3695
+ if (typeof this.manager.requestStructuralGridResync === 'function') {
3696
+ if (this.manager._recoveryState)
3697
+ this.manager._recoveryState.structuralResyncRequested = true;
3698
+ await this.manager.requestStructuralGridResync(pendingBroadcasts.length > 0
3699
+ ? 'pending broadcasts before COW create'
3700
+ : 'unmatched chain orders before COW create', pendingBroadcasts.length > 0
3701
+ ? { pendingBroadcasts: pendingBroadcasts.map(p => p.slotId) }
3702
+ : { unmatchedChainOrders: remainingUnmatched });
3454
3703
  }
3704
+ // If we have pending broadcasts, drive the recovery now so the
3705
+ // next planning cycle has a clean state.
3706
+ if (pendingBroadcasts.length > 0) {
3707
+ try {
3708
+ await this._reconcileAfterUncertainBroadcast(new BroadcastUncertainError('rejected CREATE batch had pending broadcasts', {
3709
+ operations: pendingBroadcasts.map(p => p.order),
3710
+ accountName: this.account,
3711
+ batchId: this._currentBatchId || null,
3712
+ payload: null,
3713
+ timeoutMs: null
3714
+ }), [], { fillLockAlreadyHeld: true });
3715
+ }
3716
+ catch (recoverErr) {
3717
+ this.manager.logger.log(`[COW] Recovery from pending broadcasts failed: ${recoverErr?.message || recoverErr}`, 'error');
3718
+ }
3719
+ }
3720
+ return {
3721
+ executed: false,
3722
+ aborted: true,
3723
+ reason: pendingBroadcasts.length > 0 ? 'PENDING_BROADCASTS' : 'UNMATCHED_CHAIN_ORDERS',
3724
+ unmatchedChainOrders: pendingBroadcasts.length > 0 ? [] : remainingUnmatched,
3725
+ pendingBroadcasts: pendingBroadcasts.map(p => p.slotId),
3726
+ hadRotation: false
3727
+ };
3455
3728
  }
3456
- return {
3457
- executed: false,
3458
- aborted: true,
3459
- reason: pendingBroadcasts.length > 0 ? 'PENDING_BROADCASTS' : 'UNMATCHED_CHAIN_ORDERS',
3460
- unmatchedChainOrders: pendingBroadcasts.length > 0 ? [] : unmatchedChainOrders,
3461
- pendingBroadcasts: pendingBroadcasts.map(p => p.slotId),
3462
- hadRotation: false
3463
- };
3729
+ // All unmatched were cancelled — fall through to continue batch
3464
3730
  }
3465
3731
  const { assetA, assetB } = this.manager.assets;
3466
3732
  const operations = [];
@@ -3501,7 +3767,13 @@ class DEXBot {
3501
3767
  opContexts.push({ kind: 'cancel', order });
3502
3768
  }
3503
3769
  catch (err) {
3504
- this.manager.logger.log(`Failed to prepare cancel op for ${action.id}: ${err.message}`, 'error');
3770
+ const orderNotFound = /\bnot found\b/i.test(err.message) || /\bdoes not exist\b/i.test(err.message);
3771
+ if (orderNotFound) {
3772
+ this.manager.logger.log(`[COW] Cancel skipped for ${action.id} (${action.orderId}): order already removed from chain`, 'debug');
3773
+ }
3774
+ else {
3775
+ this.manager.logger.log(`Failed to prepare cancel op for ${action.id}: ${err.message}`, 'error');
3776
+ }
3505
3777
  }
3506
3778
  }
3507
3779
  else if (action.type === COW_ACTIONS.CREATE) {
@@ -3613,8 +3885,60 @@ class DEXBot {
3613
3885
  type: orderType
3614
3886
  };
3615
3887
  opContexts.push({ kind: 'size-update', updateInfo: { partialOrder, newSize }, finalInts: op.finalInts });
3888
+ // Catch for both rotation-update and size-update branches.
3616
3889
  }
3617
3890
  catch (err) {
3891
+ const orderNotFound = /\bnot found\b/i.test(err.message) || /\bdoes not exist\b/i.test(err.message);
3892
+ if (orderNotFound) {
3893
+ try {
3894
+ const fbOrder = action.order || this.manager.orders.get(action.id);
3895
+ const fbType = fbOrder?.type;
3896
+ const fbSize = action.newSize || fbOrder?.size || 0;
3897
+ // Live price from TARGET slot (same pattern as CREATE path).
3898
+ const targetSlotId = action.newGridId || action.id;
3899
+ const plannedPrice = action.newPrice || action.order?.price || 0;
3900
+ const liveSlotForPrice = this.manager.orders.get(targetSlotId);
3901
+ const livePrice = liveSlotForPrice ? Number(liveSlotForPrice.price) : NaN;
3902
+ const priceDrift = Number.isFinite(plannedPrice) && Number.isFinite(livePrice)
3903
+ ? Math.abs(livePrice - plannedPrice)
3904
+ : 0;
3905
+ const fbPrice = (priceDrift > 0) ? livePrice : plannedPrice;
3906
+ if (priceDrift > 0) {
3907
+ this.manager.logger.log(`[COW] CREATE fallback price drift for ${action.id} -> ${targetSlotId}: ` +
3908
+ `planned=${plannedPrice} live=${livePrice} (diff=${priceDrift})`, 'debug');
3909
+ }
3910
+ // Same size gate as regular CREATE path.
3911
+ const sizeCheck = this._validateOrderSizeForExecution(fbSize, fbType, fbOrder, fbSize);
3912
+ if (!sizeCheck.isValid) {
3913
+ this.manager.logger.log(`[COW] CREATE fallback for ${action.id} rejected by size validation: ${sizeCheck.reason}`, 'warn');
3914
+ }
3915
+ else if (fbType && fbSize > 0 && fbPrice > 0) {
3916
+ const fbArgs = buildCreateOrderArgs({ type: fbType, size: fbSize, price: fbPrice }, assetA, assetB);
3917
+ const fbResult = await chainOrders.buildCreateOrderOp(this.account, fbArgs.amountToSell, fbArgs.sellAssetId, fbArgs.minToReceive, fbArgs.receiveAssetId, null);
3918
+ if (fbResult) {
3919
+ operations.push(fbResult.op);
3920
+ opContexts.push({
3921
+ kind: 'create',
3922
+ id: targetSlotId,
3923
+ order: { id: targetSlotId, type: fbType, price: fbPrice, size: fbSize },
3924
+ args: { amountToSell: fbArgs.amountToSell, minToReceive: fbArgs.minToReceive },
3925
+ finalInts: fbResult.finalInts
3926
+ });
3927
+ this._recordPendingBroadcast({
3928
+ opIndex: operations.length - 1,
3929
+ ctxIndex: opContexts.length - 1,
3930
+ order: { id: targetSlotId, type: fbType, price: fbPrice, size: fbSize },
3931
+ finalInts: fbResult.finalInts
3932
+ });
3933
+ this.manager.logger.log(`[COW] Recovered "not found" for ${action.id}: converted UPDATE to CREATE for slot ${targetSlotId}`, 'warn');
3934
+ continue;
3935
+ }
3936
+ }
3937
+ }
3938
+ catch (fbErr) {
3939
+ this.manager.logger.log(`[COW] CREATE fallback also failed for ${action.id}: ${fbErr.message}`, 'warn');
3940
+ }
3941
+ }
3618
3942
  this.manager.logger.log(`Failed to prepare update op for ${action.id}: ${err.message}`, 'error');
3619
3943
  }
3620
3944
  }
@@ -4246,6 +4570,16 @@ class DEXBot {
4246
4570
  return;
4247
4571
  this._structuralGridResyncRunning = true;
4248
4572
  try {
4573
+ // Try the lighter persisted-grid reload before full reset.
4574
+ const persistedResult = await this._recoverFromPersistedGrid();
4575
+ if (persistedResult.success) {
4576
+ if (this.manager?._recoveryState) {
4577
+ this.manager._recoveryState.attemptCount = 0;
4578
+ this.manager._recoveryState.lastAttemptAt = 0;
4579
+ this.manager._recoveryState.lastFailureAt = 0;
4580
+ }
4581
+ return;
4582
+ }
4249
4583
  const suffix = unmatchedCount > 0 ? ` (${unmatchedCount} unmatched chain order(s))` : '';
4250
4584
  this._warn(`[RECOVERY] Running structural full grid resync for ${reason}${suffix}`);
4251
4585
  const resetResult = await this.requestGridReset('rms_structural_grid_resync', {