dexbot 1.4.24 → 1.5.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 (256) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +7 -2
  3. package/analysis/README.md +61 -5
  4. package/analysis/ama_fitting/package.json +1 -1
  5. package/analysis/analyze_derivatives.ts +1 -1
  6. package/analysis/bot_key_utils.ts +272 -3
  7. package/analysis/bot_usage/discover_bot_accounts.ts +64 -9
  8. package/analysis/bot_usage/kibana_bot_queries.ts +40 -1
  9. package/analysis/chart_utils.ts +6 -3
  10. package/analysis/grid_correction_check.ts +932 -0
  11. package/analysis/resolve_bot_accounts.ts +199 -0
  12. package/analysis/trade_profitability.ts +36 -8
  13. package/analysis/tradingview/README.md +25 -0
  14. package/analysis/tradingview/analyze_tradingview.ts +2 -2
  15. package/analysis/trend_detection/package.json +1 -1
  16. package/claw/modules/claw_launcher.ts +5 -4
  17. package/claw/modules/credit_runtime_adapter.ts +2 -1
  18. package/claw/modules/dexbot_profiles.ts +4 -3
  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/tests/test_claw_mcp_transport.ts +2 -2
  23. package/dist/analysis/analyze_derivatives.js +1 -1
  24. package/dist/analysis/bot_key_utils.d.ts +28 -1
  25. package/dist/analysis/bot_key_utils.d.ts.map +1 -1
  26. package/dist/analysis/bot_key_utils.js +385 -3
  27. package/dist/analysis/bot_key_utils.js.map +1 -1
  28. package/dist/analysis/bot_usage/discover_bot_accounts.js +60 -10
  29. package/dist/analysis/bot_usage/discover_bot_accounts.js.map +1 -1
  30. package/dist/analysis/bot_usage/kibana_bot_queries.d.ts +45 -1
  31. package/dist/analysis/bot_usage/kibana_bot_queries.d.ts.map +1 -1
  32. package/dist/analysis/bot_usage/kibana_bot_queries.js +39 -1
  33. package/dist/analysis/bot_usage/kibana_bot_queries.js.map +1 -1
  34. package/dist/analysis/chart_utils.d.ts +2 -1
  35. package/dist/analysis/chart_utils.d.ts.map +1 -1
  36. package/dist/analysis/chart_utils.js +4 -1
  37. package/dist/analysis/chart_utils.js.map +1 -1
  38. package/dist/analysis/grid_correction_check.d.ts +90 -0
  39. package/dist/analysis/grid_correction_check.d.ts.map +1 -0
  40. package/dist/analysis/grid_correction_check.js +901 -0
  41. package/dist/analysis/grid_correction_check.js.map +1 -0
  42. package/dist/analysis/resolve_bot_accounts.d.ts +3 -0
  43. package/dist/analysis/resolve_bot_accounts.d.ts.map +1 -0
  44. package/dist/analysis/resolve_bot_accounts.js +216 -0
  45. package/dist/analysis/resolve_bot_accounts.js.map +1 -0
  46. package/dist/analysis/trade_profitability.d.ts.map +1 -1
  47. package/dist/analysis/trade_profitability.js +40 -8
  48. package/dist/analysis/trade_profitability.js.map +1 -1
  49. package/dist/analysis/tradingview/analyze_tradingview.js +2 -2
  50. package/dist/analysis/tradingview/analyze_tradingview.js.map +1 -1
  51. package/dist/bot.d.ts.map +1 -1
  52. package/dist/bot.js +2 -1
  53. package/dist/bot.js.map +1 -1
  54. package/dist/dexbot.d.ts.map +1 -1
  55. package/dist/dexbot.js +134 -22
  56. package/dist/dexbot.js.map +1 -1
  57. package/dist/market_adapter/ama_signal_runner.js +2 -2
  58. package/dist/market_adapter/inputs/fetch_cex_synthetic_data.js +2 -1
  59. package/dist/market_adapter/inputs/fetch_cex_synthetic_data.js.map +1 -1
  60. package/dist/market_adapter/inputs/fetch_lp_data.d.ts +4 -1
  61. package/dist/market_adapter/inputs/fetch_lp_data.d.ts.map +1 -1
  62. package/dist/market_adapter/inputs/fetch_lp_data.js +3 -2
  63. package/dist/market_adapter/inputs/fetch_lp_data.js.map +1 -1
  64. package/dist/modules/account_bots.d.ts +42 -1
  65. package/dist/modules/account_bots.d.ts.map +1 -1
  66. package/dist/modules/account_bots.js +221 -22
  67. package/dist/modules/account_bots.js.map +1 -1
  68. package/dist/modules/account_orders.d.ts +7 -10
  69. package/dist/modules/account_orders.d.ts.map +1 -1
  70. package/dist/modules/account_orders.js +26 -2
  71. package/dist/modules/account_orders.js.map +1 -1
  72. package/dist/modules/bitshares_client.d.ts +4 -1
  73. package/dist/modules/bitshares_client.d.ts.map +1 -1
  74. package/dist/modules/bitshares_client.js +12 -4
  75. package/dist/modules/bitshares_client.js.map +1 -1
  76. package/dist/modules/bot_settings.d.ts.map +1 -1
  77. package/dist/modules/bot_settings.js +2 -1
  78. package/dist/modules/bot_settings.js.map +1 -1
  79. package/dist/modules/chain_orders.d.ts.map +1 -1
  80. package/dist/modules/chain_orders.js +21 -2
  81. package/dist/modules/chain_orders.js.map +1 -1
  82. package/dist/modules/cli_colors.d.ts +39 -0
  83. package/dist/modules/cli_colors.d.ts.map +1 -0
  84. package/dist/modules/cli_colors.js +42 -0
  85. package/dist/modules/cli_colors.js.map +1 -0
  86. package/dist/modules/config.d.ts.map +1 -1
  87. package/dist/modules/config.js +27 -0
  88. package/dist/modules/config.js.map +1 -1
  89. package/dist/modules/constants.d.ts +14 -1
  90. package/dist/modules/constants.d.ts.map +1 -1
  91. package/dist/modules/constants.js +66 -11
  92. package/dist/modules/constants.js.map +1 -1
  93. package/dist/modules/credit_pricing.d.ts +61 -0
  94. package/dist/modules/credit_pricing.d.ts.map +1 -0
  95. package/dist/modules/credit_pricing.js +237 -0
  96. package/dist/modules/credit_pricing.js.map +1 -0
  97. package/dist/modules/credit_runtime.d.ts +1 -0
  98. package/dist/modules/credit_runtime.d.ts.map +1 -1
  99. package/dist/modules/credit_runtime.js +49 -107
  100. package/dist/modules/credit_runtime.js.map +1 -1
  101. package/dist/modules/dexbot_class.d.ts +25 -0
  102. package/dist/modules/dexbot_class.d.ts.map +1 -1
  103. package/dist/modules/dexbot_class.js +82 -12
  104. package/dist/modules/dexbot_class.js.map +1 -1
  105. package/dist/modules/dexbot_cow_runtime.d.ts +39 -17
  106. package/dist/modules/dexbot_cow_runtime.d.ts.map +1 -1
  107. package/dist/modules/dexbot_cow_runtime.js +875 -134
  108. package/dist/modules/dexbot_cow_runtime.js.map +1 -1
  109. package/dist/modules/dexbot_fill_runtime.d.ts +11 -1
  110. package/dist/modules/dexbot_fill_runtime.d.ts.map +1 -1
  111. package/dist/modules/dexbot_fill_runtime.js +108 -1
  112. package/dist/modules/dexbot_fill_runtime.js.map +1 -1
  113. package/dist/modules/dexbot_maintenance_runtime.d.ts +29 -14
  114. package/dist/modules/dexbot_maintenance_runtime.d.ts.map +1 -1
  115. package/dist/modules/dexbot_maintenance_runtime.js +279 -63
  116. package/dist/modules/dexbot_maintenance_runtime.js.map +1 -1
  117. package/dist/modules/dexbot_startup_runtime.d.ts +1 -0
  118. package/dist/modules/dexbot_startup_runtime.d.ts.map +1 -1
  119. package/dist/modules/dexbot_startup_runtime.js +58 -7
  120. package/dist/modules/dexbot_startup_runtime.js.map +1 -1
  121. package/dist/modules/dexbot_state_recovery.d.ts.map +1 -1
  122. package/dist/modules/dexbot_state_recovery.js +55 -3
  123. package/dist/modules/dexbot_state_recovery.js.map +1 -1
  124. package/dist/modules/fund_registry.d.ts +1 -1
  125. package/dist/modules/fund_registry.js +1 -1
  126. package/dist/modules/graceful_shutdown.d.ts +12 -1
  127. package/dist/modules/graceful_shutdown.d.ts.map +1 -1
  128. package/dist/modules/graceful_shutdown.js +39 -1
  129. package/dist/modules/graceful_shutdown.js.map +1 -1
  130. package/dist/modules/key_store.d.ts.map +1 -1
  131. package/dist/modules/key_store.js +38 -1
  132. package/dist/modules/key_store.js.map +1 -1
  133. package/dist/modules/launcher/adapter_requirement.d.ts +38 -0
  134. package/dist/modules/launcher/adapter_requirement.d.ts.map +1 -0
  135. package/dist/modules/launcher/adapter_requirement.js +141 -0
  136. package/dist/modules/launcher/adapter_requirement.js.map +1 -0
  137. package/dist/modules/launcher/bot_supervisor.d.ts +2 -2
  138. package/dist/modules/launcher/bot_supervisor.d.ts.map +1 -1
  139. package/dist/modules/launcher/bot_supervisor.js +32 -3
  140. package/dist/modules/launcher/bot_supervisor.js.map +1 -1
  141. package/dist/modules/launcher/launch_modes.d.ts.map +1 -1
  142. package/dist/modules/launcher/launch_modes.js +5 -4
  143. package/dist/modules/launcher/launch_modes.js.map +1 -1
  144. package/dist/modules/launcher/monolithic_runtime.d.ts +1 -1
  145. package/dist/modules/launcher/monolithic_runtime.d.ts.map +1 -1
  146. package/dist/modules/launcher/monolithic_runtime.js +12 -8
  147. package/dist/modules/launcher/monolithic_runtime.js.map +1 -1
  148. package/dist/modules/launcher/status_reporting.d.ts +6 -6
  149. package/dist/modules/launcher/status_reporting.d.ts.map +1 -1
  150. package/dist/modules/launcher/status_reporting.js +9 -6
  151. package/dist/modules/launcher/status_reporting.js.map +1 -1
  152. package/dist/modules/order/accounting.d.ts.map +1 -1
  153. package/dist/modules/order/accounting.js +20 -3
  154. package/dist/modules/order/accounting.js.map +1 -1
  155. package/dist/modules/order/export.d.ts +1 -1
  156. package/dist/modules/order/export.js +1 -1
  157. package/dist/modules/order/grid.d.ts +1 -1
  158. package/dist/modules/order/grid.d.ts.map +1 -1
  159. package/dist/modules/order/grid.js +335 -43
  160. package/dist/modules/order/grid.js.map +1 -1
  161. package/dist/modules/order/grid_reconcile.d.ts +4 -2
  162. package/dist/modules/order/grid_reconcile.d.ts.map +1 -1
  163. package/dist/modules/order/grid_reconcile.js +242 -222
  164. package/dist/modules/order/grid_reconcile.js.map +1 -1
  165. package/dist/modules/order/grid_reconcile_internal.d.ts +11 -1
  166. package/dist/modules/order/grid_reconcile_internal.d.ts.map +1 -1
  167. package/dist/modules/order/grid_reconcile_internal.js +132 -6
  168. package/dist/modules/order/grid_reconcile_internal.js.map +1 -1
  169. package/dist/modules/order/logger.d.ts +23 -1
  170. package/dist/modules/order/logger.d.ts.map +1 -1
  171. package/dist/modules/order/logger.js +60 -17
  172. package/dist/modules/order/logger.js.map +1 -1
  173. package/dist/modules/order/manager.d.ts +68 -14
  174. package/dist/modules/order/manager.d.ts.map +1 -1
  175. package/dist/modules/order/manager.js +281 -52
  176. package/dist/modules/order/manager.js.map +1 -1
  177. package/dist/modules/order/strategy.d.ts.map +1 -1
  178. package/dist/modules/order/strategy.js +25 -8
  179. package/dist/modules/order/strategy.js.map +1 -1
  180. package/dist/modules/order/sync_engine.d.ts.map +1 -1
  181. package/dist/modules/order/sync_engine.js +262 -167
  182. package/dist/modules/order/sync_engine.js.map +1 -1
  183. package/dist/modules/order/utils/math.d.ts +47 -5
  184. package/dist/modules/order/utils/math.d.ts.map +1 -1
  185. package/dist/modules/order/utils/math.js +175 -11
  186. package/dist/modules/order/utils/math.js.map +1 -1
  187. package/dist/modules/order/utils/order.d.ts +16 -15
  188. package/dist/modules/order/utils/order.d.ts.map +1 -1
  189. package/dist/modules/order/utils/order.js +199 -32
  190. package/dist/modules/order/utils/order.js.map +1 -1
  191. package/dist/modules/order/utils/slot.d.ts +8 -0
  192. package/dist/modules/order/utils/slot.d.ts.map +1 -0
  193. package/dist/modules/order/utils/slot.js +16 -0
  194. package/dist/modules/order/utils/slot.js.map +1 -0
  195. package/dist/modules/order/utils/system.d.ts +52 -4
  196. package/dist/modules/order/utils/system.d.ts.map +1 -1
  197. package/dist/modules/order/utils/system.js +109 -16
  198. package/dist/modules/order/utils/system.js.map +1 -1
  199. package/dist/modules/order/utils/validate.d.ts +6 -9
  200. package/dist/modules/order/utils/validate.d.ts.map +1 -1
  201. package/dist/modules/order/utils/validate.js +107 -85
  202. package/dist/modules/order/utils/validate.js.map +1 -1
  203. package/dist/modules/order/working_grid.d.ts +5 -0
  204. package/dist/modules/order/working_grid.d.ts.map +1 -1
  205. package/dist/modules/order/working_grid.js +21 -0
  206. package/dist/modules/order/working_grid.js.map +1 -1
  207. package/dist/modules/paths.d.ts +4 -2
  208. package/dist/modules/paths.d.ts.map +1 -1
  209. package/dist/modules/paths.js +79 -24
  210. package/dist/modules/paths.js.map +1 -1
  211. package/dist/modules/utils/chain_logs.d.ts +11 -0
  212. package/dist/modules/utils/chain_logs.d.ts.map +1 -0
  213. package/dist/modules/utils/chain_logs.js +23 -0
  214. package/dist/modules/utils/chain_logs.js.map +1 -0
  215. package/dist/modules/utils/sanitize_key.d.ts +7 -0
  216. package/dist/modules/utils/sanitize_key.d.ts.map +1 -1
  217. package/dist/modules/utils/sanitize_key.js +15 -0
  218. package/dist/modules/utils/sanitize_key.js.map +1 -1
  219. package/dist/pm2.d.ts +11 -1
  220. package/dist/pm2.d.ts.map +1 -1
  221. package/dist/pm2.js +66 -15
  222. package/dist/pm2.js.map +1 -1
  223. package/dist/scripts/analyze-credit.d.ts +4 -0
  224. package/dist/scripts/analyze-credit.d.ts.map +1 -0
  225. package/dist/scripts/analyze-credit.js +760 -0
  226. package/dist/scripts/analyze-credit.js.map +1 -0
  227. package/dist/scripts/analyze-orders.d.ts +1 -10
  228. package/dist/scripts/analyze-orders.d.ts.map +1 -1
  229. package/dist/scripts/analyze-orders.js +34 -36
  230. package/dist/scripts/analyze-orders.js.map +1 -1
  231. package/dist/scripts/generate_market_adapter_whitelist.d.ts +2 -5
  232. package/dist/scripts/generate_market_adapter_whitelist.d.ts.map +1 -1
  233. package/dist/scripts/generate_market_adapter_whitelist.js +83 -1
  234. package/dist/scripts/generate_market_adapter_whitelist.js.map +1 -1
  235. package/dist/scripts/test-credit-renewal.d.ts.map +1 -1
  236. package/dist/scripts/test-credit-renewal.js +37 -5
  237. package/dist/scripts/test-credit-renewal.js.map +1 -1
  238. package/dist/scripts/tv.d.ts +11 -0
  239. package/dist/scripts/tv.d.ts.map +1 -0
  240. package/dist/scripts/tv.js +357 -0
  241. package/dist/scripts/tv.js.map +1 -0
  242. package/dist/scripts/update.js +77 -10
  243. package/dist/scripts/update.js.map +1 -1
  244. package/dist/unlock.d.ts.map +1 -1
  245. package/dist/unlock.js +20 -8
  246. package/dist/unlock.js.map +1 -1
  247. package/docs/DEXBOT_COMPARISON.md +11 -11
  248. package/docs/EVOLUTION.md +10 -8
  249. package/docs/FUND_MOVEMENT_AND_ACCOUNTING.md +1 -1
  250. package/docs/GRID_RECALCULATION.md +6 -0
  251. package/docs/MPA_CREDIT_USAGE.md +3 -1
  252. package/docs/README.md +2 -2
  253. package/docs/WORKFLOW.md +3 -1
  254. package/market_adapter/README.md +10 -0
  255. package/package.json +3 -1
  256. package/scripts/README.md +16 -0
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import * as chainOrdersModule from './chain_orders.js';
11
11
  const chainOrders = chainOrdersModule;
