dexbot 1.6.1 → 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 (104) hide show
  1. package/CHANGELOG.md +34 -0
  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/tradingview/tradingview_uplot_chart_generator.ts +19 -5
  10. package/analysis/trend_detection/package.json +1 -1
  11. package/claw/package.json +1 -1
  12. package/claw/runtimes/openclaw-plugin/openclaw.plugin.json +1 -1
  13. package/claw/runtimes/openclaw-plugin/package.json +1 -1
  14. package/claw/tests/test_claw_mcp_transport.ts +2 -2
  15. package/dist/analysis/grid_correction_check.d.ts +5 -1
  16. package/dist/analysis/grid_correction_check.d.ts.map +1 -1
  17. package/dist/analysis/grid_correction_check.js +81 -7
  18. package/dist/analysis/grid_correction_check.js.map +1 -1
  19. package/dist/analysis/tradingview/tradingview_uplot_chart_generator.d.ts.map +1 -1
  20. package/dist/analysis/tradingview/tradingview_uplot_chart_generator.js +19 -5
  21. package/dist/analysis/tradingview/tradingview_uplot_chart_generator.js.map +1 -1
  22. package/dist/market_adapter/inputs/fetch_book_data.js +2 -2
  23. package/dist/market_adapter/inputs/fetch_book_data.js.map +1 -1
  24. package/dist/market_adapter/inputs/fetch_lp_data.d.ts +22 -3
  25. package/dist/market_adapter/inputs/fetch_lp_data.d.ts.map +1 -1
  26. package/dist/market_adapter/inputs/fetch_lp_data.js +15 -15
  27. package/dist/market_adapter/inputs/fetch_lp_data.js.map +1 -1
  28. package/dist/market_adapter/inputs/kibana_feed_source.js +2 -2
  29. package/dist/market_adapter/inputs/kibana_feed_source.js.map +1 -1
  30. package/dist/market_adapter/inputs/window_cache.d.ts +54 -8
  31. package/dist/market_adapter/inputs/window_cache.d.ts.map +1 -1
  32. package/dist/market_adapter/inputs/window_cache.js +328 -87
  33. package/dist/market_adapter/inputs/window_cache.js.map +1 -1
  34. package/dist/modules/bitshares-native/subscriptions.d.ts +1 -1
  35. package/dist/modules/bitshares-native/subscriptions.d.ts.map +1 -1
  36. package/dist/modules/bitshares-native/subscriptions.js +18 -4
  37. package/dist/modules/bitshares-native/subscriptions.js.map +1 -1
  38. package/dist/modules/bitshares_client.d.ts.map +1 -1
  39. package/dist/modules/bitshares_client.js +10 -0
  40. package/dist/modules/bitshares_client.js.map +1 -1
  41. package/dist/modules/constants.d.ts +7 -3
  42. package/dist/modules/constants.d.ts.map +1 -1
  43. package/dist/modules/constants.js +44 -20
  44. package/dist/modules/constants.js.map +1 -1
  45. package/dist/modules/credit_runtime.d.ts.map +1 -1
  46. package/dist/modules/credit_runtime.js +24 -6
  47. package/dist/modules/credit_runtime.js.map +1 -1
  48. package/dist/modules/dexbot_class.d.ts +32 -7
  49. package/dist/modules/dexbot_class.d.ts.map +1 -1
  50. package/dist/modules/dexbot_class.js +55 -11
  51. package/dist/modules/dexbot_class.js.map +1 -1
  52. package/dist/modules/dexbot_cow_runtime.d.ts +137 -7
  53. package/dist/modules/dexbot_cow_runtime.d.ts.map +1 -1
  54. package/dist/modules/dexbot_cow_runtime.js +955 -45
  55. package/dist/modules/dexbot_cow_runtime.js.map +1 -1
  56. package/dist/modules/dexbot_fill_runtime.d.ts +28 -1
  57. package/dist/modules/dexbot_fill_runtime.d.ts.map +1 -1
  58. package/dist/modules/dexbot_fill_runtime.js +116 -3
  59. package/dist/modules/dexbot_fill_runtime.js.map +1 -1
  60. package/dist/modules/dexbot_maintenance_runtime.d.ts +46 -0
  61. package/dist/modules/dexbot_maintenance_runtime.d.ts.map +1 -1
  62. package/dist/modules/dexbot_maintenance_runtime.js +193 -6
  63. package/dist/modules/dexbot_maintenance_runtime.js.map +1 -1
  64. package/dist/modules/dexbot_startup_runtime.d.ts.map +1 -1
  65. package/dist/modules/dexbot_startup_runtime.js +6 -1
  66. package/dist/modules/dexbot_startup_runtime.js.map +1 -1
  67. package/dist/modules/dexbot_state_recovery.d.ts.map +1 -1
  68. package/dist/modules/dexbot_state_recovery.js +16 -3
  69. package/dist/modules/dexbot_state_recovery.js.map +1 -1
  70. package/dist/modules/order/grid.d.ts +3 -1
  71. package/dist/modules/order/grid.d.ts.map +1 -1
  72. package/dist/modules/order/grid.js +47 -6
  73. package/dist/modules/order/grid.js.map +1 -1
  74. package/dist/modules/order/grid_reconcile_internal.d.ts.map +1 -1
  75. package/dist/modules/order/grid_reconcile_internal.js +39 -4
  76. package/dist/modules/order/grid_reconcile_internal.js.map +1 -1
  77. package/dist/modules/order/manager.d.ts.map +1 -1
  78. package/dist/modules/order/manager.js +3 -1
  79. package/dist/modules/order/manager.js.map +1 -1
  80. package/dist/modules/order/sync_engine.d.ts.map +1 -1
  81. package/dist/modules/order/sync_engine.js +200 -15
  82. package/dist/modules/order/sync_engine.js.map +1 -1
  83. package/dist/modules/order/utils/math.d.ts +60 -14
  84. package/dist/modules/order/utils/math.d.ts.map +1 -1
  85. package/dist/modules/order/utils/math.js +87 -20
  86. package/dist/modules/order/utils/math.js.map +1 -1
  87. package/dist/modules/order/utils/order.d.ts +95 -7
  88. package/dist/modules/order/utils/order.d.ts.map +1 -1
  89. package/dist/modules/order/utils/order.js +277 -15
  90. package/dist/modules/order/utils/order.js.map +1 -1
  91. package/dist/modules/utils/errors.d.ts +22 -0
  92. package/dist/modules/utils/errors.d.ts.map +1 -1
  93. package/dist/modules/utils/errors.js +30 -0
  94. package/dist/modules/utils/errors.js.map +1 -1
  95. package/docs/COPY_ON_WRITE_MASTER_PLAN.md +1 -1
  96. package/docs/DEXBOT_COMPARISON.md +3 -7
  97. package/docs/EVOLUTION.md +9 -7
  98. package/docs/FUND_MOVEMENT_AND_ACCOUNTING.md +4 -9
  99. package/docs/LIFECYCLE.md +2 -2
  100. package/docs/README.md +9 -1
  101. package/docs/architecture.md +7 -7
  102. package/docs/developer_guide.md +4 -4
  103. package/market_adapter/README.md +1 -1
  104. package/package.json +1 -1
@@ -76,7 +76,7 @@ import * as FormatModule from './order/format.js';
76
76
  const Format = FormatModule;
77
77
  import * as workingGridModule from './order/working_grid.js';
78
78
  const { WorkingGrid } = workingGridModule;
