dexbot 1.6.1 → 1.6.2

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 (91) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/analysis/ama_fitting/package.json +1 -1
  3. package/analysis/tradingview/tradingview_uplot_chart_generator.ts +19 -5
  4. package/analysis/trend_detection/package.json +1 -1
  5. package/claw/package.json +1 -1
  6. package/claw/runtimes/openclaw-plugin/openclaw.plugin.json +1 -1
  7. package/claw/runtimes/openclaw-plugin/package.json +1 -1
  8. package/claw/tests/test_claw_mcp_transport.ts +2 -2
  9. package/dist/analysis/tradingview/tradingview_uplot_chart_generator.d.ts.map +1 -1
  10. package/dist/analysis/tradingview/tradingview_uplot_chart_generator.js +19 -5
  11. package/dist/analysis/tradingview/tradingview_uplot_chart_generator.js.map +1 -1
  12. package/dist/market_adapter/inputs/fetch_book_data.js +2 -2
  13. package/dist/market_adapter/inputs/fetch_book_data.js.map +1 -1
  14. package/dist/market_adapter/inputs/fetch_lp_data.d.ts +22 -3
  15. package/dist/market_adapter/inputs/fetch_lp_data.d.ts.map +1 -1
  16. package/dist/market_adapter/inputs/fetch_lp_data.js +15 -15
  17. package/dist/market_adapter/inputs/fetch_lp_data.js.map +1 -1
  18. package/dist/market_adapter/inputs/kibana_feed_source.js +2 -2
  19. package/dist/market_adapter/inputs/kibana_feed_source.js.map +1 -1
  20. package/dist/market_adapter/inputs/window_cache.d.ts +54 -8
  21. package/dist/market_adapter/inputs/window_cache.d.ts.map +1 -1
  22. package/dist/market_adapter/inputs/window_cache.js +328 -87
  23. package/dist/market_adapter/inputs/window_cache.js.map +1 -1
  24. package/dist/modules/bitshares-native/subscriptions.d.ts +1 -1
  25. package/dist/modules/bitshares-native/subscriptions.d.ts.map +1 -1
  26. package/dist/modules/bitshares-native/subscriptions.js +18 -4
  27. package/dist/modules/bitshares-native/subscriptions.js.map +1 -1
  28. package/dist/modules/bitshares_client.d.ts.map +1 -1
  29. package/dist/modules/bitshares_client.js +10 -0
  30. package/dist/modules/bitshares_client.js.map +1 -1
  31. package/dist/modules/constants.d.ts +7 -3
  32. package/dist/modules/constants.d.ts.map +1 -1
  33. package/dist/modules/constants.js +44 -20
  34. package/dist/modules/constants.js.map +1 -1
  35. package/dist/modules/credit_runtime.d.ts.map +1 -1
  36. package/dist/modules/credit_runtime.js +24 -6
  37. package/dist/modules/credit_runtime.js.map +1 -1
  38. package/dist/modules/dexbot_class.d.ts +31 -6
  39. package/dist/modules/dexbot_class.d.ts.map +1 -1
  40. package/dist/modules/dexbot_class.js +55 -11
  41. package/dist/modules/dexbot_class.js.map +1 -1
  42. package/dist/modules/dexbot_cow_runtime.d.ts +57 -6
  43. package/dist/modules/dexbot_cow_runtime.d.ts.map +1 -1
  44. package/dist/modules/dexbot_cow_runtime.js +474 -38
  45. package/dist/modules/dexbot_cow_runtime.js.map +1 -1
  46. package/dist/modules/dexbot_fill_runtime.d.ts +28 -1
  47. package/dist/modules/dexbot_fill_runtime.d.ts.map +1 -1
  48. package/dist/modules/dexbot_fill_runtime.js +116 -3
  49. package/dist/modules/dexbot_fill_runtime.js.map +1 -1
  50. package/dist/modules/dexbot_maintenance_runtime.d.ts +46 -0
  51. package/dist/modules/dexbot_maintenance_runtime.d.ts.map +1 -1
  52. package/dist/modules/dexbot_maintenance_runtime.js +187 -5
  53. package/dist/modules/dexbot_maintenance_runtime.js.map +1 -1
  54. package/dist/modules/dexbot_startup_runtime.d.ts.map +1 -1
  55. package/dist/modules/dexbot_startup_runtime.js +6 -1
  56. package/dist/modules/dexbot_startup_runtime.js.map +1 -1
  57. package/dist/modules/dexbot_state_recovery.d.ts.map +1 -1
  58. package/dist/modules/dexbot_state_recovery.js +16 -3
  59. package/dist/modules/dexbot_state_recovery.js.map +1 -1
  60. package/dist/modules/order/grid.d.ts +3 -1
  61. package/dist/modules/order/grid.d.ts.map +1 -1
  62. package/dist/modules/order/grid.js +47 -6
  63. package/dist/modules/order/grid.js.map +1 -1
  64. package/dist/modules/order/grid_reconcile_internal.d.ts.map +1 -1
  65. package/dist/modules/order/grid_reconcile_internal.js +39 -4
  66. package/dist/modules/order/grid_reconcile_internal.js.map +1 -1
  67. package/dist/modules/order/sync_engine.d.ts.map +1 -1
  68. package/dist/modules/order/sync_engine.js +183 -10
  69. package/dist/modules/order/sync_engine.js.map +1 -1
  70. package/dist/modules/order/utils/math.d.ts +60 -14
  71. package/dist/modules/order/utils/math.d.ts.map +1 -1
  72. package/dist/modules/order/utils/math.js +87 -20
  73. package/dist/modules/order/utils/math.js.map +1 -1
  74. package/dist/modules/order/utils/order.d.ts +51 -1
  75. package/dist/modules/order/utils/order.d.ts.map +1 -1
  76. package/dist/modules/order/utils/order.js +118 -1
  77. package/dist/modules/order/utils/order.js.map +1 -1
  78. package/dist/modules/utils/errors.d.ts +22 -0
  79. package/dist/modules/utils/errors.d.ts.map +1 -1
  80. package/dist/modules/utils/errors.js +30 -0
  81. package/dist/modules/utils/errors.js.map +1 -1
  82. package/docs/COPY_ON_WRITE_MASTER_PLAN.md +1 -1
  83. package/docs/DEXBOT_COMPARISON.md +3 -7
  84. package/docs/EVOLUTION.md +8 -7
  85. package/docs/FUND_MOVEMENT_AND_ACCOUNTING.md +4 -9
  86. package/docs/LIFECYCLE.md +2 -2
  87. package/docs/README.md +9 -1
  88. package/docs/architecture.md +7 -7
  89. package/docs/developer_guide.md +4 -4
  90. package/market_adapter/README.md +1 -1
  91. 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