12
- const { readOpenOrdersWithMetaSafe, readOpenOrdersGuarded } = chainOrdersModule;
12
+ const { readOpenOrdersWithMetaSafe } = chainOrdersModule;
13
13
  import { BroadcastUncertainError as BroadcastUncertainErrorBinding } from './dexbot_credential_client.js';
14
14
  const BroadcastUncertainError = BroadcastUncertainErrorBinding;
15
15
  import * as orderUtils from './order/utils/order.js';
@@ -18,7 +18,27 @@ const { buildCreateOrderArgs, buildCreateOpFingerprint, extractBatchOperationRes
18
18
  import * as validate from './order/utils/validate.js';
19
19
  const { validateCreateTargetSlots, evaluateCommit, hasExecutableActions } = validate;
20
20
  import * as math from './order/utils/math.js';
21
- const { validateOrderSize, findPriceCollision } = math;
21
+ const { validateOrderSize, findCrossedOrder, priceSlotEqual } = math;
22
+ function hasSlotPriceCollision(items, targetPrice, precision, excludeId, predicate) {
23
+ for (const it of items) {
24
+ if (predicate && !predicate(it))
25
+ continue;
26
+ if (excludeId && (it.id === excludeId || it.orderId === excludeId))
27
+ continue;
28
+ const p = it.order ? it.order.price : it.price;
29
+ if (p == null)
30
+ continue;
31
+ try {
32
+ if (priceSlotEqual(p, targetPrice, precision))
33
+ return it;
34
+ }
35
+ catch {
36
+ if (p === targetPrice)
37
+ return it;
38
+ }
39
+ }
40
+ return null;
41
+ }
22
42
  // Lazy accessor so test mocks on the math module export take effect at call time.
23
43
  function getAssetFeesSafe(...args) { return require('./order/utils/math').getAssetFeesSafe(...args); }
24
44
  import * as constantsModule from './constants.js';
@@ -110,91 +130,6 @@ function findMissingCreateResultContexts(operationResults, opContexts) {
110
130
  }
111
131
  return missing;
112
132
  }
113
- /**
114
- * Run an immediate chain sync after a successful CREATE broadcast returned incomplete ids.
115
- * @param {import('./dexbot_class.js').DEXBot} bot
116
- * @param {string} [reason]
117
- * @returns {Promise<void>}
118
- */
119
- async function recoverAfterMissingCreateResults(bot, reason = 'missing create operation results') {
120
- try {
121
- const accountRef = bot.accountId || bot.account?.id || bot.account;
122
- if (!accountRef || !bot.manager || !chainOrders?.readOpenOrders) {
123
- bot.manager?.logger?.log?.(`[COW] Recovery sync unavailable after ${reason}`, 'warn');
124
- return;
125
- }
126
- const preRecoveryMissingCreateBlockers = Array.isArray(bot.manager._lastUnmatchedChainOrders)
127
- ? bot.manager._lastUnmatchedChainOrders
128
- .filter((order) => order?.reason === 'missing-create-result')
129
- .map((order) => ({ ...order }))
130
- : [];
131
- // Truncated-read guard: the freshest CREATEs sort last and are exactly
132
- // the orders a partial get_full_accounts window omits — syncing would
133
- // virtualize them (phantom cleanup). Defer; blockers stay registered
134
- // so the COW guard retries the recovery on a clean read.
135
- const openOrders = await readOpenOrdersGuarded(chainOrders, accountRef, {
136
- log: (message, level) => bot.manager?.logger?.log?.(message, level),
137
- label: 'COW',
138
- detail: `recovery sync after ${reason}`,
139
- });
140
- if (openOrders === null) {
141
- bot.manager?.logger?.log?.(`[COW] Deferring recovery sync after ${reason}: open-order read ambiguous (truncated); blockers retained for retry.`, 'warn');
142
- return;
143
- }
144
- const recoveryResult = await bot.manager.syncFromOpenOrders(openOrders, {
145
- skipAccounting: false,
146
- });
147
- preserveMissingCreateBlockersAfterRecovery(bot, preRecoveryMissingCreateBlockers, recoveryResult);
148
- if (typeof bot.manager.persistGrid === 'function') {
149
- await bot.manager.persistGrid();
150
- }
151
- }
152
- catch (err) {
153
- bot.manager?.logger?.log?.(`[COW] CRITICAL: Recovery sync failed after ${reason}: ${getErrorMessage(err)}`, 'error');
154
- if (typeof bot.manager?.requestStructuralGridResync === 'function') {
155
- try {
156
- await bot.manager.requestStructuralGridResync(`recovery sync failed after ${reason}`, {
157
- error: getErrorMessage(err)
158
- });
159
- }
160
- catch (scheduleErr) {
161
- bot.manager?.logger?.log?.(`[COW] CRITICAL: Failed to schedule structural resync after recovery failure: ${getErrorMessage(scheduleErr)}`, 'error');
162
- }
163
- }
164
- }
165
- }
166
- /**
167
- * Restore unresolved missing-create blockers after recovery if sync did not adopt them.
168
- * @param {import('./dexbot_class.js').DEXBot} bot
169
- * @param {Array} blockers
170
- * @param {Object} recoveryResult
171
- */
172
- function preserveMissingCreateBlockersAfterRecovery(bot, blockers, recoveryResult) {
173
- if (!Array.isArray(blockers) || blockers.length === 0 || !bot.manager)
174
- return;
175
- const adoptedSlotIds = new Set((Array.isArray(recoveryResult?.updatedOrders) ? recoveryResult.updatedOrders : [])
176
- .filter((order) => order?.id && order?.orderId)
177
- .map((order) => order.id));
178
- const unresolvedBlockers = blockers.filter((blocker) => !blocker.slotId || !adoptedSlotIds.has(blocker.slotId));
179
- if (unresolvedBlockers.length === 0)
180
- return;
181
- const currentUnmatched = Array.isArray(bot.manager._lastUnmatchedChainOrders)
182
- ? bot.manager._lastUnmatchedChainOrders
183
- : [];
184
- const currentKeys = new Set(currentUnmatched.map((order) => `${order.reason || ''}:${order.slotId || ''}:${order.operationIndex ?? ''}`));
185
- const restored = [...currentUnmatched];
186
- for (const blocker of unresolvedBlockers) {
187
- const key = `${blocker.reason || ''}:${blocker.slotId || ''}:${blocker.operationIndex ?? ''}`;
188
- if (!currentKeys.has(key))
189
- restored.push({ ...blocker });
190
- }
191
- if (restored.length !== currentUnmatched.length) {
192
- bot.manager._lastUnmatchedChainOrders = restored;
193
- bot.manager._lastUnmatchedChainOrdersAt = Date.now();
194
- bot.manager.logger?.log?.(`[COW] Preserving ${restored.length - currentUnmatched.length} missing-create blocker(s) after recovery sync; ` +
195
- `chain snapshot did not account for the affected slot(s).`, 'warn');
196
- }
197
- }
198
133
  /**
199
134
  * Merge missing CREATE result contexts into manager._lastUnmatchedChainOrders.
200
135
  * @param {import('./dexbot_class.js').DEXBot} bot
@@ -291,6 +226,53 @@ function clearPendingBroadcasts(pendingBroadcasts) {
291
226
  pendingBroadcasts.clear();
292
227
  }
293
228
  }
229
+ /**
230
+ * Human-readable label for an order returned by findCrossedOrder.
231
+ * Master orders carry id/orderId; unmatched chain orders carry
232
+ * chainOrderId; pending-broadcast entries carry slotId and order.
233
+ * @param {Object} crossed
234
+ * @returns {string}
235
+ */
236
+ function crossedOrderLabel(crossed) {
237
+ if (!crossed)
238
+ return 'unknown';
239
+ const id = crossed.id || crossed.chainOrderId || crossed.slotId || 'unknown';
240
+ const orderId = crossed.orderId || crossed.chainOrderId || 'n/a';
241
+ const type = crossed.type || crossed.order?.type || 'unknown';
242
+ const price = crossed.price ?? crossed.order?.price;
243
+ return `${type} ${id} (${orderId}) @${price != null ? Format.formatPrice6(price) : 'n/a'}`;
244
+ }
245
+ /**
246
+ * Build the candidate set for crossing-placement checks: master orders
247
+ * plus chain-side orders that may exist on chain but are not (yet)
248
+ * adopted into the master grid — pending broadcasts from earlier
249
+ * uncertain batches and unmatched chain orders (orphans). Without these,
250
+ * an UPDATE-only rotation batch can re-price across an un-adopted chain
251
+ * order that master-grid-only checks cannot see (the pending/unmatched
252
+ * batch guards fire only for CREATE batches).
253
+ * @param {Object} bot
254
+ * @returns {any[]}
255
+ */
256
+ function buildCrossingCandidates(bot) {
257
+ const mgr = bot?.manager;
258
+ if (!mgr)
259
+ return [];
260
+ const candidates = mgr.orders instanceof Map ? [...mgr.orders.values()] : [];
261
+ if (mgr._pendingBroadcasts instanceof Map) {
262
+ for (const entry of mgr._pendingBroadcasts.values()) {
263
+ const o = entry?.order;
264
+ if (o && o.type != null && o.price != null)
265
+ candidates.push(o);
266
+ }
267
+ }
268
+ if (Array.isArray(mgr._lastUnmatchedChainOrders)) {
269
+ for (const o of mgr._lastUnmatchedChainOrders) {
270
+ if (o && o.type != null && o.price != null)
271
+ candidates.push(o);
272
+ }
273
+ }
274
+ return candidates;
275
+ }
294
276
  /**
295
277
  * Drop only the pending-broadcast entries for the given CREATE slots.
296
278
  *
@@ -348,10 +330,24 @@ function popPushedWorkingGrid(bot, cowResult) {
348
330
  * @returns {Object} Ambiguous-read reconciliation result
349
331
  */
