dexbot 1.5.2 → 1.6.0

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 (240) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +4 -3
  3. package/analysis/README.md +1 -2
  4. package/analysis/ama_fitting/fetch_lp_candles.ts +1 -9
  5. package/analysis/ama_fitting/package.json +1 -1
  6. package/analysis/analyze_derivatives.ts +1 -1
  7. package/analysis/bot_usage/discover_bot_accounts.ts +12 -6
  8. package/analysis/resolve_source.ts +11 -1
  9. package/analysis/results/ama_sweep_results_lp_pool_133_1h.json +2455 -0
  10. package/analysis/results/bot_fitting_results_lp_pool_133_1h.json +218 -0
  11. package/analysis/tradingview/README.md +32 -10
  12. package/analysis/tradingview/analyze_tradingview.ts +118 -3
  13. package/analysis/tradingview/h-bts_tradingview.html +1570 -0
  14. package/analysis/tradingview/t-bts_tradingview.html +1570 -0
  15. package/analysis/tradingview/tradingview_uplot_chart_generator.ts +1392 -125
  16. package/analysis/trend_detection/package.json +1 -1
  17. package/claw/docs/DEXBOT2_TUNING_CHEAT_SHEET.md +1 -1
  18. package/claw/modules/dexbot_profiles.ts +22 -2
  19. package/claw/package.json +1 -1
  20. package/claw/runtimes/openclaw-plugin/openclaw.plugin.json +1 -1
  21. package/claw/runtimes/openclaw-plugin/package.json +1 -1
  22. package/claw/skills/launcher-ops/references/launcher-workflow.md +1 -1
  23. package/claw/tests/test_claw_mcp_transport.ts +2 -2
  24. package/claw/tests/test_dexbot_profiles.ts +65 -0
  25. package/dist/analysis/ama_fitting/fetch_lp_candles.js +1 -9
  26. package/dist/analysis/ama_fitting/fetch_lp_candles.js.map +1 -1
  27. package/dist/analysis/analyze_derivatives.js +1 -1
  28. package/dist/analysis/bot_usage/discover_bot_accounts.js +11 -6
  29. package/dist/analysis/bot_usage/discover_bot_accounts.js.map +1 -1
  30. package/dist/analysis/bot_usage/kibana_bot_queries.d.ts +2 -0
  31. package/dist/analysis/bot_usage/kibana_bot_queries.d.ts.map +1 -1
  32. package/dist/analysis/resolve_source.d.ts +1 -0
  33. package/dist/analysis/resolve_source.d.ts.map +1 -1
  34. package/dist/analysis/resolve_source.js +8 -1
  35. package/dist/analysis/resolve_source.js.map +1 -1
  36. package/dist/analysis/tradingview/analyze_tradingview.d.ts +8 -0
  37. package/dist/analysis/tradingview/analyze_tradingview.d.ts.map +1 -1
  38. package/dist/analysis/tradingview/analyze_tradingview.js +137 -3
  39. package/dist/analysis/tradingview/analyze_tradingview.js.map +1 -1
  40. package/dist/analysis/tradingview/tradingview_uplot_chart_generator.d.ts +5 -1
  41. package/dist/analysis/tradingview/tradingview_uplot_chart_generator.d.ts.map +1 -1
  42. package/dist/analysis/tradingview/tradingview_uplot_chart_generator.js +1387 -125
  43. package/dist/analysis/tradingview/tradingview_uplot_chart_generator.js.map +1 -1
  44. package/dist/bot.js +1 -1
  45. package/dist/bot.js.map +1 -1
  46. package/dist/dexbot.js +3 -3
  47. package/dist/dexbot.js.map +1 -1
  48. package/dist/market_adapter/ama_signal_runner.js +2 -2
  49. package/dist/market_adapter/candle_utils.d.ts +6 -0
  50. package/dist/market_adapter/candle_utils.d.ts.map +1 -1
  51. package/dist/market_adapter/candle_utils.js +6 -0
  52. package/dist/market_adapter/candle_utils.js.map +1 -1
  53. package/dist/market_adapter/core/asymmetric_bounds.d.ts +33 -1
  54. package/dist/market_adapter/core/asymmetric_bounds.d.ts.map +1 -1
  55. package/dist/market_adapter/core/asymmetric_bounds.js +38 -1
  56. package/dist/market_adapter/core/asymmetric_bounds.js.map +1 -1
  57. package/dist/market_adapter/core/kibana_candles.d.ts +17 -1
  58. package/dist/market_adapter/core/kibana_candles.d.ts.map +1 -1
  59. package/dist/market_adapter/core/kibana_candles.js +101 -19
  60. package/dist/market_adapter/core/kibana_candles.js.map +1 -1
  61. package/dist/market_adapter/core/kibana_client.d.ts +2 -0
  62. package/dist/market_adapter/core/kibana_client.d.ts.map +1 -1
  63. package/dist/market_adapter/core/kibana_client.js +31 -4
  64. package/dist/market_adapter/core/kibana_client.js.map +1 -1
  65. package/dist/market_adapter/core/market_adapter_service.d.ts.map +1 -1
  66. package/dist/market_adapter/core/market_adapter_service.js +0 -6
  67. package/dist/market_adapter/core/market_adapter_service.js.map +1 -1
  68. package/dist/market_adapter/inputs/fetch_book_data.d.ts +14 -0
  69. package/dist/market_adapter/inputs/fetch_book_data.d.ts.map +1 -0
  70. package/dist/market_adapter/inputs/fetch_book_data.js +138 -0
  71. package/dist/market_adapter/inputs/fetch_book_data.js.map +1 -0
  72. package/dist/market_adapter/inputs/fetch_cex_synthetic_data.js +1 -1
  73. package/dist/market_adapter/inputs/fetch_lp_data.d.ts +16 -2
  74. package/dist/market_adapter/inputs/fetch_lp_data.d.ts.map +1 -1
  75. package/dist/market_adapter/inputs/fetch_lp_data.js +109 -281
  76. package/dist/market_adapter/inputs/fetch_lp_data.js.map +1 -1
  77. package/dist/market_adapter/inputs/kibana_feed_source.d.ts +87 -0
  78. package/dist/market_adapter/inputs/kibana_feed_source.d.ts.map +1 -0
  79. package/dist/market_adapter/inputs/kibana_feed_source.js +570 -0
  80. package/dist/market_adapter/inputs/kibana_feed_source.js.map +1 -0
  81. package/dist/market_adapter/inputs/kibana_source.d.ts.map +1 -1
  82. package/dist/market_adapter/inputs/kibana_source.js +10 -5
  83. package/dist/market_adapter/inputs/kibana_source.js.map +1 -1
  84. package/dist/market_adapter/inputs/window_cache.d.ts +166 -0
  85. package/dist/market_adapter/inputs/window_cache.d.ts.map +1 -0
  86. package/dist/market_adapter/inputs/window_cache.js +500 -0
  87. package/dist/market_adapter/inputs/window_cache.js.map +1 -0
  88. package/dist/market_adapter/interval_utils.d.ts +8 -1
  89. package/dist/market_adapter/interval_utils.d.ts.map +1 -1
  90. package/dist/market_adapter/interval_utils.js +14 -1
  91. package/dist/market_adapter/interval_utils.js.map +1 -1
  92. package/dist/modules/account_bots.d.ts +1 -0
  93. package/dist/modules/account_bots.d.ts.map +1 -1
  94. package/dist/modules/account_bots.js +21 -5
  95. package/dist/modules/account_bots.js.map +1 -1
  96. package/dist/modules/account_orders.d.ts +25 -4
  97. package/dist/modules/account_orders.d.ts.map +1 -1
  98. package/dist/modules/account_orders.js +67 -4
  99. package/dist/modules/account_orders.js.map +1 -1
  100. package/dist/modules/bitshares-native/chain_client.d.ts +2 -0
  101. package/dist/modules/bitshares-native/chain_client.d.ts.map +1 -1
  102. package/dist/modules/bitshares-native/chain_client.js +3 -1
  103. package/dist/modules/bitshares-native/chain_client.js.map +1 -1
  104. package/dist/modules/bitshares-native/subscriptions.d.ts.map +1 -1
  105. package/dist/modules/bitshares-native/subscriptions.js +13 -3
  106. package/dist/modules/bitshares-native/subscriptions.js.map +1 -1
  107. package/dist/modules/bitshares-native/transport.d.ts +2 -0
  108. package/dist/modules/bitshares-native/transport.d.ts.map +1 -1
  109. package/dist/modules/bitshares-native/transport.js +122 -28
  110. package/dist/modules/bitshares-native/transport.js.map +1 -1
  111. package/dist/modules/bitshares_client.d.ts.map +1 -1
  112. package/dist/modules/bitshares_client.js +59 -8
  113. package/dist/modules/bitshares_client.js.map +1 -1
  114. package/dist/modules/bot_settings.d.ts.map +1 -1
  115. package/dist/modules/bot_settings.js +11 -0
  116. package/dist/modules/bot_settings.js.map +1 -1
  117. package/dist/modules/constants.d.ts +13 -0
  118. package/dist/modules/constants.d.ts.map +1 -1
  119. package/dist/modules/constants.js +68 -4
  120. package/dist/modules/constants.js.map +1 -1
  121. package/dist/modules/dexbot_class.d.ts +10 -18
  122. package/dist/modules/dexbot_class.d.ts.map +1 -1
  123. package/dist/modules/dexbot_class.js +57 -2
  124. package/dist/modules/dexbot_class.js.map +1 -1
  125. package/dist/modules/dexbot_cow_runtime.d.ts +62 -37
  126. package/dist/modules/dexbot_cow_runtime.d.ts.map +1 -1
  127. package/dist/modules/dexbot_cow_runtime.js +797 -569
  128. package/dist/modules/dexbot_cow_runtime.js.map +1 -1
  129. package/dist/modules/dexbot_fill_runtime.d.ts +41 -1
  130. package/dist/modules/dexbot_fill_runtime.d.ts.map +1 -1
  131. package/dist/modules/dexbot_fill_runtime.js +135 -5
  132. package/dist/modules/dexbot_fill_runtime.js.map +1 -1
  133. package/dist/modules/dexbot_maintenance_runtime.d.ts +79 -12
  134. package/dist/modules/dexbot_maintenance_runtime.d.ts.map +1 -1
  135. package/dist/modules/dexbot_maintenance_runtime.js +585 -97
  136. package/dist/modules/dexbot_maintenance_runtime.js.map +1 -1
  137. package/dist/modules/dexbot_startup_runtime.d.ts.map +1 -1
  138. package/dist/modules/dexbot_startup_runtime.js +13 -0
  139. package/dist/modules/dexbot_startup_runtime.js.map +1 -1
  140. package/dist/modules/dexbot_state_recovery.d.ts +15 -1
  141. package/dist/modules/dexbot_state_recovery.d.ts.map +1 -1
  142. package/dist/modules/dexbot_state_recovery.js +36 -7
  143. package/dist/modules/dexbot_state_recovery.js.map +1 -1
  144. package/dist/modules/fund_registry.d.ts +1 -1
  145. package/dist/modules/fund_registry.js +1 -1
  146. package/dist/modules/node_connect_policy.d.ts +52 -0
  147. package/dist/modules/node_connect_policy.d.ts.map +1 -0
  148. package/dist/modules/node_connect_policy.js +35 -0
  149. package/dist/modules/node_connect_policy.js.map +1 -0
  150. package/dist/modules/node_manager.d.ts +32 -0
  151. package/dist/modules/node_manager.d.ts.map +1 -1
  152. package/dist/modules/node_manager.js +76 -9
  153. package/dist/modules/node_manager.js.map +1 -1
  154. package/dist/modules/order/accounting.d.ts +30 -0
  155. package/dist/modules/order/accounting.d.ts.map +1 -1
  156. package/dist/modules/order/accounting.js +176 -3
  157. package/dist/modules/order/accounting.js.map +1 -1
  158. package/dist/modules/order/export.d.ts.map +1 -1
  159. package/dist/modules/order/export.js +1 -0
  160. package/dist/modules/order/export.js.map +1 -1
  161. package/dist/modules/order/grid.d.ts.map +1 -1
  162. package/dist/modules/order/grid.js +170 -51
  163. package/dist/modules/order/grid.js.map +1 -1
  164. package/dist/modules/order/grid_reconcile.d.ts.map +1 -1
  165. package/dist/modules/order/grid_reconcile.js +5 -3
  166. package/dist/modules/order/grid_reconcile.js.map +1 -1
  167. package/dist/modules/order/grid_reconcile_internal.d.ts.map +1 -1
  168. package/dist/modules/order/grid_reconcile_internal.js +182 -6
  169. package/dist/modules/order/grid_reconcile_internal.js.map +1 -1
  170. package/dist/modules/order/manager.d.ts +66 -3
  171. package/dist/modules/order/manager.d.ts.map +1 -1
  172. package/dist/modules/order/manager.js +238 -12
  173. package/dist/modules/order/manager.js.map +1 -1
  174. package/dist/modules/order/strategy.d.ts +3 -0
  175. package/dist/modules/order/strategy.d.ts.map +1 -1
  176. package/dist/modules/order/strategy.js +79 -7
  177. package/dist/modules/order/strategy.js.map +1 -1
  178. package/dist/modules/order/sync_engine.d.ts.map +1 -1
  179. package/dist/modules/order/sync_engine.js +90 -1
  180. package/dist/modules/order/sync_engine.js.map +1 -1
  181. package/dist/modules/order/utils/math.d.ts +20 -12
  182. package/dist/modules/order/utils/math.d.ts.map +1 -1
  183. package/dist/modules/order/utils/math.js +33 -12
  184. package/dist/modules/order/utils/math.js.map +1 -1
  185. package/dist/modules/order/utils/order.d.ts +308 -28
  186. package/dist/modules/order/utils/order.d.ts.map +1 -1
  187. package/dist/modules/order/utils/order.js +862 -38
  188. package/dist/modules/order/utils/order.js.map +1 -1
  189. package/dist/modules/order/utils/system.d.ts +37 -30
  190. package/dist/modules/order/utils/system.d.ts.map +1 -1
  191. package/dist/modules/order/utils/system.js +130 -101
  192. package/dist/modules/order/utils/system.js.map +1 -1
  193. package/dist/modules/order/utils/validate.d.ts +3 -2
  194. package/dist/modules/order/utils/validate.d.ts.map +1 -1
  195. package/dist/modules/order/utils/validate.js +11 -3
  196. package/dist/modules/order/utils/validate.js.map +1 -1
  197. package/dist/modules/paths.d.ts +2 -0
  198. package/dist/modules/paths.d.ts.map +1 -1
  199. package/dist/modules/paths.js +1 -0
  200. package/dist/modules/paths.js.map +1 -1
  201. package/dist/modules/runtime_settings.d.ts +13 -0
  202. package/dist/modules/runtime_settings.d.ts.map +1 -1
  203. package/dist/modules/runtime_settings.js +20 -0
  204. package/dist/modules/runtime_settings.js.map +1 -1
  205. package/dist/modules/utils/errors.d.ts +16 -0
  206. package/dist/modules/utils/errors.d.ts.map +1 -1
  207. package/dist/modules/utils/errors.js +31 -0
  208. package/dist/modules/utils/errors.js.map +1 -1
  209. package/dist/pm2.js +7 -7
  210. package/dist/scripts/analyze-credit.d.ts.map +1 -1
  211. package/dist/scripts/analyze-credit.js +13 -3
  212. package/dist/scripts/analyze-credit.js.map +1 -1
  213. package/dist/scripts/analyze-orders.js +2 -2
  214. package/dist/scripts/analyze-orders.js.map +1 -1
  215. package/dist/scripts/diagnose-kibana-candles.d.ts.map +1 -1
  216. package/dist/scripts/diagnose-kibana-candles.js +0 -1
  217. package/dist/scripts/diagnose-kibana-candles.js.map +1 -1
  218. package/dist/scripts/test-credit-renewal.js +1 -1
  219. package/dist/scripts/test-credit-renewal.js.map +1 -1
  220. package/dist/scripts/tv.d.ts +24 -2
  221. package/dist/scripts/tv.d.ts.map +1 -1
  222. package/dist/scripts/tv.js +190 -65
  223. package/dist/scripts/tv.js.map +1 -1
  224. package/docs/BITSHARES_ONBOARDING.md +9 -2
  225. package/docs/COW_INVARIANTS.md +18 -1
  226. package/docs/DEXBOT_COMPARISON.md +3 -3
  227. package/docs/EVOLUTION.md +9 -7
  228. package/docs/FUND_MOVEMENT_AND_ACCOUNTING.md +21 -9
  229. package/docs/GRID_RECALCULATION.md +5 -6
  230. package/docs/GRID_RECONCILE.md +12 -2
  231. package/docs/MPA_CREDIT_USAGE.md +2 -2
  232. package/docs/README.md +5 -6
  233. package/docs/architecture.md +0 -58
  234. package/docs/developer_guide.md +2 -1
  235. package/docs/docker.md +1 -1
  236. package/market_adapter/README.md +4 -0
  237. package/modules/README.md +1 -1
  238. package/package.json +1 -1
  239. package/scripts/README.md +5 -3
  240. package/tests/README.md +2 -2
