dexbot 1.2.2 → 1.2.4

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.
@@ -596,6 +596,42 @@ class DEXBot {
596
596
  }
597
597
  this.manager.logger.log(`[RECOVERY] Grid reloaded from persisted snapshot: ${this.manager.orders.size} orders, ` +
598
598
  `${chainOpenOrders.length} on-chain orders synced`, 'info');
599
+ // Gap 2: Check for unmatched chain orders after reload + sync.
600
+ // If the sync resolved all unmatched entries, _lastUnmatchedChainOrders
601
+ // was cleared by the sync engine. If any remain, the persisted snapshot
602
+ // produced an inconsistent grid — reject so the structural resync
603
+ // falls through to requestGridReset (full rebuild from chain).
604
+ const remainingUnmatched = Array.isArray(this.manager?._lastUnmatchedChainOrders)
605
+ ? this.manager._lastUnmatchedChainOrders
606
+ : [];
607
+ if (remainingUnmatched.length > 0) {
608
+ const sample = remainingUnmatched.slice(0, 3)
609
+ .map(o => this._formatUnmatchedChainOrderForLog(o))
610
+ .join(' | ');
611
+ this.manager.logger.log(`[RECOVERY] Persisted grid reloaded but ${remainingUnmatched.length} unmatched chain order(s) ` +
612
+ `remain${sample ? ` (${sample})` : ''}. Rejecting — full grid reset required.`, 'warn');
613
+ return { success: false, reason: `grid inconsistent after reload: ${remainingUnmatched.length} unmatched remain` };
614
+ }
615
+ // If the reloaded grid is still bloated, reject recovery so the
616
+ // caller falls through to a full grid reset (requestGridReset).
617
+ // Without this check, a bloated snapshot gets accepted as "success"
618
+ // and the structural-resync loop loads the same broken state forever.
619
+ //
620
+ // NOTE: loadGrid() already fires requestStructuralGridResync when it
621
+ // detects bloat internally, so the inner async resync may be in flight
622
+ // by the time this outer check runs. That's fine — the structural-resync
623
+ // gate (_structuralGridResyncRunning / _structuralGridResyncTimer) dedup's
624
+ // concurrent requests. This outer check exists so the synchronous return
625
+ // value is honest about the state; the inner resync is a safety net.
626
+ const { isGridBloated } = require('./order/grid');
627
+ const ordersArr = Array.from(this.manager.orders.values());
628
+ const bloatPostRecovery = isGridBloated(this.manager, ordersArr);
629
+ if (bloatPostRecovery.bloated) {
630
+ const d = bloatPostRecovery.details;
631
+ this.manager.logger.log(`[RECOVERY] Persisted grid reloaded but still bloated ` +
632
+ `(${d.gridSize} slots, max ${d.maxAllowed}). Rejecting — full grid reset required.`, 'warn');
633
+ return { success: false, reason: 'grid still bloated after reload' };
634
+ }
599
635
  return { success: true };
600
636
  }