350
332
  async function deferUncertainBroadcastRead(bot, detail, suffix, resyncReason, resyncOptions = {}) {
351
- bot.manager.logger.log(`[COW][UNCERTAIN] ${detail}; keeping pending-broadcast protection and requesting structural resync ${suffix}`, 'warn');
352
- if (typeof bot.manager.requestStructuralGridResync === 'function') {
333
+ bot.manager.logger.log(`[COW][UNCERTAIN] ${detail}; keeping pending-broadcast protection ${suffix}`, 'warn');
334
+ // Fix #6 (docs/CONSOLIDATED_ORPHAN_FIX_SUMMARY.md §2): an ambiguous/truncated chain read is
335
+ // node lag, not a missing order — the broadcast already succeeded. Previously every
336
+ // such read requested a structural resync, piling pending broadcasts (up to 14) and
337
+ // forcing a resync mid-broadcast (the T-BTS 06:43Z thrash). The pending-broadcast
338
+ // protection already prevents double-creates on the next cycle, so we keep it and
339
+ // only escalate to a structural resync once per cooldown window. The next clean
340
+ // read adopts any landed orders without the churn.
341
+ const cooldownMs = bot.config?.maintenance?.uncertainReadResyncCooldownMs || 30_000;
342
+ const lastAt = bot._lastUncertainResyncAt || 0;
343
+ if (Date.now() - lastAt >= cooldownMs && typeof bot.manager.requestStructuralGridResync === 'function') {
344
+ bot._lastUncertainResyncAt = Date.now();
353
345
  await bot.manager.requestStructuralGridResync(resyncReason, resyncOptions);
354
346
  }
347
+ else {
348
+ bot.manager.logger.log(`[COW][UNCERTAIN] Structural resync escalation suppressed (cooldown ${cooldownMs}ms) — ` +
349
+ `pending-broadcast protection retained; next clean read adopts landed orders.`, 'debug');
350
+ }
355
351
  return { executed: false, hadRotation: false, uncertain: true, ambiguousRead: true };
356
352
  }
357
353
  /**
@@ -712,6 +708,11 @@ async function reconcileAfterUncertainBroadcastImpl(bot, err, opContexts, _optio
712
708
  size: entry.order.size,
713
709
  price: entry.order.price,
714
710
  state: ORDER_STATES.VIRTUAL,
711
+ // Durable orphan evidence: this sized VIRTUAL slot
712
+ // is the product of a lost CREATE broadcast result,
713
+ // not a normal planned slot. The loadGrid sanitizer
714
+ // only drops the size for flagged slots.
715
+ createUncertain: true,
715
716
  // Clear any stale order identity: the broadcast
716
717
  // MAY have landed, but the slot must look like a
717
718
  // clean adoption target (no orderId/rawOnChain)
@@ -1440,6 +1441,161 @@ function resolveIdealSizeForValidation(_bot, orderLike, fallbackSize = null) {
1440
1441
  function validateOrderSizeForExecution(bot, size, type, orderLike = null, fallbackSize = null) {
1441
1442
  return validateOrderSize(size, type, bot.manager.assets, bot.config.gridLimits?.MIN_ORDER_SIZE_FACTOR, resolveIdealSizeForValidation(bot, orderLike, fallbackSize), bot.config.gridLimits?.PARTIAL_DUST_THRESHOLD_PERCENTAGE);
1442
1443
  }
1444
+ /**
1445
+ * LAST-FILL-GUARD helper — pivot ± halfIncrement (replaces price-tolerance).
1446
+ * last fill @x with increment i: BUY < x*(1 - i/2/100), SELL > x*(1 + i/2/100)
1447
+ * e.g. x=1000, i=0.5% => BUY < 997.5, SELL > 1002.5
1448
+ * Cold (pivot null or lastType null) => disabled. Spread-correction CREATES
1449
+ * bypass per-action (see broadcast sites); rotations never bypass.
1450
+ * @param {number} price - Target order price
1451
+ * @param {number} size - Order size (unused, kept for compat)
1452
+ * @param {string} type - ORDER_TYPES.BUY/SELL
1453
+ * @param {number|null} lastPrice - Most recent fill price
1454
+ * @param {string|null} lastType - Most recent fill side (BUY/SELL)
1455
+ * @param {number|any} incrementPercent - Grid increment percent (e.g. 0.5). If assets object passed, falls back to default.
1456
+ * @returns {{blocked: boolean, pivot: number|null, halfInc: number, threshold: number|null}}
1457
+ */
1458
+ function isLastFillGuardBlocked(price, _size, type, lastPrice, lastType, incrementPercent) {
1459
+ const numPrice = Number(price);
1460
+ if (!Number.isFinite(numPrice))
1461
+ return { blocked: false, pivot: null };
1462
+ if (lastPrice == null || !Number.isFinite(Number(lastPrice)) || lastType == null)
1463
+ return { blocked: false, pivot: null };
1464
+ const pivot = Number(lastPrice);
1465
+ // Resolve increment: fallback to default 0.5 (also covers legacy assets-object 6th arg)
1466
+ let inc = Number(incrementPercent);
1467
+ if (!Number.isFinite(inc) || inc <= 0) {
1468
+ inc = Number(constantsModule?.DEFAULT_CONFIG?.incrementPercent ?? 0.5);
1469
+ }
1470
+ if (!Number.isFinite(inc) || inc <= 0)
1471
+ return { blocked: false, pivot: null };
1472
+ const halfInc = inc / 2;
1473
+ const halfPct = halfInc / 100;
1474
+ const buyThreshold = pivot * (1 - halfPct);
1475
+ const sellThreshold = pivot * (1 + halfPct);
1476
+ if (type === ORDER_TYPES.BUY && numPrice > buyThreshold)
1477
+ return { blocked: true, pivot, halfInc, threshold: buyThreshold };
1478
+ if (type === ORDER_TYPES.SELL && numPrice < sellThreshold)
1479
+ return { blocked: true, pivot, halfInc, threshold: sellThreshold };
1480
+ return { blocked: false, pivot: null, halfInc, threshold: null };
1481
+ }
1482
+ /**
1483
+ * Refresh the LAST-FILL guard pivot from fills that arrived while the current
1484
+ * batch was being planned or broadcast but have not been ingested yet.
1485
+ *
1486
+ * The pivot (_lastFilledPrice) is recorded per fill-processing batch, but a
1487
+ * multi-chunk COW broadcast spans tens of seconds — fills detected mid-cycle
1488
+ * sit in _incomingFillQueue until the cycle ends, so ops built for later
1489
+ * chunks would be checked against a stale pivot (production incident: a chunk
1490
+ * planned with a pre-crash pivot placed asks 0.6% below the true latest fill
1491
+ * that was already queued). Peek-only: never drains the queue, the owning
1492
+ * fill cycle still processes every entry. Best-effort: returns false when no
1493
+ * queued fill yields a usable side + price.
1494
+ * @param {import('./dexbot_class.js').DEXBot} bot
1495
+ * @returns {boolean} True when the pivot was refreshed from queued fills
1496
+ */
1497
+ function refreshLastFillPivotFromQueue(bot) {
1498
+ try {
1499
+ const queue = bot?._incomingFillQueue;
1500
+ if (!Array.isArray(queue) || queue.length === 0)
1501
+ return false;
1502
+ const mgr = bot?.manager;
1503
+ if (!mgr || !mgr.orders)
1504
+ return false;
1505
+ const assets = mgr.assets;
1506
+ const findSlotByOrderId = (orderId) => {
1507
+ if (!orderId)
1508
+ return null;
1509
+ try {
1510
+ for (const o of mgr.orders.values()) {
1511
+ if (o?.orderId === orderId)
1512
+ return o;
1513
+ }
1514
+ }
1515
+ catch { /* ignore iteration errors */ }
1516
+ return null;
1517
+ };
1518
+ let latest = null;
1519
+ for (const fill of queue) {
1520
+ const fillOp = fill?.op?.[1] || fill;
1521
+ const orderId = fillOp?.order_id || fill?.orderId;
1522
+ let type = null;
1523
+ let price = null;
1524
+ // Prefer the grid slot: a limit fill executes at (or better than)
1525
+ // its slot price, which is exactly the guard's price convention.
1526
+ const slot = findSlotByOrderId(orderId);
1527
+ if (slot && (slot.type === ORDER_TYPES.BUY || slot.type === ORDER_TYPES.SELL)) {
1528
+ type = slot.type;
1529
+ const slotPrice = Number(slot.price);
1530
+ if (Number.isFinite(slotPrice) && slotPrice > 0)
1531
+ price = slotPrice;
1532
+ }
1533
+ // Fall back to fill economics (B/A convention, mirroring
1534
+ // _computeFillContext's pays-asset side resolution, including
1535
+ // SPREAD slots carrying on-chain orders).
1536
+ if ((type == null || price == null) && fillOp?.pays && fillOp?.receives && assets?.assetA && assets?.assetB) {
1537
+ try {
1538
+ const paysId = fillOp.pays.asset_id;
1539
+ const paysAmt = Number(fillOp.pays.amount);
1540
+ const recvAmt = Number(fillOp.receives.amount);
1541
+ if (Number.isFinite(paysAmt) && paysAmt > 0 && Number.isFinite(recvAmt) && recvAmt > 0) {
1542
+ const paysFloat = (base, precision) => base / Math.pow(10, precision);
1543
+ if (paysId === assets.assetA.id) {
1544
+ type = ORDER_TYPES.SELL;
1545
+ price = paysFloat(recvAmt, assets.assetB.precision) / paysFloat(paysAmt, assets.assetA.precision);
1546
+ }
1547
+ else if (paysId === assets.assetB.id) {
1548
+ type = ORDER_TYPES.BUY;
1549
+ price = paysFloat(paysAmt, assets.assetB.precision) / paysFloat(recvAmt, assets.assetA.precision);
1550
+ }
1551
+ }
1552
+ }
1553
+ catch { /* best-effort */ }
1554
+ }
1555
+ if ((type === ORDER_TYPES.BUY || type === ORDER_TYPES.SELL)
1556
+ && Number.isFinite(price) && price > 0) {
1557
+ latest = { price: price, type };
1558
+ }
1559
+ }
1560
+ if (!latest)
1561
+ return false;
1562
+ mgr._lastFilledPrice = latest.price;
1563
+ mgr._lastFilledType = latest.type;
1564
+ if (latest.type === ORDER_TYPES.BUY) {
1565
+ mgr._lastFilledBuyPrice = latest.price;
1566
+ }
1567
+ else {
1568
+ mgr._lastFilledSellPrice = latest.price;
1569
+ }
1570
+ try {
1571
+ mgr.logger?.log?.(`[LAST-FILL-GUARD] Pivot refreshed from ${queue.length} pending queued fill(s): ` +
1572
+ `${latest.type} @${Format.formatPrice6(latest.price)}`, 'debug');
1573
+ }
1574
+ catch { /* ignore logging errors */ }
1575
+ return true;
1576
+ }
1577
+ catch {
1578
+ return false;
1579
+ }
1580
+ }
1581
+ /**
1582
+ * Resolve the grid increment percent for the LAST-FILL guard in one place so
1583
+ * every check site and the batch summary use (and print) the same value.
1584
+ * Falls back to the default 0.5 when unset/invalid.
1585
+ * @param {import('./dexbot_class.js').DEXBot} bot
1586
+ * @returns {number} Positive increment percent
1587
+ */
1588
+ function resolveLastFillGuardIncrement(bot) {
1589
+ const raw = Number(bot?.manager?.config?.incrementPercent
1590
+ ?? bot?.config?.incrementPercent
1591
+ ?? constantsModule?.DEFAULT_CONFIG?.incrementPercent
1592
+ ?? 0.5);
1593
+ if (Number.isFinite(raw) && raw > 0)
1594
+ return raw;
1595
+ return Number(constantsModule?.DEFAULT_CONFIG?.incrementPercent) > 0
1596
+ ? Number(constantsModule?.DEFAULT_CONFIG?.incrementPercent)
1597
+ : 0.5;
1598
+ }
1443
1599
  /**
1444
1600
  * Build COW actions array from a simple plan object.
1445
1601
  * @param {import('./dexbot_class.js').DEXBot} bot
@@ -1451,6 +1607,12 @@ function buildActionsFromPlan(_bot, plan) {
1451
1607
  ? { ordersToPlace: plan }
1452
1608
  : (plan || {});
1453
1609
  const { ordersToPlace = [], ordersToRotate = [], ordersToUpdate = [], ordersToCancel = [] } = normalizedPlan;
1610
+ // Per-action origin: guard bypasses are scoped per action (not per batch),
1611
+ // so a future rotation entry merged into a correction plan cannot silently
1612
+ // inherit the spread-correction bypass. Actions built outside this helper
1613
+ // carry no origin and default to guarded (safe default).
1614
+ const planOrigin = normalizedPlan?.origin;
1615
+ const withOrigin = (action) => (planOrigin ? { ...action, origin: planOrigin } : action);
1454
1616
  const actions = [];
1455
1617
  for (const o of ordersToCancel) {
1456
1618
  if (o?.orderId) {
@@ -1471,7 +1633,7 @@ function buildActionsFromPlan(_bot, plan) {
1471
1633
  const orderType = r?.type || oldOrder?.type;
1472
1634
  if (!id || !orderId || !newGridId || !orderType || !Number.isFinite(newPrice) || !(newSize > 0))
1473
1635
  continue;
1474
- actions.push({
1636
+ actions.push(withOrigin({
1475
1637
  type: COW_ACTIONS.UPDATE,
1476
1638
  id,
1477
1639
  orderId,
@@ -1484,7 +1646,7 @@ function buildActionsFromPlan(_bot, plan) {
1484
1646
  price: newPrice,
1485
1647
  size: newSize
1486
1648
  }
1487
- });
1649
+ }));
1488
1650
  }
1489
1651
  for (const o of ordersToUpdate) {
1490
1652
  const partialOrder = o?.partialOrder || o;
@@ -1496,7 +1658,7 @@ function buildActionsFromPlan(_bot, plan) {
1496
1658
  : Number(partialOrder?.size || 0);
1497
1659
  if (!id || !orderId)
1498
1660
  continue;
1499
- actions.push({
1661
+ actions.push(withOrigin({
1500
1662
  type: COW_ACTIONS.UPDATE,
1501
1663
  id,
1502
1664
  orderId,
@@ -1508,12 +1670,12 @@ function buildActionsFromPlan(_bot, plan) {
1508
1670
  type: orderType,
1509
1671
  size: newSize
1510
1672
  }
1511
- });
1673
+ }));
1512
1674
  }
1513
1675
  for (const o of ordersToPlace) {
1514
1676
  if (!o?.id)
1515
1677
  continue;
1516
- actions.push({ type: COW_ACTIONS.CREATE, id: o.id, order: o });
1678
+ actions.push(withOrigin({ type: COW_ACTIONS.CREATE, id: o.id, order: o }));
1517
1679
  }
1518
1680
  return actions;
1519
1681
  }
@@ -1597,7 +1759,8 @@ function buildCowResultFromPlan(bot, plan) {
1597
1759
  workingGrid,
1598
1760
  workingIndexes: workingGrid.getIndexes(),
1599
1761
  workingBoundary,
1600
- actions
1762
+ actions,
1763
+ origin: plan?.origin
1601
1764
  };
1602
1765
  }
1603
1766
  /**
@@ -1821,7 +1984,10 @@ async function replanStaleBatch(bot, cowResult, replanDepth, preBroadcastGuard)
1821
1984
  bot.manager._boundaryShiftBudget = bot.manager._boundaryShiftBudgetBase;
1822
1985
  }
1823
1986
  if (typeof bot.manager.performSafeRebalance === 'function') {
1824
- replanned = await bot.manager.performSafeRebalance(cowResult.fills, cowResult.excludeIds || new Set());
1987
+ // skipBroadcastWait: this frame is itself inside the executor's
1988
+ // startBroadcasting() region — waiting on the flag we hold would
1989
+ // stall the re-plan for the full _awaitBroadcastIdle timeout.
1990
+ replanned = await bot.manager.performSafeRebalance(cowResult.fills, cowResult.excludeIds || new Set(), { skipBroadcastWait: true });
1825
1991
  }
1826
1992
  }
1827
1993
  catch (replanErr) {
@@ -1883,12 +2049,19 @@ async function replanStaleBatch(bot, cowResult, replanDepth, preBroadcastGuard)
1883
2049
  * synchronously (before the next await) once this resolves so the
1884
2050
  * check-and-set stays atomic and two planning batches cannot both win the
1885
2051
  * broadcast slot.
2052
+ *
2053
+ * Returns true when the caller must abort the batch (shutdown began before or
2054
+ * during the wait — proceeding would broadcast with post-shutdown state),
2055
+ * false when the caller may proceed to claim the broadcast slot.
1886
2056
  * @param {import('./dexbot_class.js').DEXBot} bot
1887
2057
  * @param {string} label - Stage label for the deferral log (e.g. 'entry', 'pre-broadcast')
2058
+ * @returns {Promise<boolean>}
1888
2059
  */
1889
2060
  async function waitForCowBroadcastSingleFlight(bot, label) {
2061
+ if (bot._shuttingDown)
2062
+ return true;
1890
2063
  if (!bot._cowBroadcastInFlight)
1891
- return;
2064
+ return false;
1892
2065
  bot.manager.logger.log(`[COW] ${label}: a COW broadcast is already in flight; deferring this batch until it settles (prevents overlapping-broadcast commit collision).`, 'warn');
1893
2066
  const waitDeadline = Date.now() + SINGLE_FLIGHT_MAX_WAIT_MS;
1894
2067
  while (bot._cowBroadcastInFlight) {
@@ -1896,9 +2069,14 @@ async function waitForCowBroadcastSingleFlight(bot, label) {
1896
2069
  break;
1897
2070
  await sleep(250);
1898
2071
  }
2072
+ if (bot._shuttingDown) {
2073
+ bot.manager.logger.log(`[COW] ${label}: shutdown began while waiting for the in-flight broadcast; aborting this batch.`, 'warn');
2074
+ return true;
2075
+ }
1899
2076
  if (bot._cowBroadcastInFlight) {
1900
2077
  bot.manager.logger.log(`[COW] ${label}: waited for in-flight broadcast but it did not settle within the cap; proceeding (commit guard + chain adoption will close divergence).`, 'warn');
1901
2078
  }
2079
+ return false;
1902
2080
  }
1903
2081
  /**
1904
2082
  * COW broadcast: Execute blockchain operations and commit working grid on success.
@@ -1988,6 +2166,48 @@ function detectCrossedBookPlan(manager, actions) {
1988
2166
  return null;
1989
2167
  }
1990
2168
  }
2169
+ /**
2170
+ * Derive an update action's planned target size (shared by the rotation and
2171
+ * plain size-update op builders).
2172
+ * @param {Object} action - COW action
2173
+ * @returns {number}
2174
+ */
2175
+ function plannedUpdateSize(action) {
2176
+ return Number.isFinite(Number(action.newSize))
2177
+ ? Number(action.newSize)
2178
+ : Number(action.order?.size || 0);
2179
+ }
2180
+ /**
2181
+ * Post-fill size invariant for COW UPDATE ops: a partially-filled order
2182
+ * (slot state PARTIAL — the fill is already booked into slot.size) must
2183
+ * never be GROWN in place by a plan update. Growing it would restore the
2184
+ * pre-fill size on chain while the fill accounting stays on the booked
2185
+ * remaining size — chain and books diverge and the fill effectively
2186
+ * vanishes from the bot's ledger. Clamp the target to the booked remaining
2187
+ * size and keep the plan's price intent; a deliberate full-size top-up must
2188
+ * go through a cancel+create cycle, not a silent in-place grow.
2189
+ * @param {import('./dexbot_class.js').DEXBot} bot
2190
+ * @param {Object} masterOrder - Live master-grid slot for the action
2191
+ * @param {number} newSize - Plan's target size for the update op
2192
+ * @param {Object} action - The COW action being built
2193
+ * @returns {number} The (possibly clamped) target size
2194
+ */
2195
+ function clampPostFillUpdateSize(bot, masterOrder, newSize, action) {
2196
+ const target = Number(newSize);
2197
+ if (!masterOrder || masterOrder.state !== ORDER_STATES.PARTIAL)
2198
+ return target;
2199
+ const booked = Number(masterOrder.size);
2200
+ if (!Number.isFinite(target) || !Number.isFinite(booked) || booked <= 0)
2201
+ return target;
2202
+ if (target > booked) {
2203
+ bot.manager.logger.log(`[COW] Post-fill size clamp for ${action?.id || masterOrder?.id}: slot is PARTIAL with ` +
2204
+ `booked remaining ${Format.formatAmount(booked)} but the plan targets ` +
2205
+ `${Format.formatAmount(target)} — clamping to booked remaining. ` +
2206
+ `A partially-filled order must not be grown in place by a COW update (fill accounting divergence).`, 'warn');
2207
+ return booked;
2208
+ }
2209
+ return target;
2210
+ }
1991
2211
  async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
1992
2212
  const replanDepth = Number.isFinite(Number(options?.replanDepth)) ? Number(options.replanDepth) : 0;
1993
2213
  bot._currentCycleId = (Number.isFinite(Number(bot._currentCycleId)) ? Number(bot._currentCycleId) : 0) + 1;
@@ -2012,7 +2232,34 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2012
2232
  // -> snapshot reload that can drop the adopted order and produce an
2013
2233
  // orphan fill. This entry wait is an optimization; the authoritative
2014
2234
  // atomic check-and-set happens right before the broadcast below.
2015
- await waitForCowBroadcastSingleFlight(bot, 'entry');
2235
+ if (await waitForCowBroadcastSingleFlight(bot, 'entry')) {
2236
+ popPushedWorkingGrid(bot, cowResult);
2237
+ return { executed: false, aborted: true, reason: 'SHUTDOWN_IN_PROGRESS', hadRotation: false };
2238
+ }
2239
+ // DRAIN PENDING CORRECTIONS before the batch is planned/broadcast.
2240
+ // Cancel-only corrections (duplicate-price orphans) queued by an earlier
2241
+ // sync must not sit while batches run back-to-back (startup create
2242
+ // groups, fill bursts) — with the open-orders sync loop disabled they
2243
+ // would otherwise linger indefinitely, keep blocking same-level CREATEs,
2244
+ // and risk cancelling the wrong side of a duplicate later. Draining here
2245
+ // also keeps this batch's collision checks (chain_orphan_collision)
2246
+ // honest: orphaned chain orders already queued for cancellation are
2247
+ // resolved before the plan validates its CREATE targets against them.
2248
+ const pendingCorrectionCount = Array.isArray(bot.manager?.ordersNeedingPriceCorrection)
2249
+ ? bot.manager.ordersNeedingPriceCorrection.length
2250
+ : 0;
2251
+ if (pendingCorrectionCount > 0 && !bot._shuttingDown) {
2252
+ try {
2253
+ bot.manager.logger.log(`[COW] Draining ${pendingCorrectionCount} pending correction(s) before batch`, 'info');
2254
+ const drainResult = await orderUtils.correctAllPriceMismatches(bot.manager, bot.account, bot.privateKey, chainOrders);
2255
+ if (drainResult?.failed > 0) {
2256
+ bot.manager.logger.log(`[COW] ${drainResult.failed} correction(s) failed pre-batch; remaining entries retry on next sync/maintenance tick`, 'warn');
2257
+ }
2258
+ }
2259
+ catch (drainErr) {
2260
+ bot.manager.logger.log(`[COW] Pre-batch correction drain failed: ${getErrorMessage(drainErr)}`, 'warn');
2261
+ }
2262
+ }
2016
2263
  const chainOrderCandidates = Array.isArray(bot.manager?._lastUnmatchedChainOrders)
2017
2264
  ? bot.manager._lastUnmatchedChainOrders
2018
2265
  : [];
@@ -2196,6 +2443,11 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2196
2443
  hadRotation: false
2197
2444
  };
2198
2445
  }