@@ -14,11 +14,12 @@ import { BroadcastUncertainError as BroadcastUncertainErrorBinding } from './dex
14
14
  const BroadcastUncertainError = BroadcastUncertainErrorBinding;
15
15
  import * as orderUtils from './order/utils/order.js';
16
16
  import { sleep } from './order/utils/system.js';
17
- const { buildCreateOrderArgs, buildCreateOpFingerprint, extractBatchOperationResults, formatUnmatchedChainOrder, convertToSpreadPlaceholder, toRailHolePlaceholder, buildOutsideInPairGroups, isOrderPlaced, } = orderUtils;
17
+ const { buildCreateOrderArgs, buildCreateOpFingerprint, extractBatchOperationResults, formatUnmatchedChainOrder, convertToSpreadPlaceholder, toRailHolePlaceholder, buildOutsideInPairGroups, isOrderPlaced, chainOrderUnchangedFromCache, detectCrossedBookPlan, collectKnownOnChainOrderIds, } = orderUtils;
18
18
  import * as validate from './order/utils/validate.js';
19
19
  const { validateCreateTargetSlots, evaluateCommit, hasExecutableActions, stampGapEvacuationRotation } = validate;
20
20
  import * as math from './order/utils/math.js';
21
- const { validateOrderSize, findCrossedOrder, priceSlotEqual, isEvacuationRotationAllowed, isEvacuationSizeStillValid, getSellStartIdx, getPrecisionByOrderType, isSlotIndexInGapBand } = math;
21
+ const { validateOrderSize, findCrossedOrder, priceSlotEqual, isEvacuationRotationAllowed, isEvacuationSizeStillValid, getSellStartIdx, getPrecisionByOrderType, isSlotIndexInGapBand, getAssetFeesSafe, blockchainToFloat, floatToBlockchainInt, quantizeFloat } = math;
22
+ import { parseSlotIndex } from './order/utils/slot.js';
22
23
  /**
23
24
  * Re-verify a B-stamped gap-evacuation rotation against the LIVE committed
24
25
  * geometry (boundaryIdx/_gapSlots at execution time). The stamp freezes
@@ -48,7 +49,6 @@ export function isEvacuationStampStillValid(liveBoundary, liveGapSlots, sourceId
48
49
  const dstInRail = orderType === ORDER_TYPES.SELL ? Number(dstIdx) >= sellStartIdx : Number(dstIdx) <= b;
49
50
  return dstInRail;
50
51
  }
51
- import { parseSlotIndex } from './order/utils/slot.js';
52
52
  function hasSlotPriceCollision(items, targetPrice, precision, excludeId, predicate) {
53
53
  for (const it of items) {
54
54
  if (predicate && !predicate(it))
@@ -69,8 +69,6 @@ function hasSlotPriceCollision(items, targetPrice, precision, excludeId, predica
69
69
  }
70
70
  return null;
71
71
  }
72
- // Lazy accessor so test mocks on the math module export take effect at call time.
73
- function getAssetFeesSafe(...args) { return require('./order/utils/math').getAssetFeesSafe(...args); }
74
72
  import * as constantsModule from './constants.js';
75
73
  const { COW_ACTIONS, COW_PERFORMANCE, ORDER_STATES, ORDER_TYPES, REBALANCE_STATES, } = constantsModule;
76
74
  import { acquireIfNotHeld } from './order/async_lock.js';
@@ -79,8 +77,6 @@ const Format = FormatModule;
79
77
  import * as workingGridModule from './order/working_grid.js';
80
78
  const { WorkingGrid } = workingGridModule;
81
79
  import { getErrorMessage } from './utils/errors.js';
82
- import { createRequire } from 'node:module';
83
- const require = createRequire(import.meta.url);
84
80
  // Maximum number of times the pre-broadcast staleness guard may re-plan the
85
81
  // batch from a fresh master before proceeding anyway. Bounded so a master
86
82
  // grid that keeps mutating (fill bursts, sync loops) can never livelock the
@@ -99,11 +95,7 @@ const SINGLE_FLIGHT_MAX_WAIT_MS = 120000;
99
95
  * @returns {Array<Array>}
100
96
  */
101
97
  function buildOutsideInPairGroupsForOrders(orders) {
102
- return buildOutsideInPairGroups(orders, {
103
- isValid: Boolean,
104
- getType: (o) => o.type,
105
- getPrice: (o) => o.price,
106
- });
98
+ return buildOutsideInPairGroupsWithAdapter(orders, (o) => o);
107
99
  }
108
100
  /**
109
101
  * Build outside-in pair groups for create entry contexts.
@@ -111,12 +103,128 @@ function buildOutsideInPairGroupsForOrders(orders) {
111
103
  * @returns {Array<Array>}
112
104
  */
