dexbot 1.1.12 → 1.1.14

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 (70) hide show
  1. package/dist/credential-daemon.d.ts.map +1 -1
  2. package/dist/credential-daemon.js +13 -4
  3. package/dist/credential-daemon.js.map +1 -1
  4. package/dist/market_adapter/core/market_adapter_service.d.ts.map +1 -1
  5. package/dist/market_adapter/core/market_adapter_service.js +6 -0
  6. package/dist/market_adapter/core/market_adapter_service.js.map +1 -1
  7. package/dist/market_adapter/market_adapter.d.ts.map +1 -1
  8. package/dist/market_adapter/market_adapter.js +6 -3
  9. package/dist/market_adapter/market_adapter.js.map +1 -1
  10. package/dist/modules/chain_orders.d.ts +5 -1
  11. package/dist/modules/chain_orders.d.ts.map +1 -1
  12. package/dist/modules/chain_orders.js +8 -4
  13. package/dist/modules/chain_orders.js.map +1 -1
  14. package/dist/modules/constants.d.ts +5 -0
  15. package/dist/modules/constants.d.ts.map +1 -1
  16. package/dist/modules/constants.js +28 -12
  17. package/dist/modules/constants.js.map +1 -1
  18. package/dist/modules/dexbot_class.d.ts +38 -0
  19. package/dist/modules/dexbot_class.d.ts.map +1 -1
  20. package/dist/modules/dexbot_class.js +449 -91
  21. package/dist/modules/dexbot_class.js.map +1 -1
  22. package/dist/modules/dexbot_credential_client.d.ts +3 -0
  23. package/dist/modules/dexbot_credential_client.d.ts.map +1 -1
  24. package/dist/modules/dexbot_credential_client.js +78 -43
  25. package/dist/modules/dexbot_credential_client.js.map +1 -1
  26. package/dist/modules/dexbot_maintenance_runtime.d.ts.map +1 -1
  27. package/dist/modules/dexbot_maintenance_runtime.js +100 -4
  28. package/dist/modules/dexbot_maintenance_runtime.js.map +1 -1
  29. package/dist/modules/key_store.d.ts +2 -2
  30. package/dist/modules/key_store.d.ts.map +1 -1
  31. package/dist/modules/key_store.js +6 -1
  32. package/dist/modules/key_store.js.map +1 -1
  33. package/dist/modules/node_manager.d.ts +9 -0
  34. package/dist/modules/node_manager.d.ts.map +1 -1
  35. package/dist/modules/node_manager.js +35 -17
  36. package/dist/modules/node_manager.js.map +1 -1
  37. package/dist/modules/order/accounting.d.ts.map +1 -1
  38. package/dist/modules/order/accounting.js +36 -5
  39. package/dist/modules/order/accounting.js.map +1 -1
  40. package/dist/modules/order/async_lock.d.ts +21 -0
  41. package/dist/modules/order/async_lock.d.ts.map +1 -1
  42. package/dist/modules/order/async_lock.js +43 -3
  43. package/dist/modules/order/async_lock.js.map +1 -1
  44. package/dist/modules/order/grid.d.ts +26 -0
  45. package/dist/modules/order/grid.d.ts.map +1 -1
  46. package/dist/modules/order/grid.js +53 -0
  47. package/dist/modules/order/grid.js.map +1 -1
  48. package/dist/modules/order/grid_reconcile.d.ts.map +1 -1
  49. package/dist/modules/order/grid_reconcile.js +6 -3
  50. package/dist/modules/order/grid_reconcile.js.map +1 -1
  51. package/dist/modules/order/manager.d.ts +1 -3
  52. package/dist/modules/order/manager.d.ts.map +1 -1
  53. package/dist/modules/order/manager.js +15 -30
  54. package/dist/modules/order/manager.js.map +1 -1
  55. package/dist/modules/order/sync_engine.d.ts +9 -2
  56. package/dist/modules/order/sync_engine.d.ts.map +1 -1
  57. package/dist/modules/order/sync_engine.js +79 -5
  58. package/dist/modules/order/sync_engine.js.map +1 -1
  59. package/dist/modules/order/utils/order.d.ts.map +1 -1
  60. package/dist/modules/order/utils/order.js +17 -1
  61. package/dist/modules/order/utils/order.js.map +1 -1
  62. package/dist/modules/order/utils/system.d.ts +1 -0
  63. package/dist/modules/order/utils/system.d.ts.map +1 -1
  64. package/dist/modules/order/utils/system.js +90 -38
  65. package/dist/modules/order/utils/system.js.map +1 -1
  66. package/dist/modules/paths.d.ts +1 -0
  67. package/dist/modules/paths.d.ts.map +1 -1
  68. package/dist/modules/paths.js +1 -0
  69. package/dist/modules/paths.js.map +1 -1
  70. package/package.json +14 -4
