dexbot 1.1.12 → 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 (55) 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 +2 -0
  15. package/dist/modules/constants.d.ts.map +1 -1
  16. package/dist/modules/constants.js +8 -0
  17. package/dist/modules/constants.js.map +1 -1
  18. package/dist/modules/dexbot_class.d.ts +28 -0
  19. package/dist/modules/dexbot_class.d.ts.map +1 -1
  20. package/dist/modules/dexbot_class.js +386 -91
  21. package/dist/modules/dexbot_class.js.map +1 -1
  22. package/dist/modules/dexbot_credential_client.d.ts +2 -0
  23. package/dist/modules/dexbot_credential_client.d.ts.map +1 -1
  24. package/dist/modules/dexbot_credential_client.js +69 -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 +34 -3
  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 +4 -1
  32. package/dist/modules/key_store.js.map +1 -1
  33. package/dist/modules/order/accounting.d.ts.map +1 -1
  34. package/dist/modules/order/accounting.js +20 -4
  35. package/dist/modules/order/accounting.js.map +1 -1
  36. package/dist/modules/order/grid_reconcile.d.ts.map +1 -1
  37. package/dist/modules/order/grid_reconcile.js +6 -3
  38. package/dist/modules/order/grid_reconcile.js.map +1 -1
  39. package/dist/modules/order/manager.d.ts +1 -3
  40. package/dist/modules/order/manager.d.ts.map +1 -1
  41. package/dist/modules/order/manager.js +9 -27
  42. package/dist/modules/order/manager.js.map +1 -1
  43. package/dist/modules/order/sync_engine.d.ts +9 -2
  44. package/dist/modules/order/sync_engine.d.ts.map +1 -1
  45. package/dist/modules/order/sync_engine.js +29 -4
  46. package/dist/modules/order/sync_engine.js.map +1 -1
  47. package/dist/modules/order/utils/system.d.ts +1 -0
  48. package/dist/modules/order/utils/system.d.ts.map +1 -1
  49. package/dist/modules/order/utils/system.js +90 -38
  50. package/dist/modules/order/utils/system.js.map +1 -1
  51. package/dist/modules/paths.d.ts +1 -0
  52. package/dist/modules/paths.d.ts.map +1 -1
  53. package/dist/modules/paths.js +1 -0
  54. package/dist/modules/paths.js.map +1 -1
  55. 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,6 +132,8 @@ class DEXBot {
132
132
  _lastGridActivityAt;
133
133
  _currentCycleId;
134
134
  _autoCancelOrphanCycleMarker;
135
+ _autoCancelOrphanSubCount;
136
+ _orphanFillsCreditedAt;
135
137
  _consecutiveConsumeFailures;
136
138
  _consumeFailureFirstAt;
137
139
  _reconnectUnregister;
@@ -221,6 +223,8 @@ class DEXBot {
221
223
  this._lastGridActivityAt = 0;
222
224
  this._currentCycleId = 0;
223
225
  this._autoCancelOrphanCycleMarker = null;
226
+ this._autoCancelOrphanSubCount = 0;
227
+ this._orphanFillsCreditedAt = null;
224
228
  // Per-session guard: ghost order IDs successfully cancelled
225
229
  // (avoids spamming the chain with repeated cancel attempts for the same
226
230
  // orphan residual on every fill cycle).
@@ -543,6 +547,57 @@ class DEXBot {
543
547
  }
544
548
  return Array.from(ids);
545
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
+ }
546
601
  /**
547
602
  * Attempt to repair size-drift for specific order IDs by reading their
548
603
  * current on-chain state and correcting the local grid directly.
@@ -1449,6 +1504,14 @@ class DEXBot {
1449
1504
  return;
1450
1505
  }
1451
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;
1452
1515
  while (this._incomingFillQueue.length > 0) {
1453
1516
  const batchStartTime = Date.now();
1454
1517
  // Track max queue depth
@@ -1512,6 +1575,10 @@ class DEXBot {
1512
1575
  if (accountingResult.status === 'missing_key') {
1513
1576
  requiresOpenOrdersSync = true;
1514
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();
1515
1582
  // Don't add to validFills - we can't do rebalancing without a grid slot
1516
1583
  // But the funds are now credited, preventing fund invariant violation
1517
1584
  continue;
@@ -1601,7 +1668,49 @@ class DEXBot {
1601
1668
  };
1602
1669
  this.manager.pauseFundRecalc();
1603
1670
  try {
1604
- 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;
1605
1714
  // 4. Handle Price Corrections
1606
1715
  if (ordersNeedingCorrection.length > 0) {
1607
1716
  const correctionResult = await correctAllPriceMismatches(this.manager, this.account, this.privateKey, chainOrders);
@@ -2092,7 +2201,8 @@ class DEXBot {
2092
2201
  const openOrders = await chainOrders.readOpenOrders(accountRef);
2093
2202
  const recoveryResult = await this.manager.syncFromOpenOrders(openOrders, {
2094
2203
  skipAccounting: false,
2095
- fillLockAlreadyHeld: true
2204
+ fillLockAlreadyHeld: true,
2205
+ protectCommittedOrders: true
2096
2206
  });
2097
2207
  this._preserveMissingCreateBlockersAfterRecovery(preRecoveryMissingCreateBlockers, recoveryResult);
2098
2208
  // Persist any master grid mutations from the recovery sync. The
@@ -2480,7 +2590,7 @@ class DEXBot {
2480
2590
  return { executed: false, hadRotation: false, uncertain: true };
2481
2591
  }
2482
2592
  const adopted = [];
2483
- const discarded = [];
2593
+ let discarded = [];
2484
2594
  // 2. For each pending broadcast, look for a chain match.
2485
2595
  for (const entry of pending) {
2486
2596
  const match = this._findChainOrderForSlot(chainSnapshot, entry.slotId, {
@@ -2494,45 +2604,82 @@ class DEXBot {
2494
2604
  this.manager._pendingBroadcasts.delete(entry.fingerprint);
2495
2605
  }
2496
2606
  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) {
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;
2517
2625
  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;
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);
2525
2646
  }
2526
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
+ }
2527
2673
  catch (syncErr) {
2528
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
+ }
2529
2681
  }
2530
2682
  }
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
2683
  // 4. Log structured summary.
2537
2684
  const elapsedMs = Date.now() - startedAt;
2538
2685
  const heartbeatAgeMs = this._lastBroadcastHeartbeatAt
@@ -2540,14 +2687,14 @@ class DEXBot {
2540
2687
  : null;
2541
2688
  this.manager.logger.log(`[COW][UNCERTAIN] batchId=${err?.batchId || 'n/a'} ops=${opContexts.length} ` +
2542
2689
  `staleSinceMs=${err?.timeoutMs || 'n/a'} heartbeatAgeMs=${heartbeatAgeMs ?? 'n/a'} ` +
2543
- `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');
2544
2691
  if (adopted.length > 0) {
2545
2692
  this.manager.logger.log(`[COW][UNCERTAIN] Adopted chain orders: ${adopted
2546
2693
  .map(a => `${a.slotId}->${a.chainOrderId}`)
2547
2694
  .join(', ')}`, 'info');
2548
2695
  }
2549
2696
  if (discarded.length > 0) {
2550
- 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
2551
2698
  .map(d => d.slotId)
2552
2699
  .join(', ')}`, 'warn');
2553
2700
  }
@@ -2596,8 +2743,18 @@ class DEXBot {
2596
2743
  */
2597
2744
  async _autoCancelOneUnmatchedOrphan() {
2598
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;
2599
2749
  if (this._autoCancelOrphanCycleMarker === cycleId) {
2600
- 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;
2601
2758
  }
2602
2759
  const pending = (this.manager && this.manager._pendingBroadcasts instanceof Map)
2603
2760
  ? this.manager._pendingBroadcasts.size
@@ -2626,13 +2783,15 @@ class DEXBot {
2626
2783
  return { cancelled: false, reason: 'cancelOrder-unavailable' };
2627
2784
  }
2628
2785
  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
2786
  await chainOrders.cancelOrder(this.account, this.privateKey, orderId);
2633
2787
  if (typeof chainOrders.recordOwnCancel === 'function') {
2634
2788
  chainOrders.recordOwnCancel(orderId);
2635
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');
2636
2795
  return { cancelled: true, orderId };
2637
2796
  }
2638
2797
  catch (err) {
@@ -2686,6 +2845,21 @@ class DEXBot {
2686
2845
  if (isRetriable) {
2687
2846
  this.manager.logger.log(`[COW] Broadcast uncertain (attempt ${attempt}/${MAX_RETRIES + 1}), retrying...`, 'warn');
2688
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
+ }
2689
2863
  continue;
2690
2864
  }
2691
2865
  throw err;
@@ -3448,58 +3622,111 @@ class DEXBot {
3448
3622
  ? Array.from(this.manager._pendingBroadcasts.values())
3449
3623
  : [];
3450
3624
  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');
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
+ }
3469
3670
  }
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 });
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');
3490
3685
  }