113
105
  function buildOutsideInPairGroupsForCreateEntries(createEntries) {
114
- return buildOutsideInPairGroups(createEntries, {
115
- isValid: (e) => Boolean(e?.context?.order),
116
- getType: (e) => e.context.order.type,
117
- getPrice: (e) => e.context.order.price,
106
+ return buildOutsideInPairGroupsWithAdapter(createEntries, (e) => e?.context?.order);
107
+ }
108
+ /**
109
+ * Shared outside-in delegation: project each item to its order, then group
110
+ * with the single order adapter. The ForOrders/ForCreateEntries wrappers
111
+ * differ only in this projection.
112
+ * @param {Array} items
113
+ * @param {Function} toOrder - Project an item to its order object
114
+ * @returns {Array<Array>}
115
+ */
116
+ function buildOutsideInPairGroupsWithAdapter(items, toOrder) {
117
+ return buildOutsideInPairGroups(items, {
118
+ isValid: (item) => Boolean(toOrder(item)),
119
+ getType: (item) => toOrder(item)?.type,
120
+ getPrice: (item) => toOrder(item)?.price,
121
+ });
122
+ }
123
+ /**
124
+ * Create a create-op fingerprint for a specific slot, used for
125
+ * pending-broadcast matching and chain-order fingerprinting.
126
+ * Consolidates the fingerprint construction pattern found in
127
+ * recordPendingBroadcast, buildChainOrderFingerprint, findChainOrderForSlot,
128
+ * and verifyCreateAbsent.
129
+ * @param {import('./dexbot_class.js').DEXBot} bot
130
+ * @param {Object} order - The order object
131
+ * @param {Object} finalInts - The final integer tuple { sell, receive, sellAssetId, receiveAssetId }
132
+ * @param {string} slotId - The slot identifier
133
+ * @returns {string|null}
134
+ */
135
+ function createOpFingerprintForSlot(bot, order, finalInts, slotId) {
136
+ if (!order || !finalInts || !slotId)
137
+ return null;
138
+ return buildCreateOpFingerprint({
139
+ side: order.type,
140
+ assetA: bot.manager?.assets?.assetA?.id,
141
+ assetB: bot.manager?.assets?.assetB?.id,
142
+ sellInt: finalInts.sell,
143
+ receiveInt: finalInts.receive,
144
+ slotId,
145
+ });
146
+ }
147
+ /**
148
+ * Read the pending-broadcast entries as an array. The manager map is
149
+ * created lazily by recordPendingBroadcast, so every reader must guard
150
+ * on `instanceof Map` — this helper is the single spelling of that guard.
151
+ * @param {import('./dexbot_class.js').DEXBot} bot
152
+ * @returns {Array}
153
+ */
154
+ function getPendingBroadcasts(bot) {
155
+ return (bot.manager && bot.manager._pendingBroadcasts instanceof Map)
156
+ ? Array.from(bot.manager._pendingBroadcasts.values())
157
+ : [];
158
+ }
159
+ /**
160
+ * Count pending-broadcast entries without materializing the array.
161
+ * Same instanceof Map guard as getPendingBroadcasts, for hot paths that
162
+ * only need the count (e.g. the orphan auto-cancel gate).
163
+ * @param {import('./dexbot_class.js').DEXBot} bot
164
+ * @returns {number}
165
+ */
166
+ function countPendingBroadcasts(bot) {
167
+ return (bot.manager && bot.manager._pendingBroadcasts instanceof Map)
168
+ ? bot.manager._pendingBroadcasts.size
169
+ : 0;
170
+ }
171
+ /**
172
+ * Probe the chain snapshot for a pending-broadcast entry's slot.
173
+ * Single spelling of the findChainOrderForSlot probe literal used by
174
+ * reconcile (first pass + re-read), the poll confirmer, and the
175
+ * create-absence verifier.
176
+ * @param {import('./dexbot_class.js').DEXBot} bot
177
+ * @param {Array} chainSnapshot
178
+ * @param {Object} entry - Pending entry ({ slotId, finalInts, orderType, fingerprint })
179
+ * @returns {Object|null}
180
+ */
181
+ function findChainOrderForPendingEntry(bot, chainSnapshot, entry) {
182
+ if (!entry?.slotId)
183
+ return null;
184
+ return findChainOrderForSlot(bot, chainSnapshot, entry.slotId, {
185
+ sell: entry.finalInts?.sell,
186
+ receive: entry.finalInts?.receive,
187
+ orderType: entry.orderType,
188
+ fingerprint: entry.fingerprint,
118
189
  });
119
190
  }
191
+ /**
192
+ * Check whether a chain read is authoritative enough to make
193
+ * definitive absence/lPresence decisions. An authoritative read is
194
+ * non-empty, non-truncated, and non-null.
195
+ * @param {Object} readResult - The result of readOpenOrdersWithMeta
196
+ * @returns {boolean}
197
+ */
198
+ function isAuthoritativeChainRead(readResult) {
199
+ if (!readResult || !Array.isArray(readResult.orders))
200
+ return false;
201
+ if (readResult.truncated)
202
+ return false;
203
+ return readResult.orders.length > 0;
204
+ }
205
+ /**
206
+ * Build a rawOnChain metadata object from final integer values.
207
+ * Used in processBatchResults for size-update, create, and rotation
208
+ * contexts to enrich order objects with on-chain identity.
209
+ * Returns null when the order id (or final ints) is missing: downstream
210
+ * chainOrderUnchangedFromCache treats a null cache as "not unchanged" and
211
+ * defers, which is the safer direction versus comparing an id-less object.
212
+ * @param {string} orderId - The on-chain order id
213
+ * @param {Object} finalInts - The final integer tuple
214
+ * @returns {Object|null}
215
+ */
216
+ function rawOnChainFromInts(orderId, finalInts) {
217
+ if (!orderId || !finalInts)
218
+ return null;
219
+ return {
220
+ id: orderId,
221
+ for_sale: String(finalInts.sell),
222
+ sell_price: {
223
+ base: { amount: String(finalInts.sell), asset_id: finalInts.sellAssetId },
224
+ quote: { amount: String(finalInts.receive), asset_id: finalInts.receiveAssetId },
225
+ },
226
+ };
227
+ }
120
228
  /**
121
229
  * Extract operation results from a batch transaction result.
122
230
  * @param {Object|Array|null} result
@@ -222,14 +330,7 @@ function recordPendingBroadcast(bot, entry) {
222
330
  if (!bot.manager._pendingBroadcasts || !(bot.manager._pendingBroadcasts instanceof Map)) {
223
331
  bot.manager._pendingBroadcasts = new Map();
224
332
  }
225
- const fingerprint = buildCreateOpFingerprint({
226
- side: entry.order.type,
227
- assetA: bot.manager?.assets?.assetA?.id,
228
- assetB: bot.manager?.assets?.assetB?.id,
229
- sellInt: entry.finalInts?.sell,
230
- receiveInt: entry.finalInts?.receive,
231
- slotId: entry.order.id
232
- });
333
+ const fingerprint = createOpFingerprintForSlot(bot, entry.order, entry.finalInts, entry.order.id);
233
334
  if (!fingerprint) {
234
335
  bot.manager.logger.log?.(`[COW] Skipped pending-broadcast record: could not build fingerprint for ${entry.order?.id || 'unknown'}`, 'warn');
235
336
  return;
@@ -510,72 +611,24 @@ async function reconcileAfterUncertainBroadcast(bot, err, opContexts, options =
510
611
  return acquireIfNotHeld(bot.manager?._fillProcessingLock, () => reconcileAfterUncertainBroadcastImpl(bot, err, opContexts, options));
511
612
  }
512
613
  /**
513
- * Reconcile a broadcast whose chain state is unknown (implementation).
614
+ * Match pending broadcasts against a chain snapshot (first pass by slot
615
+ * probe, second pass by fingerprint across all pending slots), then
616
+ * re-read the chain for discarded CREATEs to close the TOCTOU window.
617
+ * Returns `{ adopted, discarded }`, or `{ deferred }` when the re-read
618
+ * is ambiguous/failed and the caller must defer (pending protection kept).
514
619
  * @param {import('./dexbot_class.js').DEXBot} bot
515
- * @param {BroadcastUncertainError} err
516
- * @param {Array<Object>} opContexts
517
- * @param {Object} options
518
- * @returns {Promise<Object>}
620
+ * @param {Array} pending
621
+ * @param {Array} chainSnapshot
622
+ * @param {Array} opContexts
623
+ * @param {*} accountRef
624
+ * @param {*} err
519
625
  */
520
- async function reconcileAfterUncertainBroadcastImpl(bot, err, opContexts, _options) {
521
- const startedAt = Date.now();
522
- const pending = (bot.manager && bot.manager._pendingBroadcasts instanceof Map)
523
- ? Array.from(bot.manager._pendingBroadcasts.values())
524
- : [];
525
- const createContextCount = opContexts.filter((c) => c && c.kind === 'create').length;
526
- const nonCreateContextCount = opContexts.length - createContextCount;
527
- bot.manager.logger.log(`[COW][UNCERTAIN] batchId=${err?.batchId || 'n/a'} ops=${opContexts.length} ` +
528
- `creates=${createContextCount} nonCreates=${nonCreateContextCount} ` +
529
- `staleSinceMs=${err?.timeoutMs || 'n/a'}. Entering reconcile-then-decide.`, 'warn');
530
- if (!chainOrders?.readOpenOrdersWithMeta) {
531
- bot.manager.logger.log('[COW][UNCERTAIN] readOpenOrdersWithMeta unavailable; falling back to structural resync only.', 'error');
532
- if (typeof bot.manager.requestStructuralGridResync === 'function') {
533
- await bot.manager.requestStructuralGridResync('broadcast uncertain — readOpenOrders unavailable', { batchId: err?.batchId || null });
534
- }
535
- clearPendingBroadcasts(bot.manager?._pendingBroadcasts);
536
- return { executed: false, hadRotation: false, uncertain: true };
537
- }
538
- // 1. Read the chain
539
- const accountRef = bot.accountId || bot.account?.id || bot.account;
540
- let chainSnapshot = [];
541
- let chainReadTruncated = false;
542
- try {
543
- const chainRead = await chainOrders.readOpenOrdersWithMeta(accountRef);
544
- chainSnapshot = chainRead.orders;
545
- chainReadTruncated = chainRead.truncated;
546
- }
547
- catch (readErr) {
548
- bot.manager.logger.log(`[COW][UNCERTAIN] readOpenOrders failed: ${readErr?.message || readErr}. ` +
549
- `Falling back to structural resync.`, 'error');
550
- if (typeof bot.manager.requestStructuralGridResync === 'function') {
551
- await bot.manager.requestStructuralGridResync('broadcast uncertain — readOpenOrders failed', { batchId: err?.batchId || null, error: readErr?.message || String(readErr) });
552
- }
553
- clearPendingBroadcasts(bot.manager?._pendingBroadcasts);
554
- return { executed: false, hadRotation: false, uncertain: true };
555
- }
556
- // 1.5. Empty/truncated-read guard: an empty snapshot is ambiguous — the
557
- // account is either genuinely empty or the node is lagging behind the
558
- // just-broadcast transaction. A truncated snapshot (get_full_accounts
559
- // capped limit_orders; fresh creates sort last in the by_account index
560
- // and are the first entries omitted) is equally ambiguous: the batch's
561
- // creates may simply be missing from the returned window. Treating every
562
- // pending broadcast as discarded would clear the pending-broadcast
563
- // protection and let the next cycle re-CREATE slots whose orders may
564
- // actually be on chain (duplicate orders). Keep the protection and let
565
- // the structural resync adopt any landed orders.
566
- if (pending.length > 0 && (chainSnapshot.length === 0 || chainReadTruncated)) {
567
- return await deferUncertainBroadcastRead(bot, `${chainSnapshot.length === 0 ? 'Empty' : 'Truncated'} chain read for ${pending.length} pending broadcast(s)`, '(node may be lagging or the result set capped; no discard decisions made)', 'uncertain broadcast — empty/truncated chain read', { batchId: err?.batchId || null, truncated: chainReadTruncated });
568
- }
626
+ async function matchPendingToChain(bot, pending, chainSnapshot, opContexts, accountRef, err) {
569
627
  const adopted = [];
570
628
  let discarded = [];
571
629
  // 2. For each pending broadcast, look for a chain match.
572
630
  for (const entry of pending) {
573
- const match = findChainOrderForSlot(bot, chainSnapshot, entry.slotId, {
574
- sell: entry.finalInts?.sell,
575
- receive: entry.finalInts?.receive,
576
- orderType: entry.orderType,
577
- fingerprint: entry.fingerprint,
578
- });
631
+ const match = findChainOrderForPendingEntry(bot, chainSnapshot, entry);
579
632
  if (match) {
580
633
  adopted.push({ entry, match });
581
634
  }
@@ -624,21 +677,18 @@ async function reconcileAfterUncertainBroadcastImpl(bot, err, opContexts, _optio
624
677
  // protection and let the next cycle re-create (duplicate) an
625
678
  // order that actually landed in the TOCTOU window. Keep the
626
679
  // pending-broadcast protection and defer to a structural resync.
627
- if (!freshRead || freshRead.truncated || !Array.isArray(freshRead.orders) || freshRead.orders.length === 0) {
680
+ if (!isAuthoritativeChainRead(freshRead)) {
628
681
  const ambiguous = !freshRead || !Array.isArray(freshRead.orders) || freshRead.orders.length === 0;
629
- return await deferUncertainBroadcastRead(bot, `${ambiguous ? 'Empty' : 'Truncated'} re-read for ${createDiscarded.length} discarded CREATE(s)`, '(absence is not authoritative on an ambiguous re-read)', 'uncertain broadcast — ambiguous re-read for discarded creates', { batchId: err?.batchId || null, truncated: !ambiguous });
682
+ return {
683
+ deferred: await deferUncertainBroadcastRead(bot, `${ambiguous ? 'Empty' : 'Truncated'} re-read for ${createDiscarded.length} discarded CREATE(s)`, '(absence is not authoritative on an ambiguous re-read)', 'uncertain broadcast — ambiguous re-read for discarded creates', { batchId: err?.batchId || null, truncated: !ambiguous })
684
+ };
630
685
  }
631
686
  const freshChain = freshRead.orders;
632
687
  const remainingDiscarded = [];
633
688
  for (const entry of discarded) {
634
689
  const ctx = opContexts[entry.ctxIndex];
635
690
  if (ctx && ctx.kind === 'create') {
636
- const match = findChainOrderForSlot(bot, freshChain, entry.slotId, {
637
- sell: entry.finalInts?.sell,
638
- receive: entry.finalInts?.receive,
639
- orderType: entry.orderType,
640
- fingerprint: entry.fingerprint,
641
- });
691
+ const match = findChainOrderForPendingEntry(bot, freshChain, entry);
642
692
  if (match) {
643
693
  adopted.push({ entry, match });
644
694
  bot.manager.logger.log(`[COW][UNCERTAIN] Late-adopted discarded CREATE for slot ${entry.slotId} (${match.id}) via fresh chain read`, 'info');
@@ -654,13 +704,24 @@ async function reconcileAfterUncertainBroadcastImpl(bot, err, opContexts, _optio
654
704
  discarded = remainingDiscarded;
655
705
  }
656
706
  catch (reReadErr) {
657
- return await deferUncertainBroadcastRead(bot, `Fresh chain read for late adoption FAILED (${getErrorMessage(reReadErr)})`, '(absence is not authoritative on a failed re-read)', 'uncertain broadcast — failed re-read for discarded creates', { batchId: err?.batchId || null });
707
+ return {
708
+ deferred: await deferUncertainBroadcastRead(bot, `Fresh chain read for late adoption FAILED (${getErrorMessage(reReadErr)})`, '(absence is not authoritative on a failed re-read)', 'uncertain broadcast — failed re-read for discarded creates', { batchId: err?.batchId || null })
709
+ };
658
710
  }
659
711
  }
660
712
  }
661
- // 3b. Apply decisions
713
+ return { adopted, discarded };
714
+ }
715
+ /**
716
+ * Adopt matched pending broadcasts into the master grid (CREATE slots
717
+ * synchronize with the chain) and clear their pending entries.
718
+ * @param {import('./dexbot_class.js').DEXBot} bot
719
+ * @param {Array} adopted
720
+ * @param {Array} opContexts
721
+ * @returns {Promise<number>} adoptedCount
722
+ */
723
+ async function adoptMatchedEntries(bot, adopted, opContexts) {
662
724
  let adoptedCount = 0;
663
- let discardedCount = 0;
664
725
  for (const { entry, match } of adopted) {
665
726
  adoptedCount++;
666
727
  const plannedOpCtx = opContexts[entry.ctxIndex];
@@ -674,6 +735,7 @@ async function reconcileAfterUncertainBroadcastImpl(bot, err, opContexts, _optio
674
735
  chainOrderId,
675
736
  expectedType,
676
737
  fee: btsFeeData?.createFee || 0,
738
+ order: plannedOpCtx.order ?? entry.order ?? null,
677
739
  }, 'createOrder');
678
740
  }
679
741
  catch (syncErr) {
@@ -685,6 +747,19 @@ async function reconcileAfterUncertainBroadcastImpl(bot, err, opContexts, _optio
685
747
  bot.manager._pendingBroadcasts.delete(entry.fingerprint);
686
748
  }
687
749
  }
750
+ return adoptedCount;
751
+ }
752
+ /**
753
+ * Restore discarded CREATE slots to creation-uncertain state and clear
754
+ * their pending entries; residual pending entries that survive the
755
+ * decide phase are cleaned defensively.
756
+ * @param {import('./dexbot_class.js').DEXBot} bot
757
+ * @param {Array} discarded
758
+ * @param {Array} opContexts
759
+ * @returns {Promise<number>} discardedCount
760
+ */
761
+ async function restoreDiscardedCreates(bot, discarded, opContexts) {
762
+ let discardedCount = 0;
688
763
  for (const entry of discarded) {
689
764
  discardedCount++;
690
765
  const plannedOpCtx = opContexts[entry.ctxIndex];
@@ -761,12 +836,48 @@ async function reconcileAfterUncertainBroadcastImpl(bot, err, opContexts, _optio
761
836
  }
762
837
  }
763
838
  }
839
+ else {
840
+ // Slot missing from master (grid reset raced the uncertain
841
+ // broadcast). Deleting the pending entry below without
842
+ // restoring anything would leave a clean hole the next
843
+ // cycle re-CREATEs — duplicating a possibly-landed order.
844
+ // Materialize the creation-uncertain slot from the
845
+ // broadcast-time descriptor so the next sync's orphan
846
+ // adoption reconciles a landed order into it.
847
+ const missingType = entry.order.type;
848
+ if (missingType === ORDER_TYPES.BUY || missingType === ORDER_TYPES.SELL) {
849
+ bot.manager.logger.log(`[COW][UNCERTAIN] Slot ${entry.order.id} missing from master after discard — materializing creation-uncertain state ` +
850
+ `(type=${missingType}, size: ${entry.order.size}); next sync adoption will reconcile any landed order`, 'warn');
851
+ const updates = [{
852
+ id: entry.order.id,
853
+ type: missingType,
854
+ size: entry.order.size,
855
+ price: entry.order.price,
856
+ state: ORDER_STATES.VIRTUAL,
857
+ createUncertain: true,
858
+ orderId: null,
859
+ rawOnChain: null,
860
+ }];
861
+ if (typeof bot.manager.applyGridUpdateBatch === 'function') {
862
+ await bot.manager.applyGridUpdateBatch(updates, 'uncertain-broadcast-discard-restore');
863
+ }
864
+ }
865
+ else {
866
+ bot.manager.logger.log(`[COW][UNCERTAIN] Slot ${entry.order.id} missing from master after discard with unrecognized type ${missingType} — cannot reconstruct creation-uncertain state; next sync must adopt any landed order as an orphan`, 'error');
867
+ }
868
+ }
869
+ }
870
+ else {
871
+ bot.manager.logger.log(`[COW][UNCERTAIN] Discarded CREATE for slot ${entry.slotId} has no usable placement descriptor (id=${entry.order?.id ?? 'none'}, size=${entry.order?.size ?? 'none'}, type=${entry.order?.type ?? 'none'}) — nothing restored; next sync must adopt any landed order as an orphan`, 'error');
764
872
  }
765
873
  }
766
874
  catch (restoreErr) {
767
875
  bot.manager.logger.log(`[COW][UNCERTAIN] Failed to restore slot ${entry.slotId} after discard: ${restoreErr?.message || restoreErr}`, 'error');
768
876
  }
769
877
  }
878
+ else {
879
+ bot.manager.logger.log(`[COW][UNCERTAIN] Discarded pending broadcast for slot ${entry.slotId} is not a recognizable CREATE (no create opContext, no order id/type) — skipping restore; next sync must adopt any landed order as an orphan`, 'error');
880
+ }
770
881
  // Remove from pending broadcasts.
771
882
  if (entry.fingerprint && bot.manager._pendingBroadcasts?.has(entry.fingerprint)) {
772
883
  bot.manager._pendingBroadcasts.delete(entry.fingerprint);
@@ -786,6 +897,101 @@ async function reconcileAfterUncertainBroadcastImpl(bot, err, opContexts, _optio
786
897
  }
787
898
  bot.manager._pendingBroadcasts.clear();
788
899
  }
900
+ return discardedCount;
901
+ }
902
+ /**
903
+ * Request a structural resync when chain orders remain unaccounted for
904
+ * after the uncertain-broadcast reconciliation.
905
+ * @param {import('./dexbot_class.js').DEXBot} bot
906
+ * @param {*} err
907
+ * @param {Array} pending
908
+ * @param {Array} chainSnapshot
909
+ * @param {Array} adopted
910
+ * @param {number} adoptedCount
911
+ * @param {number} discardedCount
912
+ */
913
+ async function resyncIfUnreconciled(bot, err, pending, chainSnapshot, adopted, adoptedCount, discardedCount) {
914
+ // 7. Request structural resync if any chain orders remain unaccounted for
915
+ // after the reconciliation, ensuring the next cycle re-plans from a clean
916
+ // chain snapshot.
917
+ const alreadyScheduled = bot._structuralGridResyncRunning || bot._structuralGridResyncTimer;
918
+ if (!alreadyScheduled && chainSnapshot.length > 0) {
919
+ const reconciledOrderIds = new Set(adopted.map((a) => a.match?.id).filter(Boolean));
920
+ const unreconciledCount = chainSnapshot.filter((o) => !reconciledOrderIds.has(o.id)).length;
921
+ if (unreconciledCount > 0) {
922
+ bot.manager.logger.log(`[COW][UNCERTAIN] ${unreconciledCount} chain order(s) remain unreconciled after uncertain broadcast recovery. ` +
923
+ `These may be legitimate pre-existing orders or leftovers from a prior cycle. Requesting structural resync.`, 'warn');
924
+ await requestStructuralResync(bot, 'unreconciled orders after uncertain broadcast', {
925
+ batchId: err?.batchId || null,
926
+ pendingCount: pending.length,
927
+ adoptedCount,
928
+ discardedCount,
929
+ unreconciledCount
930
+ });
931
+ }
932
+ }
933
+ }
934
+ /**
935
+ * Reconcile a broadcast whose chain state is unknown (implementation).
936
+ * @param {import('./dexbot_class.js').DEXBot} bot
937
+ * @param {BroadcastUncertainError} err
938
+ * @param {Array<Object>} opContexts
939
+ * @param {Object} options
940
+ * @returns {Promise<Object>}
941
+ */
942
+ async function reconcileAfterUncertainBroadcastImpl(bot, err, opContexts, _options) {
943
+ const startedAt = Date.now();
944
+ const pending = getPendingBroadcasts(bot);
945
+ const createContextCount = opContexts.filter((c) => c && c.kind === 'create').length;
946
+ const nonCreateContextCount = opContexts.length - createContextCount;
947
+ bot.manager.logger.log(`[COW][UNCERTAIN] batchId=${err?.batchId || 'n/a'} ops=${opContexts.length} ` +
948
+ `creates=${createContextCount} nonCreates=${nonCreateContextCount} ` +
949
+ `staleSinceMs=${err?.timeoutMs || 'n/a'}. Entering reconcile-then-decide.`, 'warn');
950
+ if (!chainOrders?.readOpenOrdersWithMeta) {
951
+ bot.manager.logger.log('[COW][UNCERTAIN] readOpenOrdersWithMeta unavailable; falling back to structural resync only.', 'error');
952
+ await requestStructuralResync(bot, 'broadcast uncertain — readOpenOrders unavailable', { batchId: err?.batchId || null });
953
+ clearPendingBroadcasts(bot.manager?._pendingBroadcasts);
954
+ return { executed: false, hadRotation: false, uncertain: true };
955
+ }
956
+ // 1. Read the chain
957
+ const accountRef = bot.accountId || bot.account?.id || bot.account;
958
+ let chainSnapshot = [];
959
+ let chainReadTruncated = false;
960
+ try {
961
+ const chainRead = await chainOrders.readOpenOrdersWithMeta(accountRef);
962
+ chainSnapshot = chainRead.orders;
963
+ chainReadTruncated = chainRead.truncated;
964
+ }
965
+ catch (readErr) {
966
+ bot.manager.logger.log(`[COW][UNCERTAIN] readOpenOrders failed: ${readErr?.message || readErr}. ` +
967
+ `Falling back to structural resync.`, 'error');
968
+ await requestStructuralResync(bot, 'broadcast uncertain — readOpenOrders failed', { batchId: err?.batchId || null, error: readErr?.message || String(readErr) });
969
+ clearPendingBroadcasts(bot.manager?._pendingBroadcasts);
970
+ return { executed: false, hadRotation: false, uncertain: true };
971
+ }
972
+ // 1.5. Empty/truncated-read guard: an empty snapshot is ambiguous — the
973
+ // account is either genuinely empty or the node is lagging behind the
974
+ // just-broadcast transaction. A truncated snapshot (get_full_accounts
975
+ // capped limit_orders; fresh creates sort last in the by_account index
976
+ // and are the first entries omitted) is equally ambiguous: the batch's
977
+ // creates may simply be missing from the returned window. Treating every
978
+ // pending broadcast as discarded would clear the pending-broadcast
979
+ // protection and let the next cycle re-CREATE slots whose orders may
980
+ // actually be on chain (duplicate orders). Keep the protection and let
981
+ // the structural resync adopt any landed orders.
982
+ if (pending.length > 0 && (chainSnapshot.length === 0 || chainReadTruncated)) {
983
+ return await deferUncertainBroadcastRead(bot, `${chainSnapshot.length === 0 ? 'Empty' : 'Truncated'} chain read for ${pending.length} pending broadcast(s)`, '(node may be lagging or the result set capped; no discard decisions made)', 'uncertain broadcast — empty/truncated chain read', { batchId: err?.batchId || null, truncated: chainReadTruncated });
984
+ }
985
+ // 2-3a. Match pending broadcasts to the chain: slot probe, fingerprint
986
+ // second pass, and a TOCTOU re-read for discarded CREATEs (see
987
+ // matchPendingToChain). A deferred re-read returns its result directly.
988
+ const matched = await matchPendingToChain(bot, pending, chainSnapshot, opContexts, accountRef, err);
989
+ if (matched.deferred)
990
+ return matched.deferred;
991
+ const { adopted, discarded } = matched;
992
+ // 3b. Apply decisions
993
+ const adoptedCount = await adoptMatchedEntries(bot, adopted, opContexts);
994
+ let discardedCount = await restoreDiscardedCreates(bot, discarded, opContexts);
789
995
  // 5. Log structured summary
790
996
  const elapsed = Date.now() - startedAt;
791
997
  bot.manager.logger.log(`[COW][UNCERTAIN] Reconciled: ${adoptedCount} adopted, ${discardedCount} discarded ` +
@@ -805,27 +1011,7 @@ async function reconcileAfterUncertainBroadcastImpl(bot, err, opContexts, _optio
805
1011
  // 7. Request structural resync if any chain orders remain unaccounted for
806
1012
  // after the reconciliation, ensuring the next cycle re-plans from a clean
807
1013
  // chain snapshot.
808
- const alreadyScheduled = bot._structuralGridResyncRunning || bot._structuralGridResyncTimer;
809
- if (!alreadyScheduled && chainSnapshot.length > 0) {
810
- const reconciledOrderIds = new Set(adopted.map((a) => a.match?.id).filter(Boolean));
811
- const unreconciledCount = chainSnapshot.filter((o) => !reconciledOrderIds.has(o.id)).length;
812
- if (unreconciledCount > 0) {
813
- bot.manager.logger.log(`[COW][UNCERTAIN] ${unreconciledCount} chain order(s) remain unreconciled after uncertain broadcast recovery. ` +
814
- `These may be legitimate pre-existing orders or leftovers from a prior cycle. Requesting structural resync.`, 'warn');
815
- if (typeof bot.manager.requestStructuralGridResync === 'function') {
816
- await bot.manager.requestStructuralGridResync('unreconciled orders after uncertain broadcast', {
817
- batchId: err?.batchId || null,
818
- pendingCount: pending.length,
819
- adoptedCount,
820
- discardedCount,
821
- unreconciledCount
822
- });
823
- }
824
- else {
825
- bot._warn?.('[COW][UNCERTAIN] requestStructuralGridResync unavailable; cannot schedule structural resync.');
826
- }
827
- }
828
- }
1014
+ await resyncIfUnreconciled(bot, err, pending, chainSnapshot, adopted, adoptedCount, discardedCount);
829
1015
  return { executed: false, hadRotation: false, uncertain: true, adoptedCount, discardedCount };
830
1016
  }
831
1017
  /**
@@ -846,9 +1032,7 @@ async function autoCancelOneUnmatchedOrphan(bot) {
846
1032
  bot._autoCancelOrphanCycleMarker = cycleId;
847
1033
  bot._autoCancelOrphanSubCount = 0;
848
1034
  }
849
- const pending = (bot.manager && bot.manager._pendingBroadcasts instanceof Map)
850
- ? bot.manager._pendingBroadcasts.size
851
- : 0;
1035
+ const pending = countPendingBroadcasts(bot);
852
1036
  if (pending > 0) {
853
1037
  return { cancelled: false, reason: 'pending-broadcasts-active' };
854
1038
  }
@@ -864,7 +1048,7 @@ async function autoCancelOneUnmatchedOrphan(bot) {
864
1048
  }
865
1049
  const target = unmatched.find((u) => u && u.reason === 'price-drift-orphan');
866
1050
  if (!target) {
867
- return { cancelled: false, reason: 'no-price-drift-orphan', message: 'no price-drift orphan to cancel; other unmatched orders are adoptable' };
1051
+ return { cancelled: false, reason: 'no-price-drift-orphan', message: 'no price-drift orphan to cancel; remaining unmatched orders need no cancellation (adoptable or deferred holds)' };
868
1052
  }
869
1053
  const orderId = target.id || target.orderId || target.chainOrderId;
870
1054
  if (!orderId) {
@@ -945,14 +1129,7 @@ function verifyCreateAbsent(bot, freshChain, ctx) {
945
1129
  sell: ctx.finalInts.sell,
946
1130
  receive: ctx.finalInts.receive,
947
1131
  orderType: ctx.order.type,
948
- fingerprint: buildCreateOpFingerprint({
949
- side: ctx.order.type,
950
- assetA: bot.manager?.assets?.assetA?.id,
951
- assetB: bot.manager?.assets?.assetB?.id,
952
- sellInt: ctx.finalInts.sell,
953
- receiveInt: ctx.finalInts.receive,
954
- slotId: ctx.order.id
955
- })
1132
+ fingerprint: createOpFingerprintForSlot(bot, ctx.order, ctx.finalInts, ctx.order.id)
956
1133
  });
957
1134
  return match ? 'landed' : 'absent';
958
1135
  }
@@ -1033,7 +1210,11 @@ async function executeWithRetryOnUncertain(bot, operations, opContexts) {
1033
1210
  const accountRef = bot.accountId || bot.account?.id || bot.account;
1034
1211
  const freshRead = await chainOrders.readOpenOrdersWithMeta(accountRef);
1035
1212
  const freshChain = freshRead.orders;
1036
- if (Array.isArray(freshChain) && freshChain.length > 0) {
1213
+ // A truncated read (get_full_accounts caps limit_orders, and
1214
+ // fresh creates sort last in the by_account index) omits the
1215
+ // very orders this batch may have landed — 'absent' is not
1216
+ // authoritative here, degrade to 'unknown' and defer.
1217
+ if (isAuthoritativeChainRead(freshRead)) {
1037
1218
  absence = 'absent';
1038
1219
  for (const ctx of opContexts) {
1039
1220
  if (!ctx)
@@ -1045,13 +1226,6 @@ async function executeWithRetryOnUncertain(bot, operations, opContexts) {
1045
1226
  }
1046
1227
  }
1047
1228
  }
1048
- // A truncated read (get_full_accounts caps limit_orders, and
1049
- // fresh creates sort last in the by_account index) omits the
1050
- // very orders this batch may have landed — 'absent' is not
1051
- // authoritative here, degrade to 'unknown' and defer.
1052
- if (freshRead.truncated && absence === 'absent') {
1053
- absence = 'unknown';
1054
- }
1055
1229
  }
1056
1230
  catch (verifyErr) {
1057
1231
  bot.manager.logger.log(`[COW] Pre-retry chain verification failed (non-fatal): ${verifyErr?.message || verifyErr}`, 'warn');
@@ -1085,31 +1259,6 @@ async function executeWithRetryOnUncertain(bot, operations, opContexts) {
1085
1259
  }
1086
1260
  }
1087
1261
  }
1088
- /**
1089
- * Whether a chain order still matches the cached pre-update state the
1090
- * limit_order_update delta was built from. Only a provably-unchanged order
1091
- * makes a re-broadcast of the identical delta safe (it applies to the same
1092
- * base). Any other state (target applied, filled, resized) must defer.
1093
- * @param {Object} chainOrder - Raw chain order object (get_full_accounts)
1094
- * @param {Object|null} cachedRaw - The rawOnChain cache captured at build time
1095
- * @returns {boolean}
1096
- */
1097
- function chainOrderUnchangedFromCache(chainOrder, cachedRaw) {
1098
- if (!chainOrder || !cachedRaw)
1099
- return false;
1100
- const base = chainOrder.sell_price?.base;
1101
- const quote = chainOrder.sell_price?.quote;
1102
- const cachedBase = cachedRaw.sell_price?.base?.amount;
1103
- const cachedQuote = cachedRaw.sell_price?.quote?.amount;
1104
- const cachedForSale = cachedRaw.for_sale;
1105
- if (base === undefined || quote === undefined)
1106
- return false;
1107
- if (cachedForSale === undefined || cachedBase === undefined || cachedQuote === undefined)
1108
- return false;
1109
- return String(base.amount ?? '') === String(cachedBase)
1110
- && String(quote.amount ?? '') === String(cachedQuote)
1111
- && String(chainOrder.for_sale ?? '') === String(cachedForSale);
1112
- }
1113
1262
  /**
1114
1263
  * Summarize a partial (non-atomic) broadcast for the batch-failure catch log.
1115
1264
  * Supports both the pair-mode grouped path (groupsBroadcast/groupsTotal) and
@@ -1347,7 +1496,6 @@ function validateOperationFunds(bot, operations, assetA, assetB) {
1347
1496
  if (!operations || operations.length === 0) {
1348
1497
  return { isValid: true, summary: 'No operations to validate' };
1349
1498
  }
1350
- const { blockchainToFloat, floatToBlockchainInt, quantizeFloat } = require('./order/utils/math');
1351
1499
  let snap = bot.manager?.getChainFundsSnapshot?.();
1352
1500
  if (!snap) {
1353
1501
  snap = { chainFreeSell: 0, chainFreeBuy: 0 };
@@ -1842,12 +1990,21 @@ function buildCowResultFromPlan(bot, plan) {
1842
1990
  });
1843
1991
  }
1844
1992
  }
1993
+ // Refill-slot wire (boundary-hold): producers attach the slot ids of
1994
+ // hole-CREATEs that justify this plan's boundary shift; the executor
1995
+ // holds the committed boundary when a listed refill is guard-skipped.
1996
+ // Absent/non-array => undefined (guarded default at execution, never
1997
+ // fail-open). Spread-correction plans never set this (disjoint bypass).
1998
+ const refillSlotIds = Array.isArray(plan?.refillSlotIds)
1999
+ ? plan.refillSlotIds.filter((id) => typeof id === 'string' && id.length > 0)
2000
+ : undefined;
1845
2001
  return {
1846
2002
  workingGrid,
1847
2003
  workingIndexes: workingGrid.getIndexes(),
1848
2004
  workingBoundary,
1849
2005
  actions,
1850
- origin: plan?.origin
2006
+ origin: plan?.origin,
2007
+ ...(refillSlotIds !== undefined ? { refillSlotIds } : {})
1851
2008
  };
1852
2009
  }
1853
2010
  /**
@@ -1960,14 +2117,7 @@ async function pollChainForConfirmation(bot, opContexts, options = {}) {
1960
2117
  receive: ctx.finalInts.receive,
1961
2118
  orderType: ctx.order.type,
1962
2119
  fingerprint: createContexts.length > 0
1963
- ? buildCreateOpFingerprint({
1964
- side: ctx.order.type,
1965
- assetA: bot.manager?.assets?.assetA?.id,
1966
- assetB: bot.manager?.assets?.assetB?.id,
1967
- sellInt: ctx.finalInts.sell,
1968
- receiveInt: ctx.finalInts.receive,
1969
- slotId: ctx.order.id
1970
- })
2120
+ ? createOpFingerprintForSlot(bot, ctx.order, ctx.finalInts, ctx.order.id)
1971
2121
  : undefined
1972
2122
  });
1973
2123
  if (match) {
@@ -2001,6 +2151,106 @@ async function pollChainForConfirmation(bot, opContexts, options = {}) {
2001
2151
  `${remaining.length} unconfirmed. Falling back to reconciliation.`, 'warn');
2002
2152
  return { allConfirmed: false, confirmed, unconfirmed: remaining, confirmedChainIds: [...matchedChainIds] };
2003
2153
  }
2154
+ /**
2155
+ * Normalize a producer-supplied refillSlotIds wire into a Set.
2156
+ * Absent/empty/non-array => empty (guarded default, never fail-open).
2157
+ */
2158
+ function toRefillSlotIdSet(refillSlotIds) {
2159
+ const set = new Set();
2160
+ if (Array.isArray(refillSlotIds)) {
2161
+ for (const id of refillSlotIds) {
2162
+ if (typeof id === 'string' && id.length > 0)
2163
+ set.add(id);
2164
+ }
2165
+ }
2166
+ return set;
2167
+ }
2168
+ /**
2169
+ * Boundary-hold decision for guard-skipped refills.
2170
+ * When a skipped slot is one of the plan's refill slots — a hole-CREATE that
2171
+ * justified the planned boundary shift — the committed boundary is kept: the
2172
+ * slot was never placed (CREATE skip) or restored to master (UPDATE skip), so
2173
+ * committing the planned boundary would strand empty rail holes past it
2174
+ * (91->94 with 91-94 empty self-legalizes via resolveGapBand).
2175
+ * Unrelated vetoes (skip ids outside the refill set) never pin geometry.
2176
+ * The grid still commits; only the boundary value is held (same discipline
2177
+ * as the overrun-hold in validateBoundaryCommit).
2178
+ */
2179
+ function resolveRefillBoundaryHold(workingBoundary, committedBoundary, skippedUpdateSlotIds, clampedUpdateSlotIds, refillSlotIds, skippedCreateSlotIds = undefined) {
2180
+ const refills = toRefillSlotIdSet(refillSlotIds);
2181
+ const heldRefillSlotIds = [];
2182
+ if (refills.size > 0) {
2183
+ const seen = new Set();
2184
+ const skipCollections = skippedCreateSlotIds !== undefined
2185
+ ? [skippedUpdateSlotIds, clampedUpdateSlotIds, skippedCreateSlotIds]
2186
+ : [skippedUpdateSlotIds, clampedUpdateSlotIds];
2187
+ for (const coll of skipCollections) {
2188
+ if (!coll || typeof coll[Symbol.iterator] !== 'function')
2189
+ continue;
2190
+ for (const id of coll) {
2191
+ if (typeof id === 'string' && refills.has(id) && !seen.has(id)) {
2192
+ seen.add(id);
2193
+ heldRefillSlotIds.push(id);
2194
+ }
2195
+ }
2196
+ }
2197
+ }
2198
+ return {
2199
+ effectiveBoundary: heldRefillSlotIds.length > 0 ? committedBoundary : workingBoundary,
2200
+ heldRefillSlotIds
2201
+ };
2202
+ }
2203
+ /**
2204
+ * Track consecutive boundary-hold batches on the manager (ops visibility).
2205
+ *
2206
+ * A single hold is normal maker discipline: the guard vetoed stale-priced
2207
+ * refills, so the committed boundary stays instead of advancing past
2208
+ * stranded rail holes. A growing run means the grid is trailing the market
2209
+ * (plans keep pricing refills against a racing guard pivot, typically while
2210
+ * fill batches run on backlogged state) and only fresh fills unstick it —
2211
+ * worth escalating so it cannot hide inside per-batch warns.
2212
+ *
2213
+ * Also records a hold signature (`_lastHeldPlanSignature`) for the
2214
+ * identical-held-plan suppression in `performSafeRebalance`, and the caller
2215
+ * escalates a long run to a guard-aware structural re-center. A fill-less
2216
+ * re-plan from unchanged master re-derives the identical plan and re-hits
2217
+ * the identical guard blocks, so it is suppressed rather than re-broadcast;
2218
+ * the heal path is a fresh fill-driven plan or the re-center.
2219
+ * @param {any} manager - Order manager (mutable tracking fields)
2220
+ * @param {boolean} held - Whether this batch held the boundary
2221
+ * @param {any} keptBoundary - Committed boundary that was kept
2222
+ * @param {any} plannedBoundary - Boundary the plan wanted
2223
+ * @param {string[]} heldSlotIds - Refill slots skipped this batch
2224
+ * @returns {number} Consecutive-hold count after this batch (0 when clear)
2225
+ */
2226
+ function trackBoundaryHold(manager, held, keptBoundary, plannedBoundary, heldSlotIds) {
2227
+ if (!manager)
2228
+ return 0;
2229
+ const prev = Number(manager._consecutiveBoundaryHolds) || 0;
2230
+ const consecutive = held ? prev + 1 : 0;
2231
+ manager._consecutiveBoundaryHolds = consecutive;
2232
+ if (held) {
2233
+ manager._lastBoundaryHoldInfo = {
2234
+ at: Date.now(),
2235
+ kept: keptBoundary,
2236
+ planned: plannedBoundary,
2237
+ slots: Array.isArray(heldSlotIds) ? [...heldSlotIds] : [],
2238
+ };
2239
+ // Signature for the identical-held-plan suppression in
2240
+ // performSafeRebalance: a fill-less replan with the same boundary,
2241
+ // pivot and fill timestamp can only reproduce this hold.
2242
+ manager._lastHeldPlanSignature = {
2243
+ boundaryIdx: keptBoundary,
2244
+ pivot: manager._lastFilledPrice ?? null,
2245
+ fillsAt: manager._lastFilledAt ?? 0,
2246
+ wire: Array.isArray(heldSlotIds) ? [...heldSlotIds] : [],
2247
+ };
2248
+ }
2249
+ else {
2250
+ manager._lastHeldPlanSignature = null;
2251
+ }
2252
+ return consecutive;
2253
+ }
2004
2254
  /**
2005
2255
  * Restore skipped update slots in the working grid to master state.
2006
2256
  * @param {import('./dexbot_class.js').DEXBot} bot
@@ -2182,87 +2432,6 @@ async function waitForCowBroadcastSingleFlight(bot, label) {
2182
2432
  * @param {Object} [options={}] - Internal execution options (replanDepth)
2183
2433
  * @returns {Promise<Object>}
2184
2434
  */
2185
- /**
2186
- * PRE-BROADCAST CROSSED-BOOK ASSERT (defense-in-depth, any-writer detection).
2187
- *
2188
- * Simulates the post-batch book: currently placed master orders plus this
2189
- * batch's action overlay (CREATEs add, CANCELs remove, UPDATEs reprice/move).
2190
- * Returns a detail string when a planned BUY would price at-or-above a planned
2191
- * SELL — a state no honest planner produces — so the caller can refuse the
2192
- * broadcast instead of paying for adverse fills. Placed order prices are
2193
- * independent of grid geometry, so this catches boundary overruns regardless
2194
- * of which writer produced them.
2195
- *
2196
- * Detector only: any internal failure returns null (never blocks a broadcast).
2197
- */
2198
- function detectCrossedBookPlan(manager, actions) {
2199
- try {
2200
- const startPrice = Number(manager?.config?.startPrice);
2201
- const book = new Map();
2202
- for (const o of Array.from(manager?.orders?.values?.() ?? [])) {
2203
- if (!o || !o.orderId || o.price == null)
2204
- continue;
2205
- const price = Number(o.price);
2206
- if (!Number.isFinite(price))
2207
- continue;
2208
- let type = o.type;
2209
- if (type !== ORDER_TYPES.BUY && type !== ORDER_TYPES.SELL) {
2210
- // Legacy SPREAD-typed placed order: derive side from the same
2211
- // price-vs-startPrice convention used across the codebase.
2212
- if (!Number.isFinite(startPrice))
2213
- continue;
2214
- type = price < startPrice ? ORDER_TYPES.BUY : ORDER_TYPES.SELL;
2215
- }
2216
- book.set(String(o.id), { type, price });
2217
- }
2218
- for (const a of actions ?? []) {
2219
- const id = String(a.id ?? a.orderId ?? '');
2220
- if (a.type === COW_ACTIONS.CANCEL) {
2221
- if (id)
2222
- book.delete(id);
2223
- }
2224
- else if (a.type === COW_ACTIONS.UPDATE) {
2225
- const newPrice = Number(a.newPrice ?? a.order?.price);
2226
- const newType = a.order?.type;
2227
- if (id && Number.isFinite(newPrice)) {
2228
- const entry = book.get(id);
2229
- const type = (newType === ORDER_TYPES.BUY || newType === ORDER_TYPES.SELL)
2230
- ? newType
2231
- : entry?.type;
2232
- if (entry)
2233
- book.delete(id);
2234
- const key = String(a.newGridId ?? id);
2235
- if (type === ORDER_TYPES.BUY || type === ORDER_TYPES.SELL) {
2236
- book.set(key, { type, price: newPrice });
2237
- }
2238
- }
2239
- }
2240
- else if (a.type === COW_ACTIONS.CREATE) {
2241
- const price = Number(a.order?.price);
2242
- const type = a.order?.type;
2243
- if (!Number.isFinite(price) || (type !== ORDER_TYPES.BUY && type !== ORDER_TYPES.SELL))
2244
- continue;
2245
- if (id)
2246
- book.set(id, { type, price });
2247
- }
2248
- }
2249
- let maxBuy = -Infinity;
2250
- let minSell = Infinity;
2251
- for (const { type, price } of book.values()) {
2252
- if (type === ORDER_TYPES.BUY && price > maxBuy)
2253
- maxBuy = price;
2254
- else if (type === ORDER_TYPES.SELL && price < minSell)
2255
- minSell = price;
2256
- }
2257
- if (Number.isFinite(maxBuy) && Number.isFinite(minSell) && minSell <= maxBuy) {
2258
- return `bestPlacedBuy=${maxBuy} >= bestPlacedSell=${minSell}`;
2259
- }
2260
- return null;
2261
- }
2262
- catch {
2263
- return null;
2264
- }
2265
- }
2266
2435
  /**
2267
2436
  * Derive an update action's planned target size (shared by the rotation and
2268
2437
  * plain size-update op builders).
@@ -2305,58 +2474,22 @@ function clampPostFillUpdateSize(bot, masterOrder, newSize, action) {
2305
2474
  }
2306
2475
  return target;
2307
2476
  }
2308
- async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2309
- const replanDepth = Number.isFinite(Number(options?.replanDepth)) ? Number(options.replanDepth) : 0;
2310
- bot._currentCycleId = (Number.isFinite(Number(bot._currentCycleId)) ? Number(bot._currentCycleId) : 0) + 1;
2311
- const { workingGrid, workingIndexes, workingBoundary, actions } = cowResult;
2312
- if (bot.config.dryRun) {
2313
- const cancelCount = actions.filter((a) => a.type === COW_ACTIONS.CANCEL).length;
2314
- const createCount = actions.filter((a) => a.type === COW_ACTIONS.CREATE).length;
2315
- const updateCount = actions.filter((a) => a.type === COW_ACTIONS.UPDATE).length;
2316
- if (cancelCount > 0)
2317
- bot.manager.logger.log(`Dry run: would cancel ${cancelCount} orders`, 'info');
2318
- if (createCount > 0)
2319
- bot.manager.logger.log(`Dry run: would place ${createCount} new orders`, 'info');
2320
- if (updateCount > 0)
2321
- bot.manager.logger.log(`Dry run: would update ${updateCount} orders`, 'info');
2322
- popPushedWorkingGrid(bot, cowResult);
2323
- return { executed: true, hadRotation: false };
2324
- }
2325
- // Single-flight COW broadcast guard (entry check): never broadcast two
2326
- // batches concurrently. Overlapping broadcasts plan from the same base
2327
- // grid version; when the first commits it bumps _gridVersion, so the
2328
- // second's commit is refused (base-version mismatch) -> adopt-from-chain
2329
- // -> snapshot reload that can drop the adopted order and produce an
2330
- // orphan fill. This entry wait is an optimization; the authoritative
2331
- // atomic check-and-set happens right before the broadcast below.
2332
- if (await waitForCowBroadcastSingleFlight(bot, 'entry')) {
2333
- popPushedWorkingGrid(bot, cowResult);
2334
- return { executed: false, aborted: true, reason: 'SHUTDOWN_IN_PROGRESS', hadRotation: false };
2335
- }
2336
- // DRAIN PENDING CORRECTIONS before the batch is planned/broadcast.
2337
- // Cancel-only corrections (duplicate-price orphans) queued by an earlier
2338
- // sync must not sit while batches run back-to-back (startup create
2339
- // groups, fill bursts) — with the open-orders sync loop disabled they
2340
- // would otherwise linger indefinitely, keep blocking same-level CREATEs,
2341
- // and risk cancelling the wrong side of a duplicate later. Draining here
2342
- // also keeps this batch's collision checks (chain_orphan_collision)
2343
- // honest: orphaned chain orders already queued for cancellation are
2344
- // resolved before the plan validates its CREATE targets against them.
2345
- const pendingCorrectionCount = Array.isArray(bot.manager?.ordersNeedingPriceCorrection)
2346
- ? bot.manager.ordersNeedingPriceCorrection.length
2347
- : 0;
2348
- if (pendingCorrectionCount > 0 && !bot._shuttingDown) {
2349
- try {
2350
- bot.manager.logger.log(`[COW] Draining ${pendingCorrectionCount} pending correction(s) before batch`, 'info');
2351
- const drainResult = await orderUtils.correctAllPriceMismatches(bot.manager, bot.account, bot.privateKey, chainOrders);
2352
- if (drainResult?.failed > 0) {
2353
- bot.manager.logger.log(`[COW] ${drainResult.failed} correction(s) failed pre-batch; remaining entries retry on next sync/maintenance tick`, 'warn');
2354
- }
2355
- }
2356
- catch (drainErr) {
2357
- bot.manager.logger.log(`[COW] Pre-batch correction drain failed: ${getErrorMessage(drainErr)}`, 'warn');
2358
- }
2359
- }
2477
+ /**
2478
+ * Pre-broadcast guard chain for a COW batch: create-slot validation (with
2479
+ * tolerance-violation filtering), recovery-exhausted block, pending-broadcast
2480
+ * and unmatched-chain-order guards (with adoption sync), and the crossed-book
2481
+ * gate. Every refusal pops the pushed working grid before returning.
2482
+ *
2483
+ * Mutates cowResult.actions in place (tolerance-violating CREATEs are
2484
+ * filtered); the caller holds the same array reference.
2485
+ *
2486
+ * @param {import('./dexbot_class.js').DEXBot} bot
2487
+ * @param {Object} cowResult - Rebalance/COW result carrying actions
2488
+ * @returns {Promise<Object>} { proceed:false, result } on refusal, or
2489
+ * { proceed:true, crossingCandidates, intraBatchCandidates } on pass
2490
+ */
2491
+ async function runPreBroadcastGuards(bot, cowResult) {
2492
+ const { actions } = cowResult;
2360
2493
  const chainOrderCandidates = Array.isArray(bot.manager?._lastUnmatchedChainOrders)
2361
2494
  ? bot.manager._lastUnmatchedChainOrders
2362
2495
  : [];
@@ -2398,11 +2531,14 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2398
2531
  if (hasHardOccupiedViolation) {
2399
2532
  popPushedWorkingGrid(bot, cowResult);
2400
2533
  return {
2401
- executed: false,
2402
- aborted: true,
2403
- reason: 'CREATE_SLOT_OCCUPIED',
2404
- violations: createSlotValidation.violations,
2405
- hadRotation: false
2534
+ proceed: false,
2535
+ result: {
2536
+ executed: false,
2537
+ aborted: true,
2538
+ reason: 'CREATE_SLOT_OCCUPIED',
2539
+ violations: createSlotValidation.violations,
2540
+ hadRotation: false
2541
+ }
2406
2542
  };
2407
2543
  }
2408
2544
  const violatingIds = createSlotValidation.violatingTargetIds;
@@ -2424,7 +2560,7 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2424
2560
  // returns: a pushed working grid must be popped here or the
2425
2561
  // caller would leak the stack entry.
2426
2562
  popPushedWorkingGrid(bot, cowResult);
2427
- return { executed: false, hadRotation: false };
2563
+ return { proceed: false, result: { executed: false, hadRotation: false } };
2428
2564
  }
2429
2565
  }
2430
2566
  }
@@ -2436,27 +2572,29 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2436
2572
  `Waiting for next fill or sync cycle to reset recovery state.`, 'warn');
2437
2573
  popPushedWorkingGrid(bot, cowResult);
2438
2574
  return {
2439
- executed: false,
2440
- aborted: true,
2441
- reason: 'RECOVERY_EXHAUSTED',
2442
- hadRotation: false
2575
+ proceed: false,
2576
+ result: {
2577
+ executed: false,
2578
+ aborted: true,
2579
+ reason: 'RECOVERY_EXHAUSTED',
2580
+ hadRotation: false
2581
+ }
2443
2582
  };
2444
2583
  }
