dexbot 1.6.2 → 1.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +15 -1
  2. package/analysis/README.md +2 -2
  3. package/analysis/ama_fitting/package.json +1 -1
  4. package/analysis/grid_correction_check.ts +80 -7
  5. package/analysis/results/ama_sweep_results_lp_pool_133_1h.json +2455 -0
  6. package/analysis/results/bot_fitting_results_lp_pool_133_1h.json +218 -0
  7. package/analysis/tradingview/h-bts_tradingview.html +1570 -0
  8. package/analysis/tradingview/t-bts_tradingview.html +1570 -0
  9. package/analysis/trend_detection/package.json +1 -1
  10. package/claw/package.json +1 -1
  11. package/claw/runtimes/openclaw-plugin/openclaw.plugin.json +1 -1
  12. package/claw/runtimes/openclaw-plugin/package.json +1 -1
  13. package/claw/tests/test_claw_mcp_transport.ts +2 -2
  14. package/dist/analysis/grid_correction_check.d.ts +5 -1
  15. package/dist/analysis/grid_correction_check.d.ts.map +1 -1
  16. package/dist/analysis/grid_correction_check.js +81 -7
  17. package/dist/analysis/grid_correction_check.js.map +1 -1
  18. package/dist/modules/dexbot_class.d.ts +1 -1
  19. package/dist/modules/dexbot_cow_runtime.d.ts +81 -2
  20. package/dist/modules/dexbot_cow_runtime.d.ts.map +1 -1
  21. package/dist/modules/dexbot_cow_runtime.js +482 -8
  22. package/dist/modules/dexbot_cow_runtime.js.map +1 -1
  23. package/dist/modules/dexbot_maintenance_runtime.d.ts.map +1 -1
  24. package/dist/modules/dexbot_maintenance_runtime.js +6 -1
  25. package/dist/modules/dexbot_maintenance_runtime.js.map +1 -1
  26. package/dist/modules/order/manager.d.ts.map +1 -1
  27. package/dist/modules/order/manager.js +3 -1
  28. package/dist/modules/order/manager.js.map +1 -1
  29. package/dist/modules/order/sync_engine.d.ts.map +1 -1
  30. package/dist/modules/order/sync_engine.js +17 -5
  31. package/dist/modules/order/sync_engine.js.map +1 -1
  32. package/dist/modules/order/utils/order.d.ts +45 -7
  33. package/dist/modules/order/utils/order.d.ts.map +1 -1
  34. package/dist/modules/order/utils/order.js +160 -15
  35. package/dist/modules/order/utils/order.js.map +1 -1
  36. package/docs/DEXBOT_COMPARISON.md +3 -3
  37. package/docs/EVOLUTION.md +8 -7
  38. package/docs/FUND_MOVEMENT_AND_ACCOUNTING.md +1 -1
  39. package/docs/README.md +1 -1
  40. package/package.json +1 -1