2446
+ // Crossing-check candidate set (master + pending-broadcast + unmatched
2447
+ // chain orders). Built after the batch-level pending/unmatched guards so
2448
+ // it reflects any sync they triggered.
2449
+ const crossingCandidates = buildCrossingCandidates(bot);
2450
+ const intraBatchCandidates = [];
2199
2451
  const { assetA, assetB } = bot.manager.assets;
2200
2452
  // CROSSED-BOOK GATE: refuse to broadcast any batch whose simulated result
2201
2453
  // prices a BUY at-or-above a SELL (see detectCrossedBookPlan).
@@ -2215,6 +2467,25 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2215
2467
  const opContexts = [];
2216
2468
  const skippedUpdateSlotIds = new Set();
2217
2469
  let skippedUpdateCount = 0;
2470
+ // Per-batch LAST-FILL-GUARD disposition counters. Per-action pass lines
2471
+ // would spam big batches, so the guard emits one batch summary instead
2472
+ // (see the summary after the action loop below).
2473
+ const lastFillGuardStats = { checked: 0, passed: 0, skipped: 0, bypassed: 0 };
2474
+ // Whether any guard check in this batch refreshed the pivot from
2475
+ // still-queued fills — reported in the batch summary so a pivot change
2476
+ // that altered a guard decision is visible at info, not just debug.
2477
+ let lastFillGuardPivotRefreshed = false;
2478
+ // Slots whose size-update op was broadcast with a post-fill-clamped
2479
+ // target: the working grid still holds the planned (larger) size, so the
2480
+ // slots are re-synced from master before commit to keep the committed
2481
+ // books equal to the broadcast chain amounts.
2482
+ const clampedUpdateSlotIds = new Set();
2483
+ // orderId -> operations index of its cancel op. A crossing re-pricing
2484
+ // update is only safe when the crossed order's cancel was already queued
2485
+ // at an earlier position: ops broadcast in MAX_OPS_PER_BROADCAST chunks,
2486
+ // so an earlier index means the cancel confirms on chain (same or earlier
2487
+ // chunk, applied sequentially) before the crossing order lands.
2488
+ const cancelOpIndexByOrderId = new Map();
2218
2489
  const idsToLock = new Set();