601
637
  catch (err) {
@@ -2794,6 +2830,45 @@ class DEXBot {
2794
2830
  this.manager.logger.log(`[COW][UNCERTAIN] Discarded planned CREATEs (no chain match after ${recheckRound} recheck(s)): ${discarded
2795
2831
  .map(d => d.slotId)
2796
2832
  .join(', ')}`, 'warn');
2833
+ // Restore target grid sizes for discarded CREATEs so the slots are
2834
+ // not left as virtual/0 after recovery. Without this, the slot stays
2835
+ // at size 0 (set by the fill handler) and is never reactivated until
2836
+ // a fresh fill cycle triggers another rebalance that happens to succeed.
2837
+ // entry.order is the pending-broadcast target order (captured at broadcast
2838
+ // time); its .id matches entry.slotId — the lookup below keys on slotId.
2839
+ for (const entry of discarded) {
2840
+ if (entry.order && entry.slotId) {
2841
+ const current = this.manager.orders.get(entry.slotId);
2842
+ if (current && current.state === ORDER_STATES.VIRTUAL && !current.orderId) {
2843
+ try {
2844
+ await this.manager._applyOrderUpdate({ ...entry.order, state: ORDER_STATES.VIRTUAL, orderId: null }, 'uncertain-recovery-restore-size', { skipAccounting: true, fee: 0 });
2845
+ this.manager.logger.log(`[COW][UNCERTAIN] Restored target size for discarded CREATE slot ${entry.slotId} (size: ${entry.order.size})`, 'info');
2846
+ }
2847
+ catch (restoreErr) {
2848
+ this.manager.logger.log(`[COW][UNCERTAIN] Failed to restore size for slot ${entry.slotId}: ${restoreErr?.message || restoreErr}`, 'warn');
2849
+ }
2850
+ }
2851
+ }
2852
+ }
2853
+ }
2854
+ // ---- Structural resync safeguard after skipAccounting restore ----
2855
+ // The discarded CREATE restore above used skipAccounting: true to avoid
2856
+ // double-counting when the structural resync recalculates accounting
2857
+ // from scratch. Ensure one is scheduled — if already in-flight (timer
2858
+ // or running), the existing resync will handle the accounting fix.
2859
+ if (discarded.length > 0 && typeof this.manager?.requestStructuralGridResync === 'function') {
2860
+ const alreadyScheduled = this._structuralGridResyncRunning || this._structuralGridResyncTimer;
2861
+ if (!alreadyScheduled) {
2862
+ this.manager.logger.log(`[COW][UNCERTAIN] Scheduling structural resync after discarded CREATE restore ` +
2863
+ `(skipAccounting used — resync needed for fund recalculation)`, 'info');
2864
+ this.manager.requestStructuralGridResync('cow-uncertain-accounting-repair', { discardedCount: discarded.length }).catch((err) => {
2865
+ this.manager.logger.log(`[COW][UNCERTAIN] Failed to schedule accounting repair resync: ${err.message}`, 'error');
2866
+ });
2867
+ }
2868
+ else {
2869
+ this.manager.logger.log(`[COW][UNCERTAIN] Structural resync already ${this._structuralGridResyncRunning ? 'running' : 'scheduled'}; ` +
2870
+ `it will repair accounting after discarded CREATE restore`, 'debug');
2871
+ }
2797
2872
  }
2798
2873
  this._clearPendingBroadcasts();
2799
2874
  // Post-recovery safety net: if there are still unmatched chain
@@ -2861,18 +2936,22 @@ class DEXBot {
2861
2936
  return { executed: false, hadRotation, uncertain: true, adopted, discarded };
2862
2937
  }
2863
2938
  /**
2864
- * Auto-cancel a single unmatched chain order from the recovery snapshot.
2939
+ * Auto-cancel a price-drift orphan from the unmatched-order snapshot.
2940
+ *
2941
+ * Only cancels entries with reason === 'price-drift-orphan' — these are
2942
+ * surplus orders that drifted away from their slot price and have no
2943
+ * adoptable grid slot. All other unmatched orders (duplicate-price-level,
2944
+ * already-matched-slot, etc.) are adoptable positions that the structural
2945
+ * resync will integrate into the grid; cancelling them destroys capital.
2865
2946
  *
2866
2947
  * This is the post-recovery safety net: if, after
2867
- * _reconcileAfterUncertainBroadcast runs, there are still chain orders
2868
- * the bot doesn't recognize (e.g. from a network partition, or from a
2869
- * daemon timeout that we couldn't even fingerprint), we cancel ONE of
2870
- * them per call. Per-cycle cap = 1 — the next cycle will pick up the
2871
- * next unmatched order if more remain.
2948
+ * _reconcileAfterUncertainBroadcast runs, there are still price-drift
2949
+ * orphans, cancel ONE per cycle. Per-cycle cap = 1 (or 5 in recovery mode)
2950
+ * — the next cycle will pick up the next orphan if more remain.
2872
2951
  *
2873
2952
  * Safety conditions (ALL must hold):
2874
2953
  * 1. _pendingBroadcasts is empty (no in-flight recovery)
2875
- * 2. _lastUnmatchedChainOrders is non-empty
2954
+ * 2. _lastUnmatchedChainOrders contains at least one price-drift-orphan
2876
2955
  * 3. The current cycle has not already auto-cancelled an orphan
2877
2956
  * (tracked via this._autoCancelOrphanCycleMarker)
2878
2957
  *
@@ -2908,17 +2987,27 @@ class DEXBot {
2908
2987
  if (unmatched.length === 0) {
2909
2988
  return { cancelled: false, reason: 'no-unmatched' };
2910
2989
  }
2911
- const priceDriftOrphan = unmatched.find(u => u && u.reason === 'price-drift-orphan');
2912
- const target = priceDriftOrphan || unmatched[0];
2913
- const orderId = target?.id || target?.orderId || target?.chainOrderId;
2990
+ // Check for fingerprinted entries first — these came from a pending
2991
+ // broadcast (missing-create-result) and must be handled by the recovery
2992
+ // path, not by auto-cancel. The first fingerprinted entry is checked
2993
+ // regardless of its reason field.
2994
+ const fingerprinted = unmatched.find(u => u && u.fingerprint);
2995
+ if (fingerprinted) {
2996
+ return { cancelled: false, reason: 'fingerprinted-handle-via-recovery' };
2997
+ }
2998
+ // Only cancel price-drift orphans — these are surplus orders that
2999
+ // drifted away from their assigned slot price and have no adoptable
3000
+ // grid slot. All other unmatched orders (duplicate-price-level,
3001
+ // already-matched-slot, etc.) are adoptable positions that the structural
3002
+ // resync will integrate into the grid — cancelling them destroys capital.
3003
+ const target = unmatched.find(u => u && u.reason === 'price-drift-orphan');
3004
+ if (!target) {
3005
+ return { cancelled: false, reason: 'no-price-drift-orphan', message: 'no price-drift orphan to cancel; other unmatched orders are adoptable' };
3006
+ }
3007
+ const orderId = target.id || target.orderId || target.chainOrderId;
2914
3008
  if (!orderId) {
2915
3009
  return { cancelled: false, reason: 'no-orderId' };
2916
3010
  }
2917
- if (target?.fingerprint) {
2918
- // Fingerprinted unmatched orders came from a pending broadcast.
2919
- // The recovery path is the right place to handle them, not here.
2920
- return { cancelled: false, reason: 'fingerprinted-handle-via-recovery' };
2921
- }
2922
3011
  if (!chainOrders?.cancelOrder) {
2923
3012
  return { cancelled: false, reason: 'cancelOrder-unavailable' };
2924
3013
  }
@@ -3776,111 +3865,112 @@ class DEXBot {
3776
3865
  ? Array.from(this.manager._pendingBroadcasts.values())
3777
3866
  : [];
3778
3867
  if (hasCreateActions && (unmatchedChainOrders.length > 0 || pendingBroadcasts.length > 0)) {
3779
- // ---- FIX 3: Absorb unmatched orders instead of rejecting ----
3780
- // When unmatched chain orders exist, try to auto-cancel them
3781
- // inline to unblock the CREATE batch. Only fall through to
3782
- // full rejection + structural resync if cancellation fails.
3783
- let cancelledUnmatched = 0;
3784
- if (unmatchedChainOrders.length > 0 && pendingBroadcasts.length === 0) {
3785
- // Bounded-parallel cancellation: process unmatched orders in
3786
- // concurrent batches (3 at a time) to avoid serialising 19+
3787
- // individual cancel txs (~0.5s each = 10s batch hold).
3788
- const CANCEL_CONCURRENCY = 3;
3789
- const cancelable = unmatchedChainOrders.filter(u => {
3790
- const oid = u?.id || u?.orderId || u?.chainOrderId;
3791
- return Boolean(oid) && !u?.fingerprint;
3792
- });
3793
- for (let i = 0; i < cancelable.length; i += CANCEL_CONCURRENCY) {
3794
- const batch = cancelable.slice(i, i + CANCEL_CONCURRENCY);
3795
- const results = await Promise.allSettled(batch.map(async (unmatched) => {
3796
- const orderId = unmatched.id || unmatched.orderId || unmatched.chainOrderId;
3797
- this.manager.logger.log(`[COW] Auto-cancelling unmatched chain order ${orderId} ` +
3798
- `(${this._formatUnmatchedChainOrderForLog(unmatched)}) to unblock CREATE batch.`, 'warn');
3799
- await chainOrders.cancelOrder(this.account, this.privateKey, orderId);
3800
- if (typeof chainOrders.recordOwnCancel === 'function') {
3801
- chainOrders.recordOwnCancel(orderId);
3802
- }
3803
- }));
3804
- for (const r of results) {
3805
- if (r.status === 'fulfilled')
3806
- cancelledUnmatched++;
3807
- else
3808
- this.manager.logger.log(`[COW] Failed to cancel unmatched chain order: ${r.reason?.message || r.reason}`, 'warn');
3809
- }
3810
- }
3811
- if (cancelable.length > 0 && cancelledUnmatched >= cancelable.length) {
3812
- this.manager.logger.log(`[COW] Cancelled all ${cancelledUnmatched} unmatched chain order(s); proceeding with CREATE batch.`, 'warn');
3813
- // Optimistic clear: the cancel txs are sent but not yet
3814
- // confirmed. If a cancel silently fails on chain, the
3815
- // next sync cycle will re-detect the unmatched order and
3816
- // re-enter this guard — that's safe because the cancel
3817
- // is idempotent and the re-detection is non-blocking.
3818
- this.manager._lastUnmatchedChainOrders = [];
3819
- }
3820
- else if (cancelable.length > 0) {
3821
- this.manager.logger.log(`[COW] Cancelled ${cancelledUnmatched}/${cancelable.length} unmatched chain order(s); ` +
3822
- `remaining must be handled by structural reconciliation.`, 'warn');
3823
- }
3824
- }
3825
- // Re-check after auto-cancel attempt
3826
- const remainingUnmatched = Array.isArray(this.manager?._lastUnmatchedChainOrders)
3827
- ? this.manager._lastUnmatchedChainOrders
3828
- : [];
3829
- if (remainingUnmatched.length > 0 || pendingBroadcasts.length > 0) {
3830
- const blockers = [];
3831
- if (remainingUnmatched.length > 0)
3832
- blockers.push(`${remainingUnmatched.length} unmatched chain order(s)`);
3833
- if (pendingBroadcasts.length > 0)
3834
- blockers.push(`${pendingBroadcasts.length} pending broadcast(s)`);
3835
- const reasonText = blockers.join(' and ');
3836
- if (pendingBroadcasts.length > 0) {
3837
- this.manager.logger.log(`[COW] Rejecting CREATE batch: ${reasonText} from a prior uncertain ` +
3838
- `broadcast. Running recovery before placing replacement orders.`, 'error');
3839
- }
3840
- else {
3841
- const sample = remainingUnmatched
3842
- .slice(0, 3)
3843
- .map(order => this._formatUnmatchedChainOrderForLog(order))
3844
- .join(' | ');
3845
- this.manager.logger.log(`[COW] Rejecting CREATE batch: ${reasonText} ` +
3846
- `are not represented in the grid${sample ? ` (${sample})` : ''}. ` +
3847
- `Run structural reconciliation before placing replacement orders.`, 'error');
3848
- }
3868
+ // ---- Handle pending broadcasts first ----
3869
+ if (pendingBroadcasts.length > 0) {
3870
+ this.manager.logger.log(`[COW] Rejecting CREATE batch: ${pendingBroadcasts.length} pending broadcast(s) from a prior uncertain ` +
3871
+ `broadcast. Running recovery before placing replacement orders.`, 'error');
3849
3872
  if (typeof this.manager.requestStructuralGridResync === 'function') {
3850
3873
  if (this.manager._recoveryState)
3851
3874
  this.manager._recoveryState.structuralResyncRequested = true;
3852
- await this.manager.requestStructuralGridResync(pendingBroadcasts.length > 0
3853
- ? 'pending broadcasts before COW create'
3854
- : 'unmatched chain orders before COW create', pendingBroadcasts.length > 0
3855
- ? { pendingBroadcasts: pendingBroadcasts.map(p => p.slotId) }
3856
- : { unmatchedChainOrders: remainingUnmatched });
3875
+ await this.manager.requestStructuralGridResync('pending broadcasts before COW create', { pendingBroadcasts: pendingBroadcasts.map(p => p.slotId) });
3857
3876
  }
3858
- // If we have pending broadcasts, drive the recovery now so the
3859
- // next planning cycle has a clean state.
3860
- if (pendingBroadcasts.length > 0) {
3861
- try {
3862
- await this._reconcileAfterUncertainBroadcast(new BroadcastUncertainError('rejected CREATE batch had pending broadcasts', {
3863
- operations: pendingBroadcasts.map(p => p.order),
3864
- accountName: this.account,
3865
- batchId: this._currentBatchId || null,
3866
- payload: null,
3867
- timeoutMs: null
3868
- }), []);
3869
- }
3870
- catch (recoverErr) {
3871
- this.manager.logger.log(`[COW] Recovery from pending broadcasts failed: ${recoverErr?.message || recoverErr}`, 'error');
3872
- }
3877
+ try {
3878
+ await this._reconcileAfterUncertainBroadcast(new BroadcastUncertainError('rejected CREATE batch had pending broadcasts', {
3879
+ operations: pendingBroadcasts.map(p => p.order),
3880
+ accountName: this.account,
3881
+ batchId: this._currentBatchId || null,
3882
+ payload: null,
3883
+ timeoutMs: null
3884
+ }), []);
3885
+ }
3886
+ catch (recoverErr) {
3887
+ this.manager.logger.log(`[COW] Recovery from pending broadcasts failed: ${recoverErr?.message || recoverErr}`, 'error');
3873
3888
  }
3874
3889
  return {
3875
3890
  executed: false,
3876
3891
  aborted: true,
3877
- reason: pendingBroadcasts.length > 0 ? 'PENDING_BROADCASTS' : 'UNMATCHED_CHAIN_ORDERS',
3878
- unmatchedChainOrders: pendingBroadcasts.length > 0 ? [] : remainingUnmatched,
3879
- pendingBroadcasts: pendingBroadcasts.map(p => p.slotId),
3892
+ reason: 'PENDING_BROADCASTS',
3880
3893
  hadRotation: false
3881
3894
  };
3882
3895
  }
3883
- // All unmatched were cancelled — fall through to continue batch
3896
+ // ---- Unmatched orders: adopt instead of cancel ----
3897
+ // Unmatched orders are legitimate on-chain positions the grid
3898
+ // hasn't adopted yet. Cancelling them destroys capital and
3899
+ // creates gaps in the order book. Instead, re-sync to adopt
3900
+ // them into the grid, then reject (the sync invalidated the
3901
+ // working grid, so the existing COW plan is stale).
3902
+ // NOTE: syncFromOpenOrders may be a partial no-op under certain
3903
+ // lock conditions (e.g. _fillProcessingLock + !isReentrant).
3904
+ // Structural resync scheduled below handles adoption regardless.
3905
+ const unmatchedSample = unmatchedChainOrders
3906
+ .slice(0, 3)
3907
+ .map(o => this._formatUnmatchedChainOrderForLog(o))
3908
+ .join(' | ');
3909
+ this.manager.logger.log(`[COW] ${unmatchedChainOrders.length} unmatched chain order(s) blocking CREATES ` +
3910
+ (unmatchedSample ? `(${unmatchedSample})` : '') +
3911
+ ` — adopting via sync instead of cancelling`, 'info');
3912
+ try {
3913
+ const accountRef = this.account;
3914
+ const freshSnapshot = await chainOrders.readOpenOrders(accountRef);
3915
+ if (freshSnapshot && freshSnapshot.length > 0) {
3916
+ const syncResult = await this.manager.syncFromOpenOrders(freshSnapshot, {
3917
+ skipAccounting: true,
3918
+ });
3919
+ // Only overwrite _lastUnmatchedChainOrders when sync actually
3920
+ // processed orders (filled + updated + corrected > 0).
3921
+ // Force-release and lock-contention early-exit paths return
3922
+ // empty arrays without touching _lastUnmatchedChainOrders —
3923
+ // overwriting with [] would drop stale unmatched entries.
3924
+ if (syncResult && Array.isArray(syncResult.unmatchedChainOrders)) {
3925
+ const processed = (syncResult.filledOrders?.length || 0) +
3926
+ (syncResult.updatedOrders?.length || 0) +
3927
+ (syncResult.ordersNeedingCorrection?.length || 0);
3928
+ if (processed > 0) {
3929
+ this.manager._lastUnmatchedChainOrders = syncResult.unmatchedChainOrders;
3930
+ this.manager.logger.log(`[COW] Adopted chain order(s) via sync: ${processed} processed, ` +
3931
+ `${syncResult.unmatchedChainOrders.length} still unmatched`, 'info');
3932
+ }
3933
+ else {
3934
+ // processed === 0 with unmatched still present means the
3935
+ // sync could not adopt the unmatched chain orders. This is
3936
+ // normal when called re-entrantly (inside _fillProcessingLock):
3937
+ // the sync engine runs inline and either timed out (unlikely
3938
+ // for a re-entrant call) or all chain orders were already
3939
+ // matched — leaving only stale unmatched entries.
3940
+ const syncUnmatchedCount = syncResult.unmatchedChainOrders.length;
3941
+ this.manager.logger.log(`[COW] Sync returned without processing (processed=0, ` +
3942
+ `unmatched=${syncUnmatchedCount} in result, ` +
3943
+ `_lastUnmatchedChainOrders=${unmatchedChainOrders.length}). ` +
3944
+ `Structural resync will handle adoption.`, syncUnmatchedCount > 0 ? 'warn' : 'debug');
3945
+ // If the sync result itself has unmatched entries that
3946
+ // differ from _lastUnmatchedChainOrders, adopt them now
3947
+ // so the stale tracker is more accurate for the resync.
3948
+ if (syncUnmatchedCount > 0 && syncUnmatchedCount !== unmatchedChainOrders.length) {
3949
+ this.manager._lastUnmatchedChainOrders = syncResult.unmatchedChainOrders.map((o) => ({ ...o }));
3950
+ this.manager.logger.log(`[COW] Updated _lastUnmatchedChainOrders from sync result: ` +
3951
+ `${unmatchedChainOrders.length} → ${syncUnmatchedCount}`, 'debug');
3952
+ }
3953
+ }
3954
+ }
3955
+ }
3956
+ }
3957
+ catch (syncErr) {
3958
+ this.manager.logger.log(`[COW] Failed to sync/unmatched orders: ${syncErr?.message || syncErr}`, 'warn');
3959
+ }
3960
+ // Working grid is stale after master grid mutation from sync.
3961
+ // Request structural resync to rebuild the grid on the next cycle.
3962
+ if (typeof this.manager.requestStructuralGridResync === 'function') {
3963
+ if (this.manager._recoveryState)
3964
+ this.manager._recoveryState.structuralResyncRequested = true;
3965
+ await this.manager.requestStructuralGridResync('unmatched chain orders before COW create', { unmatchedChainOrders: unmatchedChainOrders });
3966
+ }
3967
+ this.manager.logger.log(`[COW] Rejecting CREATE batch after sync: working grid invalidated by master mutation`, 'info');
3968
+ return {
3969
+ executed: false,
3970
+ aborted: true,
3971
+ reason: 'UNMATCHED_CHAIN_ORDERS',
3972
+ hadRotation: false
3973
+ };
3884
3974
  }
3885
3975
  const { assetA, assetB } = this.manager.assets;
3886
3976
  const operations = [];