3491
- catch (recoverErr) {
3492
- this.manager.logger.log(`[COW] Recovery from pending broadcasts failed: ${recoverErr?.message || recoverErr}`, 'error');
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');
3694
+ }
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 });
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
+ }
3493
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
+ };
3494
3728
  }
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
- };
3729
+ // All unmatched were cancelled — fall through to continue batch
3503
3730
  }
3504
3731
  const { assetA, assetB } = this.manager.assets;
3505
3732
  const operations = [];
@@ -3540,7 +3767,13 @@ class DEXBot {
3540
3767
  opContexts.push({ kind: 'cancel', order });
3541
3768
  }
3542
3769
  catch (err) {
3543
- 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
+ }
3544
3777
  }
3545
3778
  }
3546
3779
  else if (action.type === COW_ACTIONS.CREATE) {
@@ -3652,8 +3885,60 @@ class DEXBot {
3652
3885
  type: orderType
3653
3886
  };
3654
3887
  opContexts.push({ kind: 'size-update', updateInfo: { partialOrder, newSize }, finalInts: op.finalInts });
3888
+ // Catch for both rotation-update and size-update branches.
3655
3889
  }
3656
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
+ }
3657
3942
  this.manager.logger.log(`Failed to prepare update op for ${action.id}: ${err.message}`, 'error');
3658
3943
  }
3659
3944
  }
@@ -4285,6 +4570,16 @@ class DEXBot {
4285
4570
  return;
4286
4571
  this._structuralGridResyncRunning = true;
4287
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
+ }
4288
4583
  const suffix = unmatchedCount > 0 ? ` (${unmatchedCount} unmatched chain order(s))` : '';
4289
4584
  this._warn(`[RECOVERY] Running structural full grid resync for ${reason}${suffix}`);
4290
4585
  const resetResult = await this.requestGridReset('rms_structural_grid_resync', {