2219
2490
  for (const action of actions) {
2220
2491
  if (action.type === COW_ACTIONS.CANCEL && action.orderId) {
@@ -2250,6 +2521,8 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2250
2521
  try {
2251
2522
  const op = await chainOrders.buildCancelOrderOp(bot.account, action.orderId);
2252
2523
  operations.push(op);
2524
+ if (action.orderId)
2525
+ cancelOpIndexByOrderId.set(action.orderId, operations.length - 1);
2253
2526
  const order = bot.manager.orders.get(action.id) || { id: action.id, orderId: action.orderId };
2254
2527
  opContexts.push({ kind: 'cancel', order });
2255
2528
  }
@@ -2286,8 +2559,8 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2286
2559
  `(diff=${priceDrift}); rebuilding CREATE op with live price.`, 'debug');
2287
2560
  }
2288
2561
  const createPrice = effectiveOrder.price;
2289
- const createSize = effectiveOrder.size;
2290
- const batchCollision = findPriceCollision(opContexts, order.id, createPrice, createSize, order.type, bot.manager.assets, (ctx) => ctx.kind === 'create');
2562
+ const precision = order.type === ORDER_TYPES.SELL ? bot.manager.assets.assetA.precision : bot.manager.assets.assetB.precision;
2563
+ const batchCollision = hasSlotPriceCollision(opContexts, createPrice, precision, order.id, (ctx) => ctx.kind === 'create' && ctx.order?.price != null);
2291
2564
  if (batchCollision) {
2292
2565
  bot.manager.logger.log(`[COW] Skipping CREATE for ${order.id} at ${Format.formatPrice6(createPrice)}: ` +
2293
2566
  `same-batch CREATE ${batchCollision.id} already at ` +
@@ -2295,6 +2568,60 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2295
2568
  `The next reconcile cycle will resolve the mismatch.`, 'warn');
2296
2569
  continue;
2297
2570
  }
2571
+ // CROSSING-PLACEMENT GUARD (create variant): the batch-level
2572
+ // validators (validateCreateTargetSlots, detectCrossedBookPlan)
2573
+ // simulated the PLANNED price, but the pre-broadcast price
2574
+ // freshness rebuild above can move the op's price. Re-check
2575
+ // crossing on the FINAL price against live and chain-side
2576
+ // orders not already cancelled at an earlier op position —
2577
+ // an opposite-side order cancelled in a later chunk would
2578
+ // otherwise coexist with this create mid-broadcast and
2579
+ // self-trade (production incident class).
2580
+ const createCrossed = findCrossedOrder(crossingCandidates, createPrice, order.type, bot.manager.assets, (o) => {
2581
+ const oid = o?.orderId || o?.chainOrderId;
2582
+ return o && oid && !cancelOpIndexByOrderId.has(oid);
2583
+ });
2584
+ const intraBatchCrossed = createCrossed ? null : findCrossedOrder(intraBatchCandidates, createPrice, order.type, bot.manager.assets);
2585
+ const effectiveCrossed = createCrossed || intraBatchCrossed;
2586
+ if (effectiveCrossed) {
2587
+ bot.manager.logger.log(`[COW-CROSS-GUARD] Skipping CREATE for ${order.id} at ` +
2588
+ `${Format.formatPrice6(createPrice)}: crosses live ` +
2589
+ `${crossedOrderLabel(effectiveCrossed)}; re-planned after its cancel confirms.`, 'warn');
2590
+ continue;
2591
+ }
2592
+ // LAST-FILL PRICE GUARD: pivot ± halfIncrement (BUY < pivot*(1-half), SELL > pivot*(1+half)).
2593
+ // Bypass is per-action: only spread-correction CREATES skip so gap repair can close.
2594
+ // The batch-level origin is honored only for actions without their own origin stamp
2595
+ // (back-compat for plans that bypass buildActionsFromPlan). Cold (null) => disabled.
2596
+ try {
2597
+ const actionOrigin = action?.origin;
2598
+ const batchOrigin = cowResult?.origin;
2599
+ const isCorrectionCreate = actionOrigin === 'spread-correction'
2600
+ || (actionOrigin == null && batchOrigin === 'spread-correction');
2601
+ if (!isCorrectionCreate) {
2602
+ try {
2603
+ if (refreshLastFillPivotFromQueue(bot))
2604
+ lastFillGuardPivotRefreshed = true;
2605
+ }
2606
+ catch { /* best-effort */ }
2607
+ const lastPrice = bot.manager?._lastFilledPrice;
2608
+ const lastType = bot.manager?._lastFilledType;
2609
+ const inc = resolveLastFillGuardIncrement(bot);
2610
+ const check = isLastFillGuardBlocked(createPrice, order.size, order.type, lastPrice, lastType, inc);
2611
+ lastFillGuardStats.checked++;
2612
+ if (check.blocked) {
2613
+ lastFillGuardStats.skipped++;
2614
+ const dir = order.type === ORDER_TYPES.BUY ? 'above' : 'below';
2615
+ 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');
2616
+ continue;
2617
+ }
2618
+ lastFillGuardStats.passed++;
2619
+ }
2620
+ else {
2621
+ lastFillGuardStats.bypassed++;
2622
+ }
2623
+ }
2624
+ catch (_e) { /* guard is best-effort */ }
2298
2625
  const args = buildCreateOrderArgs(effectiveOrder, assetA, assetB);
2299
2626
  const buildResult = await chainOrders.buildCreateOrderOp(bot.account, args.amountToSell, args.sellAssetId, args.minToReceive, args.receiveAssetId, null);
2300
2627
  if (!buildResult) {
@@ -2303,6 +2630,7 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2303
2630
  }
2304
2631
  operations.push(buildResult.op);
2305
2632
  opContexts.push({ kind: 'create', id: order.id, order: effectiveOrder, args, finalInts: buildResult.finalInts });