@@ -1286,11 +1286,11 @@ function formatPartialBroadcastSummary(err) {
1286
1286
  return `${broadcast ?? '?'}/${total ?? '?'} groups broadcast`;
1287
1287
  }
1288
1288
  /**
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
1289
+ * Execute a batch with retry-on-uncertain semantics, enforcing a gap-slot
1290
+ * per-broadcast operation cap (_getGapSlotBatchSize). When the batch carries
1291
+ * more operations than the cap, it is split into sequential broadcast chunks
1292
+ * of at most `maxOps` operations each, so a single on-chain transaction never
1293
+ * holds more than gapSlots order operations (the original "N fills
1294
1294
  * per broadcast" intent, applied at the op level rather than the fill level).
1295
1295
  *
1296
1296
  * Failure isolation — no swallowed orders: if one chunk's broadcast is
@@ -1316,12 +1316,11 @@ function formatPartialBroadcastSummary(err) {
1316
1316
  * @returns {Promise<{result: Object, opContexts: Array}>}
1317
1317
  */
1318
1318
  async function executeChunkedWithRetryOnUncertain(bot, operations, opContexts) {
1319
- const configuredMax = typeof bot._getMaxOpsPerBroadcast === 'function'
1319
+ const fromAccessor = typeof bot._getMaxOpsPerBroadcast === 'function'
1320
1320
  ? bot._getMaxOpsPerBroadcast()
1321
- : COW_PERFORMANCE?.MAX_OPS_PER_BROADCAST;
1322
- const maxOps = Number.isFinite(configuredMax) && configuredMax >= 1
1323
- ? Math.floor(configuredMax)
1324
- : 1;
1321
+ : (typeof bot._getGapSlotBatchSize === 'function' ? bot._getGapSlotBatchSize() : undefined);
1322
+ const requested = Number(fromAccessor);
1323
+ const maxOps = Number.isFinite(requested) && requested >= 1 ? Math.floor(requested) : 1;
1325
1324
  if (!Array.isArray(operations) || operations.length <= maxOps) {
1326
1325
  return await executeWithRetryOnUncertain(bot, operations, opContexts);
1327
1326
  }
@@ -1332,7 +1331,7 @@ async function executeChunkedWithRetryOnUncertain(bot, operations, opContexts) {
1332
1331
  opContexts: opContexts.slice(i, i + maxOps),
1333
1332
  });
1334
1333
  }
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');
1334
+ 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
1335
  const mergedOperationResults = [];
1337
1336
  const mergedRawResults = [];
1338
1337
  const mergedContexts = [];