79
- import { getErrorMessage } from './utils/errors.js';
79
+ import { getErrorMessage, resolveSeamMs, resolveSeamMsOrNull } from './utils/errors.js';
80
80
  // Maximum number of times the pre-broadcast staleness guard may re-plan the
81
81
  // batch from a fresh master before proceeding anyway. Bounded so a master
82
82
  // grid that keeps mutating (fill bursts, sync loops) can never livelock the
@@ -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.
@@ -1286,11 +1293,11 @@ function formatPartialBroadcastSummary(err) {
1286
1293
  return `${broadcast ?? '?'}/${total ?? '?'} groups broadcast`;
1287
1294
  }
1288
1295
  /**
1289
- * Execute a batch with retry-on-uncertain semantics, enforcing a per-broadcast
1290
- * operation cap (MAX_OPS_PER_BROADCAST). When the batch carries more
1291
- * operations than the cap, it is split into sequential broadcast chunks of at
1292
- * most `maxOps` operations each, so a single on-chain transaction never holds
1293
- * more than the configured number of order operations (the original "N fills
1296
+ * Execute a batch with retry-on-uncertain semantics, enforcing a gap-slot
1297
+ * per-broadcast operation cap (_getGapSlotBatchSize). When the batch carries
1298
+ * more operations than the cap, it is split into sequential broadcast chunks
1299
+ * of at most `maxOps` operations each, so a single on-chain transaction never
1300
+ * holds more than gapSlots order operations (the original "N fills
1294
1301
  * per broadcast" intent, applied at the op level rather than the fill level).
1295
1302
  *
1296
1303
  * Failure isolation — no swallowed orders: if one chunk's broadcast is
@@ -1316,12 +1323,11 @@ function formatPartialBroadcastSummary(err) {
1316
1323
  * @returns {Promise<{result: Object, opContexts: Array}>}
1317
1324
  */
1318
1325
  async function executeChunkedWithRetryOnUncertain(bot, operations, opContexts) {
1319
- const configuredMax = typeof bot._getMaxOpsPerBroadcast === 'function'
1326
+ const fromAccessor = typeof bot._getMaxOpsPerBroadcast === 'function'
1320
1327
  ? bot._getMaxOpsPerBroadcast()
1321
- : COW_PERFORMANCE?.MAX_OPS_PER_BROADCAST;
1322
- const maxOps = Number.isFinite(configuredMax) && configuredMax >= 1
1323
- ? Math.floor(configuredMax)
1324
- : 1;
1328
+ : (typeof bot._getGapSlotBatchSize === 'function' ? bot._getGapSlotBatchSize() : undefined);
1329
+ const requested = Number(fromAccessor);
1330
+ const maxOps = Number.isFinite(requested) && requested >= 1 ? Math.floor(requested) : 1;
1325
1331
  if (!Array.isArray(operations) || operations.length <= maxOps) {
1326
1332
  return await executeWithRetryOnUncertain(bot, operations, opContexts);
1327
1333
  }
@@ -1332,7 +1338,7 @@ async function executeChunkedWithRetryOnUncertain(bot, operations, opContexts) {
1332
1338
  opContexts: opContexts.slice(i, i + maxOps),
1333
1339
  });
1334
1340
  }
1335
- bot.manager.logger.log(`[COW] Splitting ${operations.length} operations into ${chunks.length} broadcast chunk(s) of at most ${maxOps} ops each (MAX_OPS_PER_BROADCAST).`, 'info');
1341
+ bot.manager.logger.log(`[COW] Splitting ${operations.length} operations into ${chunks.length} broadcast chunk(s) of at most ${maxOps} ops each (gap-slot batch size).`, 'info');
1336
1342
  const mergedOperationResults = [];
1337
1343
  const mergedRawResults = [];
1338
1344
  const mergedContexts = [];