2633
+ intraBatchCandidates.push(effectiveOrder);
2306
2634
  recordPendingBroadcast(bot, {
2307
2635
  opIndex: operations.length - 1,
2308
2636
  ctxIndex: opContexts.length - 1,
@@ -2322,17 +2650,99 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2322
2650
  const newPrice = Number.isFinite(Number(action.newPrice))
2323
2651
  ? Number(action.newPrice)
2324
2652
  : Number(action.order?.price);
2325
- const newSize = Number.isFinite(Number(action.newSize))
2326
- ? Number(action.newSize)
2327
- : Number(action.order?.size || 0);
2653
+ const newSize = plannedUpdateSize(action);
2328
2654
  if (!masterOrder || !action.orderId || !orderType || !Number.isFinite(newPrice) || newSize <= 0) {
2329
2655
  continue;
2330
2656
  }
2657
+ // POST-FILL GROWTH GUARD (rotation): a rotation must
2658
+ // not GROW a partially-filled order back above its
2659
+ // booked remaining size — the fill is already booked
2660
+ // into slot.size, and growing in place diverges the
2661
+ // fill accounting (chain restores the pre-fill size
2662
+ // while the books keep the post-fill remainder). Skip
2663
+ // like the guards above: the working grid restores the
2664
+ // slot from master and the next plan re-evaluates.
2665
+ if (masterOrder.state === ORDER_STATES.PARTIAL
2666
+ && Number.isFinite(Number(masterOrder.size))
2667
+ && Number(masterOrder.size) > 0
2668
+ && newSize > Number(masterOrder.size)) {
2669
+ skippedUpdateCount++;
2670
+ if (action.id)
2671
+ skippedUpdateSlotIds.add(action.id);
2672
+ if (action.newGridId)
2673
+ skippedUpdateSlotIds.add(action.newGridId);
2674
+ bot.manager.logger.log(`[COW] Skipping rotation update ${action.id} -> ${action.newGridId}: ` +
2675
+ `slot is PARTIAL with booked remaining ${Format.formatAmount(Number(masterOrder.size))} ` +
2676
+ `but the plan targets ${Format.formatAmount(newSize)} — a partially-filled order ` +
2677
+ `must not be grown in place (fill accounting divergence).`, 'warn');
2678
+ continue;
2679
+ }
2331
2680
  const rotationSizeValidation = validateOrderSizeForExecution(bot, newSize, orderType, action.order, newSize);
2332
2681
  if (!rotationSizeValidation.isValid) {
2333
2682
  bot.manager.logger.log(`Skipping rotation update ${action.id} -> ${action.newGridId}: ${rotationSizeValidation.reason}`, 'warn');
2334
2683
  continue;
2335
2684
  }
2685
+ // CROSSING-PLACEMENT GUARD: re-pricing an order must
2686
+ // never cross an opposite-side live order whose cancel
2687
+ // is not already queued at an earlier op position.
2688
+ // Re-pricing a buy upward across our own live sell
2689
+ // ladder self-trades during the chunked broadcast
2690
+ // window (production incident: a startup buy was
2691
+ // re-priced upward and filled against our own live
2692
+ // opposite-side sells that this same plan was still
2693
+ // cancelling in later chunks — dozens of self-fills,
2694
+ // fatal fund assert). Skipping is safe: the slot keeps its
2695
+ // old commitment and the next plan re-evaluates once
2696
+ // the crossed order's cancel confirms.
2697
+ const crossedOrder = findCrossedOrder([...crossingCandidates, ...intraBatchCandidates], newPrice, orderType, bot.manager.assets, (o) => {
2698
+ const oid = o?.orderId || o?.chainOrderId;
2699
+ return o
2700
+ && oid
2701
+ && oid !== action.orderId
2702
+ && !cancelOpIndexByOrderId.has(oid);
2703
+ });
2704
+ if (crossedOrder) {
2705
+ skippedUpdateCount++;
2706
+ if (action.id)
2707
+ skippedUpdateSlotIds.add(action.id);
2708
+ if (action.newGridId)
2709
+ skippedUpdateSlotIds.add(action.newGridId);
2710
+ bot.manager.logger.log(`[COW-CROSS-GUARD] Skipping rotation update ${action.id} -> ${action.newGridId}: ` +
2711
+ `new ${orderType} @${Format.formatPrice6(newPrice)} crosses live ` +
2712
+ `${crossedOrderLabel(crossedOrder)}; re-planned after its cancel confirms.`, 'warn');
2713
+ continue;
2714
+ }
2715
+ // LAST-FILL PRICE GUARD (UPDATE rotation): pivot ± halfIncrement — same as CREATE.
2716
+ // No origin bypass here, ever: a rotation reprices, so it always obeys the guard —
2717
+ // even if a future correction plan carries rotation entries (per-action scoping).
2718
+ // The pivot is refreshed from still-queued fills first: fills detected mid-broadcast
2719
+ // sit in _incomingFillQueue until the fill cycle ends, and without this the later
2720
+ // chunks of a long broadcast would be checked against a stale pivot.
2721
+ try {
2722
+ try {
2723
+ if (refreshLastFillPivotFromQueue(bot))
2724
+ lastFillGuardPivotRefreshed = true;
2725
+ }
2726
+ catch { /* best-effort */ }
2727
+ const lastPrice = bot.manager?._lastFilledPrice;
2728
+ const lastType = bot.manager?._lastFilledType;
2729
+ const inc = resolveLastFillGuardIncrement(bot);
2730
+ const check = isLastFillGuardBlocked(newPrice, newSize, orderType, lastPrice, lastType, inc);
2731
+ lastFillGuardStats.checked++;
2732
+ if (check.blocked) {
2733
+ lastFillGuardStats.skipped++;
2734
+ skippedUpdateCount++;
2735
+ if (action.id)
2736
+ skippedUpdateSlotIds.add(action.id);
2737
+ if (action.newGridId)
2738
+ skippedUpdateSlotIds.add(action.newGridId);
2739
+ const dir = orderType === ORDER_TYPES.BUY ? 'above' : 'below';
2740
+ 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');
2741
+ continue;
2742
+ }
2743
+ lastFillGuardStats.passed++;
2744
+ }
2745
+ catch (_e) { /* best-effort */ }
2336
2746
  const { amountToSell, minToReceive } = buildCreateOrderArgs({ type: orderType, size: newSize, price: newPrice }, assetA, assetB);
2337
2747
  const buildResult = await chainOrders.buildUpdateOrderOp(bot.account, action.orderId, { amountToSell, minToReceive, newPrice, orderType }, masterOrder.rawOnChain || null);
2338
2748
  if (!buildResult) {
@@ -2356,12 +2766,15 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2356
2766
  },
2357
2767
  finalInts: buildResult.finalInts
2358
2768
  });
2769
+ intraBatchCandidates.push({ type: orderType, price: newPrice, orderId: action.orderId, id: action.newGridId || action.id });
2359
2770
  continue;
2360
2771
  }
2361
- const newSize = Number.isFinite(Number(action.newSize))
2362
- ? Number(action.newSize)
2363
- : Number(action.order?.size || 0);
2364
2772
  const masterOrder = bot.manager.orders.get(action.id);
2773
+ const plannedNewSize = plannedUpdateSize(action);
2774
+ const newSize = clampPostFillUpdateSize(bot, masterOrder, plannedNewSize, action);
2775
+ if (newSize !== plannedNewSize) {
2776
+ clampedUpdateSlotIds.add(action.id);
2777
+ }
2365
2778
  const orderType = action.order?.type || masterOrder?.type;
2366
2779
  const cachedRawOnChain = masterOrder?.rawOnChain || action.order?.rawOnChain || null;
2367
2780
  const op = await chainOrders.buildUpdateOrderOp(bot.account, action.orderId, { amountToSell: newSize, orderType }, cachedRawOnChain);
@@ -2375,6 +2788,8 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2375
2788
  continue;
2376
2789
  }
2377
2790
  operations.push(op.op);
2791
+ if (masterOrder?.price != null)
2792
+ intraBatchCandidates.push({ type: orderType, price: masterOrder.price, orderId: action.orderId, id: action.id });
2378
2793
  const partialOrder = masterOrder || {
2379
2794
  id: action.id,
2380
2795
  orderId: action.orderId,
@@ -2406,20 +2821,63 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2406
2821
  bot.manager.logger.log(`[COW] CREATE fallback for ${action.id} rejected by size validation: ${sizeCheck.reason}`, 'warn');
2407
2822
  }
2408
2823
  else if (fbType && fbSize > 0 && fbPrice > 0) {
2409
- const fbCollision = findPriceCollision(bot.manager.orders.values(), targetSlotId, fbPrice, fbSize, fbType, bot.manager.assets, isOrderPlaced);
2824
+ const fbPrecision = fbType === ORDER_TYPES.SELL ? bot.manager.assets.assetA.precision : bot.manager.assets.assetB.precision;
2825
+ const fbCollision = hasSlotPriceCollision([...bot.manager.orders.values()], fbPrice, fbPrecision, targetSlotId, isOrderPlaced);
2410
2826
  if (fbCollision) {
2411
2827
  bot.manager.logger.log(`[COW] Skipping CREATE fallback for ${targetSlotId} at ${Format.formatPrice6(fbPrice)}: ` +
2412
2828
  `existing placed order ${fbCollision.id} (${fbCollision.orderId}) ` +
2413
2829
  `already at price ${Format.formatPrice6(fbCollision.price)}.`, 'warn');
2414
2830
  continue;
2415
2831
  }
2416
- const fbBatchCollision = findPriceCollision(opContexts, targetSlotId, fbPrice, fbSize, fbType, bot.manager.assets, (ctx) => ctx.kind === 'create');
2832
+ const fbBatchCollision = hasSlotPriceCollision(opContexts, fbPrice, fbPrecision, targetSlotId, (ctx) => ctx.kind === 'create');
2417
2833
  if (fbBatchCollision) {
2418
2834
  bot.manager.logger.log(`[COW] Skipping CREATE fallback for ${targetSlotId} at ${Format.formatPrice6(fbPrice)}: ` +
2419
2835
  `same-batch CREATE ${fbBatchCollision.id} already at ` +
2420
2836
  `price ${Format.formatPrice6(fbBatchCollision.order.price)}.`, 'warn');
2421
2837
  continue;
2422
2838
  }
2839
+ // CROSSING-PLACEMENT GUARD (fallback variant):
2840
+ // the not-found conversion re-prices to the
2841
+ // rotation's target price, so it must obey the
2842
+ // same crossing rule as the rotation UPDATE it
2843
+ // replaces — no crossing of an opposite-side
2844
+ // live order whose cancel is not already
2845
+ // queued at an earlier op position.
2846
+ const fbCrossed = findCrossedOrder(crossingCandidates, fbPrice, fbType, bot.manager.assets, (o) => {
2847
+ const oid = o?.orderId || o?.chainOrderId;
2848
+ return o && oid && !cancelOpIndexByOrderId.has(oid);
2849
+ });
2850
+ if (fbCrossed) {
2851
+ bot.manager.logger.log(`[COW-CROSS-GUARD] Skipping CREATE fallback for ${targetSlotId} at ` +
2852
+ `${Format.formatPrice6(fbPrice)}: new ${fbType} crosses live ` +
2853
+ `${crossedOrderLabel(fbCrossed)}; re-planned after its cancel confirms.`, 'warn');
2854
+ continue;
2855
+ }
2856
+ // LAST-FILL GUARD (fallback variant): this CREATE
2857
+ // replaces a rotation UPDATE at a repriced level,
2858
+ // so it obeys the same guard with a refreshed
2859
+ // pivot — no origin bypass, same as rotations.
2860
+ try {
2861
+ if (refreshLastFillPivotFromQueue(bot))
2862
+ lastFillGuardPivotRefreshed = true;
2863
+ }
2864
+ catch { /* best-effort */ }
2865
+ try {
2866
+ const fbInc = resolveLastFillGuardIncrement(bot);
2867
+ const fbCheck = isLastFillGuardBlocked(fbPrice, fbSize, fbType, bot.manager?._lastFilledPrice, bot.manager?._lastFilledType, fbInc);
2868
+ lastFillGuardStats.checked++;
2869
+ if (fbCheck.blocked) {
2870
+ lastFillGuardStats.skipped++;
2871
+ const fbDir = fbType === ORDER_TYPES.BUY ? 'above' : 'below';
2872
+ bot.manager.logger.log(`[LAST-FILL-GUARD] Skipping CREATE fallback for ${targetSlotId} at ` +
2873
+ `${Format.formatPrice6(fbPrice)}: ${fbDir} last filled ` +
2874
+ `${Format.formatPrice6(fbCheck.pivot)} (halfInc ${fbCheck.halfInc}% thr ` +
2875
+ `${Format.formatPrice6(fbCheck.threshold)}); re-planned after market moves`, 'warn');
2876
+ continue;
2877
+ }
2878
+ lastFillGuardStats.passed++;
2879
+ }
2880
+ catch (_fbGuardErr) { /* guard is best-effort */ }
2423
2881
  const fbArgs = buildCreateOrderArgs({ type: fbType, size: fbSize, price: fbPrice }, assetA, assetB);
2424
2882
  const fbResult = await chainOrders.buildCreateOrderOp(bot.account, fbArgs.amountToSell, fbArgs.sellAssetId, fbArgs.minToReceive, fbArgs.receiveAssetId, null);
2425
2883
  if (fbResult) {
@@ -2450,9 +2908,51 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2450
2908
  }
2451
2909
  }
2452
2910
  }