2445
2584
  const unmatchedChainOrders = Array.isArray(bot.manager?._lastUnmatchedChainOrders)
2446
2585
  ? bot.manager._lastUnmatchedChainOrders
2447
2586
  : [];
2448
- const pendingBroadcasts = (bot.manager && bot.manager._pendingBroadcasts instanceof Map)
2449
- ? Array.from(bot.manager._pendingBroadcasts.values())
2450
- : [];
2451
- if (hasCreateActions && (unmatchedChainOrders.length > 0 || pendingBroadcasts.length > 0)) {
2587
+ // Out-of-grid holds are permanent by design (live orders held outside
2588
+ // the frozen rail): they can never be adopted and collide with nothing,
2589
+ // so they must not block CREATES — otherwise one dip-protection hold
2590
+ // freezes the whole grid. Only adoptable/cancellable orphans block.
2591
+ const blockingUnmatched = unmatchedChainOrders.filter((u) => !orderUtils.isNonBlockingUnmatchedOrder(u));
2592
+ const pendingBroadcasts = getPendingBroadcasts(bot);
2593
+ if (hasCreateActions && (blockingUnmatched.length > 0 || pendingBroadcasts.length > 0)) {
2452
2594
  if (pendingBroadcasts.length > 0) {
2453
2595
  bot.manager.logger.log(`[COW] Rejecting CREATE batch: ${pendingBroadcasts.length} pending broadcast(s) from a prior uncertain ` +
2454
2596
  `broadcast. Running recovery before placing replacement orders.`, 'error');
2455
- if (typeof bot.manager.requestStructuralGridResync === 'function') {
2456
- if (bot.manager._recoveryState)
2457
- bot.manager._recoveryState = { ...bot.manager._recoveryState, structuralResyncRequested: true };
2458
- await bot.manager.requestStructuralGridResync('pending broadcasts before COW create', { pendingBroadcasts: pendingBroadcasts.map((p) => p.slotId) });
2459
- }
2597
+ await requestStructuralResync(bot, 'pending broadcasts before COW create', { pendingBroadcasts: pendingBroadcasts.map((p) => p.slotId) });
2460
2598
  try {
2461
2599
  await reconcileAfterUncertainBroadcast(bot, new BroadcastUncertainError('rejected CREATE batch had pending broadcasts', {
2462
2600
  operations: pendingBroadcasts.map((p) => p.order),
@@ -2471,17 +2609,20 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2471
2609
  }
2472
2610
  popPushedWorkingGrid(bot, cowResult);
2473
2611
  return {
2474
- executed: false,
2475
- aborted: true,
2476
- reason: 'PENDING_BROADCASTS',
2477
- hadRotation: false
2612
+ proceed: false,
2613
+ result: {
2614
+ executed: false,
2615
+ aborted: true,
2616
+ reason: 'PENDING_BROADCASTS',
2617
+ hadRotation: false
2618
+ }
2478
2619
  };
2479
2620
  }
2480
- const unmatchedSample = unmatchedChainOrders
2621
+ const unmatchedSample = blockingUnmatched
2481
2622
  .slice(0, 3)
2482
2623
  .map((o) => formatUnmatchedChainOrderForLog(o))
2483
2624
  .join(' | ');
2484
- bot.manager.logger.log(`[COW] ${unmatchedChainOrders.length} unmatched chain order(s) blocking CREATES ` +
2625
+ bot.manager.logger.log(`[COW] ${blockingUnmatched.length} unmatched chain order(s) blocking CREATES ` +
2485
2626
  (unmatchedSample ? `(${unmatchedSample})` : '') +
2486
2627
  ` — adopting via sync instead of cancelling`, 'info');
2487
2628
  try {
@@ -2491,8 +2632,8 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2491
2632
  // freshest orders; syncing on it would virtualize live slots and
2492
2633
  // re-create duplicates. Defer the adoption to a clean read — the
2493
2634
  // unmatched orders keep blocking CREATEs until then.
2494
- if (freshRead.truncated) {
2495
- bot.manager.logger.log('[COW] Post-guard chain snapshot TRUNCATED; skipping adoption sync (partial snapshot would virtualize live slots) — unmatched chain orders keep blocking CREATEs', 'warn');
2635
+ if (!isAuthoritativeChainRead(freshRead)) {
2636
+ bot.manager.logger.log('[COW] Post-guard chain snapshot not authoritative; skipping adoption sync (partial snapshot would virtualize live slots) — unmatched chain orders keep blocking CREATEs', 'warn');
2496
2637
  }
2497
2638
  else if (freshRead.orders && freshRead.orders.length > 0) {
2498
2639
  const freshSnapshot = freshRead.orders;
@@ -2532,18 +2673,17 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2532
2673
  catch (syncErr) {
2533
2674
  bot.manager.logger.log(`[COW] Failed to sync/unmatched orders: ${syncErr?.message || syncErr}`, 'warn');
2534
2675
  }
2535
- if (typeof bot.manager.requestStructuralGridResync === 'function') {
2536
- if (bot.manager._recoveryState)
2537
- bot.manager._recoveryState = { ...bot.manager._recoveryState, structuralResyncRequested: true };
2538
- await bot.manager.requestStructuralGridResync('unmatched chain orders before COW create', { unmatchedChainOrders: unmatchedChainOrders });
2539
- }
2676
+ await requestStructuralResync(bot, 'unmatched chain orders before COW create', { unmatchedChainOrders: unmatchedChainOrders });
2540
2677
  bot.manager.logger.log(`[COW] Rejecting CREATE batch after sync: working grid invalidated by master mutation`, 'info');
2541
2678
  popPushedWorkingGrid(bot, cowResult);
2542
2679
  return {
2543
- executed: false,
2544
- aborted: true,
2545
- reason: 'UNMATCHED_CHAIN_ORDERS',
2546
- hadRotation: false
2680
+ proceed: false,
2681
+ result: {
2682
+ executed: false,
2683
+ aborted: true,
2684
+ reason: 'UNMATCHED_CHAIN_ORDERS',
2685
+ hadRotation: false
2686
+ }
2547
2687
  };
2548
2688
  }
2549
2689
  // Crossing-check candidate set (master + pending-broadcast + unmatched
@@ -2551,7 +2691,6 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2551
2691
  // it reflects any sync they triggered.
2552
2692
  const crossingCandidates = buildCrossingCandidates(bot);
2553
2693
  const intraBatchCandidates = [];
2554
- const { assetA, assetB } = bot.manager.assets;
2555
2694
  // CROSSED-BOOK GATE: refuse to broadcast any batch whose simulated result
2556
2695
  // prices a BUY at-or-above a SELL (see detectCrossedBookPlan).
2557
2696
  const crossedBookDetail = detectCrossedBookPlan(bot.manager, actions);
@@ -2559,17 +2698,103 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2559
2698
  bot.manager.logger.log(`[COW] Rejecting batch pre-broadcast: crossed book detected (${crossedBookDetail})`, 'error');
2560
2699
  popPushedWorkingGrid(bot, cowResult);
2561
2700
  return {
2562
- executed: false,
2563
- aborted: true,
2564
- reason: 'CROSSED_BOOK',
2565
- detail: crossedBookDetail,
2566
- hadRotation: false
2701
+ proceed: false,
2702
+ result: {
2703
+ executed: false,
2704
+ aborted: true,
2705
+ reason: 'CROSSED_BOOK',
2706
+ detail: crossedBookDetail,
2707
+ hadRotation: false
2708
+ }
2567
2709
  };
2568
2710
  }
2711
+ return { proceed: true, crossingCandidates, intraBatchCandidates };
2712
+ }
2713
+ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2714
+ const replanDepth = Number.isFinite(Number(options?.replanDepth)) ? Number(options.replanDepth) : 0;
2715
+ bot._currentCycleId = (Number.isFinite(Number(bot._currentCycleId)) ? Number(bot._currentCycleId) : 0) + 1;
2716
+ const { workingGrid, workingIndexes, workingBoundary, actions } = cowResult;
2717
+ // Boundary-hold value: computed pre-broadcast after the skip-restore and
2718
+ // frozen for every downstream commit path (success + uncertain-catch).
2719
+ // workingBoundary itself stays untouched (audit trail).
2720
+ let effectiveBoundary = workingBoundary;
2721
+ // True when the refill hold pinned the committed boundary over the plan's
2722
+ // target. Hoisted out of the broadcast try block: the uncertain-broadcast
2723
+ // catch commits too, and a held boundary must keep the owed fill crawls
2724
+ // there as well (see _commitWorkingGrid pending-crawl bookkeeping).
2725
+ let boundaryHeld = false;
2726
+ // Consecutive-hold re-center tuning (see the escalation block below).
2727
+ const boundaryHoldTiming = constantsModule?.TIMING || {};
2728
+ const holdResyncThreshold = Number(boundaryHoldTiming.BOUNDARY_HOLD_RESYNC_THRESHOLD) > 0
2729
+ ? Number(boundaryHoldTiming.BOUNDARY_HOLD_RESYNC_THRESHOLD)
2730
+ : 4;
2731
+ const holdResyncCooldownMs = Number(boundaryHoldTiming.BOUNDARY_HOLD_RESYNC_COOLDOWN_MS) > 0
2732
+ ? Number(boundaryHoldTiming.BOUNDARY_HOLD_RESYNC_COOLDOWN_MS)
2733
+ : 5 * 60 * 1000;
2734
+ if (bot.config.dryRun) {
2735
+ const cancelCount = actions.filter((a) => a.type === COW_ACTIONS.CANCEL).length;
2736
+ const createCount = actions.filter((a) => a.type === COW_ACTIONS.CREATE).length;
2737
+ const updateCount = actions.filter((a) => a.type === COW_ACTIONS.UPDATE).length;
2738
+ if (cancelCount > 0)
2739
+ bot.manager.logger.log(`Dry run: would cancel ${cancelCount} orders`, 'info');
2740
+ if (createCount > 0)
2741
+ bot.manager.logger.log(`Dry run: would place ${createCount} new orders`, 'info');
2742
+ if (updateCount > 0)
2743
+ bot.manager.logger.log(`Dry run: would update ${updateCount} orders`, 'info');
2744
+ popPushedWorkingGrid(bot, cowResult);
2745
+ return { executed: true, hadRotation: false };
2746
+ }
2747
+ // Single-flight COW broadcast guard (entry check): never broadcast two
2748
+ // batches concurrently. Overlapping broadcasts plan from the same base
2749
+ // grid version; when the first commits it bumps _gridVersion, so the
2750
+ // second's commit is refused (base-version mismatch) -> adopt-from-chain
2751
+ // -> snapshot reload that can drop the adopted order and produce an
2752
+ // orphan fill. This entry wait is an optimization; the authoritative
2753
+ // atomic check-and-set happens right before the broadcast below.
2754
+ if (await waitForCowBroadcastSingleFlight(bot, 'entry')) {
2755
+ popPushedWorkingGrid(bot, cowResult);
2756
+ return { executed: false, aborted: true, reason: 'SHUTDOWN_IN_PROGRESS', hadRotation: false };
2757
+ }
2758
+ // DRAIN PENDING CORRECTIONS before the batch is planned/broadcast.
2759
+ // Cancel-only corrections (duplicate-price orphans) queued by an earlier
2760
+ // sync must not sit while batches run back-to-back (startup create
2761
+ // groups, fill bursts) — with the open-orders sync loop disabled they
2762
+ // would otherwise linger indefinitely, keep blocking same-level CREATEs,
2763
+ // and risk cancelling the wrong side of a duplicate later. Draining here
2764
+ // also keeps this batch's collision checks (chain_orphan_collision)
2765
+ // honest: orphaned chain orders already queued for cancellation are
2766
+ // resolved before the plan validates its CREATE targets against them.
2767
+ const pendingCorrectionCount = Array.isArray(bot.manager?.ordersNeedingPriceCorrection)
2768
+ ? bot.manager.ordersNeedingPriceCorrection.length
2769
+ : 0;
2770
+ if (pendingCorrectionCount > 0 && !bot._shuttingDown) {
2771
+ try {
2772
+ bot.manager.logger.log(`[COW] Draining ${pendingCorrectionCount} pending correction(s) before batch`, 'info');
2773
+ const drainResult = await orderUtils.correctAllPriceMismatches(bot.manager, bot.account, bot.privateKey, chainOrders);
2774
+ 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');
2776
+ }
2777
+ }
2778
+ catch (drainErr) {
2779
+ bot.manager.logger.log(`[COW] Pre-batch correction drain failed: ${getErrorMessage(drainErr)}`, 'warn');
2780
+ }
2781
+ }
2782
+ // Pre-broadcast guard chain (create-slot validation, recovery-exhausted
2783
+ // block, pending/unmatched guards, crossed-book gate) — see
2784
+ // runPreBroadcastGuards. Refusals already popped the working grid.
2785
+ const guards = await runPreBroadcastGuards(bot, cowResult);
2786
+ if (!guards.proceed)
2787
+ return guards.result;
2788
+ const { crossingCandidates, intraBatchCandidates } = guards;
2789
+ const { assetA, assetB } = bot.manager.assets;
2569
2790
  const operations = [];
2570
2791
  const opContexts = [];
2571
2792
  const skippedUpdateSlotIds = new Set();
2572
2793
  let skippedUpdateCount = 0;
2794
+ // Guard-skipped CREATE slot ids (hole-refills never placed). Fed to the
2795
+ // boundary-hold intersect alongside the UPDATE sets above — a skipped
2796
+ // refill CREATE strands its rail hole exactly like a restored UPDATE.
2797
+ const skippedCreateSlotIds = new Set();
2573
2798
  // Per-batch LAST-FILL-GUARD disposition counters. Per-action pass lines
2574
2799
  // would spam big batches, so the guard emits one batch summary instead
2575
2800
  // (see the summary after the action loop below).
@@ -2619,6 +2844,16 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2619
2844
  bot._markGridActivity('batch start');
2620
2845
  bot.manager._setRebalanceState(REBALANCE_STATES.BROADCASTING);
2621
2846
  bot.manager.startBroadcasting();
2847
+ // P3 — freeze the guard pivot once per batch: per-action refreshes
2848
+ // mutated the pivot mid-batch (02:03 pivots drifted 0.001523→0.001529
2849
+ // across 20 checks), so early actions were judged against a different
2850
+ // pivot than later ones. The batch summary still reports whether this
2851
+ // freeze moved the pivot under the plan.
2852
+ try {
2853
+ if (refreshLastFillPivotFromQueue(bot))
2854
+ lastFillGuardPivotRefreshed = true;
2855
+ }
2856
+ catch { /* best-effort */ }
2622
2857
  for (const action of actions) {
2623
2858
  if (action.type === COW_ACTIONS.CANCEL) {
2624
2859
  try {
@@ -2645,6 +2880,8 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2645
2880
  const sizeValidation = validateOrderSizeForExecution(bot, order.size, order.type, order, order.size);
2646
2881
  if (!sizeValidation.isValid) {
2647
2882
  bot.manager.logger.log(`Skipping create op for ${action.id}: ${sizeValidation.reason}`, 'warn');
2883
+ if (action.id)
2884
+ skippedCreateSlotIds.add(action.id);
2648
2885
  continue;
2649
2886
  }
2650
2887
  const liveSlot = bot.manager.orders.get(order.id);
@@ -2669,6 +2906,8 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2669
2906
  `same-batch CREATE ${batchCollision.id} already at ` +
2670
2907
  `price ${Format.formatPrice6(batchCollision.order.price)}. ` +
2671
2908
  `The next reconcile cycle will resolve the mismatch.`, 'warn');
2909
+ if (order.id)
2910
+ skippedCreateSlotIds.add(order.id);
2672
2911
  continue;
2673
2912
  }
2674
2913
  // CROSSING-PLACEMENT GUARD (create variant): the batch-level
@@ -2687,6 +2926,8 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2687
2926
  bot.manager.logger.log(`[COW-CROSS-GUARD] Skipping CREATE for ${order.id} at ` +
2688
2927
  `${Format.formatPrice6(createPrice)}: crosses live ` +
2689
2928
  `${crossedOrderLabel(effectiveCrossed)}; re-planned after its cancel confirms.`, 'warn');
2929
+ if (order.id)
2930
+ skippedCreateSlotIds.add(order.id);
2690
2931
  continue;
2691
2932
  }
2692
2933
  // LAST-FILL PRICE GUARD: pivot ± halfIncrement (BUY < pivot*(1-half), SELL > pivot*(1+half)).
@@ -2699,20 +2940,15 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2699
2940
  const isCorrectionCreate = actionOrigin === 'spread-correction'
2700
2941
  || (actionOrigin == null && batchOrigin === 'spread-correction');
2701
2942
  if (!isCorrectionCreate) {
2702
- try {
2703
- if (refreshLastFillPivotFromQueue(bot))
2704
- lastFillGuardPivotRefreshed = true;
2705
- }
2706
- catch { /* best-effort */ }
2707
- const lastPrice = bot.manager?._lastFilledPrice;
2708
- const lastType = bot.manager?._lastFilledType;
2709
- const inc = resolveLastFillGuardIncrement(bot);
2710
- const check = isLastFillGuardBlocked(createPrice, order.size, order.type, lastPrice, lastType, inc);
2711
- lastFillGuardStats.checked++;
2943
+ const { check, refreshed } = runLastFillGuardCheck(bot, createPrice, order.size, order.type, lastFillGuardStats, true);
2944
+ if (refreshed)
2945
+ lastFillGuardPivotRefreshed = true;
2712
2946
  if (check.blocked) {
2713
2947
  lastFillGuardStats.skipped++;
2714
2948
  const dir = order.type === ORDER_TYPES.BUY ? 'above' : 'below';
2715
- bot.manager.logger.log(`[LAST-FILL-GUARD] Skipping ${order.type} CREATE for ${order.id} at ${Format.formatPrice6(createPrice)}: ${dir} last filled ${Format.formatPrice6(check.pivot)} (halfInc ${check.halfInc}% thr ${Format.formatPrice6(check.threshold)}); re-planned after market moves`, 'warn');
2949
+ bot.manager.logger.log(`[LAST-FILL-GUARD] Skipping ${order.type} CREATE for ${order.id} at ${Format.formatPrice6(createPrice)}: ${dir} last filled ${Format.formatPrice6(check.pivot)} (halfInc ${check.halfInc}% thr ${Format.formatPrice6(check.threshold)}); re-planned after market moves`, 'debug');
2950
+ if (order.id)
2951
+ skippedCreateSlotIds.add(order.id);
2716
2952
  continue;
2717
2953
  }
2718
2954
  lastFillGuardStats.passed++;
@@ -2726,6 +2962,8 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2726
2962
  const buildResult = await chainOrders.buildCreateOrderOp(bot.account, args.amountToSell, args.sellAssetId, args.minToReceive, args.receiveAssetId, null);
2727
2963
  if (!buildResult) {
2728
2964
  bot.manager.logger.log(`Skipping create op for ${action.id}: amounts would round to 0 on blockchain`, 'warn');
2965
+ if (action.id)
2966
+ skippedCreateSlotIds.add(action.id);
2729
2967
  continue;
2730
2968
  }
2731
2969
  operations.push(buildResult.op);
@@ -2779,6 +3017,15 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2779
3017
  }
2780
3018
  const rotationSizeValidation = validateOrderSizeForExecution(bot, newSize, orderType, action.order, newSize);
2781
3019
  if (!rotationSizeValidation.isValid) {
3020
+ // Record like every sibling skip site: the skip set
3021
+ // feeds both the working-grid restore and the
3022
+ // boundary hold (a skipped refill must not let the
3023
+ // committed boundary advance past its empty slot).
3024
+ skippedUpdateCount++;
3025
+ if (action.id)
3026
+ skippedUpdateSlotIds.add(action.id);
3027
+ if (action.newGridId)
3028
+ skippedUpdateSlotIds.add(action.newGridId);
2782
3029
  bot.manager.logger.log(`Skipping rotation update ${action.id} -> ${action.newGridId}: ${rotationSizeValidation.reason}`, 'warn');
2783
3030
  continue;
2784
3031
  }
@@ -2928,16 +3175,9 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2928
3175
  lastFillGuardStats.bypassed++;
2929
3176
  }
2930
3177
  else {
2931
- try {
2932
- if (refreshLastFillPivotFromQueue(bot))
2933
- lastFillGuardPivotRefreshed = true;
2934
- }
2935
- catch { /* best-effort */ }
2936
- const lastPrice = bot.manager?._lastFilledPrice;
2937
- const lastType = bot.manager?._lastFilledType;
2938
- const inc = resolveLastFillGuardIncrement(bot);
2939
- const check = isLastFillGuardBlocked(newPrice, newSize, orderType, lastPrice, lastType, inc);
2940
- lastFillGuardStats.checked++;
3178
+ const { check, refreshed } = runLastFillGuardCheck(bot, newPrice, newSize, orderType, lastFillGuardStats, true);
3179
+ if (refreshed)
3180
+ lastFillGuardPivotRefreshed = true;
2941
3181
  if (check.blocked) {
2942
3182
  lastFillGuardStats.skipped++;
2943
3183
  skippedUpdateCount++;
@@ -2946,7 +3186,7 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2946
3186
  if (action.newGridId)
2947
3187
  skippedUpdateSlotIds.add(action.newGridId);
2948
3188
  const dir = orderType === ORDER_TYPES.BUY ? 'above' : 'below';
2949
- bot.manager.logger.log(`[LAST-FILL-GUARD] Skipping ${orderType} UPDATE for ${action.id} -> ${action.newGridId} at ${Format.formatPrice6(newPrice)}: ${dir} last filled ${Format.formatPrice6(check.pivot)} (halfInc ${check.halfInc}% thr ${Format.formatPrice6(check.threshold)})`, 'warn');
3189
+ bot.manager.logger.log(`[LAST-FILL-GUARD] Skipping ${orderType} UPDATE for ${action.id} -> ${action.newGridId} at ${Format.formatPrice6(newPrice)}: ${dir} last filled ${Format.formatPrice6(check.pivot)} (halfInc ${check.halfInc}% thr ${Format.formatPrice6(check.threshold)})`, 'debug');
2950
3190
  continue;
2951
3191
  }
2952
3192
  lastFillGuardStats.passed++;
@@ -3062,24 +3302,19 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
3062
3302
  }
3063
3303
  // LAST-FILL GUARD (fallback variant): this CREATE
3064
3304
  // replaces a rotation UPDATE at a repriced level,
3065
- // so it obeys the same guard with a refreshed
3066
- // pivot — no origin bypass, same as rotations.
3305
+ // so it obeys the same guard against the frozen
3306
+ // batch pivot — no origin bypass, same as rotations.
3067
3307
  try {
3068
- if (refreshLastFillPivotFromQueue(bot))
3308
+ const { check: fbCheck, refreshed: fbRefreshed } = runLastFillGuardCheck(bot, fbPrice, fbSize, fbType, lastFillGuardStats, true);
3309
+ if (fbRefreshed)
3069
3310
  lastFillGuardPivotRefreshed = true;
3070
- }
3071
- catch { /* best-effort */ }
3072
- try {
3073
- const fbInc = resolveLastFillGuardIncrement(bot);
3074
- const fbCheck = isLastFillGuardBlocked(fbPrice, fbSize, fbType, bot.manager?._lastFilledPrice, bot.manager?._lastFilledType, fbInc);
3075
- lastFillGuardStats.checked++;
3076
3311
  if (fbCheck.blocked) {
3077
3312
  lastFillGuardStats.skipped++;
3078
3313
  const fbDir = fbType === ORDER_TYPES.BUY ? 'above' : 'below';
3079
3314
  bot.manager.logger.log(`[LAST-FILL-GUARD] Skipping CREATE fallback for ${targetSlotId} at ` +
3080
3315
  `${Format.formatPrice6(fbPrice)}: ${fbDir} last filled ` +
3081
3316
  `${Format.formatPrice6(fbCheck.pivot)} (halfInc ${fbCheck.halfInc}% thr ` +
3082
- `${Format.formatPrice6(fbCheck.threshold)}); re-planned after market moves`, 'warn');
3317
+ `${Format.formatPrice6(fbCheck.threshold)}); re-planned after market moves`, 'debug');
3083
3318
  continue;
3084
3319
  }
3085
3320
  lastFillGuardStats.passed++;
@@ -3121,10 +3356,10 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
3121
3356
  // are what incident reconstruction needs. Origin folds in here as
3122
3357
  // mode=bypassed(<origin>); there is no second source of truth.
3123
3358
  // Cold (guard off) is warn, not info — a disabled guard must say so.
3124
- // pivotRefreshed surfaces whether a mid-broadcast queued fill moved the
3125
- // pivot under this batch's checks (the refresh itself stays debug).
3126
- // Note the printed pivot is end-of-batch state: when pivotRefreshed is
3127
- // true, early actions were checked against the older pivot.
3359
+ // pivotRefreshed surfaces whether the batch-start freeze picked up a
3360
+ // queued fill (the refresh itself stays debug). The pivot is frozen
3361
+ // for the whole batch, so every action was checked against the same
3362
+ // pivot printed here.
3128
3363
  try {
3129
3364
  const totalGuarded = lastFillGuardStats.checked + lastFillGuardStats.bypassed;
3130
3365
  if (totalGuarded > 0) {
@@ -3160,6 +3395,47 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
3160
3395
  }
3161
3396
  bot.manager.logger.log(`[COW] Re-synced ${clampedUpdateSlotIds.size} post-fill-clamped slot(s) from master before commit`, 'debug');
3162
3397
  }
3398
+ // BOUNDARY HOLD: skipped refill slots strand empty rail holes past the
3399
+ // planned boundary (commit gate skips empties, self-legalizing) — hold
3400
+ // the committed boundary; intersect-only, grid still commits.
3401
+ const refillHold = resolveRefillBoundaryHold(workingBoundary, bot.manager.boundaryIdx, skippedUpdateSlotIds, clampedUpdateSlotIds, cowResult?.refillSlotIds, skippedCreateSlotIds);
3402
+ effectiveBoundary = refillHold.effectiveBoundary;
3403
+ boundaryHeld = refillHold.heldRefillSlotIds.length > 0;
3404
+ if (refillHold.heldRefillSlotIds.length > 0) {
3405
+ bot.manager.logger.log(`[COW] Boundary hold: ${refillHold.heldRefillSlotIds.length} refill slot(s) skipped ` +
3406
+ `(${refillHold.heldRefillSlotIds.join(', ')}) — keeping ${bot.manager.boundaryIdx} over planned ${workingBoundary}`, 'warn');
3407
+ }
3408
+ // Consecutive-hold tracking (INV-COW-007 visibility): escalate a
3409
+ // growing run — the grid is trailing the market and only fresh
3410
+ // fills unstick it. Cleared automatically on the first clean batch.
3411
+ const consecutiveHolds = trackBoundaryHold(bot.manager, refillHold.heldRefillSlotIds.length > 0, bot.manager.boundaryIdx, workingBoundary, refillHold.heldRefillSlotIds);
3412
+ if (consecutiveHolds >= 3) {
3413
+ bot.manager.logger.log(`[COW] Boundary held ${consecutiveHolds} consecutive batches ` +
3414
+ `(keeping ${bot.manager.boundaryIdx} over planned ${workingBoundary}). ` +
3415
+ `Grid is trailing the market — refills re-price once the guard pivot settles; ` +
3416
+ `heals on the next fill-driven plan. Investigate only if the run keeps growing without new fills.`, 'warn');
3417
+ }
3418
+ // Guard-aware re-center escalation (INV-COW-007 heal path): a run of
3419
+ // holds carrying fresh fills means the grid is trailing the market and
3420
+ // will not heal from fill-less replans (they re-derive the identical
3421
+ // veto). Request a structural resync that re-derives centers on the
3422
+ // live pivot; the cooldown prevents resync storms. requestStructuralGridResync
3423
+ // re-defers while this batch is still in flight, so it runs in a clean context.
3424
+ if (boundaryHeld && consecutiveHolds >= holdResyncThreshold) {
3425
+ const freshFills = Array.isArray(cowResult?.fills) && cowResult.fills.length > 0;
3426
+ const lastResyncAt = Number(bot.manager._lastBoundaryHoldResyncAt) || 0;
3427
+ if (freshFills && (Date.now() - lastResyncAt) >= holdResyncCooldownMs) {
3428
+ bot.manager._lastBoundaryHoldResyncAt = Date.now();
3429
+ bot.manager.logger.log(`[COW] Boundary held ${consecutiveHolds} consecutive batches with fresh fills; ` +
3430
+ `requesting guard-aware structural re-center (cooldown ${Math.round(holdResyncCooldownMs / 1000)}s)`, 'warn');
3431
+ try {
3432
+ void bot.manager.requestStructuralGridResync?.('boundary-hold-trailing-market', { reason: 'boundary-hold-trailing-market' });
3433
+ }
3434
+ catch (err) {
3435
+ bot.manager.logger.log(`[COW] Structural re-center request failed (non-fatal): ${getErrorMessage(err)}`, 'warn');
3436
+ }
3437
+ }
3438
+ }
3163
3439
  if (operations.length === 0) {
3164
3440
  // Pop the working grid: in the re-plan recursion the fresh plan's
3165
3441
  // grid was pushed by performSafeRebalance, and nothing downstream
@@ -3255,14 +3531,7 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
3255
3531
  sell: item.ctx?.finalInts?.sell,
3256
3532
  receive: item.ctx?.finalInts?.receive,
3257
3533
  orderType: item.ctx?.order?.type,
3258
- fingerprint: buildCreateOpFingerprint({
3259
- side: item.ctx?.order?.type,
3260
- assetA: bot.manager?.assets?.assetA?.id,
3261
- assetB: bot.manager?.assets?.assetB?.id,
3262
- sellInt: item.ctx?.finalInts?.sell,
3263
- receiveInt: item.ctx?.finalInts?.receive,
3264
- slotId
3265
- })
3534
+ fingerprint: createOpFingerprintForSlot(bot, item.ctx?.order, item.ctx?.finalInts, slotId)
3266
3535
  });
3267
3536
  if (match?.id) {
3268
3537
  workingGrid.set(slotId, { ...slot, orderId: match.id });
@@ -3305,29 +3574,17 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
3305
3574
  // options.result, so a later throw in this frame (e.g.
3306
3575
  // processBatchResults after a successful commit) cannot pop a
3307
3576
  // second time for the same grid in the batch catch below.
3308
- const commitOk = await bot.manager._commitWorkingGrid(workingGrid, workingIndexes, workingBoundary, { skipRecalc: true, result: cowResult });
3577
+ const commitOk = await bot.manager._commitWorkingGrid(workingGrid, workingIndexes, effectiveBoundary, { skipRecalc: true, result: cowResult, boundaryHeld });
3309
3578
  if (!commitOk) {
3310
3579
  // Master changed during broadcast (e.g. a fill landed and was
3311
3580
  // processed concurrently) so the commit was refused. The batch
3312
3581
  // is on chain; adopt the placed orders from the chain so master
3313
3582
  // converges instead of remaining divergent until a later sync.
3314
- bot.manager.logger.log('[COW] Commit refused after broadcast; adopting placed orders from chain to keep master in sync', 'warn');
3315
- const adopted = await adoptPlacedBatchFromChain(bot, chainOrders, '[COW]', { placedResults: result, placedContexts: executedContexts });
3316
- if (!adopted) {
3317
- // Chain state unknown (empty/lagging read or sync failure):
3318
- // keep the pending-broadcast protection so a later plan
3319
- // cannot duplicate the placed orders, and let the structural
3320
- // resync adopt them from the chain.
3321
- bot.manager.logger.log('[COW] Commit refused and chain adoption unavailable; keeping pending-broadcast protection pending structural resync', 'error');
3322
- await requestStructuralResync(bot, 'commit refused after broadcast (chain adoption unavailable)', { reason: 'chain-adoption-unavailable' });
3323
- return { executed: false, hadRotation: false, commitRefused: true, chainAdoptionPending: true };
3324
- }
3325
- // Deduct create fees for the placed orders (mirrors
3326
- // processBatchResults, which the refused path bypasses).
3327
- await applyAdoptionFeeAccounting(bot, executedContexts);
3328
- await restoreBoundaryAfterAdoption(bot, workingBoundary);
3329
- await persistGridAndClearPendingBroadcasts(bot, '[COW]');
3330
- return { executed: false, hadRotation: false, commitRefused: true };
3583
+ return await recoverRefusedCommit(bot, chainOrders, '[COW]', { placedResults: result, placedContexts: executedContexts }, executedContexts, effectiveBoundary, {
3584
+ failureResyncReason: 'commit refused after broadcast (chain adoption unavailable)',
3585
+ failureLogMessage: 'Commit refused and chain adoption unavailable; keeping pending-broadcast protection pending structural resync',
3586
+ preAdoptLogMessage: 'Commit refused after broadcast; adopting placed orders from chain to keep master in sync',
3587
+ });
3331
3588
  }
3332
3589
  const batchResult = await processBatchResults(bot, result, executedContexts);
3333
3590
  const persistResult = await bot.manager.persistGrid();
@@ -3342,10 +3599,7 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
3342
3599
  bot.manager.logger.log(`[COW][PERSIST-GUARD] Retry also skipped/invalid ` +
3343
3600
  `(${retryResult.reason || 'no reason'}). Master grid in memory ` +
3344
3601
  `is ahead of disk snapshot; structural resync requested.`, 'error');
3345
- if (typeof bot.manager.requestStructuralGridResync === 'function') {
3346
- bot.manager._recoveryState = { ...bot.manager._recoveryState, structuralResyncRequested: true };
3347
- await bot.manager.requestStructuralGridResync('persistence guard triggered after COW batch', { persistReason: retryResult.reason || 'unknown' });
3348
- }
3602
+ await requestStructuralResync(bot, 'persistence guard triggered after COW batch', { persistReason: retryResult.reason || 'unknown' });
3349
3603
  }
3350
3604
  else {
3351
3605
  delete bot.manager._persistenceWarning;
@@ -3410,22 +3664,17 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
3410
3664
  // _commitWorkingGrid pops on every settle path and clears
3411
3665
  // the push marker via options.result, so a later throw here
3412
3666
  // must not pop again in the batch catch below.
3413
- const pollCommitOk = await bot.manager._commitWorkingGrid(workingGrid, workingIndexes, workingBoundary, { skipRecalc: true, result: cowResult });
3667
+ const pollCommitOk = await bot.manager._commitWorkingGrid(workingGrid, workingIndexes, effectiveBoundary, { skipRecalc: true, result: cowResult, boundaryHeld });
3414
3668
  if (!pollCommitOk) {
3415
3669
  // Master moved while polling — same recovery as the
3416
3670
  // refused-commit path: adopt from chain, keep pending
3417
3671
  // protection if adoption is unavailable.
3418
- bot.manager.logger.log(`[COW][UNCERTAIN] Poll-confirmed commit refused; adopting placed orders from chain`, 'warn');
3419
- const pollAdopted = await adoptPlacedBatchFromChain(bot, chainOrders, '[COW][UNCERTAIN]', { placedContexts: opContexts, polledCreateIds: confirmation.confirmedChainIds });
3420
- if (!pollAdopted) {
3421
- bot.manager.logger.log('[COW][UNCERTAIN] Poll-refused commit with unavailable chain adoption; keeping pending protection pending structural resync', 'error');
3422
- await requestStructuralResync(bot, 'poll-confirmed commit refused (chain adoption unavailable)', { reason: 'chain-adoption-unavailable' });
3423
- return { executed: false, hadRotation: false, commitRefused: true, chainAdoptionPending: true };
3424
- }
3425
- await applyAdoptionFeeAccounting(bot, opContexts);
3426
- await restoreBoundaryAfterAdoption(bot, workingBoundary);
3427
- await persistGridAndClearPendingBroadcasts(bot, '[COW][UNCERTAIN]');
3428
- return { executed: false, hadRotation: false, commitRefused: true, uncertainResolved: true };
3672
+ return await recoverRefusedCommit(bot, chainOrders, '[COW][UNCERTAIN]', { placedContexts: opContexts, polledCreateIds: confirmation.confirmedChainIds }, opContexts, effectiveBoundary, {
3673
+ successReturn: { executed: false, hadRotation: false, commitRefused: true, uncertainResolved: true },
3674
+ failureResyncReason: 'poll-confirmed commit refused (chain adoption unavailable)',
3675
+ failureLogMessage: 'Poll-refused commit with unavailable chain adoption; keeping pending protection pending structural resync',
3676
+ preAdoptLogMessage: 'Poll-confirmed commit refused; adopting placed orders from chain',
3677
+ });
3429
3678
  }
3430
3679
  // Enrich master grid with chain-assigned order IDs and amounts;
3431
3680
  // accounting enabled so the adopted orders' capital is locked
@@ -3436,19 +3685,16 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
3436
3685
  // on a failed adoption would let the next cycle re-create the
3437
3686
  // VIRTUAL slots as duplicate on-chain orders. Keep the
3438
3687
  // protection and defer to a structural resync instead.
3439
- const pollAdoptedOk = await adoptPlacedBatchFromChain(bot, chainOrders, '[COW][UNCERTAIN]', { placedContexts: opContexts, polledCreateIds: confirmation.confirmedChainIds });
3440
- if (!pollAdoptedOk) {
3441
- bot.manager.logger.log('[COW][UNCERTAIN] Poll-confirmed commit with unavailable chain adoption; keeping pending protection pending structural resync', 'error');
3442
- await requestStructuralResync(bot, 'poll-confirmed commit (chain adoption unavailable)', { reason: 'chain-adoption-unavailable' });
3443
- return { executed: false, hadRotation: false, commitRefused: false, chainAdoptionPending: true };
3444
- }
3445
3688
  // The commit happened without processBatchResults (no success
3446
3689
  // result to extract); deduct create fees so the optimistic
3447
- // balance reflects the on-chain cost.
3448
- await applyAdoptionFeeAccounting(bot, opContexts);
3449
- await restoreBoundaryAfterAdoption(bot, workingBoundary);
3450
- await persistGridAndClearPendingBroadcasts(bot, '[COW][UNCERTAIN]');
3451
- return { executed: true, hadRotation: false, uncertainResolved: true };
3690
+ // balance reflects the on-chain cost (shared with the
3691
+ // refused-commit paths via recoverRefusedCommit).
3692
+ return await recoverRefusedCommit(bot, chainOrders, '[COW][UNCERTAIN]', { placedContexts: opContexts, polledCreateIds: confirmation.confirmedChainIds }, opContexts, effectiveBoundary, {
3693
+ extraReturn: { commitRefused: false },
3694
+ successReturn: { executed: true, hadRotation: false, uncertainResolved: true },
3695
+ failureResyncReason: 'poll-confirmed commit (chain adoption unavailable)',
3696
+ failureLogMessage: 'Poll-confirmed commit with unavailable chain adoption; keeping pending protection pending structural resync',
3697
+ });
3452
3698
  }
3453
3699
  }
3454
3700
  catch (pollErr) {
@@ -3502,8 +3748,10 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
3502
3748
  * @param {Object} [details={}] - Details passed to the resync handler
3503
3749
  */
3504
3750
  async function requestStructuralResync(bot, reason, details = {}) {
3505
- if (typeof bot.manager?.requestStructuralGridResync !== 'function')
3751
+ if (typeof bot.manager?.requestStructuralGridResync !== 'function') {
3752
+ bot._warn?.(`[COW] requestStructuralGridResync unavailable; cannot schedule structural resync (reason: ${reason}).`);
3506
3753
  return;
3754
+ }
3507
3755
  if (bot.manager._recoveryState) {
3508
3756
  bot.manager._recoveryState = { ...bot.manager._recoveryState, structuralResyncRequested: true };
3509
3757
  }
@@ -3523,91 +3771,6 @@ async function requestStructuralResync(bot, reason, details = {}) {
3523
3771
  * @param {string} logPrefix - Log prefix for sync failure messages
3524
3772
  * @returns {Promise<boolean>}
3525
3773
  */
3526
- /**
3527
- * Collect every on-chain order id master currently needs to converge against,
3528
- * so adoption can re-read them by id (immune to the get_full_accounts window
3529
- * truncation) instead of relying on a partial window read.
3530
- *
3531
- * Sources:
3532
- * - master's own tracked order ids (existing on-chain orders);
3533
- * - the batch's fresh CREATE ids extracted from the broadcast result
3534
- * (operation_results[i][1] aligns positionally with placedContexts[i]).
3535
- *
3536
- * @param {any} mgr - bot.manager
3537
- * @param {any} placedResults - broadcast result (has operation_results); null when unavailable
3538
- * @param {any[]} placedContexts - opContexts (aligned with operation_results); null when unavailable
3539
- * @param {string[]|null} [extraCreateIds=null] - fresh CREATE chain ids from another
3540
- * authoritative source (e.g. the uncertain-broadcast poll confirmation) when
3541
- * no broadcast result exists; merged into createIds so the lagging-create
3542
- * retry guards them
3543
- * @returns {string[]} Unique, well-formed 1.7.x order ids
3544
- */
3545
- function collectKnownOnChainOrderIds(mgr, placedResults, placedContexts, extraCreateIds = null) {
3546
- const masterIds = new Set();
3547
- const grid = mgr && mgr.grid;
3548
- if (Array.isArray(grid)) {
3549
- for (const slot of grid) {
3550
- if (slot && slot.orderId && /^1\.7\.\d+$/.test(String(slot.orderId))) {
3551
- masterIds.add(String(slot.orderId));
3552
- }
3553
- }
3554
- }
3555
- // Master tracked ids live in the orders Map (mgr.grid is legacy and
3556
- // unset on OrderManager — without this the by-id set omits every
3557
- // pre-existing ACTIVE order and pass-1 phantom cleanup would virtualize
3558
- // them as fills on a partial snapshot).
3559
- if (mgr && mgr.orders instanceof Map) {
3560
- for (const slot of mgr.orders.values()) {
3561
- if (slot && slot.orderId && /^1\.7\.\d+$/.test(String(slot.orderId))) {
3562
- masterIds.add(String(slot.orderId));
3563
- }
3564
- }
3565
- }
3566
- const createIds = new Set();
3567
- if (placedResults && Array.isArray(placedContexts)) {
3568
- const opResults = extractBatchOperationResults(placedResults);
3569
- if (Array.isArray(opResults)) {
3570
- for (let i = 0; i < placedContexts.length; i++) {
3571
- const ctx = placedContexts[i];
3572
- if (!ctx || ctx.kind !== 'create')
3573
- continue;
3574
- const opResult = opResults[i] && opResults[i][1];
3575
- if (opResult && /^1\.7\.\d+$/.test(String(opResult))) {
3576
- createIds.add(String(opResult));
3577
- }
3578
- }
3579
- }
3580
- }
3581
- if (Array.isArray(extraCreateIds)) {
3582
- for (const id of extraCreateIds) {
3583
- if (id && /^1\.7\.\d+$/.test(String(id)))
3584
- createIds.add(String(id));
3585
- }
3586
- }
3587
- // Existing chain ids referenced by non-create op contexts (cancel /
3588
- // rotation / size-update). Pre-existing orders whose absence is expected
3589
- // (cancels/fills in this batch), so they join the by-id set but never
3590
- // the lagging-create guard.
3591
- if (Array.isArray(placedContexts)) {
3592
- for (const ctx of placedContexts) {
3593
- if (!ctx || ctx.kind === 'create')
3594
- continue;
3595
- const refs = [];
3596
- if (ctx.kind === 'cancel' && ctx.order)
3597
- refs.push(ctx.order.orderId);
3598
- else if (ctx.kind === 'rotation' && ctx.rotation?.oldOrder)
3599
- refs.push(ctx.rotation.oldOrder.orderId);
3600
- else if (ctx.kind === 'size-update' && ctx.updateInfo?.partialOrder)
3601
- refs.push(ctx.updateInfo.partialOrder.orderId);
3602
- for (const id of refs) {
3603
- if (id && /^1\.7\.\d+$/.test(String(id)))
3604
- masterIds.add(String(id));
3605
- }
3606
- }
3607
- }
3608
- const all = new Set([...masterIds, ...createIds]);
3609
- return { masterIds: [...masterIds], createIds: [...createIds], all: [...all] };
3610
- }
3611
3774
  /**
3612
3775
  * Converge master with the chain after a COW commit was refused (master moved
3613
3776
  * during broadcast) or an uncertain broadcast was poll-confirmed.
@@ -3760,8 +3923,8 @@ async function adoptPlacedBatchFromChain(bot, chainOrders, logPrefix, opts = {})
3760
3923
  }
3761
3924
  // FALLBACK: window read (ambiguous when truncated).
3762
3925
  const freshRead = await readOpenOrdersWithMetaSafe(chainOrders, accountRef);
3763
- if (freshRead.truncated) {
3764
- bot.manager.logger.log(`${logPrefix} Chain read TRUNCATED after batch broadcast; adoption deferred (pending-broadcast protection kept)`, 'warn');
3926
+ if (!isAuthoritativeChainRead(freshRead)) {
3927
+ bot.manager.logger.log(`${logPrefix} Chain read ${freshRead?.truncated ? 'TRUNCATED' : 'EMPTY'} after batch broadcast; adoption deferred (pending-broadcast protection kept)`, 'warn');
3765
3928
  return false;
3766
3929
  }
3767
3930
  const freshChain = freshRead.orders;
@@ -3791,6 +3954,83 @@ async function persistGridAndClearPendingBroadcasts(bot, logPrefix) {
3791
3954
  }
3792
3955
  clearPendingBroadcasts(bot.manager?._pendingBroadcasts);
3793
3956
  }
3957
+ /**
3958
+ * Recover from a refused commit after a COW batch broadcast.
3959
+ * Adopts placed orders from the chain, applies fee accounting, restores
3960
+ * the boundary, and persists. All three commit-refused paths
3961
+ * (success-path refused, poll-refused, and poll-confirmed uncertain)
3962
+ * share this exact sequence.
3963
+ * @param {import('./dexbot_class.js').DEXBot} bot
3964
+ * @param {Object} chainOrders - Chain orders module
3965
+ * @param {string} logPrefix - Log prefix for messages
3966
+ * @param {Object} adoptOpts - Options passed to adoptPlacedBatchFromChain
3967
+ * @param {Array} contexts - Op contexts for fee accounting
3968
+ * @param {Object} workingBoundary - The working boundary
3969
+ * @param {Object} [opts={}] - Named options (object form: adjacent string
3970
+ * options were positional before, a transposition would compile silently).
3971
+ * @param {Object} [opts.extraReturn={}] - Extra fields merged into the failure return object
3972
+ * @param {Object|null} [opts.successReturn=null] - Return object on successful adoption
3973
+ * (defaults to the commit-refused shape; the poll-confirmed path passes its
3974
+ * executed:true shape)
3975
+ * @param {string} [opts.failureResyncReason='commit refused after broadcast (chain adoption unavailable)']
3976
+ * @param {string} [opts.failureLogMessage='Commit refused and chain adoption unavailable; keeping pending-broadcast protection pending structural resync']
3977
+ * - Failure-path log body (prefixed with logPrefix); each call site passes
3978
+ * its original wording so log greps keep matching.
3979
+ * @param {string|null} [opts.preAdoptLogMessage=null] - Optional warn logged before
3980
+ * the adoption attempt (the success-path and poll-refused sites log one;
3981
+ * the poll-confirmed site never did)
3982
+ * @returns {Promise<Object>} The commit-refused return object
3983
+ */
3984
+ async function recoverRefusedCommit(bot, chainOrders, logPrefix, adoptOpts, contexts, workingBoundary, opts = {}) {
3985
+ const { extraReturn = {}, successReturn = null, failureResyncReason = 'commit refused after broadcast (chain adoption unavailable)', failureLogMessage = 'Commit refused and chain adoption unavailable; keeping pending-broadcast protection pending structural resync', preAdoptLogMessage = null, } = opts;
3986
+ if (preAdoptLogMessage) {
3987
+ bot.manager.logger.log(`${logPrefix} ${preAdoptLogMessage}`, 'warn');
3988
+ }
3989
+ const adopted = await adoptPlacedBatchFromChain(bot, chainOrders, logPrefix, adoptOpts);
3990
+ if (!adopted) {
3991
+ bot.manager.logger.log(`${logPrefix} ${failureLogMessage}`, 'error');
3992
+ await requestStructuralResync(bot, failureResyncReason, { reason: 'chain-adoption-unavailable' });
3993
+ return { executed: false, hadRotation: false, commitRefused: true, chainAdoptionPending: true, ...extraReturn };
3994
+ }
3995
+ await applyAdoptionFeeAccounting(bot, contexts);
3996
+ await restoreBoundaryAfterAdoption(bot, workingBoundary);
3997
+ await persistGridAndClearPendingBroadcasts(bot, logPrefix);
3998
+ return successReturn ?? { executed: false, hadRotation: false, commitRefused: true, ...extraReturn };
3999
+ }
4000
+ /**
4001
+ * Run the last-fill guard probe: optionally refresh the pivot from
4002
+ * still-queued fills, read the durable pivot, and evaluate
4003
+ * isLastFillGuardBlocked. Consolidates the identical probe pattern in the
4004
+ * CREATE, UPDATE-rotation, and CREATE-fallback guards (origin bypasses and
4005
+ * skip logging stay at the call sites, which differ per action kind).
4006
+ * Batch callers pass skipRefresh=true: the batch-start freeze owns refreshes
4007
+ * so every action in a batch is judged against the same pivot.
4008
+ * @param {Object} bot
4009
+ * @param {number} price - Target order price
4010
+ * @param {number} size - Order size
4011
+ * @param {string} type - ORDER_TYPES.BUY/SELL
4012
+ * @param {Object} stats - lastFillGuardStats tracker (checked++ here)
4013
+ * @param {boolean} [skipRefresh=false] - Skip the queued-fill pivot refresh
4014
+ * @returns {{check: Object, refreshed: boolean}}
4015
+ */
4016
+ function runLastFillGuardCheck(bot, price, size, type, stats, skipRefresh = false) {
4017
+ let refreshed = false;
4018
+ // The batch-start freeze (see broadcast loop head) owns pivot refreshes;
4019
+ // per-action refreshes are disabled so every action in a batch is judged
4020
+ // against the same pivot. Kept opt-in for non-batch callers.
4021
+ if (!skipRefresh) {
4022
+ try {
4023
+ refreshed = !!refreshLastFillPivotFromQueue(bot);
4024
+ }
4025
+ catch { /* best-effort */ }
4026
+ }
4027
+ const lastPrice = bot.manager?._lastFilledPrice;
4028
+ const lastType = bot.manager?._lastFilledType;
4029
+ const inc = resolveLastFillGuardIncrement(bot);
4030
+ const check = isLastFillGuardBlocked(price, size, type, lastPrice, lastType, inc);
4031
+ stats.checked++;
4032
+ return { check, refreshed };
4033
+ }
3794
4034
  /**
3795
4035
  * Apply BTS create-fee accounting for a batch that bypassed the normal
3796
4036
  * processBatchResults pipeline (commit refused after broadcast, or
@@ -3827,6 +4067,7 @@ async function applyAdoptionFeeAccounting(bot, contexts) {
3827
4067
  isPartialPlacement: false,
3828
4068
  expectedType: ctx.order.type,
3829
4069
  fee: btsFeeData?.createFee || 0,
4070
+ order: ctx.order ?? null,
3830
4071
  }, 'createOrder');
3831
4072
  }
3832
4073
  catch (feeErr) {
@@ -3866,6 +4107,22 @@ async function applyAdoptionFeeAccounting(bot, contexts) {
3866
4107
  }
3867
4108
  }
3868
4109
  }
4110
+ /**
4111
+ * Table-driven optimistic fee call for processBatchResults: the cancel,
4112
+ * size-update, and both rotation branches all invoke
4113
+ * updateOptimisticFreeBalance(oldOrder, newOrder, context, fee, false)
4114
+ * under the same accountant guard — one spelling instead of four.
4115
+ * @param {import('./dexbot_class.js').DEXBot} bot
4116
+ * @param {Object} oldOrder
4117
+ * @param {Object} newOrder
4118
+ * @param {string} context - Fee context ('fill-cancel' | 'order-update')
4119
+ * @param {number} fee - BTS fee amount
4120
+ */
4121
+ async function applyOptimisticFeeBalance(bot, oldOrder, newOrder, context, fee) {
4122
+ if (oldOrder && newOrder && bot.manager.accountant) {
4123
+ await bot.manager.accountant.updateOptimisticFreeBalance(oldOrder, newOrder, context, fee || 0, false);
4124
+ }
4125
+ }
3869
4126
  /**
3870
4127
  * Process results from batch transaction execution.
3871
4128
  * Updates order state, synchronizes with chain, and deducts BTS fees.
@@ -3893,27 +4150,16 @@ async function processBatchResults(bot, result, opContexts) {
3893
4150
  bot.manager.logger.log(`Cancelled surplus order ${ctx.order.id} (${ctx.order.orderId})`, 'info');
3894
4151
  const oldOrder = ctx.order;
3895
4152
  const committedOrder = oldOrder?.id ? bot.manager.orders.get(oldOrder.id) : null;
3896
- if (oldOrder && committedOrder && bot.manager.accountant) {
3897
- await bot.manager.accountant.updateOptimisticFreeBalance(oldOrder, committedOrder, 'fill-cancel', btsFeeData?.cancelFee || 0, false);
3898
- }
4153
+ await applyOptimisticFeeBalance(bot, oldOrder, committedOrder, 'fill-cancel', btsFeeData?.cancelFee || 0);
3899
4154
  }
3900
4155
  else if (ctx.kind === 'size-update') {
3901
4156
  const oldOrder = ctx.updateInfo.partialOrder;
3902
4157
  const ord = bot.manager.orders.get(oldOrder.id);
3903
- if (oldOrder && ord && bot.manager.accountant) {
3904
- await bot.manager.accountant.updateOptimisticFreeBalance(oldOrder, ord, 'order-update', btsFeeData?.updateFee || 0, false);
3905
- }
4158
+ await applyOptimisticFeeBalance(bot, oldOrder, ord, 'order-update', btsFeeData?.updateFee || 0);
3906
4159
  if (ord) {
3907
4160
  const updatedSlot = { ...ord, size: ctx.updateInfo.newSize };
3908
4161
  if (ctx.finalInts) {
3909
- updatedSlot.rawOnChain = {
3910
- id: ord.orderId,
3911
- for_sale: String(ctx.finalInts.sell),
3912
- sell_price: {
3913
- base: { amount: String(ctx.finalInts.sell), asset_id: ctx.finalInts.sellAssetId },
3914
- quote: { amount: String(ctx.finalInts.receive), asset_id: ctx.finalInts.receiveAssetId }
3915
- }
3916
- };
4162
+ updatedSlot.rawOnChain = rawOnChainFromInts(ord.orderId, ctx.finalInts);
3917
4163
  }
3918
4164
  updatesToApply.push({ order: updatedSlot, context: 'post-update-metadata' });
3919
4165
  }
@@ -3924,7 +4170,8 @@ async function processBatchResults(bot, result, opContexts) {
3924
4170
  const chainOrderId = res && res[1];
3925
4171
  if (chainOrderId) {
3926
4172
  await bot.manager.synchronizeWithChain({
3927
- gridOrderId: ctx.order.id, chainOrderId, expectedType: ctx.order.type, fee: btsFeeData?.createFee || 0
4173
+ gridOrderId: ctx.order.id, chainOrderId, expectedType: ctx.order.type, fee: btsFeeData?.createFee || 0,
4174
+ order: ctx.order ?? null,
3928
4175
  }, 'createOrder');
3929
4176
  if (ctx.finalInts) {
3930
4177
  const syncedOrder = bot.manager.orders.get(ctx.order.id);
@@ -3932,14 +4179,7 @@ async function processBatchResults(bot, result, opContexts) {
3932
4179
  updatesToApply.push({
3933
4180
  order: {
3934
4181
  ...syncedOrder,
3935
- rawOnChain: {
3936
- id: chainOrderId,
3937
- for_sale: String(ctx.finalInts.sell),
3938
- sell_price: {
3939
- base: { amount: String(ctx.finalInts.sell), asset_id: ctx.finalInts.sellAssetId },
3940
- quote: { amount: String(ctx.finalInts.receive), asset_id: ctx.finalInts.receiveAssetId }
3941
- }
3942
- }
4182
+ rawOnChain: rawOnChainFromInts(chainOrderId, ctx.finalInts)
3943
4183
  },
3944
4184
  context: 'post-placement-metadata'
3945
4185
  });
@@ -3969,20 +4209,11 @@ async function processBatchResults(bot, result, opContexts) {
3969
4209
  const { oldOrder, newPrice, newGridId, newSize, type } = rotation;
3970
4210
  if (!newGridId) {
3971
4211
  const ord = bot.manager.orders.get(oldOrder.id || rotation.id);
3972
- if (oldOrder && ord && bot.manager.accountant) {
3973
- await bot.manager.accountant.updateOptimisticFreeBalance(oldOrder, ord, 'order-update', btsFeeData?.updateFee || 0, false);
3974
- }
4212
+ await applyOptimisticFeeBalance(bot, oldOrder, ord, 'order-update', btsFeeData?.updateFee || 0);
3975
4213
  if (ord) {
3976
4214
  const updatedSlot = { ...ord, size: newSize };
3977
4215
  if (ctx.finalInts) {
3978
- updatedSlot.rawOnChain = {
3979
- id: ord.orderId,
3980
- for_sale: String(ctx.finalInts.sell),
3981
- sell_price: {
3982
- base: { amount: String(ctx.finalInts.sell), asset_id: ctx.finalInts.sellAssetId },
3983
- quote: { amount: String(ctx.finalInts.receive), asset_id: ctx.finalInts.receiveAssetId }
3984
- }
3985
- };
4216
+ updatedSlot.rawOnChain = rawOnChainFromInts(ord.orderId, ctx.finalInts);
3986
4217
  }
3987
4218
  updatesToApply.push({ order: updatedSlot, context: 'post-update-metadata' });
3988
4219
  }
@@ -4013,18 +4244,9 @@ async function processBatchResults(bot, result, opContexts) {
4013
4244
  orderId: oldOrder?.orderId || slot.orderId || null
4014
4245
  };
4015
4246
  if (ctx.finalInts) {
4016
- updatedSlot.rawOnChain = {
4017
- id: updatedSlot.orderId,
4018
- for_sale: String(ctx.finalInts.sell),
4019
- sell_price: {
4020
- base: { amount: String(ctx.finalInts.sell), asset_id: ctx.finalInts.sellAssetId },
4021
- quote: { amount: String(ctx.finalInts.receive), asset_id: ctx.finalInts.receiveAssetId }
4022
- }
4023
- };
4024
- }
4025
- if (oldOrder && updatedSlot && bot.manager.accountant) {
4026
- await bot.manager.accountant.updateOptimisticFreeBalance(oldOrder, updatedSlot, 'order-update', btsFeeData?.updateFee || 0, false);
4247
+ updatedSlot.rawOnChain = rawOnChainFromInts(updatedSlot.orderId, ctx.finalInts);
4027
4248
  }
4249
+ await applyOptimisticFeeBalance(bot, oldOrder, updatedSlot, 'order-update', btsFeeData?.updateFee || 0);
4028
4250
  if (oldOrder?.id && oldOrder.id !== newGridId) {
4029
4251
  const currentSource = bot.manager.orders.get(oldOrder.id);
4030
4252
  if (currentSource && currentSource.orderId) {
@@ -4051,7 +4273,10 @@ async function processBatchResults(bot, result, opContexts) {
4051
4273
  updateOperationCount
4052
4274
  };
4053
4275
  }
4054
- 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 };
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 };
4277
+ // Exported for regression tests (issue #23 sibling): the uncertain-broadcast
4278
+ // discard path must never drop a placement silently when master lost the slot.
4279
+ export { restoreDiscardedCreates };
4055
4280
  export default {
4056
4281
  buildOutsideInPairGroupsForOrders,
4057
4282
  buildOutsideInPairGroupsForCreateEntries,
@@ -4080,6 +4305,9 @@ export default {
4080
4305
  buildActionsFromPlan,
4081
4306
  buildCowResultFromPlan,
4082
4307
  restoreSkippedUpdateSlotsInWorkingGrid,
4308
+ resolveRefillBoundaryHold,
4309
+ toRefillSlotIdSet,
4310
+ trackBoundaryHold,
4083
4311
  applyRotationTransitionsToWorkingGrid,
4084
4312
  pollChainForConfirmation,
4085
4313
  updateOrdersOnChainBatchCOW,