@@ -323,17 +323,23 @@ function formatUnmatchedChainOrderForLog(order) {
323
323
  * Record a pending CREATE broadcast on the manager.
324
324
  * @param {import('./dexbot_class.js').DEXBot} bot
325
325
  * @param {Object} entry
326
+ * @returns {string|null} The fingerprint the entry was stored under (null
327
+ * when recording was skipped). Callers that must later remap the entry's
328
+ * stored opIndex/ctxIndex (the final pivot gate's compaction) collect
329
+ * these to identify exactly which pending entries belong to THIS batch —
330
+ * entry.batchId is unreliable because _currentBatchId is never populated
331
+ * in production (always null), so batchId scoping cannot discriminate.
326
332
  */
327
333
  function recordPendingBroadcast(bot, entry) {
328
334
  if (!bot.manager || !entry || !entry.order)
329
- return;
335
+ return null;
330
336
  if (!bot.manager._pendingBroadcasts || !(bot.manager._pendingBroadcasts instanceof Map)) {
331
337
  bot.manager._pendingBroadcasts = new Map();
332
338
  }
333
339
  const fingerprint = createOpFingerprintForSlot(bot, entry.order, entry.finalInts, entry.order.id);
334
340
  if (!fingerprint) {
335
341
  bot.manager.logger.log?.(`[COW] Skipped pending-broadcast record: could not build fingerprint for ${entry.order?.id || 'unknown'}`, 'warn');
336
- return;
342
+ return null;
337
343
  }
338
344
  bot.manager._pendingBroadcasts.set(fingerprint, {
339
345
  fingerprint,
@@ -347,6 +353,7 @@ function recordPendingBroadcast(bot, entry) {
347
353
  batchId: bot._currentBatchId || null,
348
354
  recordedAt: Date.now()
349
355
  });
356
+ return fingerprint;
350
357
  }
351
358
  /**
352
359
  * Clear the pending-broadcast cache.
@@ -3028,7 +3035,12 @@ async function updateOrdersOnChainBatchCOWBody(bot, cowResult, replanDepth, seam
3028
3035
  bot.manager.logger.log(`[COW] Draining ${pendingCorrectionCount} pending correction(s) before batch`, 'info');
3029
3036
  const drainResult = await orderUtils.correctAllPriceMismatches(bot.manager, bot.account, bot.privateKey, chainOrders);
3030
3037
  if (drainResult?.failed > 0) {
3031
- bot.manager.logger.log(`[COW] ${drainResult.failed} correction(s) failed pre-batch; remaining entries retry on next sync/maintenance tick`, 'warn');
3038
+ bot.manager.logger.log(`[COW] ${drainResult.failed} correction(s) failed pre-batch` +
3039
+ (drainResult.staleDropped > 0 ? `, ${drainResult.staleDropped} stale dropped` : '') +
3040
+ `; remaining entries retry on next sync/maintenance tick`, 'warn');
3041
+ }
3042
+ else if (drainResult?.staleDropped > 0) {
3043
+ bot.manager.logger.log(`[COW] Pre-batch drain resolved, ${drainResult.staleDropped} stale correction(s) dropped`, 'info');
3032
3044
  }
3033
3045
  }
3034
3046
  catch (drainErr) {
@@ -3055,6 +3067,12 @@ async function updateOrdersOnChainBatchCOWBody(bot, cowResult, replanDepth, seam
3055
3067
  // would spam big batches, so the guard emits one batch summary instead
3056
3068
  // (see the summary after the action loop below).
3057
3069
  const lastFillGuardStats = { checked: 0, passed: 0, skipped: 0, bypassed: 0, pivotOffGrid: 0 };
3070
+ // Fingerprints of the pending-broadcast entries recorded by THIS batch's
3071
+ // op-building (both CREATE paths). The final pivot gate's compaction
3072
+ // remaps these entries' stored opIndex/ctxIndex to their post-drop
3073
+ // positions; entry.batchId cannot discriminate batches (always null in
3074
+ // production), so the fingerprint set is the ownership marker.
3075
+ const batchPendingFps = new Set();
3058
3076
  // Per-batch GRID-PRICE-INVARIANT counters (BLOCKING). Tracks emitted prices
3059
3077
  // that are not the genesis level for their slot — such emissions are
3060
3078
  // rejected, not placed. One batch summary.
@@ -3108,12 +3126,33 @@ async function updateOrdersOnChainBatchCOWBody(bot, cowResult, replanDepth, seam
3108
3126
  // mutated the pivot mid-batch (02:03 pivots drifted 0.001523→0.001529
3109
3127
  // across 20 checks), so early actions were judged against a different
3110
3128
  // pivot than later ones. The batch summary still reports whether this
3111
- // freeze moved the pivot under the plan.
3129
+ // freeze moved the pivot under the plan. The frozen value feeds the
3130
+ // final pre-broadcast gate below (runFinalPivotGate), which re-checks
3131
+ // built ops when a fill queued AFTER the freeze moved the pivot.
3132
+ // freezeQueueDepth is the Step-2 observability half: queue depth at
3133
+ // freeze time, paired with the gate's own queue readout.
3134
+ let freezeQueueDepth = null;
3135
+ try {
3136
+ freezeQueueDepth = Array.isArray(bot?._incomingFillQueue)
3137
+ ? bot._incomingFillQueue.length
3138
+ : null;
3139
+ }
3140
+ catch {
3141
+ freezeQueueDepth = null;
3142
+ }
3112
3143
  try {
3113
3144
  if (refreshLastFillPivotFromQueue(bot))
3114
3145
  lastFillGuardPivotRefreshed = true;
3115
3146
  }
3116
3147
  catch { /* best-effort */ }
3148
+ // Captured AFTER the freeze refresh, not before: the refresh is part
3149
+ // of the freeze, so the baseline must be the pivot the ops are about
3150
+ // to be judged against. Capturing pre-refresh would make every batch
3151
+ // whose freeze picked up a pre-freeze queued fill look "moved" at the
3152
+ // gate — a spurious warn plus a redundant full re-check against the
3153
+ // identical pivot.
3154
+ const frozenPivotAtBatchStart = bot.manager?._lastFilledPrice;
3155
+ const frozenTypeAtBatchStart = bot.manager?._lastFilledType;
3117
3156
  for (const action of actions) {
3118
3157
  if (action.type === COW_ACTIONS.CANCEL) {
3119
3158
  try {
@@ -3248,12 +3287,14 @@ async function updateOrdersOnChainBatchCOWBody(bot, cowResult, replanDepth, seam
3248
3287
  operations.push(buildResult.op);
3249
3288
  opContexts.push({ kind: 'create', id: order.id, order: effectiveOrder, args, finalInts: buildResult.finalInts });
3250
3289
  intraBatchCandidates.push(effectiveOrder);
3251
- recordPendingBroadcast(bot, {
3290
+ const recordedFp = recordPendingBroadcast(bot, {
3252
3291
  opIndex: operations.length - 1,
3253
3292
  ctxIndex: opContexts.length - 1,
3254
3293
  order: effectiveOrder,
3255
3294
  finalInts: buildResult.finalInts
3256
3295
  });
3296
+ if (recordedFp)
3297
+ batchPendingFps.add(recordedFp);
3257
3298
  }
3258
3299
  catch (err) {
3259
3300
  bot.manager.logger.log(`Failed to prepare create op for ${action.id}: ${getErrorMessage(err)}`, 'error');
@@ -3652,12 +3693,14 @@ async function updateOrdersOnChainBatchCOWBody(bot, cowResult, replanDepth, seam
3652
3693
  args: { amountToSell: fbArgs.amountToSell, minToReceive: fbArgs.minToReceive },
3653
3694
  finalInts: fbResult.finalInts
3654
3695
  });
3655
- recordPendingBroadcast(bot, {
3696
+ const fbRecordedFp = recordPendingBroadcast(bot, {
3656
3697
  opIndex: operations.length - 1,
3657
3698
  ctxIndex: opContexts.length - 1,
3658
3699
  order: { id: targetSlotId, type: fbType, price: fbPrice, size: fbSize },
3659
3700
  finalInts: fbResult.finalInts
3660
3701
  });
3702
+ if (fbRecordedFp)
3703
+ batchPendingFps.add(fbRecordedFp);
3661
3704
  bot.manager.logger.log(`[COW] Recovered "not found" for ${action.id}: converted UPDATE to CREATE for slot ${targetSlotId}`, 'warn');
3662
3705
  continue;
3663
3706
  }
@@ -3671,6 +3714,73 @@ async function updateOrdersOnChainBatchCOWBody(bot, cowResult, replanDepth, seam
3671
3714
  }
3672
3715
  }
3673
3716
  }
3717
+ // FINAL PRE-BROADCAST PIVOT GATE (2026-09-13 incident on a live
3718
+ // market-pair bot): a
3719
+ // fill queued AFTER the batch-start freeze but BEFORE broadcast
3720
+ // passes every per-action check on a stale pivot (freeze .745, fill
3721
+ // queued .765, broadcast .910). Re-check the BUILT ops against a
3722
+ // re-refreshed pivot here — after op-building, before the batch
3723
+ // summary (drops count as skipped, not passed), fund validation
3724
+ // (snapshot reflects filtered ops) and single-flight claim.
3725
+ // op indexes recorded during op-building are rebuilt from kept
3726
+ // contexts below, so any future reader sees live indexes.
3727
+ // The gate runs on its OWN stats object: its re-checks would
3728
+ // otherwise double-count the build loop's checked/passed/skipped
3729
+ // totals in the batch summary below. Gate contributions are
3730
+ // reported separately (gateChecked=... fields).
3731
+ const skippedUpdateCountRef = { count: 0 };
3732
+ const finalGateStats = { checked: 0, passed: 0, skipped: 0, bypassed: 0, pivotOffGrid: 0 };
3733
+ let finalGate = null;
3734
+ try {
3735
+ finalGate = runFinalPivotGate(bot, operations, opContexts, {
3736
+ actions,
3737
+ cowResult,
3738
+ frozenPivot: frozenPivotAtBatchStart,
3739
+ frozenType: frozenTypeAtBatchStart,
3740
+ lastFillGuardStats: finalGateStats,
3741
+ skippedUpdateSlotIds,
3742
+ skippedCreateSlotIds,
3743
+ skippedUpdateCountRef,
3744
+ freezeQueueDepth,
3745
+ batchPendingFps,
3746
+ });
3747
+ if (finalGate.refreshed)
3748
+ lastFillGuardPivotRefreshed = true;
3749
+ if (finalGate.pivotChanged) {
3750
+ try {
3751
+ const fmtP = (v) => (v == null || !Number.isFinite(Number(v)) ? 'none' : Format.formatPrice6(Number(v)));
3752
+ bot.manager?.logger?.log?.(`[LAST-FILL-GUARD] Final gate: pivot moved under batch ` +
3753
+ `${fmtP(frozenPivotAtBatchStart)}(${frozenTypeAtBatchStart ?? 'cold'})` +
3754
+ `->${fmtP(bot.manager?._lastFilledPrice)}(${bot.manager?._lastFilledType ?? 'cold'}) ` +
3755
+ `(freezeQueue=${freezeQueueDepth ?? '?'}) ` +
3756
+ `dropped=${finalGate.dropped.length}`, 'warn');
3757
+ }
3758
+ catch { /* logging is best-effort */ }
3759
+ }
3760
+ // Rebuild cancelOpIndexByOrderId from the KEPT contexts: the
3761
+ // gate compacted operations/opContexts in lockstep, so indexes
3762
+ // recorded during op-building are stale for every op after the
3763
+ // first drop. cancelOpIndexByOrderId has NO readers past this
3764
+ // point (verified: the op-building loop is its last use —
3765
+ // pair-mode/chunk grouping is computed lazily at broadcast from
3766
+ // opContexts with no stored indexes), so this rebuild is
3767
+ // defence-in-depth for future readers, not a live fix.
3768
+ cancelOpIndexByOrderId.clear();
3769
+ for (let ci = 0; ci < opContexts.length; ci++) {
3770
+ const cctx = opContexts[ci];
3771
+ const cord = cctx?.order;
3772
+ const cOrderId = cctx?.kind === 'cancel'
3773
+ ? cctx?.order?.orderId || cctx?.orderId
3774
+ : cord?.orderId;
3775
+ if (cOrderId)
3776
+ cancelOpIndexByOrderId.set(cOrderId, ci);
3777
+ }
3778
+ // skippedUpdateCount is threaded via countRef so the restore
3779
+ // below covers gate-dropped rotations too.
3780
+ if (skippedUpdateCountRef.count > 0)
3781
+ skippedUpdateCount += skippedUpdateCountRef.count;
3782
+ }
3783
+ catch (_gateErr) { /* gate is fail-open: keep the built ops */ }
3674
3784
  // Batch-level LAST-FILL-GUARD summary: per-action pass lines would spam
3675
3785
  // big batches, so one line per batch records the mode, pivot, resolved
3676
3786
  // increment, and pass/skip/bypass counts — the guard's pass decisions
@@ -3682,7 +3792,16 @@ async function updateOrdersOnChainBatchCOWBody(bot, cowResult, replanDepth, seam
3682
3792
  // for the whole batch, so every action was checked against the same
3683
3793
  // pivot printed here.
3684
3794
  try {
3685
- const totalGuarded = lastFillGuardStats.checked + lastFillGuardStats.bypassed;
3795
+ // The final gate reports on its OWN counters (finalGateStats), so
3796
+ // checked/passed/skipped/bypassed here are the build loop's
3797
+ // verdicts only — the gate's re-checks never inflate them. The
3798
+ // gate's contributions ride along as gate* fields, omitted when
3799
+ // the gate did not re-check anything (same convention as
3800
+ // pivotOffGrid above). totalGuarded includes the gate so a
3801
+ // batch that was ONLY gate-checked (e.g. cold freeze armed
3802
+ // mid-batch) still prints.
3803
+ const gateGuarded = finalGateStats.checked + finalGateStats.bypassed;
3804
+ const totalGuarded = lastFillGuardStats.checked + lastFillGuardStats.bypassed + gateGuarded;
3686
3805
  if (totalGuarded > 0) {
3687
3806
  const sumPivotRaw = bot.manager?._lastFilledPrice;
3688
3807
  const sumType = bot.manager?._lastFilledType;
@@ -3720,6 +3839,10 @@ async function updateOrdersOnChainBatchCOWBody(bot, cowResult, replanDepth, seam
3720
3839
  // "counter unavailable".
3721
3840
  (Number(lastFillGuardStats.pivotOffGrid) > 0
3722
3841
  ? ` pivotOffGrid=${lastFillGuardStats.pivotOffGrid}`
3842
+ : '') +
3843
+ (gateGuarded > 0
3844
+ ? ` gateChecked=${finalGateStats.checked} gatePassed=${finalGateStats.passed} ` +
3845
+ `gateSkipped=${finalGateStats.skipped} gateBypassed=${finalGateStats.bypassed}`
3723
3846
  : ''), cold || (batchPivot && batchPivot.idx == null) ? 'warn' : 'info');
3724
3847
  }
3725
3848
  }
@@ -4410,6 +4533,13 @@ function resolveOnGridPivot(manager, rawPrice) {
4410
4533
  * skip logging stay at the call sites, which differ per action kind).
4411
4534
  * Batch callers pass skipRefresh=true: the batch-start freeze owns refreshes
4412
4535
  * so every action in a batch is judged against the same pivot.
4536
+ *
4537
+ * FINAL-GATE CONTRACT (see runFinalPivotGate): the gate re-checks BUILT ops
4538
+ * against a re-refreshed pivot AFTER the op-building loop. It must run BEFORE
4539
+ * any later mutation of operations/opContexts (fund validation snapshot,
4540
+ * pair-mode/chunk grouping) — those stages index op positions and read stale
4541
+ * indexes after a filter. Call sites after the gate must treat
4542
+ * operations/opContexts as the filtered arrays.
4413
4543
  * @param {Object} bot
4414
4544
  * @param {number} price - Target order price
4415
4545
  * @param {number} size - Order size
@@ -4467,6 +4597,349 @@ function runLastFillGuardCheck(bot, price, size, type, stats, skipRefresh = fals
4467
4597
  stats.checked++;
4468
4598
  return { check, refreshed };
4469
4599
  }
4600
+ /**
4601
+ * Final pre-broadcast pivot gate: re-check BUILT ops against a re-refreshed
4602
+ * pivot RIGHT BEFORE broadcast.
4603
+ *
4604
+ * Why this exists (2026-09-13 incident on a live market-pair bot): the batch-start freeze
4605
+ * refreshes the pivot once, then every CREATE / rotation-UPDATE /
4606
+ * fallback-CREATE is judged against that frozen value (skipRefresh=true).
4607
+ * A fill that is queued AFTER the freeze but BEFORE broadcast (in the
4608
+ * incident: freeze at .745, fill queued at .765, broadcast at .910) passes
4609
+ * every per-action check on a stale pivot and ships. The batch summary
4610
+ * prints pivotRefreshed=false — the freeze honestly found nothing — and
4611
+ * the violating ops broadcast anyway.
4612
+ *
4613
+ * Placement (see FINAL-GATE CONTRACT on runLastFillGuardCheck):
4614
+ * 1. Called after the op-building action loop, BEFORE the LAST-FILL-GUARD
4615
+ * batch summary line (so dropped ops are visible as skipped, not
4616
+ * passed) and BEFORE fund validation (so the VALIDATION snapshot
4617
+ * reflects the filtered ops).
4618
+ * 2. Must run before pair-mode/chunk grouping, which indexes op positions.
4619
+ * executeOperationsWithStrategy groups lazily at broadcast time from
4620
+ * the (filtered) opContexts it receives, so filtering here is safe —
4621
+ * but any future grouping computed between op-building and broadcast
4622
+ * must be rebuilt after the gate (same rule as the contract).
4623
+ *
4624
+ * Semantics:
4625
+ * - Peek-only refresh (never drains the fill queue — same as the freeze).
4626
+ * - Pivot UNCHANGED since the freeze => pure no-op: returns the input
4627
+ * arrays untouched, no extra log lines beyond queue-depth debug.
4628
+ * - Pivot CHANGED => re-run isLastFillGuardBlocked against each built op's
4629
+ * final price with the SAME bypass rules as the build loop
4630
+ * (spread-correction CREATEs, stamped gap-evacuation UPDATEs). Violating
4631
+ * ops + their contexts are dropped; their slots feed the existing
4632
+ * skippedUpdateSlotIds/skippedCreateSlotIds restore paths so the working
4633
+ * grid stays consistent (dropped rotations restore from master, dropped
4634
+ * creates count toward the boundary-hold intersect).
4635
+ * - fail-open on everything unjudgeable: unresolvable price/type, cold
4636
+ * pivot (null), or a refresh/inference throw => the op is KEPT. A gate
4637
+ * that cannot prove a violation must not invent one — dropping a healthy
4638
+ * op strands its slot, while a missed violation is still caught by the
4639
+ * next cycle's guard + commit chain adoption.
4640
+ * - size-update ops are NEVER gated (same-price, no repricing).
4641
+ * - cancel ops are NEVER gated or dropped.
4642
+ *
4643
+ * Stale-index hygiene: recordPendingBroadcast stores opIndex/ctxIndex
4644
+ * against the build-time arrays. Dropped CREATEs must have their pending
4645
+ * entries removed (else the reconcile path adopts a broadcast that never
4646
+ * shipped), and KEPT entries must have their stored indexes REMAPPED: a
4647
+ * lockstep compaction keeps operations/opContexts aligned with each other,
4648
+ * but the absolute indexes stored inside the pending entries are not
4649
+ * rewritten by it — after the first drop, an unremapped ctxIndex resolves
4650
+ * to a shifted position (undefined at best, a DIFFERENT create's context at
4651
+ * worst, which would let adoptMatchedEntries synchronize the wrong slot
4652
+ * with a matched chain order). The gate therefore builds an old→new index
4653
+ * map during compaction and remaps/removes entries identified by
4654
+ * opts.batchPendingFps (the fingerprints THIS batch recorded — entry.batchId
4655
+ * is always null in production, so it cannot discriminate). Sibling
4656
+ * batches' entries are never touched (their indexes refer to their own
4657
+ * build-time arrays).
4658
+ *
4659
+ * @param {import('./dexbot_class.js').DEXBot} bot
4660
+ * @param {Array} operations - Built chain ops (mutated in place on drop)
4661
+ * @param {Array} opContexts - Built op contexts (mutated in place on drop)
4662
+ * @param {Object} opts - { actions, cowResult, frozenPivot, frozenType,
4663
+ * lastFillGuardStats, skippedUpdateSlotIds, skippedCreateSlotIds,
4664
+ * skippedUpdateCountRef: { count }, freezeQueueDepth, batchPendingFps }
4665
+ * @returns {{ dropped: Array, pivotChanged: boolean, refreshed: boolean }}
4666
+ */
4667
+ function runFinalPivotGate(bot, operations, opContexts, opts = {}) {
4668
+ const empty = { dropped: [], pivotChanged: false, refreshed: false };
4669
+ try {
4670
+ if (!Array.isArray(operations) || !Array.isArray(opContexts) || operations.length === 0)
4671
+ return empty;
4672
+ const stats = opts?.lastFillGuardStats;
4673
+ const frozenPivot = Number(opts?.frozenPivot);
4674
+ const frozenType = opts?.frozenType;
4675
+ const frozenCold = !Number.isFinite(frozenPivot) || frozenType == null;
4676
+ const queueDepthBefore = Array.isArray(bot?._incomingFillQueue)
4677
+ ? bot._incomingFillQueue.length
4678
+ : null;
4679
+ // Peek-only re-refresh: never drains the queue (same as the freeze).
4680
+ let refreshed = false;
4681
+ try {
4682
+ refreshed = !!refreshLastFillPivotFromQueue(bot);
4683
+ }
4684
+ catch {
4685
+ refreshed = false;
4686
+ }
4687
+ const queueDepthAfter = Array.isArray(bot?._incomingFillQueue)
4688
+ ? bot._incomingFillQueue.length
4689
+ : null;
4690
+ const livePivot = Number(bot.manager?._lastFilledPrice);
4691
+ const liveType = bot.manager?._lastFilledType;
4692
+ const liveCold = !Number.isFinite(livePivot) || liveType == null;
4693
+ // No-op fast path: pivot unchanged (or uncomparable) since the freeze.
4694
+ // frozenCold + liveCold: guard stayed disabled — nothing to re-check.
4695
+ // frozenCold + liveArmed: the freeze ran cold but a fill arrived
4696
+ // mid-batch. The built ops were NEVER guarded; treat as changed so
4697
+ // they are checked below (fail-open keeps whatever is unjudgeable).
4698
+ let pivotChanged = refreshed;
4699
+ if (frozenCold && liveCold)
4700
+ pivotChanged = false;
4701
+ else if (!frozenCold && !liveCold)
4702
+ pivotChanged = livePivot !== frozenPivot || liveType !== frozenType;
4703
+ else
4704
+ pivotChanged = true;
4705
+ try {
4706
+ bot.manager?.logger?.log?.(`[LAST-FILL-GUARD] Final gate: queue ${queueDepthBefore ?? '?'}->${queueDepthAfter ?? '?'} ` +
4707
+ `pivot ${Number.isFinite(frozenPivot) ? Format.formatPrice6(frozenPivot) : 'none'}(${frozenType ?? 'cold'})` +
4708
+ `->${Number.isFinite(livePivot) ? Format.formatPrice6(livePivot) : 'none'}(${liveType ?? 'cold'}) ` +
4709
+ `changed=${pivotChanged} refreshed=${refreshed}`, 'debug');
4710
+ }
4711
+ catch { /* logging is best-effort */ }
4712
+ if (!pivotChanged)
4713
+ return { ...empty, refreshed };
4714
+ // Pivot moved (or armed mid-batch): re-check every built op's FINAL
4715
+ // price. Same bypass rules as the build loop; unjudgeable => KEEP.
4716
+ const actions = Array.isArray(opts?.actions) ? opts.actions : [];
4717
+ const batchOrigin = opts?.cowResult?.origin;
4718
+ const actionBySlot = new Map();
4719
+ for (const a of actions) {
4720
+ if (!a)
4721
+ continue;
4722
+ // Rotation UPDATEs are keyed by DESTINATION slot (the emitted
4723
+ // price is the destination's level); plain CREATEs by slot id.
4724
+ const rotDest = a?.newGridId;
4725
+ const key = (a?.type === COW_ACTIONS.UPDATE && rotDest && rotDest !== a?.id) ? rotDest : a?.id;
4726
+ if (key && !actionBySlot.has(key))
4727
+ actionBySlot.set(key, a);
4728
+ }
4729
+ const dropIdx = new Set();
4730
+ const dropped = [];
4731
+ const inc = resolveLastFillGuardIncrement(bot);
4732
+ const onGrid = resolveOnGridPivot(bot.manager, bot.manager?._lastFilledPrice);
4733
+ for (let i = 0; i < opContexts.length; i++) {
4734
+ const ctx = opContexts[i];
4735
+ if (!ctx || ctx.kind === 'cancel' || ctx.kind === 'size-update')
4736
+ continue;
4737
+ let price = null;
4738
+ let type = null;
4739
+ let size = null;
4740
+ let slotId = null;
4741
+ let action = null;
4742
+ if (ctx.kind === 'create') {
4743
+ slotId = ctx.id || ctx.order?.id || null;
4744
+ price = Number(ctx.order?.price);
4745
+ type = ctx.order?.type || null;
4746
+ size = Number(ctx.order?.size);
4747
+ action = (slotId && actionBySlot.get(slotId)) || null;
4748
+ // Spread-correction CREATE bypass (mirrors the build loop:
4749
+ // per-action origin, batch origin as back-compat fallback).
4750
+ const actionOrigin = action?.origin;
4751
+ if (actionOrigin === 'spread-correction'
4752
+ || (actionOrigin == null && batchOrigin === 'spread-correction')) {
4753
+ if (stats)
4754
+ stats.bypassed = (Number(stats.bypassed) || 0) + 1;
4755
+ continue;
4756
+ }
4757
+ }
4758
+ else if (ctx.kind === 'rotation') {
4759
+ const rot = ctx.rotation || {};
4760
+ slotId = rot.newGridId || rot.oldOrder?.id || null;
4761
+ price = Number(rot.newPrice);
4762
+ type = rot.type || null;
4763
+ size = Number(rot.newSize);
4764
+ // Rotation UPDATEs are keyed by DESTINATION slot (the
4765
+ // emitted price is the destination's level) — EXCEPT the
4766
+ // same-slot size-only form (no newGridId, or newGridId ===
4767
+ // source id), which buildActionsFromPlan emits for
4768
+ // ordersToUpdate and which must resolve to the source
4769
+ // action. A same-slot UPDATE carries no repricing, so a
4770
+ // dest-keyed lookup that misses it would ALSO miss its
4771
+ // origin stamp — fall back to the source id before
4772
+ // judging the bypass.
4773
+ action = (slotId && actionBySlot.get(slotId)) || null;
4774
+ if (!action) {
4775
+ const srcId = rot.oldOrder?.id || null;
4776
+ if (srcId)
4777
+ action = actionBySlot.get(srcId) || null;
4778
+ }
4779
+ // Gap-evacuation UPDATE bypass mirrors the build loop's
4780
+ // stamped path ONLY — with one deliberate asymmetry (see
4781
+ // below): the build loop re-proves UNSTAMPED evacuations
4782
+ // live from the master grid; the gate guards them normally.
4783
+ // An unstamped rotation reaching the final gate was either
4784
+ // (a) probed-and-allowed at build time — in which case its
4785
+ // price already survived an evacuation proof and the guard
4786
+ // re-check here is harmless duplication, or (b) probe-
4787
+ // rejected/failed-closed — in which case it was SKIPPED at
4788
+ // build time and never reached op-building, so the gate
4789
+ // cannot see it either. Either way there is no live
4790
+ // unstamped evacuation in the built ops that needs
4791
+ // re-proving: re-proving here would need the master-grid
4792
+ // source read the build loop does, and the source may have
4793
+ // been pre-applied since. Stamped rotations carry origin +
4794
+ // evacBoundary/evacGapSlots.
4795
+ //
4796
+ // ASYMMETRY (fail-open, not fail-closed): the build loop's
4797
+ // unstamped path FAILS CLOSED (unresolvable source => skip
4798
+ // the op). The gate FAILS OPEN (unjudgeable => keep). A
4799
+ // dropped op strands its slot until the next cycle; a kept
4800
+ // op is still subject to the commit guard + chain adoption.
4801
+ // The gate must not invent a block it cannot prove —
4802
+ // especially not on an op the build loop already allowed.
4803
+ const rotOrigin = action?.origin;
4804
+ if (rotOrigin === 'gap-evacuation'
4805
+ && Number.isFinite(Number(action?.evacBoundary))
4806
+ && Number.isFinite(Number(action?.evacGapSlots))) {
4807
+ if (stats)
4808
+ stats.bypassed = (Number(stats.bypassed) || 0) + 1;
4809
+ continue;
4810
+ }
4811
+ }
4812
+ else {
4813
+ continue;
4814
+ }
4815
+ // Fail-open: unresolvable price/type => KEEP (never invent a
4816
+ // violation the gate cannot prove).
4817
+ if (!Number.isFinite(price) || price <= 0)
4818
+ continue;
4819
+ if (type !== ORDER_TYPES.BUY && type !== ORDER_TYPES.SELL)
4820
+ continue;
4821
+ const check = isLastFillGuardBlocked(price, size, type, onGrid.price, liveType, inc);
4822
+ if (stats)
4823
+ stats.checked = (Number(stats.checked) || 0) + 1;
4824
+ if (!check.blocked) {
4825
+ if (stats)
4826
+ stats.passed = (Number(stats.passed) || 0) + 1;
4827
+ continue;
4828
+ }
4829
+ // Blocked: drop the op + context, restore the slot below.
4830
+ dropIdx.add(i);
4831
+ if (stats)
4832
+ stats.skipped = (Number(stats.skipped) || 0) + 1;
4833
+ const dir = type === ORDER_TYPES.BUY ? 'above' : 'below';
4834
+ dropped.push({ index: i, kind: ctx.kind, slotId, price, type });
4835
+ try {
4836
+ bot.manager?.logger?.log?.(`[LAST-FILL-GUARD] Final gate dropping ${type} ${ctx.kind} for ${slotId ?? 'unknown'} at ` +
4837
+ `${Format.formatPrice6(price)}: ${dir} last filled ${Format.formatPrice6(check.pivot)} ` +
4838
+ `(halfInc ${check.halfInc}% thr ${Format.formatPrice6(check.threshold)}); re-planned after market moves`, 'warn');
4839
+ }
4840
+ catch { /* logging is best-effort */ }
4841
+ }
4842
+ if (dropIdx.size === 0)
4843
+ return { dropped, pivotChanged, refreshed };
4844
+ // Compact operations/opContexts in lockstep so every surviving index
4845
+ // still lines up. The caller rebuilds cancelOpIndexByOrderId from the
4846
+ // kept contexts (it was built during op-building and goes stale for
4847
+ // every op after the first drop), and the remap pass below re-points
4848
+ // THIS batch's kept pending-broadcast entries at their new positions
4849
+ // — lockstep keeps the two arrays aligned with each other, but the
4850
+ // absolute indexes stored INSIDE pending entries are not rewritten
4851
+ // by a compaction, so they must be remapped explicitly.
4852
+ const keptOps = [];
4853
+ const keptCtxs = [];
4854
+ const oldToNew = new Map();
4855
+ for (let i = 0, ni = 0; i < opContexts.length; i++) {
4856
+ if (dropIdx.has(i))
4857
+ continue;
4858
+ oldToNew.set(i, ni);
4859
+ ni++;
4860
+ keptOps.push(operations[i]);
4861
+ keptCtxs.push(opContexts[i]);
4862
+ }
4863
+ operations.length = 0;
4864
+ operations.push(...keptOps);
4865
+ opContexts.length = 0;
4866
+ opContexts.push(...keptCtxs);
4867
+ // Slot restore: dropped rotations restore source+dest from master
4868
+ // (same sets the build loop feeds to restoreSkippedUpdateSlots...);
4869
+ // dropped creates count toward the refill-hold intersect. Pending
4870
+ // entries for dropped CREATEs are removed (never shipped); kept
4871
+ // CREATEs get their stored opIndex/ctxIndex remapped to the
4872
+ // compacted positions (see the remap pass below — an unremapped
4873
+ // absolute index would resolve to a SHIFTED context after the first
4874
+ // drop: undefined at best, a DIFFERENT create's context at worst,
4875
+ // which would let the uncertain-broadcast reconcile adopt a matched
4876
+ // chain order into the wrong slot).
4877
+ const skippedUpdateSlotIds = opts?.skippedUpdateSlotIds;
4878
+ const skippedCreateSlotIds = opts?.skippedCreateSlotIds;
4879
+ const countRef = opts?.skippedUpdateCountRef;
4880
+ try {
4881
+ const pending = bot.manager?._pendingBroadcasts;
4882
+ for (const d of dropped) {
4883
+ if (d.kind === 'rotation') {
4884
+ const act = (d.slotId && actionBySlot.get(d.slotId)) || null;
4885
+ const srcId = act?.id || null;
4886
+ if (srcId && skippedUpdateSlotIds instanceof Set)
4887
+ skippedUpdateSlotIds.add(srcId);
4888
+ if (d.slotId && skippedUpdateSlotIds instanceof Set)
4889
+ skippedUpdateSlotIds.add(d.slotId);
4890
+ if (countRef && typeof countRef === 'object')
4891
+ countRef.count = (Number(countRef.count) || 0) + 1;
4892
+ }
4893
+ else if (d.kind === 'create') {
4894
+ if (d.slotId && skippedCreateSlotIds instanceof Set)
4895
+ skippedCreateSlotIds.add(d.slotId);
4896
+ if (pending instanceof Map) {
4897
+ for (const [fp, entry] of pending) {
4898
+ // Match by slot only: the fingerprint embeds
4899
+ // side/amounts/slot (no op indexes), but entry.slotId
4900
+ // is the narrowest predicate that cannot touch a
4901
+ // sibling batch's entry for a different slot.
4902
+ if (entry?.slotId && entry.slotId === d.slotId) {
4903
+ pending.delete(fp);
4904
+ }
4905
+ }
4906
+ }
4907
+ }
4908
+ }
4909
+ // Remap THIS batch's kept pending entries (identified by the
4910
+ // fingerprint set the build loop collected — entry.batchId is
4911
+ // always null in production and cannot discriminate). A stored
4912
+ // ctxIndex whose old position was dropped means the entry was
4913
+ // not slot-matched above; it could never resolve post-compaction,
4914
+ // so it is removed. Everything else is re-pointed at the same
4915
+ // context object it was recorded with. Sibling batches' entries
4916
+ // are never touched: their indexes refer to their own long-gone
4917
+ // build-time arrays.
4918
+ if (pending instanceof Map && opts?.batchPendingFps instanceof Set) {
4919
+ for (const fp of opts.batchPendingFps) {
4920
+ const entry = pending.get(fp);
4921
+ if (!entry)
4922
+ continue; // dropped create: already removed by slot
4923
+ const newCtx = oldToNew.get(Number(entry.ctxIndex));
4924
+ if (newCtx == null) {
4925
+ // Referenced op was dropped (or the entry predates
4926
+ // lockstep indexing) — the index cannot be healed.
4927
+ pending.delete(fp);
4928
+ continue;
4929
+ }
4930
+ entry.ctxIndex = newCtx;
4931
+ const newOp = oldToNew.get(Number(entry.opIndex));
4932
+ entry.opIndex = newOp == null ? entry.opIndex : newOp;
4933
+ }
4934
+ }
4935
+ }
4936
+ catch { /* restore bookkeeping is best-effort */ }
4937
+ return { dropped, pivotChanged, refreshed };
4938
+ }
4939
+ catch {
4940
+ return empty;
4941
+ }
4942
+ }
4470
4943
  /**
4471
4944
  * Apply BTS create-fee accounting for a batch that bypassed the normal
4472
4945
  * processBatchResults pipeline (commit refused after broadcast, or
@@ -4709,7 +5182,7 @@ async function processBatchResults(bot, result, opContexts) {
4709
5182
  updateOperationCount
4710
5183
  };
4711
5184
  }
4712
- export { isLastFillGuardBlocked, resolveOnGridPivot, checkGridPriceInvariant, deriveRotationPrice, refreshLastFillPivotFromQueue, buildOutsideInPairGroupsForOrders, buildOutsideInPairGroupsForCreateEntries, extractOperationResults, findMissingCreateResultContexts, markMissingCreateResultsAsStructuralBlocker, formatUnmatchedChainOrderForLog, recordPendingBroadcast, clearPendingBroadcasts, clearPendingBroadcastsForSlots, popPushedWorkingGrid, buildChainOrderFingerprint, normalizeChainOrderForPendingMatch, findChainOrderForSlot, reconcileAfterUncertainBroadcast, reconcileAfterUncertainBroadcastImpl, autoCancelOneUnmatchedOrphan, shouldExecuteCreatePairMode, executeWithRetryOnUncertain, executeChunkedWithRetryOnUncertain, formatPartialBroadcastSummary, executeOperationsWithStrategy, validateOperationFunds, resolveIdealSizeForValidation, validateOrderSizeForExecution, buildActionsFromPlan, buildCowResultFromPlan, restoreSkippedUpdateSlotsInWorkingGrid, applyRotationTransitionsToWorkingGrid, pollChainForConfirmation, updateOrdersOnChainBatchCOW, processBatchResults, adoptPlacedBatchFromChain, resolveRefillBoundaryHold, toRefillSlotIdSet, trackBoundaryHold };
5185
+ export { isLastFillGuardBlocked, resolveOnGridPivot, checkGridPriceInvariant, deriveRotationPrice, refreshLastFillPivotFromQueue, runFinalPivotGate, buildOutsideInPairGroupsForOrders, buildOutsideInPairGroupsForCreateEntries, extractOperationResults, findMissingCreateResultContexts, markMissingCreateResultsAsStructuralBlocker, formatUnmatchedChainOrderForLog, recordPendingBroadcast, clearPendingBroadcasts, clearPendingBroadcastsForSlots, popPushedWorkingGrid, buildChainOrderFingerprint, normalizeChainOrderForPendingMatch, findChainOrderForSlot, reconcileAfterUncertainBroadcast, reconcileAfterUncertainBroadcastImpl, autoCancelOneUnmatchedOrphan, shouldExecuteCreatePairMode, executeWithRetryOnUncertain, executeChunkedWithRetryOnUncertain, formatPartialBroadcastSummary, executeOperationsWithStrategy, validateOperationFunds, resolveIdealSizeForValidation, validateOrderSizeForExecution, buildActionsFromPlan, buildCowResultFromPlan, restoreSkippedUpdateSlotsInWorkingGrid, applyRotationTransitionsToWorkingGrid, pollChainForConfirmation, updateOrdersOnChainBatchCOW, processBatchResults, adoptPlacedBatchFromChain, resolveRefillBoundaryHold, toRefillSlotIdSet, trackBoundaryHold };
4713
5186
  // Exported for regression tests (issue #23 sibling): the uncertain-broadcast
4714
5187
  // discard path must never drop a placement silently when master lost the slot.
4715
5188
  export { restoreDiscardedCreates };
@@ -4748,5 +5221,6 @@ export default {
4748
5221
  pollChainForConfirmation,
4749
5222
  updateOrdersOnChainBatchCOW,
4750
5223
  processBatchResults,
5224
+ runFinalPivotGate,
4751
5225
  };
4752
5226
  //# sourceMappingURL=dexbot_cow_runtime.js.map