2911
+ // Batch-level LAST-FILL-GUARD summary: per-action pass lines would spam
2912
+ // big batches, so one line per batch records the mode, pivot, resolved
2913
+ // increment, and pass/skip/bypass counts — the guard's pass decisions
2914
+ // are what incident reconstruction needs. Origin folds in here as
2915
+ // mode=bypassed(<origin>); there is no second source of truth.
2916
+ // Cold (guard off) is warn, not info — a disabled guard must say so.
2917
+ // pivotRefreshed surfaces whether a mid-broadcast queued fill moved the
2918
+ // pivot under this batch's checks (the refresh itself stays debug).
2919
+ // Note the printed pivot is end-of-batch state: when pivotRefreshed is
2920
+ // true, early actions were checked against the older pivot.
2921
+ try {
2922
+ const totalGuarded = lastFillGuardStats.checked + lastFillGuardStats.bypassed;
2923
+ if (totalGuarded > 0) {
2924
+ const sumPivotRaw = bot.manager?._lastFilledPrice;
2925
+ const sumType = bot.manager?._lastFilledType;
2926
+ const sumInc = resolveLastFillGuardIncrement(bot);
2927
+ const cold = sumPivotRaw == null || !Number.isFinite(Number(sumPivotRaw)) || sumType == null;
2928
+ const batchOrigin = cowResult?.origin;
2929
+ const mode = cold
2930
+ ? 'disabled(cold)'
2931
+ : (lastFillGuardStats.bypassed > 0 && lastFillGuardStats.checked === 0)
2932
+ ? `bypassed(${batchOrigin || 'unknown'})`
2933
+ : (lastFillGuardStats.bypassed > 0
2934
+ ? `active+bypassed(${batchOrigin || 'unknown'})`
2935
+ : 'active');
2936
+ const pivotStr = cold ? 'none' : `${Format.formatPrice6(Number(sumPivotRaw))}(${sumType})`;
2937
+ bot.manager.logger.log(`[LAST-FILL-GUARD] mode=${mode} pivot=${pivotStr} inc=${sumInc}% ` +
2938
+ `pivotRefreshed=${lastFillGuardPivotRefreshed} ` +
2939
+ `checked=${lastFillGuardStats.checked} passed=${lastFillGuardStats.passed} ` +
2940
+ `skipped=${lastFillGuardStats.skipped} bypassed=${lastFillGuardStats.bypassed}`, cold ? 'warn' : 'info');
2941
+ }
2942
+ }
2943
+ catch { /* summary is best-effort */ }
2453
2944
  if (skippedUpdateCount > 0) {
2454
2945
  restoreSkippedUpdateSlotsInWorkingGrid(bot, workingGrid, skippedUpdateSlotIds, skippedUpdateCount);
2455
2946
  }
2947
+ if (clampedUpdateSlotIds.size > 0) {
2948
+ const masterVersion = Number.isFinite(Number(bot.manager?._gridVersion))
2949
+ ? Number(bot.manager._gridVersion)
2950
+ : undefined;
2951
+ for (const slotId of clampedUpdateSlotIds) {
2952
+ workingGrid.syncFromMaster(bot.manager.orders, slotId, masterVersion);
2953
+ }
2954
+ bot.manager.logger.log(`[COW] Re-synced ${clampedUpdateSlotIds.size} post-fill-clamped slot(s) from master before commit`, 'debug');
2955
+ }
2456
2956
  if (operations.length === 0) {
2457
2957
  // Pop the working grid: in the re-plan recursion the fresh plan's
2458
2958
  // grid was pushed by performSafeRebalance, and nothing downstream
@@ -2495,7 +2995,10 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2495
2995
  // for it to settle, then claim the slot synchronously (no await
2496
2996
  // between the check inside waitForCowBroadcastSingleFlight and the
2497
2997
  // assignment below), so two batches can never broadcast together.
2498
- await waitForCowBroadcastSingleFlight(bot, 'pre-broadcast');
2998
+ if (await waitForCowBroadcastSingleFlight(bot, 'pre-broadcast')) {
2999
+ popPushedWorkingGrid(bot, cowResult);
3000
+ return { executed: false, aborted: true, reason: 'SHUTDOWN_IN_PROGRESS', hadRotation: false };
3001
+ }
2499
3002
  bot._cowBroadcastInFlight = true;
2500
3003
  heldBroadcastSlot = true;
2501
3004
  await bot._ensureCredentialDaemonWritable('COW batch broadcast');
@@ -2514,19 +3017,74 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2514
3017
  const missingSlots = missingCreateResults
2515
3018
  .map((item) => item.ctx?.order?.id || item.ctx?.id || `op-${item.index}`)
2516
3019
  .join(', ');
2517
- bot.manager.logger.log(`[COW] Refusing to commit working grid: ${missingCreateResults.length} CREATE op(s) ` +
2518
- `returned no chainOrderId (${missingSlots}). Discarding working grid and syncing from chain.`, 'error');
2519
- popPushedWorkingGrid(bot, cowResult);
2520
- markMissingCreateResultsAsStructuralBlocker(bot, missingCreateResults);
2521
- await recoverAfterMissingCreateResults(bot, 'missing create operation results');
2522
- return {
2523
- executed: false,
2524
- hadRotation: false,
2525
- missingCreateResults: missingCreateResults.map((item) => ({
2526
- index: item.index,
2527
- slotId: item.ctx?.order?.id || item.ctx?.id || null
2528
- }))
2529
- };
3020
+ bot.manager.logger.log(`[COW] ${missingCreateResults.length} CREATE op(s) returned no chainOrderId ` +
3021
+ `(${missingSlots}). Resolving from chain before commit (normalize, don't reject).`, 'warn');
3022
+ // Layer 2 (normalize, don't reject + uncertain-broadcast routing):
3023
+ // the batch otherwise succeeded, so a missing id is the
3024
+ // "broadcast succeeded but attach lost" ambiguous case. Poll the
3025
+ // chain to separate REAL on-chain orders (adopt their id into the
3026
+ // working grid so they commit normally) from PHANTOMS (normalize the
3027
+ // slot in place to a clean empty). Either way the REST of the batch
3028
+ // commits — we never discard a whole batch over one missing id.
3029
+ const confirmation = await pollChainForConfirmation(bot, missingCreateResults.map((m) => m.ctx));
3030
+ const accountRef = bot.accountId || bot.account?.id || bot.account;
3031
+ let chainSnap = null;
3032
+ try {
3033
+ const cr = await readOpenOrdersWithMetaSafe(chainOrders, accountRef);
3034
+ if (cr && !cr.truncated)
3035
+ chainSnap = cr.orders;
3036
+ }
3037
+ catch { /* best-effort; confirmed set already known from poll */ }
3038
+ let adoptedCount = 0;
3039
+ let normalizedCount = 0;
3040
+ for (const item of missingCreateResults) {
3041
+ const slotId = item.ctx?.order?.id || item.ctx?.id;
3042
+ const slot = workingGrid.get(slotId);
3043
+ if (!slot)
3044
+ continue;
3045
+ const isConfirmed = confirmation.confirmed.some((c) => (c?.order?.id || c?.id) === slotId);
3046
+ if (isConfirmed && chainSnap) {
3047
+ const match = findChainOrderForSlot(bot, chainSnap, slotId, {
3048
+ sell: item.ctx?.finalInts?.sell,
3049
+ receive: item.ctx?.finalInts?.receive,
3050
+ orderType: item.ctx?.order?.type,
3051
+ fingerprint: buildCreateOpFingerprint({
3052
+ side: item.ctx?.order?.type,
3053
+ assetA: bot.manager?.assets?.assetA?.id,
3054
+ assetB: bot.manager?.assets?.assetB?.id,
3055
+ sellInt: item.ctx?.finalInts?.sell,
3056
+ receiveInt: item.ctx?.finalInts?.receive,
3057
+ slotId
3058
+ })
3059
+ });
3060
+ if (match?.id) {
3061
+ workingGrid.set(slotId, { ...slot, orderId: match.id });
3062
+ adoptedCount++;
3063
+ continue;
3064
+ }
3065
+ }
3066
+ // Normalize: drop the size so the slot cannot persist as a
3067
+ // phantom placed order (VIRTUAL + size>0 + no orderId) — the
3068
+ // exact corrupt shape that recurs as a sized orphan. It commits
3069
+ // as a clean empty and is re-placed by the next cycle / spread
3070
+ // correction.
3071
+ workingGrid.set(slotId, {
3072
+ ...slot,
3073
+ size: 0,
3074
+ orderId: null,
3075
+ state: ORDER_STATES.VIRTUAL
3076
+ });
3077
+ normalizedCount++;
3078
+ }
3079
+ bot.manager.logger.log(`[COW] Missing CREATEs resolved: ${adoptedCount} adopted from chain, ` +
3080
+ `${normalizedCount} normalized in place; committing remainder of batch.`, 'warn');
3081
+ // NOTE: we intentionally do NOT mark these as structural blockers.
3082
+ // Normalizing the slot to a clean empty lets the next cycle re-create
3083
+ // it, and the regular open-orders sync adopts any order that really
3084
+ // did land on chain — so a permanent block (which would reject every
3085
+ // subsequent CREATE batch) is avoided.
3086
+ // DO NOT pop/discard the working grid — fall through to commit the
3087
+ // rest of the batch below.
2530
3088
  }
2531
3089
  // Pre-apply rotation state transitions to the working grid so the
2532
3090
  // COW commit is truly atomic for structural changes (source → VIRTUAL,
@@ -2547,7 +3105,7 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2547
3105
  // is on chain; adopt the placed orders from the chain so master
2548
3106
  // converges instead of remaining divergent until a later sync.
2549
3107
  bot.manager.logger.log('[COW] Commit refused after broadcast; adopting placed orders from chain to keep master in sync', 'warn');
2550
- const adopted = await adoptPlacedBatchFromChain(bot, chainOrders, '[COW]');
3108
+ const adopted = await adoptPlacedBatchFromChain(bot, chainOrders, '[COW]', { placedResults: result, placedContexts: executedContexts });
2551
3109
  if (!adopted) {
2552
3110
  // Chain state unknown (empty/lagging read or sync failure):
2553
3111
  // keep the pending-broadcast protection so a later plan
@@ -2560,6 +3118,7 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2560
3118
  // Deduct create fees for the placed orders (mirrors
2561
3119
  // processBatchResults, which the refused path bypasses).
2562
3120
  await applyAdoptionFeeAccounting(bot, executedContexts);
3121
+ await restoreBoundaryAfterAdoption(bot, workingBoundary);
2563
3122
  await persistGridAndClearPendingBroadcasts(bot, '[COW]');
2564
3123
  return { executed: false, hadRotation: false, commitRefused: true };
2565
3124
  }
@@ -2657,6 +3216,7 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2657
3216
  return { executed: false, hadRotation: false, commitRefused: true, chainAdoptionPending: true };
2658
3217
  }
2659
3218
  await applyAdoptionFeeAccounting(bot, opContexts);
3219
+ await restoreBoundaryAfterAdoption(bot, workingBoundary);
2660
3220
  await persistGridAndClearPendingBroadcasts(bot, '[COW][UNCERTAIN]');
2661
3221
  return { executed: false, hadRotation: false, commitRefused: true, uncertainResolved: true };
2662
3222
  }
@@ -2679,6 +3239,7 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2679
3239
  // result to extract); deduct create fees so the optimistic
2680
3240
  // balance reflects the on-chain cost.
2681
3241
  await applyAdoptionFeeAccounting(bot, opContexts);
3242
+ await restoreBoundaryAfterAdoption(bot, workingBoundary);
2682
3243
  await persistGridAndClearPendingBroadcasts(bot, '[COW][UNCERTAIN]');
2683
3244
  return { executed: true, hadRotation: false, uncertainResolved: true };
2684
3245
  }