@@ -1609,6 +1615,192 @@ function resolveIdealSizeForValidation(_bot, orderLike, fallbackSize = null) {
1609
1615
  function validateOrderSizeForExecution(bot, size, type, orderLike = null, fallbackSize = null) {
1610
1616
  return validateOrderSize(size, type, bot.manager.assets, bot.config.gridLimits?.MIN_ORDER_SIZE_FACTOR, resolveIdealSizeForValidation(bot, orderLike, fallbackSize), bot.config.gridLimits?.PARTIAL_DUST_THRESHOLD_PERCENTAGE);
1611
1617
  }
1618
+ function checkGridPriceInvariant(slotId, price, genesis) {
1619
+ return orderUtils.checkGridPriceInvariant(slotId, price, genesis);
1620
+ }
1621
+ /**
1622
+ * Derive the authoritative price for a rotation destination slot.
1623
+ *
1624
+ * A rotation re-prices to the destination slot, so the destination's genesis
1625
+ * level is the authoritative price — not `action.newPrice`, which the planner
1626
+ * copies from the destination hole's `order.price` and which is therefore only
1627
+ * as sound as whatever last wrote that object. Deriving the emitted price here
1628
+ * means a planner bug cannot produce a mis-priced UPDATE on its own; the
1629
+ * invariant check remains as the backstop for the no-genesis case.
1630
+ *
1631
+ * @returns {number} the destination's genesis level, or NaN when there is no
1632
+ * genesis ladder / no parseable destination index (caller falls back to the
1633
+ * planned price, which is the migration case the checker fails open on).
1634
+ */
1635
+ function deriveRotationPrice(bot, newGridId) {
1636
+ try {
1637
+ const idx = parseSlotIndex(newGridId);
1638
+ if (idx === null || idx === undefined || !Number.isFinite(idx))
1639
+ return NaN;
1640
+ const genesis = bot?.manager?._genesis;
1641
+ if (!Array.isArray(genesis?.priceLevels) || genesis.priceLevels.length === 0)
1642
+ return NaN;
1643
+ const lvl = Number(math.priceForSlot(idx, genesis));
1644
+ return (Number.isFinite(lvl) && lvl > 0) ? lvl : NaN;
1645
+ }
1646
+ catch {
1647
+ return NaN;
1648
+ }
1649
+ }
1650
+ /**
1651
+ * Record one GRID-PRICE-INVARIANT check for an emitted order price.
1652
+ *
1653
+ * BLOCKING: returns false when the price is a genuine off-grid mismatch, so the
1654
+ * caller must SKIP the emission. A price that is not a slot's genesis level is
1655
+ * not a valid grid price, so placing it is the failure this guard exists to
1656
+ * prevent — rejecting is the point, not a side effect.
1657
+ *
1658
+ * Fails OPEN on anything unjudgeable (no genesis, unparseable id, non-finite
1659
+ * price, checker error): those are metadata problems, not off-grid prices, and
1660
+ * blocking on them would halt legitimate trading. Only 'off-grid-price' blocks.
1661
+ *
1662
+ * A missing slotId is counted as unchecked rather than falling back to another
1663
+ * id: for a rotation UPDATE the id must be the DESTINATION slot (newPrice is
1664
+ * that slot's price), so substituting the source would flag every legitimate
1665
+ * relocation. Callers pass the id they actually mean, or nothing.
1666
+ *
1667
+ * @returns {boolean} true when the caller MAY emit; false when it must skip
1668
+ */
1669
+ function recordGridPriceInvariantCheck(bot, slotId, price, stats, site) {
1670
+ try {
1671
+ if (slotId == null || slotId === '') {
1672
+ stats.unchecked++;
1673
+ return true;
1674
+ }
1675
+ const inv = checkGridPriceInvariant(slotId, price, bot?.manager?._genesis);
1676
+ if (inv.reason !== 'ok' && inv.reason !== 'off-grid-price') {
1677
+ stats.unchecked++;
1678
+ return true;
1679
+ }
1680
+ stats.checked++;
1681
+ if (inv.ok) {
1682
+ // A CLEAN check clears the run for this slot: escalation must mean
1683
+ // "rejected N consecutive batches", not "rejected N times ever".
1684
+ // Without this reset a slot rejected once an hour would accumulate
1685
+ // to the threshold over a day and fire a resync it never earned.
1686
+ const streakMap = getInvariantRejectStreak(bot);
1687
+ if (streakMap.has(String(slotId)))
1688
+ streakMap.delete(String(slotId));
1689
+ return true;
1690
+ }
1691
+ stats.violated++;
1692
+ orderUtils.reportGridPriceInvariant(bot?.manager, slotId, price, site);
1693
+ considerGridPriceInvariantEscalation(bot, slotId, price, inv, site);
1694
+ return false;
1695
+ }
1696
+ catch {
1697
+ // A checker failure must never block a broadcast.
1698
+ return true;
1699
+ }
1700
+ }
1701
+ /**
1702
+ * Per-slot count of CONSECUTIVE batches that rejected this slot's emission as
1703
+ * off-grid, scoped to the BOT rather than the module.
1704
+ *
1705
+ * Scope matters because the monolithic runtime (`dexbot.ts`, the `dexbot` bin)
1706
+ * constructs EVERY active bot in one process, so a module-level map would pool
1707
+ * unrelated bots' rejections: one bot rejecting a slot twice would leave the
1708
+ * next bot at the threshold on its FIRST rejection and fire a spurious
1709
+ * structural resync (reload, possibly a full grid reset) on a healthy bot.
1710
+ * Keeping it on the bot also keeps the count from outliving the resync that
1711
+ * repairs the slot, and lets tests start from a clean slate.
1712
+ *
1713
+ * @param {any} bot
1714
+ * @returns {Map<string, number>}
1715
+ */
1716
+ function getInvariantRejectStreak(bot) {
1717
+ if (!(bot?._gridPriceInvariantRejectStreak instanceof Map)) {
1718
+ bot._gridPriceInvariantRejectStreak = new Map();
1719
+ }
1720
+ return bot._gridPriceInvariantRejectStreak;
1721
+ }
1722
+ /**
1723
+ * Escalate a PERSISTENT off-grid rejection to a structural resync.
1724
+ *
1725
+ * A single rejection is handled correctly by skipping the emission and warning;
1726
+ * the next cycle re-plans. But if the corruption lives in-process (the planner
1727
+ * carries `candidate.price` straight from `manager.orders`), the next cycle
1728
+ * re-plans from the SAME bad `slot.price`, is rejected identically, and warns
1729
+ * again -- forever. The slot is dead while the bot looks healthy.
1730
+ *
1731
+ * After `GRID_PRICE_INVARIANT_RESYNC_THRESHOLD` consecutive rejecting batches
1732
+ * for one slot, ask for the structural resync that repairs it (loadGrid derives
1733
+ * slot prices from the genesis ladder). Fire-and-forget: the resync is already
1734
+ * debounced (`_structuralGridResyncRunning`/`Timer`) and batch-in-flight aware,
1735
+ * so repeats inside the cooldown are cheap and safe.
1736
+ *
1737
+ * @returns {number} the slot's current consecutive-rejection streak
1738
+ */
1739
+ function considerGridPriceInvariantEscalation(bot, slotId, price, inv, site) {
1740
+ const key = String(slotId);
1741
+ const streakMap = getInvariantRejectStreak(bot);
1742
+ const streak = (streakMap.get(key) || 0) + 1;
1743
+ streakMap.set(key, streak);
1744
+ const threshold = Number(constantsModule.TIMING?.GRID_PRICE_INVARIANT_RESYNC_THRESHOLD) > 0
1745
+ ? Number(constantsModule.TIMING.GRID_PRICE_INVARIANT_RESYNC_THRESHOLD)
1746
+ : 3;
1747
+ if (streak < threshold)
1748
+ return streak;
1749
+ if (typeof bot?.manager?.requestStructuralGridResync !== 'function') {
1750
+ bot?.manager?.logger?.log?.(`[GRID-PRICE-INVARIANT] ${slotId} rejected ${streak}x consecutively but ` +
1751
+ `requestStructuralGridResync is unavailable; slot stays unhealed until restart`, 'error');
1752
+ return streak;
1753
+ }
1754
+ // Dedicated cooldown key (NOT BOUNDARY_HOLD_RESYNC_COOLDOWN_MS: the watchdogs
1755
+ // must tune independently). Once per cooldown window is enough -- the streak
1756
+ // keeps counting so a later window escalates again if still unhealed.
1757
+ const cooldownMs = Number(constantsModule.TIMING?.GRID_PRICE_INVARIANT_RESYNC_COOLDOWN_MS) > 0
1758
+ ? Number(constantsModule.TIMING.GRID_PRICE_INVARIANT_RESYNC_COOLDOWN_MS)
1759
+ : 15 * 60 * 1000;
1760
+ const now = Date.now();
1761
+ const lastAt = Number(bot?._lastGridPriceInvariantResyncAt) || 0;
1762
+ if (now - lastAt < cooldownMs)
1763
+ return streak;
1764
+ bot._lastGridPriceInvariantResyncAt = now;
1765
+ const expected = inv?.expected != null ? Number(inv.expected) : NaN;
1766
+ const actual = Number(price);
1767
+ bot?.manager?.logger?.log?.(`[GRID-PRICE-INVARIANT] ${slotId} (${site}) rejected ${streak} consecutive batch(es) at ` +
1768
+ `${Number.isFinite(actual) ? Format.formatPrice6(actual) : 'n/a'} ` +
1769
+ `(genesis ${Number.isFinite(expected) ? Format.formatPrice6(expected) : 'n/a'}); ` +
1770
+ `requesting structural resync to repair the slot`, 'error');
1771
+ try {
1772
+ const res = bot.manager.requestStructuralGridResync('grid-price-invariant-violation', {
1773
+ slotId: String(slotId),
1774
+ expected: Number.isFinite(expected) ? expected : null,
1775
+ actual: Number.isFinite(actual) ? actual : null,
1776
+ site,
1777
+ streak,
1778
+ });
1779
+ res?.catch?.((err) => {
1780
+ bot.manager?.logger?.log?.(`[GRID-PRICE-INVARIANT] Structural resync request failed: ${getErrorMessage(err)}`, 'error');
1781
+ });
1782
+ }
1783
+ catch (err) {
1784
+ bot.manager?.logger?.log?.(`[GRID-PRICE-INVARIANT] Structural resync request failed: ${getErrorMessage(err)}`, 'error');
1785
+ }
1786
+ return streak;
1787
+ }
1788
+ /**
1789
+ * Emit the per-batch GRID-PRICE-INVARIANT summary. Quiet when the batch had no
1790
+ * checkable emission; warn when any off-grid emission was REJECTED, else info.
1791
+ * A rejected emission is logged per-site by reportGridPriceInvariant; this is
1792
+ * the aggregate so a batch that silently placed fewer orders than planned is
1793
+ * explained without reading every line.
1794
+ */
1795
+ function logGridPriceInvariantSummary(bot, stats, site) {
1796
+ try {
1797
+ if (!stats || stats.checked === 0)
1798
+ return;
1799
+ bot?.manager?.logger?.log?.(`[GRID-PRICE-INVARIANT] site=${site} checked=${stats.checked} ` +
1800
+ `violated=${stats.violated} unchecked=${stats.unchecked}`, stats.violated > 0 ? 'warn' : 'info');
1801
+ }
1802
+ catch { /* summary is best-effort */ }
1803
+ }
1612
1804
  /**
1613
1805
  * LAST-FILL-GUARD helper — pivot ± halfIncrement (replaces price-tolerance).
1614
1806
  * last fill @x with increment i: BUY < x*(1 - i/2/100), SELL > x*(1 + i/2/100)
@@ -1756,6 +1948,14 @@ function refreshLastFillPivotFromQueue(bot) {
1756
1948
  * Resolve the grid increment percent for the LAST-FILL guard in one place so
1757
1949
  * every check site and the batch summary use (and print) the same value.
1758
1950
  * Falls back to the default 0.5 when unset/invalid.
1951
+ *
1952
+ * CALL CONTRACT: this takes the BOT (`{ manager }`), not the increment or the
1953
+ * manager. The lookup order below is `bot.manager.config` first, which works
1954
+ * only because `OrderManager` exposes its own `config`. Callers that pass a
1955
+ * bare manager instead of a bot get the `bot.config` / DEFAULT_CONFIG
1956
+ * fallbacks and silently lose the manager's tuning, so pass the bot.
1957
+ * (The manager-first order is deliberate: the guard must use the same
1958
+ * increment the grid was built with, not whatever the bot-level config holds.)
1759
1959
  * @param {import('./dexbot_class.js').DEXBot} bot
1760
1960
  * @returns {number} Positive increment percent
1761
1961
  */
@@ -2074,11 +2274,20 @@ function applyRotationTransitionsToWorkingGrid(bot, workingGrid, executedContext
2074
2274
  * CANCELs modify existing orders and cannot be reliably distinguished from
2075
2275
  * "not yet visible" state by simple polling.
2076
2276
  *
2277
+ * @param {Object} [options]
2278
+ * @param {number} [options.maxPollRetries] - Poll attempts before reconciliation fallback
2279
+ * @param {number} [options.pollIntervalMs] - Delay between polls; tests pass 0
2280
+ * to skip the production 1.5s pacing without changing the poll semantics
2077
2281
  * @returns {{ allConfirmed: boolean, confirmed: Array, unconfirmed: Array }}
2078
2282
  */
2079
2283
  async function pollChainForConfirmation(bot, opContexts, options = {}) {
2080
2284
  const maxPollRetries = options.maxPollRetries || 4;
2081
- const pollIntervalMs = options.pollIntervalMs || 1500;
2285
+ // Test seam (see updateOrdersOnChainBatchCOW): bot._testPollIntervalMs
2286
+ // overrides the production pacing when set by the tests. An explicit 0 is
2287
+ // honored; null/undefined falls through to the production 1.5s default.
2288
+ const explicitPollMs = options.pollIntervalMs;
2289
+ const seamMs = bot?._testPollIntervalMs;
2290
+ const pollIntervalMs = resolveSeamMs(explicitPollMs, resolveSeamMs(seamMs, 1500));
2082
2291
  // Only CREATE operations can be confirmed by polling (they appear as new orders on chain)
2083
2292
  const createContexts = opContexts.filter((ctx) => ctx && ctx.kind === 'create' && ctx.finalInts && ctx.order);
2084
2293
  if (createContexts.length === 0) {
@@ -2300,7 +2509,7 @@ function restoreSkippedUpdateSlotsInWorkingGrid(bot, workingGrid, skippedSlotIds
2300
2509
  * skipped as already-consistent); handled=false when the caller must proceed
2301
2510
  * with the original plan.
2302
2511
  */
2303
- async function replanStaleBatch(bot, cowResult, replanDepth, preBroadcastGuard) {
2512
+ async function replanStaleBatch(bot, cowResult, replanDepth, preBroadcastGuard, seamPollIntervalMs) {
2304
2513
  const canReplan = replanDepth < STALE_PLAN_REPLAN_LIMIT
2305
2514
  && Array.isArray(cowResult.fills) && cowResult.fills.length > 0;
2306
2515
  if (!canReplan) {
@@ -2356,7 +2565,11 @@ async function replanStaleBatch(bot, cowResult, replanDepth, preBroadcastGuard)
2356
2565
  return {
2357
2566
  handled: true,
2358
2567
  result: await updateOrdersOnChainBatchCOW(bot, replanned, {
2359
- replanDepth: replanDepth + 1
2568
+ replanDepth: replanDepth + 1,
2569
+ // Carry the seam through the recursion explicitly: the inner
2570
+ // frame re-sets and re-restores it, so it must not depend on
2571
+ // the outer frame's side-channel value surviving.
2572
+ ...(seamPollIntervalMs != null ? { pollIntervalMs: seamPollIntervalMs } : {})
2360
2573
  }),
2361
2574
  };
2362
2575
  }
@@ -2710,8 +2923,58 @@ async function runPreBroadcastGuards(bot, cowResult) {
2710
2923
  }
2711
2924
  return { proceed: true, crossingCandidates, intraBatchCandidates };
2712
2925
  }
2926
+ function restoreTestPollIntervalSeam(bot, prev) {
2927
+ try {
2928
+ if (prev === undefined)
2929
+ delete bot._testPollIntervalMs;
2930
+ else
2931
+ bot._testPollIntervalMs = prev;
2932
+ }
2933
+ catch { /* seam restore must never break the batch */ }
2934
+ }
2713
2935
  async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2714
2936
  const replanDepth = Number.isFinite(Number(options?.replanDepth)) ? Number(options.replanDepth) : 0;
2937
+ // Test seam: options.pollIntervalMs overrides the production 1.5s pacing
2938
+ // in pollChainForConfirmation (missing-create path below) so tests do
2939
+ // not sleep on wall-clock time. Held on the bot only for the duration of
2940
+ // this call (see the wrapper's finally) so the inner missing-create
2941
+ // branch and any re-plan recursion pick it up without changing the
2942
+ // production call signature used by the runtime.
2943
+ const seamPollIntervalMs = resolveSeamMsOrNull(options?.pollIntervalMs);
2944
+ const prevSeamPollIntervalMs = bot?._testPollIntervalMs;
2945
+ if (seamPollIntervalMs != null) {
2946
+ bot._testPollIntervalMs = seamPollIntervalMs;
2947
+ }
2948
+ // Expose the resolved interval using the same precedence the missing-create
2949
+ // poll path applies (explicit option > bot seam > production 1500ms).
2950
+ // Recorded in the wrapper rather than in pollChainForConfirmation so it is
2951
+ // observable on every batch exit, including the pre-broadcast guard
2952
+ // refusals that never reach the poll (a `||`-vs-`??` regression here is
2953
+ // otherwise invisible: the only effect is a slower poll).
2954
+ bot._lastResolvedPollIntervalMs = resolveSeamMs(seamPollIntervalMs, resolveSeamMs(bot?._testPollIntervalMs, 1500));
2955
+ // The seam override must not survive this call: every exit path (dry run,
2956
+ // entry/pre-broadcast single-flight aborts, guard refusals, re-plan
2957
+ // recursion, throws) funnels through the body() finally below, so no exit
2958
+ // can leak bot._testPollIntervalMs onto the bot.
2959
+ try {
2960
+ return await updateOrdersOnChainBatchCOWBody(bot, cowResult, replanDepth, seamPollIntervalMs ?? undefined);
2961
+ }
2962
+ finally {
2963
+ // Restore only when this frame actually wrote a seam. The entry
2964
+ // single-flight await (inside body()) lets a second, seam-less batch
2965
+ // run concurrently: an unguarded restore would delete the seam owned
2966
+ // by an in-flight sibling call (captured prev === undefined), and that
2967
+ // sibling's pollChainForConfirmation — which reads the bot field only,
2968
+ // since it takes no pollIntervalMs option at its call sites — would
2969
+ // silently fall back to the production 1500ms pacing. Guarding keeps
2970
+ // the write-ownership scoped to this call; the saved prev still makes
2971
+ // the nested re-plan recursion LIFO-correct.
2972
+ if (seamPollIntervalMs != null) {
2973
+ restoreTestPollIntervalSeam(bot, prevSeamPollIntervalMs);
2974
+ }
2975
+ }
2976
+ }
2977
+ async function updateOrdersOnChainBatchCOWBody(bot, cowResult, replanDepth, seamPollIntervalMs) {
2715
2978
  bot._currentCycleId = (Number.isFinite(Number(bot._currentCycleId)) ? Number(bot._currentCycleId) : 0) + 1;
2716
2979
  const { workingGrid, workingIndexes, workingBoundary, actions } = cowResult;
2717
2980
  // Boundary-hold value: computed pre-broadcast after the skip-restore and
@@ -2772,7 +3035,12 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2772
3035
  bot.manager.logger.log(`[COW] Draining ${pendingCorrectionCount} pending correction(s) before batch`, 'info');
2773
3036
  const drainResult = await orderUtils.correctAllPriceMismatches(bot.manager, bot.account, bot.privateKey, chainOrders);
2774
3037
  if (drainResult?.failed > 0) {
2775
- 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');
2776
3044
  }
2777
3045
  }
2778
3046
  catch (drainErr) {
@@ -2798,7 +3066,17 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2798
3066
  // Per-batch LAST-FILL-GUARD disposition counters. Per-action pass lines
2799
3067
  // would spam big batches, so the guard emits one batch summary instead
2800
3068
  // (see the summary after the action loop below).
2801
- const lastFillGuardStats = { checked: 0, passed: 0, skipped: 0, bypassed: 0 };
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();
3076
+ // Per-batch GRID-PRICE-INVARIANT counters (BLOCKING). Tracks emitted prices
3077
+ // that are not the genesis level for their slot — such emissions are
3078
+ // rejected, not placed. One batch summary.
3079
+ const gridPriceInvariantStats = { checked: 0, violated: 0, unchecked: 0 };
2802
3080
  // Whether any guard check in this batch refreshed the pivot from
2803
3081
  // still-queued fills — reported in the batch summary so a pivot change
2804
3082
  // that altered a guard decision is visible at info, not just debug.
@@ -2810,7 +3088,7 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2810
3088
  const clampedUpdateSlotIds = new Set();
2811
3089
  // orderId -> operations index of its cancel op. A crossing re-pricing
2812
3090
  // update is only safe when the crossed order's cancel was already queued
2813
- // at an earlier position: ops broadcast in MAX_OPS_PER_BROADCAST chunks,
3091
+ // at an earlier position: ops broadcast in gap-slot-sized chunks,
2814
3092
  // so an earlier index means the cancel confirms on chain (same or earlier
2815
3093
  // chunk, applied sequentially) before the crossing order lands.
2816
3094
  const cancelOpIndexByOrderId = new Map();
@@ -2848,12 +3126,33 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2848
3126
  // mutated the pivot mid-batch (02:03 pivots drifted 0.001523→0.001529
2849
3127
  // across 20 checks), so early actions were judged against a different
2850
3128
  // pivot than later ones. The batch summary still reports whether this
2851
- // 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
+ }
2852
3143
  try {
2853
3144
  if (refreshLastFillPivotFromQueue(bot))
2854
3145
  lastFillGuardPivotRefreshed = true;
2855
3146
  }
2856
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;
2857
3156
  for (const action of actions) {
2858
3157
  if (action.type === COW_ACTIONS.CANCEL) {
2859
3158
  try {
@@ -2890,13 +3189,21 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2890
3189
  const priceDrift = Number.isFinite(plannedPrice) && Number.isFinite(livePrice)
2891
3190
  ? Math.abs(livePrice - plannedPrice)
2892
3191
  : 0;
2893
- const effectiveOrder = (priceDrift > 0)
2894
- ? { ...order, price: livePrice, size: order.size, type: order.type }
2895
- : order;
3192
+ // The planned price is emitted as-is. A pre-broadcast
3193
+ // substitution with liveSlot.price used to rewrite it here
3194
+ // at `debug` level, which meant a slot whose price had been
3195
+ // mutated off its genesis level got re-broadcast under a
3196
+ // different number than the plan validated (crossing,
3197
+ // collision and last-fill guards all ran on `createPrice`).
3198
+ // slot.price is derived from the genesis ladder, not
3199
+ // authoritative, so a divergence is a signal to report —
3200
+ // never a value to adopt. Reported at `warn`: a divergence
3201
+ // here is a writer bug, not routine freshness.
3202
+ const effectiveOrder = order;
2896
3203
  if (priceDrift > 0) {
2897
- bot.manager.logger.log(`[COW] Pre-broadcast price freshness: slot ${order.id} ` +
2898
- `drifted from planned=${plannedPrice} to live=${livePrice} ` +
2899
- `(diff=${priceDrift}); rebuilding CREATE op with live price.`, 'debug');
3204
+ bot.manager.logger.log(`[COW] Pre-broadcast price drift on slot ${order.id}: ` +
3205
+ `planned=${plannedPrice} live=${livePrice} (diff=${priceDrift}); ` +
3206
+ `emitting planned price (live slot price is derived from genesis and is not authoritative).`, 'warn');
2900
3207
  }
2901
3208
  const createPrice = effectiveOrder.price;
2902
3209
  const precision = order.type === ORDER_TYPES.SELL ? bot.manager.assets.assetA.precision : bot.manager.assets.assetB.precision;
@@ -2912,13 +3219,14 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2912
3219
  }
2913
3220
  // CROSSING-PLACEMENT GUARD (create variant): the batch-level
2914
3221
  // validators (validateCreateTargetSlots, detectCrossedBookPlan)
2915
- // simulated the PLANNED price, but the pre-broadcast price
2916
- // freshness rebuild above can move the op's price. Re-check
2917
- // crossing on the FINAL price against live and chain-side
2918
- // orders not already cancelled at an earlier op position —
2919
- // an opposite-side order cancelled in a later chunk would
2920
- // otherwise coexist with this create mid-broadcast and
2921
- // self-trade (production incident class).
3222
+ // simulated the PLANNED price. That is now the price emitted
3223
+ // (the pre-broadcast substitution was removed), but the guard
3224
+ // is retained as defence-in-depth: it re-checks on the FINAL
3225
+ // price against live and chain-side orders not already
3226
+ // cancelled at an earlier op position — an opposite-side
3227
+ // order cancelled in a later chunk would otherwise coexist
3228
+ // with this create mid-broadcast and self-trade (production
3229
+ // incident class).
2922
3230
  const createCrossed = findCrossedOrder(crossingCandidates, createPrice, order.type, bot.manager.assets, (o) => orderUtils.isCrossingCheckCandidate(o, null, cancelOpIndexByOrderId));
2923
3231
  const intraBatchCrossed = createCrossed ? null : findCrossedOrder(intraBatchCandidates, createPrice, order.type, bot.manager.assets);
2924
3232
  const effectiveCrossed = createCrossed || intraBatchCrossed;
@@ -2959,6 +3267,16 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2959
3267
  }
2960
3268
  catch (_e) { /* guard is best-effort */ }
2961
3269
  const args = buildCreateOrderArgs(effectiveOrder, assetA, assetB);
3270
+ // GRID-PRICE-INVARIANT (blocking): the CREATE price must be
3271
+ // the genesis level for this slot. A mismatch means state
3272
+ // corruption upstream, so the emission is skipped rather
3273
+ // than placed — the next reconcile cycle re-plans. See
3274
+ // docs/GRID_PRICE_INVARIANT.md.
3275
+ if (!recordGridPriceInvariantCheck(bot, order.id, createPrice, gridPriceInvariantStats, 'CREATE')) {
3276
+ if (order.id)
3277
+ skippedCreateSlotIds.add(order.id);
3278
+ continue;
3279
+ }
2962
3280
  const buildResult = await chainOrders.buildCreateOrderOp(bot.account, args.amountToSell, args.sellAssetId, args.minToReceive, args.receiveAssetId, null);
2963
3281
  if (!buildResult) {
2964
3282
  bot.manager.logger.log(`Skipping create op for ${action.id}: amounts would round to 0 on blockchain`, 'warn');
@@ -2969,12 +3287,14 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2969
3287
  operations.push(buildResult.op);
2970
3288
  opContexts.push({ kind: 'create', id: order.id, order: effectiveOrder, args, finalInts: buildResult.finalInts });
2971
3289
  intraBatchCandidates.push(effectiveOrder);
2972
- recordPendingBroadcast(bot, {
3290
+ const recordedFp = recordPendingBroadcast(bot, {
2973
3291
  opIndex: operations.length - 1,
2974
3292
  ctxIndex: opContexts.length - 1,
2975
3293
  order: effectiveOrder,
2976
3294
  finalInts: buildResult.finalInts
2977
3295
  });
3296
+ if (recordedFp)
3297
+ batchPendingFps.add(recordedFp);
2978
3298
  }
2979
3299
  catch (err) {
2980
3300
  bot.manager.logger.log(`Failed to prepare create op for ${action.id}: ${getErrorMessage(err)}`, 'error');
@@ -2985,9 +3305,29 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2985
3305
  if (action.newGridId && action.newGridId !== action.id) {
2986
3306
  const masterOrder = bot.manager.orders.get(action.id);
2987
3307
  const orderType = action.order?.type || masterOrder?.type;
2988
- const newPrice = Number.isFinite(Number(action.newPrice))
3308
+ // ROTATION PRICE IS THE DESTINATION'S GENESIS LEVEL.
3309
+ //
3310
+ // A rotation re-prices to the destination slot, so the
3311
+ // destination's level is the authoritative price — not
3312
+ // action.newPrice, which the planner copies from
3313
+ // hole.order.price and which is therefore only as sound
3314
+ // as whatever wrote that object. Deriving it here means a
3315
+ // planner bug cannot produce a mis-priced UPDATE even if
3316
+ // the invariant check below were bypassed; the check
3317
+ // stays as the backstop that catches a missing genesis
3318
+ // ladder (where it fails open) rather than the only
3319
+ // thing standing between a bad plan and a live order.
3320
+ const derivedNewPrice = deriveRotationPrice(bot, action.newGridId);
3321
+ const plannedNewPrice = Number.isFinite(Number(action.newPrice))
2989
3322
  ? Number(action.newPrice)
2990
3323
  : Number(action.order?.price);
3324
+ let newPrice = Number.isFinite(derivedNewPrice) ? derivedNewPrice : plannedNewPrice;
3325
+ if (Number.isFinite(derivedNewPrice) && Number.isFinite(plannedNewPrice)
3326
+ && Math.abs(derivedNewPrice - plannedNewPrice) > Math.max(1e-12, Math.abs(derivedNewPrice) * 1e-9)) {
3327
+ bot.manager.logger.log(`[GRID-PRICE-INVARIANT] Rotation ${action.id} -> ${action.newGridId}: planned price ${Format.formatPrice6(plannedNewPrice)} ` +
3328
+ `differs from the destination's genesis level ${Format.formatPrice6(derivedNewPrice)} — ` +
3329
+ `emitting the genesis level`, 'warn');
3330
+ }
2991
3331
  const newSize = plannedUpdateSize(action);
2992
3332
  if (!masterOrder || !action.orderId || !orderType || !Number.isFinite(newPrice) || newSize <= 0) {
2993
3333
  continue;
@@ -3194,6 +3534,20 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
3194
3534
  }
3195
3535
  catch (_e) { /* best-effort */ }
3196
3536
  const { amountToSell, minToReceive } = buildCreateOrderArgs({ type: orderType, size: newSize, price: newPrice }, assetA, assetB);
3537
+ // GRID-PRICE-INVARIANT (blocking): the emitted price
3538
+ // is action.newPrice, which every planner derives from the
3539
+ // object named by action.newGridId (verified: grid.ts:1867,
3540
+ // utils/system.ts:1195, validate.ts:600/746). Pass the
3541
+ // destination id only — no source fallback, so a missing id
3542
+ // counts as unchecked instead of checking the wrong slot.
3543
+ if (!recordGridPriceInvariantCheck(bot, action.newGridId, newPrice, gridPriceInvariantStats, 'UPDATE')) {
3544
+ skippedUpdateCount++;
3545
+ if (action.id)
3546
+ skippedUpdateSlotIds.add(action.id);
3547
+ if (action.newGridId)
3548
+ skippedUpdateSlotIds.add(action.newGridId);
3549
+ continue;
3550
+ }
3197
3551
  const buildResult = await chainOrders.buildUpdateOrderOp(bot.account, action.orderId, { amountToSell, minToReceive, newPrice, orderType }, masterOrder.rawOnChain || null);
3198
3552
  if (!buildResult) {
3199
3553
  skippedUpdateCount++;
@@ -3261,10 +3615,14 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
3261
3615
  const priceDrift = Number.isFinite(plannedPrice) && Number.isFinite(livePrice)
3262
3616
  ? Math.abs(livePrice - plannedPrice)
3263
3617
  : 0;
3264
- const fbPrice = (priceDrift > 0) ? livePrice : plannedPrice;
3618
+ const fbPrice = plannedPrice;
3265
3619
  if (priceDrift > 0) {
3620
+ // Same rationale as the primary CREATE path: emit
3621
+ // the planned price and report the divergence
3622
+ // rather than adopting a derived live price.
3266
3623
  bot.manager.logger.log(`[COW] CREATE fallback price drift for ${action.id} -> ${targetSlotId}: ` +
3267
- `planned=${plannedPrice} live=${livePrice} (diff=${priceDrift})`, 'debug');
3624
+ `planned=${plannedPrice} live=${livePrice} (diff=${priceDrift}); ` +
3625
+ `emitting planned price.`, 'warn');
3268
3626
  }
3269
3627
  const sizeCheck = validateOrderSizeForExecution(bot, fbSize, fbType, fbOrder, fbSize);
3270
3628
  if (!sizeCheck.isValid) {
@@ -3321,6 +3679,10 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
3321
3679
  }
3322
3680
  catch (_fbGuardErr) { /* guard is best-effort */ }
3323
3681
  const fbArgs = buildCreateOrderArgs({ type: fbType, size: fbSize, price: fbPrice }, assetA, assetB);
3682
+ // GRID-PRICE-INVARIANT (blocking): fallback CREATE.
3683
+ if (!recordGridPriceInvariantCheck(bot, targetSlotId, fbPrice, gridPriceInvariantStats, 'CREATE-FALLBACK')) {
3684
+ continue;
3685
+ }
3324
3686
  const fbResult = await chainOrders.buildCreateOrderOp(bot.account, fbArgs.amountToSell, fbArgs.sellAssetId, fbArgs.minToReceive, fbArgs.receiveAssetId, null);
3325
3687
  if (fbResult) {
3326
3688
  operations.push(fbResult.op);
@@ -3331,12 +3693,14 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
3331
3693
  args: { amountToSell: fbArgs.amountToSell, minToReceive: fbArgs.minToReceive },
3332
3694
  finalInts: fbResult.finalInts
3333
3695
  });
3334
- recordPendingBroadcast(bot, {
3696
+ const fbRecordedFp = recordPendingBroadcast(bot, {
3335
3697
  opIndex: operations.length - 1,
3336
3698
  ctxIndex: opContexts.length - 1,
3337
3699
  order: { id: targetSlotId, type: fbType, price: fbPrice, size: fbSize },
3338
3700
  finalInts: fbResult.finalInts
3339
3701
  });
3702
+ if (fbRecordedFp)
3703
+ batchPendingFps.add(fbRecordedFp);
3340
3704
  bot.manager.logger.log(`[COW] Recovered "not found" for ${action.id}: converted UPDATE to CREATE for slot ${targetSlotId}`, 'warn');
3341
3705
  continue;
3342
3706
  }
@@ -3350,6 +3714,73 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
3350
3714
  }
3351
3715
  }
3352
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 */ }
3353
3784
  // Batch-level LAST-FILL-GUARD summary: per-action pass lines would spam
3354
3785
  // big batches, so one line per batch records the mode, pivot, resolved
3355
3786
  // increment, and pass/skip/bypass counts — the guard's pass decisions
@@ -3361,7 +3792,16 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
3361
3792
  // for the whole batch, so every action was checked against the same
3362
3793
  // pivot printed here.
3363
3794
  try {
3364
- 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;
3365
3805
  if (totalGuarded > 0) {
3366
3806
  const sumPivotRaw = bot.manager?._lastFilledPrice;
3367
3807
  const sumType = bot.manager?._lastFilledType;
@@ -3376,13 +3816,39 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
3376
3816
  ? `active+bypassed(${batchOrigin || 'unknown'})`
3377
3817
  : 'active');
3378
3818
  const pivotStr = cold ? 'none' : `${Format.formatPrice6(Number(sumPivotRaw))}(${sumType})`;
3379
- bot.manager.logger.log(`[LAST-FILL-GUARD] mode=${mode} pivot=${pivotStr} inc=${sumInc}% ` +
3819
+ // Report when the pivot used for this batch was NOT a ladder
3820
+ // level: that is the precondition for a pivoted ratchet, and
3821
+ // the value alone does not reveal it.
3822
+ const batchPivot = cold ? null : resolveOnGridPivot(bot.manager, sumPivotRaw);
3823
+ const pivotGridStr = cold
3824
+ ? ''
3825
+ : (batchPivot && batchPivot.idx != null
3826
+ ? ` pivotSlot=${batchPivot.idx}${batchPivot.snapped ? '(snapped)' : ''}`
3827
+ : ' pivotOffGrid=true');
3828
+ bot.manager.logger.log(`[LAST-FILL-GUARD] mode=${mode} pivot=${pivotStr}${pivotGridStr} inc=${sumInc}% ` +
3380
3829
  `pivotRefreshed=${lastFillGuardPivotRefreshed} ` +
3381
3830
  `checked=${lastFillGuardStats.checked} passed=${lastFillGuardStats.passed} ` +
3382
- `skipped=${lastFillGuardStats.skipped} bypassed=${lastFillGuardStats.bypassed}`, cold ? 'warn' : 'info');
3831
+ `skipped=${lastFillGuardStats.skipped} bypassed=${lastFillGuardStats.bypassed}` +
3832
+ // Per-action off-ladder count. The batch-level
3833
+ // `pivotOffGrid=true` flag above only says the pivot was
3834
+ // off-grid; this says HOW MANY guarded probes judged a
3835
+ // placement against an unsnapped pivot, which is the
3836
+ // quantity that grows during the ratchet this guard
3837
+ // exists to catch. Omitted (not `0`) when never set, so
3838
+ // "no off-grid probes" stays distinguishable from
3839
+ // "counter unavailable".
3840
+ (Number(lastFillGuardStats.pivotOffGrid) > 0
3841
+ ? ` pivotOffGrid=${lastFillGuardStats.pivotOffGrid}`
3842
+ : '') +
3843
+ (gateGuarded > 0
3844
+ ? ` gateChecked=${finalGateStats.checked} gatePassed=${finalGateStats.passed} ` +
3845
+ `gateSkipped=${finalGateStats.skipped} gateBypassed=${finalGateStats.bypassed}`
3846
+ : ''), cold || (batchPivot && batchPivot.idx == null) ? 'warn' : 'info');
3383
3847
  }
3384
3848
  }
3385
3849
  catch { /* summary is best-effort */ }
3850
+ // Batch-level GRID-PRICE-INVARIANT summary (rejected emissions).
3851
+ logGridPriceInvariantSummary(bot, gridPriceInvariantStats, 'COW');
3386
3852
  if (skippedUpdateCount > 0) {
3387
3853
  restoreSkippedUpdateSlotsInWorkingGrid(bot, workingGrid, skippedUpdateSlotIds, skippedUpdateCount);
3388
3854
  }
@@ -3466,7 +3932,7 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
3466
3932
  });
3467
3933
  if (!preBroadcastGuard.canCommit) {
3468
3934
  // Bounded re-plan + proceed — policy documented on replanStaleBatch.
3469
- const replan = await replanStaleBatch(bot, cowResult, replanDepth, preBroadcastGuard);
3935
+ const replan = await replanStaleBatch(bot, cowResult, replanDepth, preBroadcastGuard, seamPollIntervalMs);
3470
3936
  if (replan.handled) {
3471
3937
  return replan.result;
3472
3938
  }
@@ -3997,6 +4463,68 @@ async function recoverRefusedCommit(bot, chainOrders, logPrefix, adoptOpts, cont
3997
4463
  await persistGridAndClearPendingBroadcasts(bot, logPrefix);
3998
4464
  return successReturn ?? { executed: false, hadRotation: false, commitRefused: true, ...extraReturn };
3999
4465
  }
4466
+ /**
4467
+ * Resolve the LAST-FILL guard pivot to an ON-GRID price.
4468
+ *
4469
+ * The pivot was a raw fill price, and nothing validated it. That made it a
4470
+ * ratchet input: any bad pivot (a corrupt fill price, an adopted off-grid
4471
+ * order) shifted every subsequent threshold, so an off-market buy could read
4472
+ * as legitimate. A price is only meaningful relative to the ladder it trades
4473
+ * on, so the pivot is converted to its nearest slot index and back to that
4474
+ * slot's genesis price: the result is by construction a real grid level and
4475
+ * cannot drift off the ladder.
4476
+ *
4477
+ * Falls back to the raw price when genesis is unavailable (pre-genesis
4478
+ * startup), so the guard degrades to its previous behaviour rather than
4479
+ * silently disabling. Never throws.
4480
+ *
4481
+ * @param {any} manager
4482
+ * @param {number|null|undefined} rawPrice
4483
+ * @returns {{price: number|null, snapped: boolean, idx: number|null, nearestDrift: number|null}}
4484
+ */
4485
+ function resolveOnGridPivot(manager, rawPrice) {
4486
+ const price = Number(rawPrice);
4487
+ if (!Number.isFinite(price) || price <= 0)
4488
+ return { price: null, snapped: false, idx: null, nearestDrift: null };
4489
+ try {
4490
+ const genesis = manager?._genesis;
4491
+ const levels = genesis?.priceLevels;
4492
+ if (!Array.isArray(levels) || levels.length === 0)
4493
+ return { price, snapped: false, idx: null, nearestDrift: null };
4494
+ const idx = math.slotIndexForPrice(price, genesis);
4495
+ if (!Number.isFinite(idx))
4496
+ return { price, snapped: false, idx: null, nearestDrift: null };
4497
+ // Only accept a genuine ladder level and only a nearest match. A price
4498
+ // that is wildly off-ladder (an adopted orphan far outside the grid)
4499
+ // must NOT be silently rewritten onto an edge slot -- that would make a
4500
+ // bad pivot look like a legitimate grid fill. Leave it snapped=false and
4501
+ // let the caller decide.
4502
+ const candidate = Number(math.priceForSlot(idx, genesis));
4503
+ if (!Number.isFinite(candidate) || candidate <= 0)
4504
+ return { price, snapped: false, idx: null, nearestDrift: null };
4505
+ // Reject a snap that moves the pivot by more than one increment: any
4506
+ // in-grid fill is within half an increment of its slot price, so a
4507
+ // larger move means the pivot itself is not a real fill price.
4508
+ const drift = Math.abs(candidate - price) / price;
4509
+ // resolveLastFillGuardIncrement takes a BOT-shaped argument and reads
4510
+ // `bot.manager.config` first. resolveOnGridPivot only ever has the
4511
+ // manager (no bot in scope), so wrap it to satisfy that contract.
4512
+ // Passing the manager directly would silently skip its own tuning and
4513
+ // fall through to DEFAULT_CONFIG, making the snap tolerance wrong for
4514
+ // any grid not built on the default increment.
4515
+ const increment = resolveLastFillGuardIncrement({ manager }) / 100;
4516
+ const maxDrift = Number.isFinite(increment) && increment > 0 ? increment : 0.005;
4517
+ // nearestDrift is reported even when the snap is refused, so a caller
4518
+ // can tell a near-miss (rounding, one increment out) from a price that
4519
+ // is nowhere near the ladder (a genuinely corrupt pivot).
4520
+ if (drift > maxDrift)
4521
+ return { price, snapped: false, idx: null, nearestDrift: drift };
4522
+ return { price: candidate, snapped: drift > 0, idx, nearestDrift: drift };
4523
+ }
4524
+ catch {
4525
+ return { price, snapped: false, idx: null, nearestDrift: null };
4526
+ }
4527
+ }
4000
4528
  /**
4001
4529
  * Run the last-fill guard probe: optionally refresh the pivot from
4002
4530
  * still-queued fills, read the durable pivot, and evaluate
@@ -4005,6 +4533,13 @@ async function recoverRefusedCommit(bot, chainOrders, logPrefix, adoptOpts, cont
4005
4533
  * skip logging stay at the call sites, which differ per action kind).
4006
4534
  * Batch callers pass skipRefresh=true: the batch-start freeze owns refreshes
4007
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.
4008
4543
  * @param {Object} bot
4009
4544
  * @param {number} price - Target order price
4010
4545
  * @param {number} size - Order size
@@ -4027,10 +4562,384 @@ function runLastFillGuardCheck(bot, price, size, type, stats, skipRefresh = fals
4027
4562
  const lastPrice = bot.manager?._lastFilledPrice;
4028
4563
  const lastType = bot.manager?._lastFilledType;
4029
4564
  const inc = resolveLastFillGuardIncrement(bot);
4030
- const check = isLastFillGuardBlocked(price, size, type, lastPrice, lastType, inc);
4565
+ // Validate the pivot onto the ladder before use (see resolveOnGridPivot).
4566
+ // Reported once per probe at warn when the raw pivot was NOT a grid level:
4567
+ // that is the ratchet precondition, and it is otherwise invisible because
4568
+ // the pivot is only ever logged by value.
4569
+ const onGrid = resolveOnGridPivot(bot.manager, lastPrice);
4570
+ if (lastPrice != null && onGrid.idx == null && Number.isFinite(Number(lastPrice))) {
4571
+ try {
4572
+ // A persistently off-ladder pivot is exactly the corruption case, so
4573
+ // this branch would otherwise warn once per guarded action per batch
4574
+ // on top of the batch summary (which already reports pivotOffGrid).
4575
+ // Warn once per distinct pivot value per batch: repeated identical
4576
+ // pivots are the same condition, and a CHANGED pivot still warns.
4577
+ const warnedKey = `lastFillPivotWarned:${bot?._currentCycleId ?? 'na'}`;
4578
+ const alreadyWarned = bot[warnedKey];
4579
+ if (alreadyWarned !== Number(lastPrice)) {
4580
+ bot[warnedKey] = Number(lastPrice);
4581
+ // Distinguish "close to a level but too far to snap" from "nowhere
4582
+ // near the ladder". The former is a rounding/drift artifact; the
4583
+ // latter means the pivot itself is not a real fill price.
4584
+ const far = onGrid.nearestDrift == null || onGrid.nearestDrift > 0.02;
4585
+ const kind = far ? 'off-ladder' : 'near-ladder'
4586
+ + (onGrid.nearestDrift != null ? ` (${(onGrid.nearestDrift * 100).toFixed(4)}% from the nearest level)` : '');
4587
+ bot.manager?.logger?.log?.(`[LAST-FILL-GUARD] Pivot ${Format.formatPrice6(Number(lastPrice))} is not a grid level ` +
4588
+ `(${kind}); using raw value. A non-grid pivot can ` +
4589
+ `misjudge which placements are off-market. ` +
4590
+ `(reported once per batch per pivot value)`, 'warn');
4591
+ }
4592
+ }
4593
+ catch { /* logging is best-effort */ }
4594
+ stats.pivotOffGrid = (stats.pivotOffGrid || 0) + 1;
4595
+ }
4596
+ const check = isLastFillGuardBlocked(price, size, type, onGrid.price, lastType, inc);
4031
4597
  stats.checked++;
4032
4598
  return { check, refreshed };
4033
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
+ }
4034
4943
  /**
4035
4944
  * Apply BTS create-fee accounting for a batch that bypassed the normal
4036
4945
  * processBatchResults pipeline (commit refused after broadcast, or
@@ -4273,7 +5182,7 @@ async function processBatchResults(bot, result, opContexts) {
4273
5182
  updateOperationCount
4274
5183
  };
4275
5184
  }
4276
- export { isLastFillGuardBlocked, 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 };
4277
5186
  // Exported for regression tests (issue #23 sibling): the uncertain-broadcast
4278
5187
  // discard path must never drop a placement silently when master lost the slot.
4279
5188
  export { restoreDiscardedCreates };
@@ -4312,5 +5221,6 @@ export default {
4312
5221
  pollChainForConfirmation,
4313
5222
  updateOrdersOnChainBatchCOW,
4314
5223
  processBatchResults,
5224
+ runFinalPivotGate,
4315
5225
  };
4316
5226
  //# sourceMappingURL=dexbot_cow_runtime.js.map