@@ -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, GRID_LIMITS, 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');
@@ -127,11 +127,14 @@ class DEXBot {
127
127
  _batchInFlight;
128
128
  _recoverySyncInFlight;
129
129
  _lastTargetedDriftSyncAt;
130
+ _lightweightSyncCheckAt;
130
131
  _targetedDriftSyncCooldownMs;
131
132
  _maintenanceCooldownCycles;
132
133
  _lastGridActivityAt;
133
134
  _currentCycleId;
134
135
  _autoCancelOrphanCycleMarker;
136
+ _autoCancelOrphanSubCount;
137
+ _orphanFillsCreditedAt;
135
138
  _consecutiveConsumeFailures;
136
139
  _consumeFailureFirstAt;
137
140
  _reconnectUnregister;
@@ -216,11 +219,14 @@ class DEXBot {
216
219
  this._batchInFlight = false;
217
220
  this._recoverySyncInFlight = false;
218
221
  this._lastTargetedDriftSyncAt = 0;
222
+ this._lightweightSyncCheckAt = 0;
219
223
  this._targetedDriftSyncCooldownMs = this.config.timing.TARGETED_DRIFT_SYNC_COOLDOWN_MS;
220
224
  this._maintenanceCooldownCycles = 0;
221
225
  this._lastGridActivityAt = 0;
222
226
  this._currentCycleId = 0;
223
227
  this._autoCancelOrphanCycleMarker = null;
228
+ this._autoCancelOrphanSubCount = 0;
229
+ this._orphanFillsCreditedAt = null;
224
230
  // Per-session guard: ghost order IDs successfully cancelled
225
231
  // (avoids spamming the chain with repeated cancel attempts for the same
226
232
  // orphan residual on every fill cycle).
@@ -543,6 +549,90 @@ class DEXBot {
543
549
  }
544
550
  return Array.from(ids);
545
551
  }
552
+ /**
553
+ * Reload the entire grid from the persisted on-disk snapshot and reconcile
554
+ * with current chain state. Mirrors the startup recovery path.
555
+ *
556
+ * LOCK SAFETY: Does NOT acquire _fillProcessingLock. Caller must either
557
+ * hold it already or accept concurrent fill-processing risk — matches
558
+ * the startup pattern at line ~1340.
559
+ *
560
+ * @returns {Promise<{success: boolean, reason?: string}>}
561
+ */
562
+ async _recoverFromPersistedGrid() {
563
+ if (!this.accountOrders || !this.manager) {
564
+ return { success: false, reason: 'accountOrders or manager unavailable' };
565
+ }
566
+ const accountRef = this.accountId || this.account?.id || this.account;
567
+ if (!accountRef) {
568
+ return { success: false, reason: 'no account reference' };
569
+ }
570
+ this.manager.logger.log('[RECOVERY] Attempting full grid reload from persisted snapshot...', 'warn');
571
+ try {
572
+ // 1. Force reload from disk
573
+ const persistedGrid = this.accountOrders.loadGrid(true);
574
+ if (!persistedGrid || persistedGrid.length === 0) {
575
+ return { success: false, reason: 'no persisted grid on disk' };
576
+ }
577
+ const boundaryIdx = this.accountOrders.loadBoundaryIdx(true);
578
+ // 2. Load into manager (same path as startup at line 1340)
579
+ await Grid.loadGrid(this.manager, persistedGrid, boundaryIdx);
580
+ // Gap 1: Grid snapshot sanity check — shared logic with startup path.
581
+ if (await this._rejectCorruptedGridSnapshot('recovery')) {
582
+ return { success: false, reason: 'corrupted grid snapshot rejected (fund drift)' };
583
+ }
584
+ // 3. Read current chain state
585
+ const chainOpenOrders = await chainOrders.readOpenOrders(accountRef);
586
+ // 4. Reconcile
587
+ if (chainOpenOrders.length > 0 && this.manager?.syncFromOpenOrders) {
588
+ await this.manager.syncFromOpenOrders(chainOpenOrders, {
589
+ skipAccounting: true,
590
+ fillLockAlreadyHeld: true,
591
+ protectCommittedOrders: true
592
+ });
593
+ }
594
+ // 5. Persist the reconciled state
595
+ if (typeof this.manager.persistGrid === 'function') {
596
+ await this.manager.persistGrid();
597
+ }
598
+ this.manager.logger.log(`[RECOVERY] Grid reloaded from persisted snapshot: ${this.manager.orders.size} orders, ` +
599
+ `${chainOpenOrders.length} on-chain orders synced`, 'info');
600
+ return { success: true };
601
+ }
602
+ catch (err) {
603
+ this.manager.logger.log(`[RECOVERY] Full grid reload from persisted snapshot failed: ${err.message}`, 'error');
604
+ return { success: false, reason: err.message };
605
+ }
606
+ }
607
+ /**
608
+ * Reject a corrupted grid snapshot when catastrophic fund drift is detected.
609
+ * Shared between startup and recovery paths to avoid duplicating the
610
+ * drift-ratio math and snapshot-clearing logic.
611
+ *
612
+ * @param {'startup'|'recovery'} context - Controls log prefix.
613
+ * @returns {Promise<boolean>} True if the snapshot was rejected (cleared).
614
+ */
615
+ async _rejectCorruptedGridSnapshot(context) {
616
+ if (!this.manager?.checkFundDriftAfterFills)
617
+ return false;
618
+ const driftCheck = this.manager.checkFundDriftAfterFills();
619
+ if (driftCheck.isValid)
620
+ return false;
621
+ const tag = context === 'recovery' ? '[RECOVERY][SNAPSHOT-REJECT]' : '[SNAPSHOT-REJECT]';
622
+ this._warn(`${tag} Corrupted grid snapshot detected: ` +
623
+ `drift sell=${driftCheck.driftSell.toFixed(2)} buy=${driftCheck.driftBuy.toFixed(2)}. ` +
624
+ `Deleting corrupted snapshot.`);
625
+ if (this.accountOrders && typeof this.accountOrders.clearGrid === 'function') {
626
+ try {
627
+ await this.accountOrders.clearGrid();
628
+ this._warn(`${tag} Corrupted grid snapshot deleted.`);
629
+ }
630
+ catch (clearErr) {
631
+ this._warn(`${tag} Failed to delete corrupted snapshot: ${clearErr.message}`);
632
+ }
633
+ }
634
+ return true;
635
+ }
546
636
  /**
547
637
  * Attempt to repair size-drift for specific order IDs by reading their
548
638
  * current on-chain state and correcting the local grid directly.
@@ -1180,6 +1270,8 @@ class DEXBot {
1180
1270
  await this._executeBatchIfNeeded(rebalanceResult, 'startup reconcile (loaded grid)');
1181
1271
  // Dust state is no longer persisted — cancelled immediately on detection.
1182
1272
  await this._persistAndRecoverIfNeeded();
1273
+ // Gap 1: Grid snapshot sanity check — shared logic with recovery path.
1274
+ await this._rejectCorruptedGridSnapshot('startup');
1183
1275
  }
1184
1276
  // Drain any fills that arrived during startup while still in bootstrap
1185
1277
  // mode. Safe to call directly since we already hold _fillProcessingLock
@@ -1449,6 +1541,14 @@ class DEXBot {
1449
1541
  return;
1450
1542
  }
1451
1543
  await this.manager._fillProcessingLock.acquire(async () => {
1544
+ // Reset orphan-fill credit timestamp at the start of each
1545
+ // fill cycle. It is re-set when orphan fills are credited,
1546
+ // and consumed (set to null) on the next fund-invariant check
1547
+ // in accounting.ts, which widens tolerance by 5x while set.
1548
+ // Also cleared by _performStateRecovery after a fresh chain
1549
+ // fetch. The timestamp value itself is not compared against a
1550
+ // window — it acts as a consume-on-read boolean.
1551
+ this._orphanFillsCreditedAt = null;
1452
1552
  while (this._incomingFillQueue.length > 0) {
1453
1553
  const batchStartTime = Date.now();
1454
1554
  // Track max queue depth
@@ -1512,6 +1612,10 @@ class DEXBot {
1512
1612
  if (accountingResult.status === 'missing_key') {
1513
1613
  requiresOpenOrdersSync = true;
1514
1614
  }
1615
+ // Record orphan fill credit timestamp for fund invariant
1616
+ // tolerance widening. Accounting checks recency via
1617
+ // _checkOrphanFillRecency() instead of a cross-module flag.
1618
+ this._orphanFillsCreditedAt = Date.now();
1515
1619
  // Don't add to validFills - we can't do rebalancing without a grid slot
1516
1620
  // But the funds are now credited, preventing fund invariant violation
1517
1621
  continue;
@@ -1601,7 +1705,49 @@ class DEXBot {
1601
1705
  };
1602
1706
  this.manager.pauseFundRecalc();
1603
1707
  try {
1604
- allFilledOrders = await processValidFills(validFills);
1708
+ // FIX 1: Block-level fill batching — group valid fills by block
1709
+ // and process each block group as a unit. This prevents slot
1710
+ // collisions when fills from the same block (original + replacement
1711
+ // orders both filled) arrive in different sync batches. Processing
1712
+ // all fills from a block together lets the sync engine see every
1713
+ // fill for overlapping slots simultaneously.
1714
+ const fillsByBlock = new Map();
1715
+ const fillsWithoutBlock = [];
1716
+ for (const fill of validFills) {
1717
+ if (fill.block_num != null) {
1718
+ const list = fillsByBlock.get(fill.block_num);
1719
+ if (list)
1720
+ list.push(fill);
1721
+ else
1722
+ fillsByBlock.set(fill.block_num, [fill]);
1723
+ }
1724
+ else {
1725
+ fillsWithoutBlock.push(fill);
1726
+ }
1727
+ }
1728
+ // Process block groups in ascending block order so the sync
1729
+ // engine sees a deterministic, chronological fill sequence.
1730
+ const sortedBlocks = [...fillsByBlock.keys()].sort((a, b) => a - b);
1731
+ const accumulatedOrders = [];
1732
+ let anyRequiresSync = false;
1733
+ for (const blockNum of sortedBlocks) {
1734
+ // Reset requiresOpenOrdersSync per block group so one
1735
+ // block's history-id gap doesn't force the next block
1736
+ // into an unnecessary open-orders snapshot re-fetch.
1737
+ requiresOpenOrdersSync = false;
1738
+ this.manager.logger.log(`[FILL-BLOCK] Processing ${fillsByBlock.get(blockNum).length} fill(s) from block ${blockNum}`, 'debug');
1739
+ const blockResult = await processValidFills(fillsByBlock.get(blockNum));
1740
+ accumulatedOrders.push(...blockResult);
1741
+ if (requiresOpenOrdersSync)
1742
+ anyRequiresSync = true;
1743
+ }
1744
+ requiresOpenOrdersSync = anyRequiresSync;
1745
+ if (fillsWithoutBlock.length > 0) {
1746
+ this.manager.logger.log(`[FILL-BLOCK] Processing ${fillsWithoutBlock.length} fill(s) without block info`, 'debug');
1747
+ const noBlockResult = await processValidFills(fillsWithoutBlock);
1748
+ accumulatedOrders.push(...noBlockResult);
1749
+ }
1750
+ allFilledOrders = accumulatedOrders;
1605
1751
  // 4. Handle Price Corrections
1606
1752
  if (ordersNeedingCorrection.length > 0) {
1607
1753
  const correctionResult = await correctAllPriceMismatches(this.manager, this.account, this.privateKey, chainOrders);
@@ -2092,7 +2238,8 @@ class DEXBot {
2092
2238
  const openOrders = await chainOrders.readOpenOrders(accountRef);
2093
2239
  const recoveryResult = await this.manager.syncFromOpenOrders(openOrders, {
2094
2240
  skipAccounting: false,
2095
- fillLockAlreadyHeld: true
2241
+ fillLockAlreadyHeld: true,
2242
+ protectCommittedOrders: true
2096
2243
  });
2097
2244
  this._preserveMissingCreateBlockersAfterRecovery(preRecoveryMissingCreateBlockers, recoveryResult);
2098
2245
  // Persist any master grid mutations from the recovery sync. The
@@ -2480,7 +2627,7 @@ class DEXBot {
2480
2627
  return { executed: false, hadRotation: false, uncertain: true };
2481
2628
  }
2482
2629
  const adopted = [];
2483
- const discarded = [];
2630
+ let discarded = [];
2484
2631
  // 2. For each pending broadcast, look for a chain match.
2485
2632
  for (const entry of pending) {
2486
2633
  const match = this._findChainOrderForSlot(chainSnapshot, entry.slotId, {
@@ -2494,45 +2641,91 @@ class DEXBot {
2494
2641
  this.manager._pendingBroadcasts.delete(entry.fingerprint);
2495
2642
  }
2496
2643
  else {
2497
- discarded.push({ slotId: entry.slotId });
2498
- }
2499
- }
2500
- // 3. Apply the result to the working grid.
2501
- // - For adopted entries: re-run a structural sync so the manager picks up
2502
- // the chain order into the planned slot. The pre-broadcast guard
2503
- // already cleared the working grid, so we use a fresh sync.
2504
- // - For discarded entries: leave the planned slot empty; the next
2505
- // planning cycle will refill it.
2506
- // - Short-circuit the re-sync when every CREATE in the batch was
2507
- // fingerprinted AND adopted (the happy path of a slow but successful
2508
- // chain). The chain state is already known for those slots, so a full
2509
- // sync would just produce a burst of false-positive "no adoptable
2510
- // slot" warnings for any non-CREATE orders that were already in
2511
- // place before this batch.
2512
- let hadRotation = false;
2513
- const allCreatesAdopted = pending.length > 0
2514
- && pending.every(p => adopted.some(a => a.slotId === p.slotId));
2515
- const shouldRunHeavySync = !(allCreatesAdopted && discarded.length === 0);
2516
- if (shouldRunHeavySync) {
2644
+ // Preserve full entry context so the recheck below can use
2645
+ // both the fingerprint match path (against _pendingBroadcasts)
2646
+ // and the near-match path (via finalInts/orderType), not just
2647
+ // slotId alone.
2648
+ discarded.push(entry);
2649
+ }
2650
+ }
2651
+ // ---- RACE MITIGATION: block-delay recheck before discarding creates ----
2652
+ // BROADCAST_DEADLINE fires while the transaction may still be valid
2653
+ // and land in the next block. Wait for up to 3 blocks (~1 BitShares
2654
+ // block interval each) and re-check before permanently discarding.
2655
+ const UNCERTAIN_RECHECK_MAX_ATTEMPTS = PIPELINE_TIMING.RETRY_MAX_ATTEMPTS; // 3
2656
+ const UNCERTAIN_RECHECK_INTERVAL_MS = TIMING.MILLISECONDS_PER_SECOND * 3; // 3s
2657
+ let recheckRound = 0;
2658
+ while (discarded.length > 0 && recheckRound < UNCERTAIN_RECHECK_MAX_ATTEMPTS) {
2659
+ recheckRound++;
2660
+ await new Promise(r => setTimeout(r, UNCERTAIN_RECHECK_INTERVAL_MS));
2661
+ let freshSnapshot;
2517
2662
  try {
2518
- if (chainSnapshot && chainSnapshot.length > 0 && this.manager?.syncFromOpenOrders) {
2519
- await this.manager.syncFromOpenOrders(chainSnapshot, {
2520
- skipAccounting: true,
2521
- fillLockAlreadyHeld: true,
2522
- protectCommittedOrders: true
2523
- });
2524
- hadRotation = true;
2663
+ freshSnapshot = await chainOrders.readOpenOrders(accountRef);
2664
+ }
2665
+ catch {
2666
+ break;
2667
+ }
2668
+ const stillDiscarded = [];
2669
+ for (const entry of discarded) {
2670
+ const match = this._findChainOrderForSlot(freshSnapshot, entry.slotId, {
2671
+ sell: entry.finalInts?.sell,
2672
+ receive: entry.finalInts?.receive,
2673
+ orderType: entry.orderType || entry.order?.type,
2674
+ fingerprint: entry.fingerprint
2675
+ });
2676
+ if (match) {
2677
+ adopted.push({ slotId: entry.slotId, chainOrderId: match.id });
2678
+ this.manager.logger.log(`[COW][UNCERTAIN] CREATE re-adopted after ${recheckRound} block(s): ` +
2679
+ `${entry.slotId}->${match.id}`, 'info');
2525
2680
  }
2681
+ else {
2682
+ stillDiscarded.push(entry);
2683
+ }
2684
+ }
2685
+ discarded = stillDiscarded;
2686
+ }
2687
+ // 3. Re-read chain with latest state. The initial snapshot may be
2688
+ // stale for non-CREATE ops (updates/cancels) not tracked in
2689
+ // _pendingBroadcasts — the seed cause of the slot-115 cascade.
2690
+ let latestSnapshot;
2691
+ try {
2692
+ latestSnapshot = await chainOrders.readOpenOrders(accountRef);
2693
+ }
2694
+ catch {
2695
+ latestSnapshot = chainSnapshot;
2696
+ }
2697
+ // Always sync to link adopted chain orders into grid slots.
2698
+ // Otherwise an adopted slot stays VIRTUAL and the next COW cycle
2699
+ // places a duplicate order at the same price.
2700
+ let hadRotation = false;
2701
+ if (latestSnapshot && latestSnapshot.length > 0 && this.manager?.syncFromOpenOrders) {
2702
+ try {
2703
+ await this.manager.syncFromOpenOrders(latestSnapshot, {
2704
+ skipAccounting: true,
2705
+ fillLockAlreadyHeld: true,
2706
+ protectCommittedOrders: true
2707
+ });
2708
+ hadRotation = true;
2526
2709
  }
2527
2710
  catch (syncErr) {
2528
2711
  this.manager.logger.log(`[COW][UNCERTAIN] syncFromOpenOrders failed during recovery: ${syncErr?.message || syncErr}`, 'error');
2712
+ // Fallback: reload from persisted snapshot + re-sync.
2713
+ this.manager.logger.log('[COW][UNCERTAIN] syncFromOpenOrders failed; falling back to full grid reload from persisted snapshot', 'warn');
2714
+ const fallbackResult = await this._recoverFromPersistedGrid();
2715
+ if (fallbackResult.success) {
2716
+ hadRotation = true;
2717
+ }
2718
+ else {
2719
+ this.manager.logger.log(`[COW][UNCERTAIN] Persisted grid reload failed: ${fallbackResult.reason}. ` +
2720
+ `Escalating to structural resync.`, 'warn');
2721
+ if (typeof this.manager.requestStructuralGridResync === 'function') {
2722
+ this.manager.requestStructuralGridResync('cow-uncertain-recovery-failed', { reason: fallbackResult.reason }).catch((escalateErr) => {
2723
+ this.manager.logger.log(`[COW][UNCERTAIN] Escalation to structural resync failed: ${escalateErr.message}`, 'error');
2724
+ });
2725
+ }
2726
+ }
2529
2727
  }
2530
2728
  }
2531
- else {
2532
- hadRotation = true;
2533
- this.manager.logger.log(`[COW][UNCERTAIN] All ${adopted.length} fingerprinted CREATE(s) adopted; ` +
2534
- `skipping heavy re-sync to avoid false-positive unmatched warnings.`, 'debug');
2535
- }
2536
2729
  // 4. Log structured summary.
2537
2730
  const elapsedMs = Date.now() - startedAt;
2538
2731
  const heartbeatAgeMs = this._lastBroadcastHeartbeatAt
@@ -2540,14 +2733,14 @@ class DEXBot {
2540
2733
  : null;
2541
2734
  this.manager.logger.log(`[COW][UNCERTAIN] batchId=${err?.batchId || 'n/a'} ops=${opContexts.length} ` +
2542
2735
  `staleSinceMs=${err?.timeoutMs || 'n/a'} heartbeatAgeMs=${heartbeatAgeMs ?? 'n/a'} ` +
2543
- `adopted=${adopted.length} discarded=${discarded.length} elapsedMs=${elapsedMs}`, adopted.length > 0 ? 'info' : 'warn');
2736
+ `adopted=${adopted.length} discarded=${discarded.length} recheckRounds=${recheckRound} elapsedMs=${elapsedMs}`, adopted.length > 0 ? 'info' : 'warn');
2544
2737
  if (adopted.length > 0) {
2545
2738
  this.manager.logger.log(`[COW][UNCERTAIN] Adopted chain orders: ${adopted
2546
2739
  .map(a => `${a.slotId}->${a.chainOrderId}`)
2547
2740
  .join(', ')}`, 'info');
2548
2741
  }
2549
2742
  if (discarded.length > 0) {
2550
- this.manager.logger.log(`[COW][UNCERTAIN] Discarded planned CREATEs (no chain match): ${discarded
2743
+ this.manager.logger.log(`[COW][UNCERTAIN] Discarded planned CREATEs (no chain match after ${recheckRound} recheck(s)): ${discarded
2551
2744
  .map(d => d.slotId)
2552
2745
  .join(', ')}`, 'warn');
2553
2746
  }
@@ -2596,8 +2789,18 @@ class DEXBot {
2596
2789
  */
2597
2790
  async _autoCancelOneUnmatchedOrphan() {
2598
2791
  const cycleId = this._currentCycleId || 0;
2792
+ // FIX 4: Higher cap during recovery mode so orphan backlog clears faster.
2793
+ const recoveryActive = this.manager?._recoveryState?.structuralResyncRequested === true;
2794
+ const cycleCap = recoveryActive ? 5 : 1;
2599
2795
  if (this._autoCancelOrphanCycleMarker === cycleId) {
2600
- return { cancelled: false, reason: 'cap-reached-this-cycle' };
2796
+ if (this._autoCancelOrphanSubCount >= cycleCap) {
2797
+ return { cancelled: false, reason: 'cap-reached-this-cycle', subCount: this._autoCancelOrphanSubCount };
2798
+ }
2799
+ }
2800
+ else {
2801
+ // Reset sub-count on first call this cycle
2802
+ this._autoCancelOrphanCycleMarker = cycleId;
2803
+ this._autoCancelOrphanSubCount = 0;
2601
2804
  }
2602
2805
  const pending = (this.manager && this.manager._pendingBroadcasts instanceof Map)
2603
2806
  ? this.manager._pendingBroadcasts.size
@@ -2626,13 +2829,15 @@ class DEXBot {
2626
2829
  return { cancelled: false, reason: 'cancelOrder-unavailable' };
2627
2830
  }
2628
2831
  try {
2629
- this._autoCancelOrphanCycleMarker = cycleId;
2630
- this.manager.logger.log(`[COW] Auto-cancelling 1/${unmatched.length} unmatched chain order ` +
2631
- `(${this._formatUnmatchedChainOrderForLog(target)}) — per-cycle cap=1.`, 'warn');
2632
2832
  await chainOrders.cancelOrder(this.account, this.privateKey, orderId);
2633
2833
  if (typeof chainOrders.recordOwnCancel === 'function') {
2634
2834
  chainOrders.recordOwnCancel(orderId);
2635
2835
  }
2836
+ // Increment sub-count only after a confirmed successful cancel.
2837
+ // Failed cancels do not consume the per-cycle cap budget.
2838
+ this._autoCancelOrphanSubCount++;
2839
+ this.manager.logger.log(`[COW] Auto-cancelled ${this._autoCancelOrphanSubCount}/${unmatched.length} unmatched chain order ` +
2840
+ `(${this._formatUnmatchedChainOrderForLog(target)}) — per-cycle cap=${cycleCap}.`, 'warn');
2636
2841
  return { cancelled: true, orderId };
2637
2842
  }
2638
2843
  catch (err) {
@@ -2686,6 +2891,21 @@ class DEXBot {
2686
2891
  if (isRetriable) {
2687
2892
  this.manager.logger.log(`[COW] Broadcast uncertain (attempt ${attempt}/${MAX_RETRIES + 1}), retrying...`, 'warn');
2688
2893
  await this._ensureCredentialDaemonWritable('COW batch retry');
2894
+ // Reconcile before retry — stale ops would fail "not found".
2895
+ try {
2896
+ const accountRef = this.accountId || this.account?.id || this.account;
2897
+ const freshChain = await chainOrders.readOpenOrders(accountRef);
2898
+ if (freshChain.length > 0 && this.manager?.syncFromOpenOrders) {
2899
+ await this.manager.syncFromOpenOrders(freshChain, {
2900
+ skipAccounting: true,
2901
+ fillLockAlreadyHeld: true,
2902
+ protectCommittedOrders: true
2903
+ });
2904
+ }
2905
+ }
2906
+ catch (syncErr) {
2907
+ this.manager.logger.log(`[COW] Pre-retry sync failed (non-fatal): ${syncErr?.message || syncErr}`, 'warn');
2908
+ }
2689
2909
  continue;
2690
2910
  }
2691
2911
  throw err;
@@ -3435,6 +3655,22 @@ class DEXBot {
3435
3655
  };
3436
3656
  }
3437
3657
  const hasCreateActions = actions.some(action => action.type === COW_ACTIONS.CREATE);
3658
+ // Gap 5: Recovery exhausted — block all CREATES. The bot has exhausted
3659
+ // its recovery attempt budget and needs a fill or sync cycle to reset.
3660
+ // Existing orders remain active and are monitored, but no new orders
3661
+ // are placed until recovery resets.
3662
+ if (hasCreateActions && this.manager?._recoveryExhaustedAt) {
3663
+ const exhaustedAge = Date.now() - this.manager._recoveryExhaustedAt;
3664
+ this.manager.logger.log?.(`[RECOVERY-EXHAUSTED] Blocking ${actions.filter(a => a.type === COW_ACTIONS.CREATE).length} CREATE(s) ` +
3665
+ `(exhausted ${(exhaustedAge / 1000).toFixed(0)}s ago). ` +
3666
+ `Waiting for next fill or sync cycle to reset recovery state.`, 'warn');
3667
+ return {
3668
+ executed: false,
3669
+ aborted: true,
3670
+ reason: 'RECOVERY_EXHAUSTED',
3671
+ hadRotation: false
3672
+ };
3673
+ }
3438
3674
  const unmatchedChainOrders = Array.isArray(this.manager?._lastUnmatchedChainOrders)
3439
3675
  ? this.manager._lastUnmatchedChainOrders
3440
3676
  : [];
@@ -3448,58 +3684,111 @@ class DEXBot {
3448
3684
  ? Array.from(this.manager._pendingBroadcasts.values())
3449
3685
  : [];
3450
3686
  if (hasCreateActions && (unmatchedChainOrders.length > 0 || pendingBroadcasts.length > 0)) {
3451
- const blockers = [];
3452
- if (unmatchedChainOrders.length > 0)
3453
- blockers.push(`${unmatchedChainOrders.length} unmatched chain order(s)`);
3454
- if (pendingBroadcasts.length > 0)
3455
- blockers.push(`${pendingBroadcasts.length} pending broadcast(s)`);
3456
- const reasonText = blockers.join(' and ');
3457
- if (pendingBroadcasts.length > 0) {
3458
- this.manager.logger.log(`[COW] Rejecting CREATE batch: ${reasonText} from a prior uncertain ` +
3459
- `broadcast. Running recovery before placing replacement orders.`, 'error');
3460
- }
3461
- else {
3462
- const sample = unmatchedChainOrders
3463
- .slice(0, 3)
3464
- .map(order => this._formatUnmatchedChainOrderForLog(order))
3465
- .join(' | ');
3466
- this.manager.logger.log(`[COW] Rejecting CREATE batch: ${reasonText} ` +
3467
- `are not represented in the grid${sample ? ` (${sample})` : ''}. ` +
3468
- `Run structural reconciliation before placing replacement orders.`, 'error');
3687
+ // ---- FIX 3: Absorb unmatched orders instead of rejecting ----
3688
+ // When unmatched chain orders exist, try to auto-cancel them
3689
+ // inline to unblock the CREATE batch. Only fall through to
3690
+ // full rejection + structural resync if cancellation fails.
3691
+ let cancelledUnmatched = 0;
3692
+ if (unmatchedChainOrders.length > 0 && pendingBroadcasts.length === 0) {
3693
+ // Bounded-parallel cancellation: process unmatched orders in
3694
+ // concurrent batches (3 at a time) to avoid serialising 19+
3695
+ // individual cancel txs (~0.5s each = 10s batch hold).
3696
+ const CANCEL_CONCURRENCY = 3;
3697
+ const cancelable = unmatchedChainOrders.filter(u => {
3698
+ const oid = u?.id || u?.orderId || u?.chainOrderId;
3699
+ return Boolean(oid) && !u?.fingerprint;
3700
+ });
3701
+ for (let i = 0; i < cancelable.length; i += CANCEL_CONCURRENCY) {
3702
+ const batch = cancelable.slice(i, i + CANCEL_CONCURRENCY);
3703
+ const results = await Promise.allSettled(batch.map(async (unmatched) => {
3704
+ const orderId = unmatched.id || unmatched.orderId || unmatched.chainOrderId;
3705
+ this.manager.logger.log(`[COW] Auto-cancelling unmatched chain order ${orderId} ` +
3706
+ `(${this._formatUnmatchedChainOrderForLog(unmatched)}) to unblock CREATE batch.`, 'warn');
3707
+ await chainOrders.cancelOrder(this.account, this.privateKey, orderId);
3708
+ if (typeof chainOrders.recordOwnCancel === 'function') {
3709
+ chainOrders.recordOwnCancel(orderId);
3710
+ }
3711
+ }));
3712
+ for (const r of results) {
3713
+ if (r.status === 'fulfilled')
3714
+ cancelledUnmatched++;
3715
+ else
3716
+ this.manager.logger.log(`[COW] Failed to cancel unmatched chain order: ${r.reason?.message || r.reason}`, 'warn');
3717
+ }
3718
+ }
3719
+ if (cancelable.length > 0 && cancelledUnmatched >= cancelable.length) {
3720
+ this.manager.logger.log(`[COW] Cancelled all ${cancelledUnmatched} unmatched chain order(s); proceeding with CREATE batch.`, 'warn');
3721
+ // Optimistic clear: the cancel txs are sent but not yet
3722
+ // confirmed. If a cancel silently fails on chain, the
3723
+ // next sync cycle will re-detect the unmatched order and
3724
+ // re-enter this guard — that's safe because the cancel
3725
+ // is idempotent and the re-detection is non-blocking.
3726
+ this.manager._lastUnmatchedChainOrders = [];
3727
+ }
3728
+ else if (cancelable.length > 0) {
3729
+ this.manager.logger.log(`[COW] Cancelled ${cancelledUnmatched}/${cancelable.length} unmatched chain order(s); ` +
3730
+ `remaining must be handled by structural reconciliation.`, 'warn');
3731
+ }
3469
3732
  }
3470
- if (typeof this.manager.requestStructuralGridResync === 'function') {
3471
- if (this.manager._recoveryState)
3472
- this.manager._recoveryState.structuralResyncRequested = true;
3473
- await this.manager.requestStructuralGridResync(pendingBroadcasts.length > 0
3474
- ? 'pending broadcasts before COW create'
3475
- : 'unmatched chain orders before COW create', pendingBroadcasts.length > 0
3476
- ? { pendingBroadcasts: pendingBroadcasts.map(p => p.slotId) }
3477
- : { unmatchedChainOrders });
3478
- }
3479
- // If we have pending broadcasts, drive the recovery now so the
3480
- // next planning cycle has a clean state.
3481
- if (pendingBroadcasts.length > 0) {
3482
- try {
3483
- await this._reconcileAfterUncertainBroadcast(new BroadcastUncertainError('rejected CREATE batch had pending broadcasts', {
3484
- operations: pendingBroadcasts.map(p => p.order),
3485
- accountName: this.account,
3486
- batchId: this._currentBatchId || null,
3487
- payload: null,
3488
- timeoutMs: null
3489
- }), [], { fillLockAlreadyHeld: true });
3733
+ // Re-check after auto-cancel attempt
3734
+ const remainingUnmatched = Array.isArray(this.manager?._lastUnmatchedChainOrders)
3735
+ ? this.manager._lastUnmatchedChainOrders
3736
+ : [];
3737
+ if (remainingUnmatched.length > 0 || pendingBroadcasts.length > 0) {
3738
+ const blockers = [];
3739
+ if (remainingUnmatched.length > 0)
3740
+ blockers.push(`${remainingUnmatched.length} unmatched chain order(s)`);
3741
+ if (pendingBroadcasts.length > 0)
3742
+ blockers.push(`${pendingBroadcasts.length} pending broadcast(s)`);
3743
+ const reasonText = blockers.join(' and ');
3744
+ if (pendingBroadcasts.length > 0) {
3745
+ this.manager.logger.log(`[COW] Rejecting CREATE batch: ${reasonText} from a prior uncertain ` +
3746
+ `broadcast. Running recovery before placing replacement orders.`, 'error');
3747
+ }
3748
+ else {
3749
+ const sample = remainingUnmatched
3750
+ .slice(0, 3)
3751
+ .map(order => this._formatUnmatchedChainOrderForLog(order))
3752
+ .join(' | ');
3753
+ this.manager.logger.log(`[COW] Rejecting CREATE batch: ${reasonText} ` +
3754
+ `are not represented in the grid${sample ? ` (${sample})` : ''}. ` +
3755
+ `Run structural reconciliation before placing replacement orders.`, 'error');
3756
+ }
3757
+ if (typeof this.manager.requestStructuralGridResync === 'function') {
3758
+ if (this.manager._recoveryState)
3759
+ this.manager._recoveryState.structuralResyncRequested = true;
3760
+ await this.manager.requestStructuralGridResync(pendingBroadcasts.length > 0
3761
+ ? 'pending broadcasts before COW create'
3762
+ : 'unmatched chain orders before COW create', pendingBroadcasts.length > 0
3763
+ ? { pendingBroadcasts: pendingBroadcasts.map(p => p.slotId) }
3764
+ : { unmatchedChainOrders: remainingUnmatched });
3490
3765
  }
3491
- catch (recoverErr) {
3492
- this.manager.logger.log(`[COW] Recovery from pending broadcasts failed: ${recoverErr?.message || recoverErr}`, 'error');
3766
+ // If we have pending broadcasts, drive the recovery now so the
3767
+ // next planning cycle has a clean state.
3768
+ if (pendingBroadcasts.length > 0) {
3769
+ try {
3770
+ await this._reconcileAfterUncertainBroadcast(new BroadcastUncertainError('rejected CREATE batch had pending broadcasts', {
3771
+ operations: pendingBroadcasts.map(p => p.order),
3772
+ accountName: this.account,
3773
+ batchId: this._currentBatchId || null,
3774
+ payload: null,
3775
+ timeoutMs: null
3776
+ }), [], { fillLockAlreadyHeld: true });
3777
+ }
3778
+ catch (recoverErr) {
3779
+ this.manager.logger.log(`[COW] Recovery from pending broadcasts failed: ${recoverErr?.message || recoverErr}`, 'error');
3780
+ }
3493
3781
  }
3782
+ return {
3783
+ executed: false,
3784
+ aborted: true,
3785
+ reason: pendingBroadcasts.length > 0 ? 'PENDING_BROADCASTS' : 'UNMATCHED_CHAIN_ORDERS',
3786
+ unmatchedChainOrders: pendingBroadcasts.length > 0 ? [] : remainingUnmatched,
3787
+ pendingBroadcasts: pendingBroadcasts.map(p => p.slotId),
3788
+ hadRotation: false
3789
+ };
3494
3790
  }
3495
- return {
3496
- executed: false,
3497
- aborted: true,
3498
- reason: pendingBroadcasts.length > 0 ? 'PENDING_BROADCASTS' : 'UNMATCHED_CHAIN_ORDERS',
3499
- unmatchedChainOrders: pendingBroadcasts.length > 0 ? [] : unmatchedChainOrders,
3500
- pendingBroadcasts: pendingBroadcasts.map(p => p.slotId),
3501
- hadRotation: false
3502
- };
3791
+ // All unmatched were cancelled — fall through to continue batch
3503
3792
  }
3504
3793
  const { assetA, assetB } = this.manager.assets;
3505
3794
  const operations = [];
@@ -3540,7 +3829,13 @@ class DEXBot {
3540
3829
  opContexts.push({ kind: 'cancel', order });
3541
3830
  }
3542
3831
  catch (err) {
3543
- this.manager.logger.log(`Failed to prepare cancel op for ${action.id}: ${err.message}`, 'error');
3832
+ const orderNotFound = /\bnot found\b/i.test(err.message) || /\bdoes not exist\b/i.test(err.message);
3833
+ if (orderNotFound) {
3834
+ this.manager.logger.log(`[COW] Cancel skipped for ${action.id} (${action.orderId}): order already removed from chain`, 'debug');
3835
+ }
3836
+ else {
3837
+ this.manager.logger.log(`Failed to prepare cancel op for ${action.id}: ${err.message}`, 'error');
3838
+ }
3544
3839
  }
3545
3840
  }
3546
3841
  else if (action.type === COW_ACTIONS.CREATE) {
@@ -3652,8 +3947,60 @@ class DEXBot {
3652
3947
  type: orderType
3653
3948
  };
3654
3949
  opContexts.push({ kind: 'size-update', updateInfo: { partialOrder, newSize }, finalInts: op.finalInts });
3950
+ // Catch for both rotation-update and size-update branches.
3655
3951
  }
3656
3952
  catch (err) {
3953
+ const orderNotFound = /\bnot found\b/i.test(err.message) || /\bdoes not exist\b/i.test(err.message);
3954
+ if (orderNotFound) {
3955
+ try {
3956
+ const fbOrder = action.order || this.manager.orders.get(action.id);
3957
+ const fbType = fbOrder?.type;
3958
+ const fbSize = action.newSize || fbOrder?.size || 0;
3959
+ // Live price from TARGET slot (same pattern as CREATE path).
3960
+ const targetSlotId = action.newGridId || action.id;
3961
+ const plannedPrice = action.newPrice || action.order?.price || 0;
3962
+ const liveSlotForPrice = this.manager.orders.get(targetSlotId);
3963
+ const livePrice = liveSlotForPrice ? Number(liveSlotForPrice.price) : NaN;
3964
+ const priceDrift = Number.isFinite(plannedPrice) && Number.isFinite(livePrice)
3965
+ ? Math.abs(livePrice - plannedPrice)
3966
+ : 0;
3967
+ const fbPrice = (priceDrift > 0) ? livePrice : plannedPrice;
3968
+ if (priceDrift > 0) {
3969
+ this.manager.logger.log(`[COW] CREATE fallback price drift for ${action.id} -> ${targetSlotId}: ` +
3970
+ `planned=${plannedPrice} live=${livePrice} (diff=${priceDrift})`, 'debug');
3971
+ }
3972
+ // Same size gate as regular CREATE path.
3973
+ const sizeCheck = this._validateOrderSizeForExecution(fbSize, fbType, fbOrder, fbSize);
3974
+ if (!sizeCheck.isValid) {
3975
+ this.manager.logger.log(`[COW] CREATE fallback for ${action.id} rejected by size validation: ${sizeCheck.reason}`, 'warn');
3976
+ }
3977
+ else if (fbType && fbSize > 0 && fbPrice > 0) {
3978
+ const fbArgs = buildCreateOrderArgs({ type: fbType, size: fbSize, price: fbPrice }, assetA, assetB);
3979
+ const fbResult = await chainOrders.buildCreateOrderOp(this.account, fbArgs.amountToSell, fbArgs.sellAssetId, fbArgs.minToReceive, fbArgs.receiveAssetId, null);
3980
+ if (fbResult) {
3981
+ operations.push(fbResult.op);
3982
+ opContexts.push({
3983
+ kind: 'create',
3984
+ id: targetSlotId,
3985
+ order: { id: targetSlotId, type: fbType, price: fbPrice, size: fbSize },
3986
+ args: { amountToSell: fbArgs.amountToSell, minToReceive: fbArgs.minToReceive },
3987
+ finalInts: fbResult.finalInts
3988
+ });
3989
+ this._recordPendingBroadcast({
3990
+ opIndex: operations.length - 1,
3991
+ ctxIndex: opContexts.length - 1,
3992
+ order: { id: targetSlotId, type: fbType, price: fbPrice, size: fbSize },
3993
+ finalInts: fbResult.finalInts
3994
+ });
3995
+ this.manager.logger.log(`[COW] Recovered "not found" for ${action.id}: converted UPDATE to CREATE for slot ${targetSlotId}`, 'warn');
3996
+ continue;
3997
+ }
3998
+ }
3999
+ }
4000
+ catch (fbErr) {
4001
+ this.manager.logger.log(`[COW] CREATE fallback also failed for ${action.id}: ${fbErr.message}`, 'warn');
4002
+ }
4003
+ }
3657
4004
  this.manager.logger.log(`Failed to prepare update op for ${action.id}: ${err.message}`, 'error');
3658
4005
  }
3659
4006
  }
@@ -4285,6 +4632,16 @@ class DEXBot {
4285
4632
  return;
4286
4633
  this._structuralGridResyncRunning = true;
4287
4634
  try {
4635
+ // Try the lighter persisted-grid reload before full reset.
4636
+ const persistedResult = await this._recoverFromPersistedGrid();
4637
+ if (persistedResult.success) {
4638
+ if (this.manager?._recoveryState) {
4639
+ this.manager._recoveryState.attemptCount = 0;
4640
+ this.manager._recoveryState.lastAttemptAt = 0;
4641
+ this.manager._recoveryState.lastFailureAt = 0;
4642
+ }
4643
+ return;
4644
+ }
4288
4645
  const suffix = unmatchedCount > 0 ? ` (${unmatchedCount} unmatched chain order(s))` : '';
4289
4646
  this._warn(`[RECOVERY] Running structural full grid resync for ${reason}${suffix}`);
4290
4647
  const resetResult = await this.requestGridReset('rms_structural_grid_resync', {
@@ -4321,6 +4678,7 @@ class DEXBot {
4321
4678
  fillProcessingLockActive: this.manager?._fillProcessingLock?.isLocked() || false,
4322
4679
  divergenceLockActive: this.manager?._divergenceLock?.isLocked() || false,
4323
4680
  shadowLocksActive: this.manager?.shadowOrderIds?.size || 0,
4681
+ recoveryExhaustedAt: this.manager?._recoveryExhaustedAt || null,
4324
4682
  recentFillsTracked: this._recentlyProcessedFills.size
4325
4683
  };
4326
4684
  }