@@ -1609,6 +1608,192 @@ function resolveIdealSizeForValidation(_bot, orderLike, fallbackSize = null) {
1609
1608
  function validateOrderSizeForExecution(bot, size, type, orderLike = null, fallbackSize = null) {
1610
1609
  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
1610
  }
1611
+ function checkGridPriceInvariant(slotId, price, genesis) {
1612
+ return orderUtils.checkGridPriceInvariant(slotId, price, genesis);
1613
+ }
1614
+ /**
1615
+ * Derive the authoritative price for a rotation destination slot.
1616
+ *
1617
+ * A rotation re-prices to the destination slot, so the destination's genesis
1618
+ * level is the authoritative price — not `action.newPrice`, which the planner
1619
+ * copies from the destination hole's `order.price` and which is therefore only
1620
+ * as sound as whatever last wrote that object. Deriving the emitted price here
1621
+ * means a planner bug cannot produce a mis-priced UPDATE on its own; the
1622
+ * invariant check remains as the backstop for the no-genesis case.
1623
+ *
1624
+ * @returns {number} the destination's genesis level, or NaN when there is no
1625
+ * genesis ladder / no parseable destination index (caller falls back to the
1626
+ * planned price, which is the migration case the checker fails open on).
1627
+ */
1628
+ function deriveRotationPrice(bot, newGridId) {
1629
+ try {
1630
+ const idx = parseSlotIndex(newGridId);
1631
+ if (idx === null || idx === undefined || !Number.isFinite(idx))
1632
+ return NaN;
1633
+ const genesis = bot?.manager?._genesis;
1634
+ if (!Array.isArray(genesis?.priceLevels) || genesis.priceLevels.length === 0)
1635
+ return NaN;
1636
+ const lvl = Number(math.priceForSlot(idx, genesis));
1637
+ return (Number.isFinite(lvl) && lvl > 0) ? lvl : NaN;
1638
+ }
1639
+ catch {
1640
+ return NaN;
1641
+ }
1642
+ }
1643
+ /**
1644
+ * Record one GRID-PRICE-INVARIANT check for an emitted order price.
1645
+ *
1646
+ * BLOCKING: returns false when the price is a genuine off-grid mismatch, so the
1647
+ * caller must SKIP the emission. A price that is not a slot's genesis level is
1648
+ * not a valid grid price, so placing it is the failure this guard exists to
1649
+ * prevent — rejecting is the point, not a side effect.
1650
+ *
1651
+ * Fails OPEN on anything unjudgeable (no genesis, unparseable id, non-finite
1652
+ * price, checker error): those are metadata problems, not off-grid prices, and
1653
+ * blocking on them would halt legitimate trading. Only 'off-grid-price' blocks.
1654
+ *
1655
+ * A missing slotId is counted as unchecked rather than falling back to another
1656
+ * id: for a rotation UPDATE the id must be the DESTINATION slot (newPrice is
1657
+ * that slot's price), so substituting the source would flag every legitimate
1658
+ * relocation. Callers pass the id they actually mean, or nothing.
1659
+ *
1660
+ * @returns {boolean} true when the caller MAY emit; false when it must skip
1661
+ */
1662
+ function recordGridPriceInvariantCheck(bot, slotId, price, stats, site) {
1663
+ try {
1664
+ if (slotId == null || slotId === '') {
1665
+ stats.unchecked++;
1666
+ return true;
1667
+ }
1668
+ const inv = checkGridPriceInvariant(slotId, price, bot?.manager?._genesis);
1669
+ if (inv.reason !== 'ok' && inv.reason !== 'off-grid-price') {
1670
+ stats.unchecked++;
1671
+ return true;
1672
+ }
1673
+ stats.checked++;
1674
+ if (inv.ok) {
1675
+ // A CLEAN check clears the run for this slot: escalation must mean
1676
+ // "rejected N consecutive batches", not "rejected N times ever".
1677
+ // Without this reset a slot rejected once an hour would accumulate
1678
+ // to the threshold over a day and fire a resync it never earned.
1679
+ const streakMap = getInvariantRejectStreak(bot);
1680
+ if (streakMap.has(String(slotId)))
1681
+ streakMap.delete(String(slotId));
1682
+ return true;
1683
+ }
1684
+ stats.violated++;
1685
+ orderUtils.reportGridPriceInvariant(bot?.manager, slotId, price, site);
1686
+ considerGridPriceInvariantEscalation(bot, slotId, price, inv, site);
1687
+ return false;
1688
+ }
1689
+ catch {
1690
+ // A checker failure must never block a broadcast.
1691
+ return true;
1692
+ }
1693
+ }
1694
+ /**
1695
+ * Per-slot count of CONSECUTIVE batches that rejected this slot's emission as
1696
+ * off-grid, scoped to the BOT rather than the module.
1697
+ *
1698
+ * Scope matters because the monolithic runtime (`dexbot.ts`, the `dexbot` bin)
1699
+ * constructs EVERY active bot in one process, so a module-level map would pool
1700
+ * unrelated bots' rejections: one bot rejecting a slot twice would leave the
1701
+ * next bot at the threshold on its FIRST rejection and fire a spurious
1702
+ * structural resync (reload, possibly a full grid reset) on a healthy bot.
1703
+ * Keeping it on the bot also keeps the count from outliving the resync that
1704
+ * repairs the slot, and lets tests start from a clean slate.
1705
+ *
1706
+ * @param {any} bot
1707
+ * @returns {Map<string, number>}
1708
+ */
1709
+ function getInvariantRejectStreak(bot) {
1710
+ if (!(bot?._gridPriceInvariantRejectStreak instanceof Map)) {
1711
+ bot._gridPriceInvariantRejectStreak = new Map();
1712
+ }
1713
+ return bot._gridPriceInvariantRejectStreak;
1714
+ }
1715
+ /**
1716
+ * Escalate a PERSISTENT off-grid rejection to a structural resync.
1717
+ *
1718
+ * A single rejection is handled correctly by skipping the emission and warning;
1719
+ * the next cycle re-plans. But if the corruption lives in-process (the planner
1720
+ * carries `candidate.price` straight from `manager.orders`), the next cycle
1721
+ * re-plans from the SAME bad `slot.price`, is rejected identically, and warns
1722
+ * again -- forever. The slot is dead while the bot looks healthy.
1723
+ *
1724
+ * After `GRID_PRICE_INVARIANT_RESYNC_THRESHOLD` consecutive rejecting batches
1725
+ * for one slot, ask for the structural resync that repairs it (loadGrid derives
1726
+ * slot prices from the genesis ladder). Fire-and-forget: the resync is already
1727
+ * debounced (`_structuralGridResyncRunning`/`Timer`) and batch-in-flight aware,
1728
+ * so repeats inside the cooldown are cheap and safe.
1729
+ *
1730
+ * @returns {number} the slot's current consecutive-rejection streak
1731
+ */
1732
+ function considerGridPriceInvariantEscalation(bot, slotId, price, inv, site) {
1733
+ const key = String(slotId);
1734
+ const streakMap = getInvariantRejectStreak(bot);
1735
+ const streak = (streakMap.get(key) || 0) + 1;
1736
+ streakMap.set(key, streak);
1737
+ const threshold = Number(constantsModule.TIMING?.GRID_PRICE_INVARIANT_RESYNC_THRESHOLD) > 0
1738
+ ? Number(constantsModule.TIMING.GRID_PRICE_INVARIANT_RESYNC_THRESHOLD)
1739
+ : 3;
1740
+ if (streak < threshold)
1741
+ return streak;
1742
+ if (typeof bot?.manager?.requestStructuralGridResync !== 'function') {
1743
+ bot?.manager?.logger?.log?.(`[GRID-PRICE-INVARIANT] ${slotId} rejected ${streak}x consecutively but ` +
1744
+ `requestStructuralGridResync is unavailable; slot stays unhealed until restart`, 'error');
1745
+ return streak;
1746
+ }
1747
+ // Dedicated cooldown key (NOT BOUNDARY_HOLD_RESYNC_COOLDOWN_MS: the watchdogs
1748
+ // must tune independently). Once per cooldown window is enough -- the streak
1749
+ // keeps counting so a later window escalates again if still unhealed.
1750
+ const cooldownMs = Number(constantsModule.TIMING?.GRID_PRICE_INVARIANT_RESYNC_COOLDOWN_MS) > 0
1751
+ ? Number(constantsModule.TIMING.GRID_PRICE_INVARIANT_RESYNC_COOLDOWN_MS)
1752
+ : 15 * 60 * 1000;
1753
+ const now = Date.now();
1754
+ const lastAt = Number(bot?._lastGridPriceInvariantResyncAt) || 0;
1755
+ if (now - lastAt < cooldownMs)
1756
+ return streak;
1757
+ bot._lastGridPriceInvariantResyncAt = now;
1758
+ const expected = inv?.expected != null ? Number(inv.expected) : NaN;
1759
+ const actual = Number(price);
1760
+ bot?.manager?.logger?.log?.(`[GRID-PRICE-INVARIANT] ${slotId} (${site}) rejected ${streak} consecutive batch(es) at ` +
1761
+ `${Number.isFinite(actual) ? Format.formatPrice6(actual) : 'n/a'} ` +
1762
+ `(genesis ${Number.isFinite(expected) ? Format.formatPrice6(expected) : 'n/a'}); ` +
1763
+ `requesting structural resync to repair the slot`, 'error');
1764
+ try {
1765
+ const res = bot.manager.requestStructuralGridResync('grid-price-invariant-violation', {
1766
+ slotId: String(slotId),
1767
+ expected: Number.isFinite(expected) ? expected : null,
1768
+ actual: Number.isFinite(actual) ? actual : null,
1769
+ site,
1770
+ streak,
1771
+ });
1772
+ res?.catch?.((err) => {
1773
+ bot.manager?.logger?.log?.(`[GRID-PRICE-INVARIANT] Structural resync request failed: ${getErrorMessage(err)}`, 'error');
1774
+ });
1775
+ }
1776
+ catch (err) {
1777
+ bot.manager?.logger?.log?.(`[GRID-PRICE-INVARIANT] Structural resync request failed: ${getErrorMessage(err)}`, 'error');
1778
+ }
1779
+ return streak;
1780
+ }
1781
+ /**
1782
+ * Emit the per-batch GRID-PRICE-INVARIANT summary. Quiet when the batch had no
1783
+ * checkable emission; warn when any off-grid emission was REJECTED, else info.
1784
+ * A rejected emission is logged per-site by reportGridPriceInvariant; this is
1785
+ * the aggregate so a batch that silently placed fewer orders than planned is
1786
+ * explained without reading every line.
1787
+ */
1788
+ function logGridPriceInvariantSummary(bot, stats, site) {
1789
+ try {
1790
+ if (!stats || stats.checked === 0)
1791
+ return;
1792
+ bot?.manager?.logger?.log?.(`[GRID-PRICE-INVARIANT] site=${site} checked=${stats.checked} ` +
1793
+ `violated=${stats.violated} unchecked=${stats.unchecked}`, stats.violated > 0 ? 'warn' : 'info');
1794
+ }
1795
+ catch { /* summary is best-effort */ }
1796
+ }
1612
1797
  /**
1613
1798
  * LAST-FILL-GUARD helper — pivot ± halfIncrement (replaces price-tolerance).
1614
1799
  * last fill @x with increment i: BUY < x*(1 - i/2/100), SELL > x*(1 + i/2/100)
@@ -1756,6 +1941,14 @@ function refreshLastFillPivotFromQueue(bot) {
1756
1941
  * Resolve the grid increment percent for the LAST-FILL guard in one place so
1757
1942
  * every check site and the batch summary use (and print) the same value.
1758
1943
  * Falls back to the default 0.5 when unset/invalid.
1944
+ *
1945
+ * CALL CONTRACT: this takes the BOT (`{ manager }`), not the increment or the
1946
+ * manager. The lookup order below is `bot.manager.config` first, which works
1947
+ * only because `OrderManager` exposes its own `config`. Callers that pass a
1948
+ * bare manager instead of a bot get the `bot.config` / DEFAULT_CONFIG
1949
+ * fallbacks and silently lose the manager's tuning, so pass the bot.
1950
+ * (The manager-first order is deliberate: the guard must use the same
1951
+ * increment the grid was built with, not whatever the bot-level config holds.)
1759
1952
  * @param {import('./dexbot_class.js').DEXBot} bot
1760
1953
  * @returns {number} Positive increment percent
1761
1954
  */
@@ -2074,11 +2267,20 @@ function applyRotationTransitionsToWorkingGrid(bot, workingGrid, executedContext
2074
2267
  * CANCELs modify existing orders and cannot be reliably distinguished from
2075
2268
  * "not yet visible" state by simple polling.
2076
2269
  *
2270
+ * @param {Object} [options]
2271
+ * @param {number} [options.maxPollRetries] - Poll attempts before reconciliation fallback
2272
+ * @param {number} [options.pollIntervalMs] - Delay between polls; tests pass 0
2273
+ * to skip the production 1.5s pacing without changing the poll semantics
2077
2274
  * @returns {{ allConfirmed: boolean, confirmed: Array, unconfirmed: Array }}
2078
2275
  */
2079
2276
  async function pollChainForConfirmation(bot, opContexts, options = {}) {
2080
2277
  const maxPollRetries = options.maxPollRetries || 4;
2081
- const pollIntervalMs = options.pollIntervalMs || 1500;
2278
+ // Test seam (see updateOrdersOnChainBatchCOW): bot._testPollIntervalMs
2279
+ // overrides the production pacing when set by the tests. An explicit 0 is
2280
+ // honored; null/undefined falls through to the production 1.5s default.
2281
+ const explicitPollMs = options.pollIntervalMs;
2282
+ const seamMs = bot?._testPollIntervalMs;
2283
+ const pollIntervalMs = resolveSeamMs(explicitPollMs, resolveSeamMs(seamMs, 1500));
2082
2284
  // Only CREATE operations can be confirmed by polling (they appear as new orders on chain)
2083
2285
  const createContexts = opContexts.filter((ctx) => ctx && ctx.kind === 'create' && ctx.finalInts && ctx.order);
2084
2286
  if (createContexts.length === 0) {
@@ -2300,7 +2502,7 @@ function restoreSkippedUpdateSlotsInWorkingGrid(bot, workingGrid, skippedSlotIds
2300
2502
  * skipped as already-consistent); handled=false when the caller must proceed
2301
2503
  * with the original plan.
2302
2504
  */
2303
- async function replanStaleBatch(bot, cowResult, replanDepth, preBroadcastGuard) {
2505
+ async function replanStaleBatch(bot, cowResult, replanDepth, preBroadcastGuard, seamPollIntervalMs) {
2304
2506
  const canReplan = replanDepth < STALE_PLAN_REPLAN_LIMIT
2305
2507
  && Array.isArray(cowResult.fills) && cowResult.fills.length > 0;
2306
2508
  if (!canReplan) {
@@ -2356,7 +2558,11 @@ async function replanStaleBatch(bot, cowResult, replanDepth, preBroadcastGuard)
2356
2558
  return {
2357
2559
  handled: true,
2358
2560
  result: await updateOrdersOnChainBatchCOW(bot, replanned, {
2359
- replanDepth: replanDepth + 1
2561
+ replanDepth: replanDepth + 1,
2562
+ // Carry the seam through the recursion explicitly: the inner
2563
+ // frame re-sets and re-restores it, so it must not depend on
2564
+ // the outer frame's side-channel value surviving.
2565
+ ...(seamPollIntervalMs != null ? { pollIntervalMs: seamPollIntervalMs } : {})
2360
2566
  }),
2361
2567
  };
2362
2568
  }
@@ -2710,8 +2916,58 @@ async function runPreBroadcastGuards(bot, cowResult) {
2710
2916
  }
2711
2917
  return { proceed: true, crossingCandidates, intraBatchCandidates };
2712
2918
  }
2919
+ function restoreTestPollIntervalSeam(bot, prev) {
2920
+ try {
2921
+ if (prev === undefined)
2922
+ delete bot._testPollIntervalMs;
2923
+ else
2924
+ bot._testPollIntervalMs = prev;
2925
+ }
2926
+ catch { /* seam restore must never break the batch */ }
2927
+ }
2713
2928
  async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2714
2929
  const replanDepth = Number.isFinite(Number(options?.replanDepth)) ? Number(options.replanDepth) : 0;
2930
+ // Test seam: options.pollIntervalMs overrides the production 1.5s pacing
2931
+ // in pollChainForConfirmation (missing-create path below) so tests do
2932
+ // not sleep on wall-clock time. Held on the bot only for the duration of
2933
+ // this call (see the wrapper's finally) so the inner missing-create
2934
+ // branch and any re-plan recursion pick it up without changing the
2935
+ // production call signature used by the runtime.
2936
+ const seamPollIntervalMs = resolveSeamMsOrNull(options?.pollIntervalMs);
2937
+ const prevSeamPollIntervalMs = bot?._testPollIntervalMs;
2938
+ if (seamPollIntervalMs != null) {
2939
+ bot._testPollIntervalMs = seamPollIntervalMs;
2940
+ }
2941
+ // Expose the resolved interval using the same precedence the missing-create
2942
+ // poll path applies (explicit option > bot seam > production 1500ms).
2943
+ // Recorded in the wrapper rather than in pollChainForConfirmation so it is
2944
+ // observable on every batch exit, including the pre-broadcast guard
2945
+ // refusals that never reach the poll (a `||`-vs-`??` regression here is
2946
+ // otherwise invisible: the only effect is a slower poll).
2947
+ bot._lastResolvedPollIntervalMs = resolveSeamMs(seamPollIntervalMs, resolveSeamMs(bot?._testPollIntervalMs, 1500));
2948
+ // The seam override must not survive this call: every exit path (dry run,
2949
+ // entry/pre-broadcast single-flight aborts, guard refusals, re-plan
2950
+ // recursion, throws) funnels through the body() finally below, so no exit
2951
+ // can leak bot._testPollIntervalMs onto the bot.
2952
+ try {
2953
+ return await updateOrdersOnChainBatchCOWBody(bot, cowResult, replanDepth, seamPollIntervalMs ?? undefined);
2954
+ }
2955
+ finally {
2956
+ // Restore only when this frame actually wrote a seam. The entry
2957
+ // single-flight await (inside body()) lets a second, seam-less batch
2958
+ // run concurrently: an unguarded restore would delete the seam owned
2959
+ // by an in-flight sibling call (captured prev === undefined), and that
2960
+ // sibling's pollChainForConfirmation — which reads the bot field only,
2961
+ // since it takes no pollIntervalMs option at its call sites — would
2962
+ // silently fall back to the production 1500ms pacing. Guarding keeps
2963
+ // the write-ownership scoped to this call; the saved prev still makes
2964
+ // the nested re-plan recursion LIFO-correct.
2965
+ if (seamPollIntervalMs != null) {
2966
+ restoreTestPollIntervalSeam(bot, prevSeamPollIntervalMs);
2967
+ }
2968
+ }
2969
+ }
2970
+ async function updateOrdersOnChainBatchCOWBody(bot, cowResult, replanDepth, seamPollIntervalMs) {
2715
2971
  bot._currentCycleId = (Number.isFinite(Number(bot._currentCycleId)) ? Number(bot._currentCycleId) : 0) + 1;
2716
2972
  const { workingGrid, workingIndexes, workingBoundary, actions } = cowResult;
2717
2973
  // Boundary-hold value: computed pre-broadcast after the skip-restore and
@@ -2798,7 +3054,11 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2798
3054
  // Per-batch LAST-FILL-GUARD disposition counters. Per-action pass lines
2799
3055
  // would spam big batches, so the guard emits one batch summary instead
2800
3056
  // (see the summary after the action loop below).
2801
- const lastFillGuardStats = { checked: 0, passed: 0, skipped: 0, bypassed: 0 };
3057
+ const lastFillGuardStats = { checked: 0, passed: 0, skipped: 0, bypassed: 0, pivotOffGrid: 0 };
3058
+ // Per-batch GRID-PRICE-INVARIANT counters (BLOCKING). Tracks emitted prices
3059
+ // that are not the genesis level for their slot — such emissions are
3060
+ // rejected, not placed. One batch summary.
3061
+ const gridPriceInvariantStats = { checked: 0, violated: 0, unchecked: 0 };
2802
3062
  // Whether any guard check in this batch refreshed the pivot from
2803
3063
  // still-queued fills — reported in the batch summary so a pivot change
2804
3064
  // that altered a guard decision is visible at info, not just debug.
@@ -2810,7 +3070,7 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2810
3070
  const clampedUpdateSlotIds = new Set();
2811
3071
  // orderId -> operations index of its cancel op. A crossing re-pricing
2812
3072
  // 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,
3073
+ // at an earlier position: ops broadcast in gap-slot-sized chunks,
2814
3074
  // so an earlier index means the cancel confirms on chain (same or earlier
2815
3075
  // chunk, applied sequentially) before the crossing order lands.
2816
3076
  const cancelOpIndexByOrderId = new Map();
@@ -2890,13 +3150,21 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2890
3150
  const priceDrift = Number.isFinite(plannedPrice) && Number.isFinite(livePrice)
2891
3151
  ? Math.abs(livePrice - plannedPrice)
2892
3152
  : 0;
2893
- const effectiveOrder = (priceDrift > 0)
2894
- ? { ...order, price: livePrice, size: order.size, type: order.type }
2895
- : order;
3153
+ // The planned price is emitted as-is. A pre-broadcast
3154
+ // substitution with liveSlot.price used to rewrite it here
3155
+ // at `debug` level, which meant a slot whose price had been
3156
+ // mutated off its genesis level got re-broadcast under a
3157
+ // different number than the plan validated (crossing,
3158
+ // collision and last-fill guards all ran on `createPrice`).
3159
+ // slot.price is derived from the genesis ladder, not
3160
+ // authoritative, so a divergence is a signal to report —
3161
+ // never a value to adopt. Reported at `warn`: a divergence
3162
+ // here is a writer bug, not routine freshness.
3163
+ const effectiveOrder = order;
2896
3164
  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');
3165
+ bot.manager.logger.log(`[COW] Pre-broadcast price drift on slot ${order.id}: ` +
3166
+ `planned=${plannedPrice} live=${livePrice} (diff=${priceDrift}); ` +
3167
+ `emitting planned price (live slot price is derived from genesis and is not authoritative).`, 'warn');
2900
3168
  }
2901
3169
  const createPrice = effectiveOrder.price;
2902
3170
  const precision = order.type === ORDER_TYPES.SELL ? bot.manager.assets.assetA.precision : bot.manager.assets.assetB.precision;
@@ -2912,13 +3180,14 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2912
3180
  }
2913
3181
  // CROSSING-PLACEMENT GUARD (create variant): the batch-level
2914
3182
  // 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).
3183
+ // simulated the PLANNED price. That is now the price emitted
3184
+ // (the pre-broadcast substitution was removed), but the guard
3185
+ // is retained as defence-in-depth: it re-checks on the FINAL
3186
+ // price against live and chain-side orders not already
3187
+ // cancelled at an earlier op position — an opposite-side
3188
+ // order cancelled in a later chunk would otherwise coexist
3189
+ // with this create mid-broadcast and self-trade (production
3190
+ // incident class).
2922
3191
  const createCrossed = findCrossedOrder(crossingCandidates, createPrice, order.type, bot.manager.assets, (o) => orderUtils.isCrossingCheckCandidate(o, null, cancelOpIndexByOrderId));
2923
3192
  const intraBatchCrossed = createCrossed ? null : findCrossedOrder(intraBatchCandidates, createPrice, order.type, bot.manager.assets);
2924
3193
  const effectiveCrossed = createCrossed || intraBatchCrossed;
@@ -2959,6 +3228,16 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2959
3228
  }
2960
3229
  catch (_e) { /* guard is best-effort */ }
2961
3230
  const args = buildCreateOrderArgs(effectiveOrder, assetA, assetB);
3231
+ // GRID-PRICE-INVARIANT (blocking): the CREATE price must be
3232
+ // the genesis level for this slot. A mismatch means state
3233
+ // corruption upstream, so the emission is skipped rather
3234
+ // than placed — the next reconcile cycle re-plans. See
3235
+ // docs/GRID_PRICE_INVARIANT.md.
3236
+ if (!recordGridPriceInvariantCheck(bot, order.id, createPrice, gridPriceInvariantStats, 'CREATE')) {
3237
+ if (order.id)
3238
+ skippedCreateSlotIds.add(order.id);
3239
+ continue;
3240
+ }
2962
3241
  const buildResult = await chainOrders.buildCreateOrderOp(bot.account, args.amountToSell, args.sellAssetId, args.minToReceive, args.receiveAssetId, null);
2963
3242
  if (!buildResult) {
2964
3243
  bot.manager.logger.log(`Skipping create op for ${action.id}: amounts would round to 0 on blockchain`, 'warn');
@@ -2985,9 +3264,29 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2985
3264
  if (action.newGridId && action.newGridId !== action.id) {
2986
3265
  const masterOrder = bot.manager.orders.get(action.id);
2987
3266
  const orderType = action.order?.type || masterOrder?.type;
2988
- const newPrice = Number.isFinite(Number(action.newPrice))
3267
+ // ROTATION PRICE IS THE DESTINATION'S GENESIS LEVEL.
3268
+ //
3269
+ // A rotation re-prices to the destination slot, so the
3270
+ // destination's level is the authoritative price — not
3271
+ // action.newPrice, which the planner copies from
3272
+ // hole.order.price and which is therefore only as sound
3273
+ // as whatever wrote that object. Deriving it here means a
3274
+ // planner bug cannot produce a mis-priced UPDATE even if
3275
+ // the invariant check below were bypassed; the check
3276
+ // stays as the backstop that catches a missing genesis
3277
+ // ladder (where it fails open) rather than the only
3278
+ // thing standing between a bad plan and a live order.
3279
+ const derivedNewPrice = deriveRotationPrice(bot, action.newGridId);
3280
+ const plannedNewPrice = Number.isFinite(Number(action.newPrice))
2989
3281
  ? Number(action.newPrice)
2990
3282
  : Number(action.order?.price);
3283
+ let newPrice = Number.isFinite(derivedNewPrice) ? derivedNewPrice : plannedNewPrice;
3284
+ if (Number.isFinite(derivedNewPrice) && Number.isFinite(plannedNewPrice)
3285
+ && Math.abs(derivedNewPrice - plannedNewPrice) > Math.max(1e-12, Math.abs(derivedNewPrice) * 1e-9)) {
3286
+ bot.manager.logger.log(`[GRID-PRICE-INVARIANT] Rotation ${action.id} -> ${action.newGridId}: planned price ${Format.formatPrice6(plannedNewPrice)} ` +
3287
+ `differs from the destination's genesis level ${Format.formatPrice6(derivedNewPrice)} — ` +
3288
+ `emitting the genesis level`, 'warn');
3289
+ }
2991
3290
  const newSize = plannedUpdateSize(action);
2992
3291
  if (!masterOrder || !action.orderId || !orderType || !Number.isFinite(newPrice) || newSize <= 0) {
2993
3292
  continue;
@@ -3194,6 +3493,20 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
3194
3493
  }
3195
3494
  catch (_e) { /* best-effort */ }
3196
3495
  const { amountToSell, minToReceive } = buildCreateOrderArgs({ type: orderType, size: newSize, price: newPrice }, assetA, assetB);
3496
+ // GRID-PRICE-INVARIANT (blocking): the emitted price
3497
+ // is action.newPrice, which every planner derives from the
3498
+ // object named by action.newGridId (verified: grid.ts:1867,
3499
+ // utils/system.ts:1195, validate.ts:600/746). Pass the
3500
+ // destination id only — no source fallback, so a missing id
3501
+ // counts as unchecked instead of checking the wrong slot.
3502
+ if (!recordGridPriceInvariantCheck(bot, action.newGridId, newPrice, gridPriceInvariantStats, 'UPDATE')) {
3503
+ skippedUpdateCount++;
3504
+ if (action.id)
3505
+ skippedUpdateSlotIds.add(action.id);
3506
+ if (action.newGridId)
3507
+ skippedUpdateSlotIds.add(action.newGridId);
3508
+ continue;
3509
+ }
3197
3510
  const buildResult = await chainOrders.buildUpdateOrderOp(bot.account, action.orderId, { amountToSell, minToReceive, newPrice, orderType }, masterOrder.rawOnChain || null);
3198
3511
  if (!buildResult) {
3199
3512
  skippedUpdateCount++;
@@ -3261,10 +3574,14 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
3261
3574
  const priceDrift = Number.isFinite(plannedPrice) && Number.isFinite(livePrice)
3262
3575
  ? Math.abs(livePrice - plannedPrice)
3263
3576
  : 0;
3264
- const fbPrice = (priceDrift > 0) ? livePrice : plannedPrice;
3577
+ const fbPrice = plannedPrice;
3265
3578
  if (priceDrift > 0) {
3579
+ // Same rationale as the primary CREATE path: emit
3580
+ // the planned price and report the divergence
3581
+ // rather than adopting a derived live price.
3266
3582
  bot.manager.logger.log(`[COW] CREATE fallback price drift for ${action.id} -> ${targetSlotId}: ` +
3267
- `planned=${plannedPrice} live=${livePrice} (diff=${priceDrift})`, 'debug');
3583
+ `planned=${plannedPrice} live=${livePrice} (diff=${priceDrift}); ` +
3584
+ `emitting planned price.`, 'warn');
3268
3585
  }
3269
3586
  const sizeCheck = validateOrderSizeForExecution(bot, fbSize, fbType, fbOrder, fbSize);
3270
3587
  if (!sizeCheck.isValid) {
@@ -3321,6 +3638,10 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
3321
3638
  }
3322
3639
  catch (_fbGuardErr) { /* guard is best-effort */ }
3323
3640
  const fbArgs = buildCreateOrderArgs({ type: fbType, size: fbSize, price: fbPrice }, assetA, assetB);
3641
+ // GRID-PRICE-INVARIANT (blocking): fallback CREATE.
3642
+ if (!recordGridPriceInvariantCheck(bot, targetSlotId, fbPrice, gridPriceInvariantStats, 'CREATE-FALLBACK')) {
3643
+ continue;
3644
+ }
3324
3645
  const fbResult = await chainOrders.buildCreateOrderOp(bot.account, fbArgs.amountToSell, fbArgs.sellAssetId, fbArgs.minToReceive, fbArgs.receiveAssetId, null);
3325
3646
  if (fbResult) {
3326
3647
  operations.push(fbResult.op);
@@ -3376,13 +3697,35 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
3376
3697
  ? `active+bypassed(${batchOrigin || 'unknown'})`
3377
3698
  : 'active');
3378
3699
  const pivotStr = cold ? 'none' : `${Format.formatPrice6(Number(sumPivotRaw))}(${sumType})`;
3379
- bot.manager.logger.log(`[LAST-FILL-GUARD] mode=${mode} pivot=${pivotStr} inc=${sumInc}% ` +
3700
+ // Report when the pivot used for this batch was NOT a ladder
3701
+ // level: that is the precondition for a pivoted ratchet, and
3702
+ // the value alone does not reveal it.
3703
+ const batchPivot = cold ? null : resolveOnGridPivot(bot.manager, sumPivotRaw);
3704
+ const pivotGridStr = cold
3705
+ ? ''
3706
+ : (batchPivot && batchPivot.idx != null
3707
+ ? ` pivotSlot=${batchPivot.idx}${batchPivot.snapped ? '(snapped)' : ''}`
3708
+ : ' pivotOffGrid=true');
3709
+ bot.manager.logger.log(`[LAST-FILL-GUARD] mode=${mode} pivot=${pivotStr}${pivotGridStr} inc=${sumInc}% ` +
3380
3710
  `pivotRefreshed=${lastFillGuardPivotRefreshed} ` +
3381
3711
  `checked=${lastFillGuardStats.checked} passed=${lastFillGuardStats.passed} ` +
3382
- `skipped=${lastFillGuardStats.skipped} bypassed=${lastFillGuardStats.bypassed}`, cold ? 'warn' : 'info');
3712
+ `skipped=${lastFillGuardStats.skipped} bypassed=${lastFillGuardStats.bypassed}` +
3713
+ // Per-action off-ladder count. The batch-level
3714
+ // `pivotOffGrid=true` flag above only says the pivot was
3715
+ // off-grid; this says HOW MANY guarded probes judged a
3716
+ // placement against an unsnapped pivot, which is the
3717
+ // quantity that grows during the ratchet this guard
3718
+ // exists to catch. Omitted (not `0`) when never set, so
3719
+ // "no off-grid probes" stays distinguishable from
3720
+ // "counter unavailable".
3721
+ (Number(lastFillGuardStats.pivotOffGrid) > 0
3722
+ ? ` pivotOffGrid=${lastFillGuardStats.pivotOffGrid}`
3723
+ : ''), cold || (batchPivot && batchPivot.idx == null) ? 'warn' : 'info');
3383
3724
  }
3384
3725
  }
3385
3726
  catch { /* summary is best-effort */ }
3727
+ // Batch-level GRID-PRICE-INVARIANT summary (rejected emissions).
3728
+ logGridPriceInvariantSummary(bot, gridPriceInvariantStats, 'COW');
3386
3729
  if (skippedUpdateCount > 0) {
3387
3730
  restoreSkippedUpdateSlotsInWorkingGrid(bot, workingGrid, skippedUpdateSlotIds, skippedUpdateCount);
3388
3731
  }
@@ -3466,7 +3809,7 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
3466
3809
  });