@@ -2755,16 +3316,193 @@ async function requestStructuralResync(bot, reason, details = {}) {
2755
3316
  * @param {string} logPrefix - Log prefix for sync failure messages
2756
3317
  * @returns {Promise<boolean>}
2757
3318
  */
2758
- async function adoptPlacedBatchFromChain(bot, chainOrders, logPrefix) {
3319
+ /**
3320
+ * Collect every on-chain order id master currently needs to converge against,
3321
+ * so adoption can re-read them by id (immune to the get_full_accounts window
3322
+ * truncation) instead of relying on a partial window read.
3323
+ *
3324
+ * Sources:
3325
+ * - master's own tracked order ids (existing on-chain orders);
3326
+ * - the batch's fresh CREATE ids extracted from the broadcast result
3327
+ * (operation_results[i][1] aligns positionally with placedContexts[i]).
3328
+ *
3329
+ * @param {any} mgr - bot.manager
3330
+ * @param {any} placedResults - broadcast result (has operation_results); null when unavailable
3331
+ * @param {any[]} placedContexts - opContexts (aligned with operation_results); null when unavailable
3332
+ * @returns {string[]} Unique, well-formed 1.7.x order ids
3333
+ */
3334
+ function collectKnownOnChainOrderIds(mgr, placedResults, placedContexts) {
3335
+ const masterIds = new Set();
3336
+ const grid = mgr && mgr.grid;
3337
+ if (Array.isArray(grid)) {
3338
+ for (const slot of grid) {
3339
+ if (slot && slot.orderId && /^1\.7\.\d+$/.test(String(slot.orderId))) {
3340
+ masterIds.add(String(slot.orderId));
3341
+ }
3342
+ }
3343
+ }
3344
+ const createIds = new Set();
3345
+ if (placedResults && Array.isArray(placedContexts)) {
3346
+ const opResults = extractBatchOperationResults(placedResults);
3347
+ if (Array.isArray(opResults)) {
3348
+ for (let i = 0; i < placedContexts.length; i++) {
3349
+ const ctx = placedContexts[i];
3350
+ if (!ctx || ctx.kind !== 'create')
3351
+ continue;
3352
+ const opResult = opResults[i] && opResults[i][1];
3353
+ if (opResult && /^1\.7\.\d+$/.test(String(opResult))) {
3354
+ createIds.add(String(opResult));
3355
+ }
3356
+ }
3357
+ }
3358
+ }
3359
+ const all = new Set([...masterIds, ...createIds]);
3360
+ return { masterIds: [...masterIds], createIds: [...createIds], all: [...all] };
3361
+ }
3362
+ /**
3363
+ * Converge master with the chain after a COW commit was refused (master moved
3364
+ * during broadcast) or an uncertain broadcast was poll-confirmed.
3365
+ *
3366
+ * Preferred path (when the broadcast result is available): re-read the exact
3367
+ * on-chain orders BY ID — every id master already tracks plus the batch's
3368
+ * fresh CREATE ids. get_objects returns the complete, authoritative set
3369
+ * regardless of account size, so syncFromOpenOrders converges master instead
3370
+ * of dropping the freshest creates (which previously left permanent orphans
3371
+ * and tripped the fund invariant). This is fully immune to the
3372
+ * get_full_accounts window truncation that broke the old window-read path.
3373
+ *
3374
+ * Fallback (no broadcast result, e.g. uncertain paths, or by-id read
3375
+ * unavailable): window read. A truncated read is ambiguous — only the
3376
+ * freshest orders are dropped — so it MUST NOT drive adoption; we return false
3377
+ * and let the caller keep pending-broadcast protection + structural resync.
3378
+ *
3379
+ * @param {any} bot
3380
+ * @param {any} chainOrders - chain_orders module (has readOpenOrdersWithMetaSafe + batchReadOrders)
3381
+ * @param {string} logPrefix
3382
+ * @param {Object} [opts]
3383
+ * @param {any} [opts.placedResults] - broadcast result carrying operation_results
3384
+ * @param {any[]} [opts.placedContexts] - opContexts aligned with operation_results
3385
+ * @returns {Promise<boolean>} true if master was adopted from the chain
3386
+ */
3387
+ /**
3388
+ * P1-atomic: after a refused/uncertain COW commit is recovered by adopting the
3389
+ * placed orders from the chain, re-apply the rotational boundary this batch had
3390
+ * already computed (workingBoundary) to master. The refused commit discarded
3391
+ * the working grid, so without this the next rebalance would re-derive the
3392
+ * boundary from a master that still reflects the pre-fill layout and could
3393
+ * re-stamp the just-filled slot x. Applying workingBoundary commits the
3394
+ * post-fill rotation immediately (no re-broadcast), so the next placement lands
3395
+ * at the shifted slot, never at x.
3396
+ *
3397
+ * @param {any} bot
3398
+ * @param {number} workingBoundary - boundary index the refused batch targeted
3399
+ */
3400
+ async function restoreBoundaryAfterAdoption(bot, workingBoundary) {
2759
3401
  try {
3402
+ if (workingBoundary === undefined || workingBoundary === null)
3403
+ return;
3404
+ if (typeof bot.manager._restoreBoundary === 'function') {
3405
+ bot.manager._restoreBoundary(workingBoundary);
3406
+ }
3407
+ else {
3408
+ bot.manager.boundaryIdx = workingBoundary;
3409
+ }
3410
+ bot.manager.logger.log(`[COW] Restored rotational boundary ${workingBoundary} after refused/uncertain commit + chain adoption (atomic re-plan)`, 'info');
3411
+ }
3412
+ catch (e) {
3413
+ bot.manager.logger.log(`[COW] Post-adoption boundary restore failed: ${getErrorMessage(e)}`, 'warn');
3414
+ }
3415
+ }
3416
+ async function adoptPlacedBatchFromChain(bot, chainOrders, logPrefix, opts = {}) {
3417
+ const { placedResults = null, placedContexts = null } = opts || {};
3418
+ try {
3419
+ const mgr = bot.manager;
2760
3420
  const accountRef = bot.accountId || bot.account?.id || bot.account;
3421
+ // PREFERRED: re-read the exact placed/existing orders by id. Only when we
3422
+ // have the broadcast result (so the set includes the freshest CREATE ids);
3423
+ // without it the by-id set would be incomplete and would wrongly sync
3424
+ // master against a partial picture.
3425
+ const { all: knownIds, createIds } = placedResults
3426
+ ? collectKnownOnChainOrderIds(mgr, placedResults, placedContexts)
3427
+ : { all: [], createIds: [] };
3428
+ if (knownIds.length > 0 && typeof chainOrders.batchReadOrders === 'function') {
3429
+ // Retry/backoff (fix #6): a fresh CREATE absent from the first read
3430
+ // is a lagging node, not a missing order; a read error is equally
3431
+ // transient. Retry with backoff before deferring to the structural
3432
+ // resync path — deferral blocks CREATEs for minutes.
3433
+ const maxAttempts = Math.max(1, Number(COW_PERFORMANCE.ADOPTION_READ_MAX_ATTEMPTS) || 3);
3434
+ const baseBackoff = Math.max(250, Number(COW_PERFORMANCE.ADOPTION_READ_BACKOFF_MS) || 2000);
3435
+ let chainMap = null;
3436
+ let laggingCreateIds = [];
3437
+ let lastReadError = null;
3438
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
3439
+ chainMap = null;
3440
+ laggingCreateIds = [];
3441
+ lastReadError = null;
3442
+ try {
3443
+ chainMap = await chainOrders.batchReadOrders(knownIds);
3444
+ }
3445
+ catch (byIdErr) {
3446
+ // A by-id read failure must NOT fall through to the window read
3447
+ // (which would also miss the freshest creates and virtualize them).
3448
+ lastReadError = getErrorMessage(byIdErr);
3449
+ }
3450
+ if (chainMap) {
3451
+ // Lagging-node guard (phantom-virtualization risk): a FRESHLY
3452
+ // BROADCAST create id returning null here almost certainly means the
3453
+ // queried node has not yet indexed the order (it was just placed), not
3454
+ // that it is gone. If we synced a partial set, syncFromOpenOrders'
3455
+ // phantom-cleanup would virtualize that live order and count it as a
3456
+ // fill, re-creating a duplicate on the next cycle — the exact orphan
3457
+ // class this path exists to prevent.
3458
+ for (const id of createIds) {
3459
+ if (chainMap.get(id) == null)
3460
+ laggingCreateIds.push(id);
3461
+ }
3462
+ }
3463
+ if (chainMap && laggingCreateIds.length === 0)
3464
+ break;
3465
+ if (attempt < maxAttempts) {
3466
+ const wait = baseBackoff * Math.pow(2, attempt - 1);
3467
+ bot.manager.logger.log(`${logPrefix} By-id adoption read retry ${attempt}/${maxAttempts - 1} in ${wait}ms ` +
3468
+ `(${chainMap ? `fresh CREATE(s) absent: ${laggingCreateIds.join(', ')}` : `read failed: ${lastReadError}`})`, 'warn');
3469
+ await sleep(wait);
3470
+ }
3471
+ }
3472
+ if (!chainMap) {
3473
+ bot.manager.logger.log(`${logPrefix} By-id adoption read failed after ${maxAttempts} attempt(s): ${lastReadError}; deferring (pending-broadcast protection kept)`, 'warn');
3474
+ return false;
3475
+ }
3476
+ if (laggingCreateIds.length > 0) {
3477
+ bot.manager.logger.log(`${logPrefix} By-id adoption deferred: fresh CREATE(s) ${laggingCreateIds.join(', ')} absent from ` +
3478
+ `${maxAttempts} chain read(s) (likely lagging node). ` +
3479
+ 'Keeping pending-broadcast protection pending a caught-up read.', 'error');
3480
+ return false;
3481
+ }
3482
+ const fullChain = [];
3483
+ if (chainMap && typeof chainMap.forEach === 'function') {
3484
+ chainMap.forEach((order) => { if (order)
3485
+ fullChain.push(order); });
3486
+ }
3487
+ else if (Array.isArray(chainMap)) {
3488
+ for (const o of chainMap)
3489
+ if (o)
3490
+ fullChain.push(o);
3491
+ }
3492
+ // Informational: any other known id (master's pre-existing orders)
3493
+ // absent is expected — those were cancelled/filled in this batch.
3494
+ if (fullChain.length < knownIds.length) {
3495
+ bot.manager.logger.log(`${logPrefix} By-id adoption: ${knownIds.length - fullChain.length}/${knownIds.length} known id(s) absent ` +
3496
+ '(expected cancels/fills); adopting the rest', 'debug');
3497
+ }
3498
+ if (fullChain.length > 0 && typeof mgr.syncFromOpenOrders === 'function') {
3499
+ await mgr.syncFromOpenOrders(fullChain, { skipAccounting: false });
3500
+ bot.manager.logger.log(`${logPrefix} Adopted ${fullChain.length} on-chain order(s) by id after refused/uncertain commit (truncation-immune)`, 'info');
3501
+ return true;
3502
+ }
3503
+ }
3504
+ // FALLBACK: window read (ambiguous when truncated).
2761
3505
  const freshRead = await readOpenOrdersWithMetaSafe(chainOrders, accountRef);
2762
- // A truncated read (get_full_accounts caps the limit_orders window and
2763
- // fresh creates sort last) omits the very orders this batch just
2764
- // broadcast — the adoption sync could not register them, and clearing
2765
- // the pending-broadcast protection would let the next cycle re-create
2766
- // them as duplicates on chain. Treat truncated like an unreadable
2767
- // chain state: keep the protection and defer to a structural resync.
2768
3506
  if (freshRead.truncated) {
2769
3507
  bot.manager.logger.log(`${logPrefix} Chain read TRUNCATED after batch broadcast; adoption deferred (pending-broadcast protection kept)`, 'warn');
2770
3508
  return false;
@@ -2950,7 +3688,12 @@ async function processBatchResults(bot, result, opContexts) {
2950
3688
  });
2951
3689
  }
2952
3690
  }
2953
- bot.manager.logger.log(`Placed ${ctx.order.type} order ${ctx.order.id} -> ${chainOrderId}`, 'info');
3691
+ // Success line mirrors the failure-path fingerprint below
3692
+ // (type/price/size) so both are greppable by the same keys —
3693
+ // this is what ties a fill back to the slot that placed it.
3694
+ bot.manager.logger.log(`Placed ${ctx.order.type} order ${ctx.order.id} ` +
3695
+ `@${Format.formatPrice6(ctx.order.price)} x${Format.formatAmount(ctx.order.size)} ` +
3696
+ `-> ${chainOrderId}`, 'info');
2954
3697
  }
2955
3698
  else {
2956
3699
  const fingerprint = [
@@ -3051,14 +3794,12 @@ async function processBatchResults(bot, result, opContexts) {
3051
3794
  updateOperationCount
3052
3795
  };
3053
3796
  }
3054
- export { buildOutsideInPairGroupsForOrders, buildOutsideInPairGroupsForCreateEntries, extractOperationResults, findMissingCreateResultContexts, recoverAfterMissingCreateResults, preserveMissingCreateBlockersAfterRecovery, 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 };
3797
+ 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 };
3055
3798
  export default {
3056
3799
  buildOutsideInPairGroupsForOrders,
3057
3800
  buildOutsideInPairGroupsForCreateEntries,
3058
3801
  extractOperationResults,
3059
3802
  findMissingCreateResultContexts,
3060
- recoverAfterMissingCreateResults,
3061
- preserveMissingCreateBlockersAfterRecovery,
3062
3803
  markMissingCreateResultsAsStructuralBlocker,
3063
3804
  formatUnmatchedChainOrderForLog,
3064
3805
  recordPendingBroadcast,