3467
3810
  if (!preBroadcastGuard.canCommit) {
3468
3811
  // Bounded re-plan + proceed — policy documented on replanStaleBatch.
3469
- const replan = await replanStaleBatch(bot, cowResult, replanDepth, preBroadcastGuard);
3812
+ const replan = await replanStaleBatch(bot, cowResult, replanDepth, preBroadcastGuard, seamPollIntervalMs);
3470
3813
  if (replan.handled) {
3471
3814
  return replan.result;
3472
3815
  }
@@ -3997,6 +4340,68 @@ async function recoverRefusedCommit(bot, chainOrders, logPrefix, adoptOpts, cont
3997
4340
  await persistGridAndClearPendingBroadcasts(bot, logPrefix);
3998
4341
  return successReturn ?? { executed: false, hadRotation: false, commitRefused: true, ...extraReturn };
3999
4342
  }
4343
+ /**
4344
+ * Resolve the LAST-FILL guard pivot to an ON-GRID price.
4345
+ *
4346
+ * The pivot was a raw fill price, and nothing validated it. That made it a
4347
+ * ratchet input: any bad pivot (a corrupt fill price, an adopted off-grid
4348
+ * order) shifted every subsequent threshold, so an off-market buy could read
4349
+ * as legitimate. A price is only meaningful relative to the ladder it trades
4350
+ * on, so the pivot is converted to its nearest slot index and back to that
4351
+ * slot's genesis price: the result is by construction a real grid level and
4352
+ * cannot drift off the ladder.
4353
+ *
4354
+ * Falls back to the raw price when genesis is unavailable (pre-genesis
4355
+ * startup), so the guard degrades to its previous behaviour rather than
4356
+ * silently disabling. Never throws.
4357
+ *
4358
+ * @param {any} manager
4359
+ * @param {number|null|undefined} rawPrice
4360
+ * @returns {{price: number|null, snapped: boolean, idx: number|null, nearestDrift: number|null}}
4361
+ */
4362
+ function resolveOnGridPivot(manager, rawPrice) {
4363
+ const price = Number(rawPrice);
4364
+ if (!Number.isFinite(price) || price <= 0)
4365
+ return { price: null, snapped: false, idx: null, nearestDrift: null };
4366
+ try {
4367
+ const genesis = manager?._genesis;
4368
+ const levels = genesis?.priceLevels;
4369
+ if (!Array.isArray(levels) || levels.length === 0)
4370
+ return { price, snapped: false, idx: null, nearestDrift: null };
4371
+ const idx = math.slotIndexForPrice(price, genesis);
4372
+ if (!Number.isFinite(idx))
4373
+ return { price, snapped: false, idx: null, nearestDrift: null };
4374
+ // Only accept a genuine ladder level and only a nearest match. A price
4375
+ // that is wildly off-ladder (an adopted orphan far outside the grid)
4376
+ // must NOT be silently rewritten onto an edge slot -- that would make a
4377
+ // bad pivot look like a legitimate grid fill. Leave it snapped=false and
4378
+ // let the caller decide.
4379
+ const candidate = Number(math.priceForSlot(idx, genesis));
4380
+ if (!Number.isFinite(candidate) || candidate <= 0)
4381
+ return { price, snapped: false, idx: null, nearestDrift: null };
4382
+ // Reject a snap that moves the pivot by more than one increment: any
4383
+ // in-grid fill is within half an increment of its slot price, so a
4384
+ // larger move means the pivot itself is not a real fill price.
4385
+ const drift = Math.abs(candidate - price) / price;
4386
+ // resolveLastFillGuardIncrement takes a BOT-shaped argument and reads
4387
+ // `bot.manager.config` first. resolveOnGridPivot only ever has the
4388
+ // manager (no bot in scope), so wrap it to satisfy that contract.
4389
+ // Passing the manager directly would silently skip its own tuning and
4390
+ // fall through to DEFAULT_CONFIG, making the snap tolerance wrong for
4391
+ // any grid not built on the default increment.
4392
+ const increment = resolveLastFillGuardIncrement({ manager }) / 100;
4393
+ const maxDrift = Number.isFinite(increment) && increment > 0 ? increment : 0.005;
4394
+ // nearestDrift is reported even when the snap is refused, so a caller
4395
+ // can tell a near-miss (rounding, one increment out) from a price that
4396
+ // is nowhere near the ladder (a genuinely corrupt pivot).
4397
+ if (drift > maxDrift)
4398
+ return { price, snapped: false, idx: null, nearestDrift: drift };
4399
+ return { price: candidate, snapped: drift > 0, idx, nearestDrift: drift };
4400
+ }
4401
+ catch {
4402
+ return { price, snapped: false, idx: null, nearestDrift: null };
4403
+ }
4404
+ }
4000
4405
  /**
4001
4406
  * Run the last-fill guard probe: optionally refresh the pivot from
4002
4407
  * still-queued fills, read the durable pivot, and evaluate
@@ -4027,7 +4432,38 @@ function runLastFillGuardCheck(bot, price, size, type, stats, skipRefresh = fals
4027
4432
  const lastPrice = bot.manager?._lastFilledPrice;
4028
4433
  const lastType = bot.manager?._lastFilledType;
4029
4434
  const inc = resolveLastFillGuardIncrement(bot);
4030
- const check = isLastFillGuardBlocked(price, size, type, lastPrice, lastType, inc);
4435
+ // Validate the pivot onto the ladder before use (see resolveOnGridPivot).
4436
+ // Reported once per probe at warn when the raw pivot was NOT a grid level:
4437
+ // that is the ratchet precondition, and it is otherwise invisible because
4438
+ // the pivot is only ever logged by value.
4439
+ const onGrid = resolveOnGridPivot(bot.manager, lastPrice);
4440
+ if (lastPrice != null && onGrid.idx == null && Number.isFinite(Number(lastPrice))) {
4441
+ try {
4442
+ // A persistently off-ladder pivot is exactly the corruption case, so
4443
+ // this branch would otherwise warn once per guarded action per batch
4444
+ // on top of the batch summary (which already reports pivotOffGrid).
4445
+ // Warn once per distinct pivot value per batch: repeated identical
4446
+ // pivots are the same condition, and a CHANGED pivot still warns.
4447
+ const warnedKey = `lastFillPivotWarned:${bot?._currentCycleId ?? 'na'}`;
4448
+ const alreadyWarned = bot[warnedKey];
4449
+ if (alreadyWarned !== Number(lastPrice)) {
4450
+ bot[warnedKey] = Number(lastPrice);
4451
+ // Distinguish "close to a level but too far to snap" from "nowhere
4452
+ // near the ladder". The former is a rounding/drift artifact; the
4453
+ // latter means the pivot itself is not a real fill price.
4454
+ const far = onGrid.nearestDrift == null || onGrid.nearestDrift > 0.02;
4455
+ const kind = far ? 'off-ladder' : 'near-ladder'
4456
+ + (onGrid.nearestDrift != null ? ` (${(onGrid.nearestDrift * 100).toFixed(4)}% from the nearest level)` : '');
4457
+ bot.manager?.logger?.log?.(`[LAST-FILL-GUARD] Pivot ${Format.formatPrice6(Number(lastPrice))} is not a grid level ` +
4458
+ `(${kind}); using raw value. A non-grid pivot can ` +
4459
+ `misjudge which placements are off-market. ` +
4460
+ `(reported once per batch per pivot value)`, 'warn');
4461
+ }
4462
+ }
4463
+ catch { /* logging is best-effort */ }
4464
+ stats.pivotOffGrid = (stats.pivotOffGrid || 0) + 1;
4465
+ }
4466
+ const check = isLastFillGuardBlocked(price, size, type, onGrid.price, lastType, inc);
4031
4467
  stats.checked++;
4032
4468
  return { check, refreshed };
4033
4469
  }
@@ -4273,7 +4709,7 @@ async function processBatchResults(bot, result, opContexts) {
4273
4709
  updateOperationCount
4274
4710
  };
4275
4711
  }
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 };
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 };
4277
4713
  // Exported for regression tests (issue #23 sibling): the uncertain-broadcast
4278
4714
  // discard path must never drop a placement silently when master lost the slot.
4279
4715
  export { restoreDiscardedCreates };