dexbot 1.4.23 → 1.4.25

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 (137) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +1 -0
  3. package/analysis/README.md +49 -0
  4. package/analysis/ama_fitting/package.json +1 -1
  5. package/analysis/grid_correction_check.ts +896 -0
  6. package/analysis/trend_detection/package.json +1 -1
  7. package/claw/package.json +1 -1
  8. package/claw/runtimes/openclaw-plugin/openclaw.plugin.json +1 -1
  9. package/claw/runtimes/openclaw-plugin/package.json +1 -1
  10. package/claw/tests/test_claw_mcp_transport.ts +2 -2
  11. package/dist/analysis/grid_correction_check.d.ts +90 -0
  12. package/dist/analysis/grid_correction_check.d.ts.map +1 -0
  13. package/dist/analysis/grid_correction_check.js +861 -0
  14. package/dist/analysis/grid_correction_check.js.map +1 -0
  15. package/dist/dexbot.d.ts.map +1 -1
  16. package/dist/dexbot.js +62 -6
  17. package/dist/dexbot.js.map +1 -1
  18. package/dist/modules/account_bots.d.ts.map +1 -1
  19. package/dist/modules/account_bots.js +53 -5
  20. package/dist/modules/account_bots.js.map +1 -1
  21. package/dist/modules/account_orders.d.ts +7 -10
  22. package/dist/modules/account_orders.d.ts.map +1 -1
  23. package/dist/modules/account_orders.js +26 -2
  24. package/dist/modules/account_orders.js.map +1 -1
  25. package/dist/modules/bitshares-native/subscriptions.d.ts.map +1 -1
  26. package/dist/modules/bitshares-native/subscriptions.js +176 -21
  27. package/dist/modules/bitshares-native/subscriptions.js.map +1 -1
  28. package/dist/modules/chain_orders.d.ts.map +1 -1
  29. package/dist/modules/chain_orders.js +21 -2
  30. package/dist/modules/chain_orders.js.map +1 -1
  31. package/dist/modules/config.d.ts.map +1 -1
  32. package/dist/modules/config.js +27 -0
  33. package/dist/modules/config.js.map +1 -1
  34. package/dist/modules/constants.d.ts +15 -1
  35. package/dist/modules/constants.d.ts.map +1 -1
  36. package/dist/modules/constants.js +70 -6
  37. package/dist/modules/constants.js.map +1 -1
  38. package/dist/modules/credit_runtime.d.ts.map +1 -1
  39. package/dist/modules/credit_runtime.js +31 -32
  40. package/dist/modules/credit_runtime.js.map +1 -1
  41. package/dist/modules/dexbot_class.d.ts +24 -0
  42. package/dist/modules/dexbot_class.d.ts.map +1 -1
  43. package/dist/modules/dexbot_class.js +66 -12
  44. package/dist/modules/dexbot_class.js.map +1 -1
  45. package/dist/modules/dexbot_cow_runtime.d.ts +22 -17
  46. package/dist/modules/dexbot_cow_runtime.d.ts.map +1 -1
  47. package/dist/modules/dexbot_cow_runtime.js +646 -128
  48. package/dist/modules/dexbot_cow_runtime.js.map +1 -1
  49. package/dist/modules/dexbot_fill_runtime.d.ts +11 -1
  50. package/dist/modules/dexbot_fill_runtime.d.ts.map +1 -1
  51. package/dist/modules/dexbot_fill_runtime.js +108 -1
  52. package/dist/modules/dexbot_fill_runtime.js.map +1 -1
  53. package/dist/modules/dexbot_maintenance_runtime.d.ts +17 -1
  54. package/dist/modules/dexbot_maintenance_runtime.d.ts.map +1 -1
  55. package/dist/modules/dexbot_maintenance_runtime.js +215 -26
  56. package/dist/modules/dexbot_maintenance_runtime.js.map +1 -1
  57. package/dist/modules/dexbot_startup_runtime.d.ts +1 -0
  58. package/dist/modules/dexbot_startup_runtime.d.ts.map +1 -1
  59. package/dist/modules/dexbot_startup_runtime.js +58 -7
  60. package/dist/modules/dexbot_startup_runtime.js.map +1 -1
  61. package/dist/modules/dexbot_state_recovery.d.ts.map +1 -1
  62. package/dist/modules/dexbot_state_recovery.js +55 -3
  63. package/dist/modules/dexbot_state_recovery.js.map +1 -1
  64. package/dist/modules/graceful_shutdown.d.ts +12 -1
  65. package/dist/modules/graceful_shutdown.d.ts.map +1 -1
  66. package/dist/modules/graceful_shutdown.js +39 -1
  67. package/dist/modules/graceful_shutdown.js.map +1 -1
  68. package/dist/modules/key_store.d.ts.map +1 -1
  69. package/dist/modules/key_store.js +38 -1
  70. package/dist/modules/key_store.js.map +1 -1
  71. package/dist/modules/order/accounting.d.ts.map +1 -1
  72. package/dist/modules/order/accounting.js +20 -3
  73. package/dist/modules/order/accounting.js.map +1 -1
  74. package/dist/modules/order/grid.d.ts +1 -1
  75. package/dist/modules/order/grid.d.ts.map +1 -1
  76. package/dist/modules/order/grid.js +288 -31
  77. package/dist/modules/order/grid.js.map +1 -1
  78. package/dist/modules/order/grid_reconcile.d.ts +4 -2
  79. package/dist/modules/order/grid_reconcile.d.ts.map +1 -1
  80. package/dist/modules/order/grid_reconcile.js +242 -222
  81. package/dist/modules/order/grid_reconcile.js.map +1 -1
  82. package/dist/modules/order/grid_reconcile_internal.d.ts +11 -1
  83. package/dist/modules/order/grid_reconcile_internal.d.ts.map +1 -1
  84. package/dist/modules/order/grid_reconcile_internal.js +132 -6
  85. package/dist/modules/order/grid_reconcile_internal.js.map +1 -1
  86. package/dist/modules/order/manager.d.ts +68 -14
  87. package/dist/modules/order/manager.d.ts.map +1 -1
  88. package/dist/modules/order/manager.js +241 -52
  89. package/dist/modules/order/manager.js.map +1 -1
  90. package/dist/modules/order/strategy.d.ts.map +1 -1
  91. package/dist/modules/order/strategy.js +25 -8
  92. package/dist/modules/order/strategy.js.map +1 -1
  93. package/dist/modules/order/sync_engine.d.ts.map +1 -1
  94. package/dist/modules/order/sync_engine.js +261 -166
  95. package/dist/modules/order/sync_engine.js.map +1 -1
  96. package/dist/modules/order/utils/math.d.ts +47 -5
  97. package/dist/modules/order/utils/math.d.ts.map +1 -1
  98. package/dist/modules/order/utils/math.js +175 -11
  99. package/dist/modules/order/utils/math.js.map +1 -1
  100. package/dist/modules/order/utils/order.d.ts +16 -15
  101. package/dist/modules/order/utils/order.d.ts.map +1 -1
  102. package/dist/modules/order/utils/order.js +199 -32
  103. package/dist/modules/order/utils/order.js.map +1 -1
  104. package/dist/modules/order/utils/slot.d.ts +8 -0
  105. package/dist/modules/order/utils/slot.d.ts.map +1 -0
  106. package/dist/modules/order/utils/slot.js +16 -0
  107. package/dist/modules/order/utils/slot.js.map +1 -0
  108. package/dist/modules/order/utils/system.d.ts.map +1 -1
  109. package/dist/modules/order/utils/system.js +3 -2
  110. package/dist/modules/order/utils/system.js.map +1 -1
  111. package/dist/modules/order/utils/validate.d.ts +5 -8
  112. package/dist/modules/order/utils/validate.d.ts.map +1 -1
  113. package/dist/modules/order/utils/validate.js +64 -82
  114. package/dist/modules/order/utils/validate.js.map +1 -1
  115. package/dist/modules/order/working_grid.d.ts +5 -0
  116. package/dist/modules/order/working_grid.d.ts.map +1 -1
  117. package/dist/modules/order/working_grid.js +21 -0
  118. package/dist/modules/order/working_grid.js.map +1 -1
  119. package/dist/modules/paths.d.ts +4 -2
  120. package/dist/modules/paths.d.ts.map +1 -1
  121. package/dist/modules/paths.js +79 -24
  122. package/dist/modules/paths.js.map +1 -1
  123. package/dist/scripts/analyze-orders.d.ts.map +1 -1
  124. package/dist/scripts/analyze-orders.js +23 -2
  125. package/dist/scripts/analyze-orders.js.map +1 -1
  126. package/dist/scripts/generate_market_adapter_whitelist.d.ts +2 -5
  127. package/dist/scripts/generate_market_adapter_whitelist.d.ts.map +1 -1
  128. package/dist/scripts/generate_market_adapter_whitelist.js +83 -1
  129. package/dist/scripts/generate_market_adapter_whitelist.js.map +1 -1
  130. package/docs/DEXBOT_COMPARISON.md +15 -15
  131. package/docs/EVOLUTION.md +10 -8
  132. package/docs/FUND_MOVEMENT_AND_ACCOUNTING.md +1 -1
  133. package/docs/GRID_RECALCULATION.md +6 -0
  134. package/docs/README.md +2 -2
  135. package/market_adapter/README.md +10 -0
  136. package/package.json +2 -1
  137. package/scripts/README.md +4 -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,43 @@ 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 bypasses via cowResult.origin === 'spread-correction'.
1449
+ * @param {number} price - Target order price
1450
+ * @param {number} size - Order size (unused, kept for compat)
1451
+ * @param {string} type - ORDER_TYPES.BUY/SELL
1452
+ * @param {number|null} lastPrice - Most recent fill price
1453
+ * @param {string|null} lastType - Most recent fill side (BUY/SELL)
1454
+ * @param {number|any} incrementPercent - Grid increment percent (e.g. 0.5). If assets object passed, falls back to default.
1455
+ * @returns {{blocked: boolean, pivot: number|null, halfInc: number, threshold: number|null}}
1456
+ */
1457
+ function isLastFillGuardBlocked(price, _size, type, lastPrice, lastType, incrementPercent) {
1458
+ const numPrice = Number(price);
1459
+ if (!Number.isFinite(numPrice))
1460
+ return { blocked: false, pivot: null };
1461
+ if (lastPrice == null || !Number.isFinite(Number(lastPrice)) || lastType == null)
1462
+ return { blocked: false, pivot: null };
1463
+ const pivot = Number(lastPrice);
1464
+ // Resolve increment: fallback to default 0.5 (also covers legacy assets-object 6th arg)
1465
+ let inc = Number(incrementPercent);
1466
+ if (!Number.isFinite(inc) || inc <= 0) {
1467
+ inc = Number(constantsModule?.DEFAULT_CONFIG?.incrementPercent ?? 0.5);
1468
+ }
1469
+ if (!Number.isFinite(inc) || inc <= 0)
1470
+ return { blocked: false, pivot: null };
1471
+ const halfInc = inc / 2;
1472
+ const halfPct = halfInc / 100;
1473
+ const buyThreshold = pivot * (1 - halfPct);
1474
+ const sellThreshold = pivot * (1 + halfPct);
1475
+ if (type === ORDER_TYPES.BUY && numPrice > buyThreshold)
1476
+ return { blocked: true, pivot, halfInc, threshold: buyThreshold };
1477
+ if (type === ORDER_TYPES.SELL && numPrice < sellThreshold)
1478
+ return { blocked: true, pivot, halfInc, threshold: sellThreshold };
1479
+ return { blocked: false, pivot: null, halfInc, threshold: null };
1480
+ }
1443
1481
  /**
1444
1482
  * Build COW actions array from a simple plan object.
1445
1483
  * @param {import('./dexbot_class.js').DEXBot} bot
@@ -1597,7 +1635,8 @@ function buildCowResultFromPlan(bot, plan) {
1597
1635
  workingGrid,
1598
1636
  workingIndexes: workingGrid.getIndexes(),
1599
1637
  workingBoundary,
1600
- actions
1638
+ actions,
1639
+ origin: plan?.origin
1601
1640
  };
1602
1641
  }
1603
1642
  /**
@@ -1821,7 +1860,10 @@ async function replanStaleBatch(bot, cowResult, replanDepth, preBroadcastGuard)
1821
1860
  bot.manager._boundaryShiftBudget = bot.manager._boundaryShiftBudgetBase;
1822
1861
  }
1823
1862
  if (typeof bot.manager.performSafeRebalance === 'function') {
1824
- replanned = await bot.manager.performSafeRebalance(cowResult.fills, cowResult.excludeIds || new Set());
1863
+ // skipBroadcastWait: this frame is itself inside the executor's
1864
+ // startBroadcasting() region — waiting on the flag we hold would
1865
+ // stall the re-plan for the full _awaitBroadcastIdle timeout.
1866
+ replanned = await bot.manager.performSafeRebalance(cowResult.fills, cowResult.excludeIds || new Set(), { skipBroadcastWait: true });
1825
1867
  }
1826
1868
  }
1827
1869
  catch (replanErr) {
@@ -1883,12 +1925,19 @@ async function replanStaleBatch(bot, cowResult, replanDepth, preBroadcastGuard)
1883
1925
  * synchronously (before the next await) once this resolves so the
1884
1926
  * check-and-set stays atomic and two planning batches cannot both win the
1885
1927
  * broadcast slot.
1928
+ *
1929
+ * Returns true when the caller must abort the batch (shutdown began before or
1930
+ * during the wait — proceeding would broadcast with post-shutdown state),
1931
+ * false when the caller may proceed to claim the broadcast slot.
1886
1932
  * @param {import('./dexbot_class.js').DEXBot} bot
1887
1933
  * @param {string} label - Stage label for the deferral log (e.g. 'entry', 'pre-broadcast')
1934
+ * @returns {Promise<boolean>}
1888
1935
  */
1889
1936
  async function waitForCowBroadcastSingleFlight(bot, label) {
1937
+ if (bot._shuttingDown)
1938
+ return true;
1890
1939
  if (!bot._cowBroadcastInFlight)
1891
- return;
1940
+ return false;
1892
1941
  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
1942
  const waitDeadline = Date.now() + SINGLE_FLIGHT_MAX_WAIT_MS;
1894
1943
  while (bot._cowBroadcastInFlight) {
@@ -1896,9 +1945,14 @@ async function waitForCowBroadcastSingleFlight(bot, label) {
1896
1945
  break;
1897
1946
  await sleep(250);
1898
1947
  }
1948
+ if (bot._shuttingDown) {
1949
+ bot.manager.logger.log(`[COW] ${label}: shutdown began while waiting for the in-flight broadcast; aborting this batch.`, 'warn');
1950
+ return true;
1951
+ }
1899
1952
  if (bot._cowBroadcastInFlight) {
1900
1953
  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
1954
  }
1955
+ return false;
1902
1956
  }
1903
1957
  /**
1904
1958
  * COW broadcast: Execute blockchain operations and commit working grid on success.
@@ -1988,6 +2042,48 @@ function detectCrossedBookPlan(manager, actions) {
1988
2042
  return null;
1989
2043
  }
1990
2044
  }
2045
+ /**
2046
+ * Derive an update action's planned target size (shared by the rotation and
2047
+ * plain size-update op builders).
2048
+ * @param {Object} action - COW action
2049
+ * @returns {number}
2050
+ */
2051
+ function plannedUpdateSize(action) {
2052
+ return Number.isFinite(Number(action.newSize))
2053
+ ? Number(action.newSize)
2054
+ : Number(action.order?.size || 0);
2055
+ }
2056
+ /**
2057
+ * Post-fill size invariant for COW UPDATE ops: a partially-filled order
2058
+ * (slot state PARTIAL — the fill is already booked into slot.size) must
2059
+ * never be GROWN in place by a plan update. Growing it would restore the
2060
+ * pre-fill size on chain while the fill accounting stays on the booked
2061
+ * remaining size — chain and books diverge and the fill effectively
2062
+ * vanishes from the bot's ledger. Clamp the target to the booked remaining
2063
+ * size and keep the plan's price intent; a deliberate full-size top-up must
2064
+ * go through a cancel+create cycle, not a silent in-place grow.
2065
+ * @param {import('./dexbot_class.js').DEXBot} bot
2066
+ * @param {Object} masterOrder - Live master-grid slot for the action
2067
+ * @param {number} newSize - Plan's target size for the update op
2068
+ * @param {Object} action - The COW action being built
2069
+ * @returns {number} The (possibly clamped) target size
2070
+ */
2071
+ function clampPostFillUpdateSize(bot, masterOrder, newSize, action) {
2072
+ const target = Number(newSize);
2073
+ if (!masterOrder || masterOrder.state !== ORDER_STATES.PARTIAL)
2074
+ return target;
2075
+ const booked = Number(masterOrder.size);
2076
+ if (!Number.isFinite(target) || !Number.isFinite(booked) || booked <= 0)
2077
+ return target;
2078
+ if (target > booked) {
2079
+ bot.manager.logger.log(`[COW] Post-fill size clamp for ${action?.id || masterOrder?.id}: slot is PARTIAL with ` +
2080
+ `booked remaining ${Format.formatAmount(booked)} but the plan targets ` +
2081
+ `${Format.formatAmount(target)} — clamping to booked remaining. ` +
2082
+ `A partially-filled order must not be grown in place by a COW update (fill accounting divergence).`, 'warn');
2083
+ return booked;
2084
+ }
2085
+ return target;
2086
+ }
1991
2087
  async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
1992
2088
  const replanDepth = Number.isFinite(Number(options?.replanDepth)) ? Number(options.replanDepth) : 0;
1993
2089
  bot._currentCycleId = (Number.isFinite(Number(bot._currentCycleId)) ? Number(bot._currentCycleId) : 0) + 1;
@@ -2012,7 +2108,34 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2012
2108
  // -> snapshot reload that can drop the adopted order and produce an
2013
2109
  // orphan fill. This entry wait is an optimization; the authoritative
2014
2110
  // atomic check-and-set happens right before the broadcast below.
2015
- await waitForCowBroadcastSingleFlight(bot, 'entry');
2111
+ if (await waitForCowBroadcastSingleFlight(bot, 'entry')) {
2112
+ popPushedWorkingGrid(bot, cowResult);
2113
+ return { executed: false, aborted: true, reason: 'SHUTDOWN_IN_PROGRESS', hadRotation: false };
2114
+ }
2115
+ // DRAIN PENDING CORRECTIONS before the batch is planned/broadcast.
2116
+ // Cancel-only corrections (duplicate-price orphans) queued by an earlier
2117
+ // sync must not sit while batches run back-to-back (startup create
2118
+ // groups, fill bursts) — with the open-orders sync loop disabled they
2119
+ // would otherwise linger indefinitely, keep blocking same-level CREATEs,
2120
+ // and risk cancelling the wrong side of a duplicate later. Draining here
2121
+ // also keeps this batch's collision checks (chain_orphan_collision)
2122
+ // honest: orphaned chain orders already queued for cancellation are
2123
+ // resolved before the plan validates its CREATE targets against them.
2124
+ const pendingCorrectionCount = Array.isArray(bot.manager?.ordersNeedingPriceCorrection)
2125
+ ? bot.manager.ordersNeedingPriceCorrection.length
2126
+ : 0;
2127
+ if (pendingCorrectionCount > 0 && !bot._shuttingDown) {
2128
+ try {
2129
+ bot.manager.logger.log(`[COW] Draining ${pendingCorrectionCount} pending correction(s) before batch`, 'info');
2130
+ const drainResult = await orderUtils.correctAllPriceMismatches(bot.manager, bot.account, bot.privateKey, chainOrders);
2131
+ if (drainResult?.failed > 0) {
2132
+ bot.manager.logger.log(`[COW] ${drainResult.failed} correction(s) failed pre-batch; remaining entries retry on next sync/maintenance tick`, 'warn');
2133
+ }
2134
+ }
2135
+ catch (drainErr) {
2136
+ bot.manager.logger.log(`[COW] Pre-batch correction drain failed: ${getErrorMessage(drainErr)}`, 'warn');
2137
+ }
2138
+ }
2016
2139
  const chainOrderCandidates = Array.isArray(bot.manager?._lastUnmatchedChainOrders)
2017
2140
  ? bot.manager._lastUnmatchedChainOrders
2018
2141
  : [];
@@ -2196,6 +2319,11 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2196
2319
  hadRotation: false
2197
2320
  };
2198
2321
  }
2322
+ // Crossing-check candidate set (master + pending-broadcast + unmatched
2323
+ // chain orders). Built after the batch-level pending/unmatched guards so
2324
+ // it reflects any sync they triggered.
2325
+ const crossingCandidates = buildCrossingCandidates(bot);
2326
+ const intraBatchCandidates = [];
2199
2327
  const { assetA, assetB } = bot.manager.assets;
2200
2328
  // CROSSED-BOOK GATE: refuse to broadcast any batch whose simulated result
2201
2329
  // prices a BUY at-or-above a SELL (see detectCrossedBookPlan).
@@ -2215,6 +2343,17 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2215
2343
  const opContexts = [];
2216
2344
  const skippedUpdateSlotIds = new Set();
2217
2345
  let skippedUpdateCount = 0;
2346
+ // Slots whose size-update op was broadcast with a post-fill-clamped
2347
+ // target: the working grid still holds the planned (larger) size, so the
2348
+ // slots are re-synced from master before commit to keep the committed
2349
+ // books equal to the broadcast chain amounts.
2350
+ const clampedUpdateSlotIds = new Set();
2351
+ // orderId -> operations index of its cancel op. A crossing re-pricing
2352
+ // update is only safe when the crossed order's cancel was already queued
2353
+ // at an earlier position: ops broadcast in MAX_OPS_PER_BROADCAST chunks,
2354
+ // so an earlier index means the cancel confirms on chain (same or earlier
2355
+ // chunk, applied sequentially) before the crossing order lands.
2356
+ const cancelOpIndexByOrderId = new Map();
2218
2357
  const idsToLock = new Set();
2219
2358
  for (const action of actions) {
2220
2359
  if (action.type === COW_ACTIONS.CANCEL && action.orderId) {
@@ -2250,6 +2389,8 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2250
2389
  try {
2251
2390
  const op = await chainOrders.buildCancelOrderOp(bot.account, action.orderId);
2252
2391
  operations.push(op);
2392
+ if (action.orderId)
2393
+ cancelOpIndexByOrderId.set(action.orderId, operations.length - 1);
2253
2394
  const order = bot.manager.orders.get(action.id) || { id: action.id, orderId: action.orderId };
2254
2395
  opContexts.push({ kind: 'cancel', order });
2255
2396
  }
@@ -2286,8 +2427,8 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2286
2427
  `(diff=${priceDrift}); rebuilding CREATE op with live price.`, 'debug');
2287
2428
  }
2288
2429
  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');
2430
+ const precision = order.type === ORDER_TYPES.SELL ? bot.manager.assets.assetA.precision : bot.manager.assets.assetB.precision;
2431
+ const batchCollision = hasSlotPriceCollision(opContexts, createPrice, precision, order.id, (ctx) => ctx.kind === 'create' && ctx.order?.price != null);
2291
2432
  if (batchCollision) {
2292
2433
  bot.manager.logger.log(`[COW] Skipping CREATE for ${order.id} at ${Format.formatPrice6(createPrice)}: ` +
2293
2434
  `same-batch CREATE ${batchCollision.id} already at ` +
@@ -2295,6 +2436,43 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2295
2436
  `The next reconcile cycle will resolve the mismatch.`, 'warn');
2296
2437
  continue;
2297
2438
  }
2439
+ // CROSSING-PLACEMENT GUARD (create variant): the batch-level
2440
+ // validators (validateCreateTargetSlots, detectCrossedBookPlan)
2441
+ // simulated the PLANNED price, but the pre-broadcast price
2442
+ // freshness rebuild above can move the op's price. Re-check
2443
+ // crossing on the FINAL price against live and chain-side
2444
+ // orders not already cancelled at an earlier op position —
2445
+ // an opposite-side order cancelled in a later chunk would
2446
+ // otherwise coexist with this create mid-broadcast and
2447
+ // self-trade (production incident class).
2448
+ const createCrossed = findCrossedOrder(crossingCandidates, createPrice, order.type, bot.manager.assets, (o) => {
2449
+ const oid = o?.orderId || o?.chainOrderId;
2450
+ return o && oid && !cancelOpIndexByOrderId.has(oid);
2451
+ });
2452
+ const intraBatchCrossed = createCrossed ? null : findCrossedOrder(intraBatchCandidates, createPrice, order.type, bot.manager.assets);
2453
+ const effectiveCrossed = createCrossed || intraBatchCrossed;
2454
+ if (effectiveCrossed) {
2455
+ bot.manager.logger.log(`[COW-CROSS-GUARD] Skipping CREATE for ${order.id} at ` +
2456
+ `${Format.formatPrice6(createPrice)}: crosses live ` +
2457
+ `${crossedOrderLabel(effectiveCrossed)}; re-planned after its cancel confirms.`, 'warn');
2458
+ continue;
2459
+ }
2460
+ // LAST-FILL PRICE GUARD: pivot ± halfIncrement (BUY < pivot*(1-half), SELL > pivot*(1+half)).
2461
+ // Spread correction bypasses so gap repair can close. Cold (null) => disabled.
2462
+ try {
2463
+ if (cowResult?.origin !== 'spread-correction') {
2464
+ const lastPrice = bot.manager?._lastFilledPrice;
2465
+ const lastType = bot.manager?._lastFilledType;
2466
+ const inc = Number(bot.manager?.config?.incrementPercent ?? bot?.config?.incrementPercent ?? constantsModule?.DEFAULT_CONFIG?.incrementPercent ?? 0.5);
2467
+ const check = isLastFillGuardBlocked(createPrice, order.size, order.type, lastPrice, lastType, inc);
2468
+ if (check.blocked) {
2469
+ const dir = order.type === ORDER_TYPES.BUY ? 'above' : 'below';
2470
+ 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');
2471
+ continue;
2472
+ }
2473
+ }
2474
+ }
2475
+ catch (_e) { /* guard is best-effort */ }
2298
2476
  const args = buildCreateOrderArgs(effectiveOrder, assetA, assetB);
2299
2477
  const buildResult = await chainOrders.buildCreateOrderOp(bot.account, args.amountToSell, args.sellAssetId, args.minToReceive, args.receiveAssetId, null);
2300
2478
  if (!buildResult) {
@@ -2303,6 +2481,7 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2303
2481
  }
2304
2482
  operations.push(buildResult.op);
2305
2483
  opContexts.push({ kind: 'create', id: order.id, order: effectiveOrder, args, finalInts: buildResult.finalInts });
2484
+ intraBatchCandidates.push(effectiveOrder);
2306
2485
  recordPendingBroadcast(bot, {
2307
2486
  opIndex: operations.length - 1,
2308
2487
  ctxIndex: opContexts.length - 1,
@@ -2322,17 +2501,88 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2322
2501
  const newPrice = Number.isFinite(Number(action.newPrice))
2323
2502
  ? Number(action.newPrice)
2324
2503
  : Number(action.order?.price);
2325
- const newSize = Number.isFinite(Number(action.newSize))
2326
- ? Number(action.newSize)
2327
- : Number(action.order?.size || 0);
2504
+ const newSize = plannedUpdateSize(action);
2328
2505
  if (!masterOrder || !action.orderId || !orderType || !Number.isFinite(newPrice) || newSize <= 0) {
2329
2506
  continue;
2330
2507
  }
2508
+ // POST-FILL GROWTH GUARD (rotation): a rotation must
2509
+ // not GROW a partially-filled order back above its
2510
+ // booked remaining size — the fill is already booked
2511
+ // into slot.size, and growing in place diverges the
2512
+ // fill accounting (chain restores the pre-fill size
2513
+ // while the books keep the post-fill remainder). Skip
2514
+ // like the guards above: the working grid restores the
2515
+ // slot from master and the next plan re-evaluates.
2516
+ if (masterOrder.state === ORDER_STATES.PARTIAL
2517
+ && Number.isFinite(Number(masterOrder.size))
2518
+ && Number(masterOrder.size) > 0
2519
+ && newSize > Number(masterOrder.size)) {
2520
+ skippedUpdateCount++;
2521
+ if (action.id)
2522
+ skippedUpdateSlotIds.add(action.id);
2523
+ if (action.newGridId)
2524
+ skippedUpdateSlotIds.add(action.newGridId);
2525
+ bot.manager.logger.log(`[COW] Skipping rotation update ${action.id} -> ${action.newGridId}: ` +
2526
+ `slot is PARTIAL with booked remaining ${Format.formatAmount(Number(masterOrder.size))} ` +
2527
+ `but the plan targets ${Format.formatAmount(newSize)} — a partially-filled order ` +
2528
+ `must not be grown in place (fill accounting divergence).`, 'warn');
2529
+ continue;
2530
+ }
2331
2531
  const rotationSizeValidation = validateOrderSizeForExecution(bot, newSize, orderType, action.order, newSize);
2332
2532
  if (!rotationSizeValidation.isValid) {
2333
2533
  bot.manager.logger.log(`Skipping rotation update ${action.id} -> ${action.newGridId}: ${rotationSizeValidation.reason}`, 'warn');
2334
2534
  continue;
2335
2535
  }
2536
+ // CROSSING-PLACEMENT GUARD: re-pricing an order must
2537
+ // never cross an opposite-side live order whose cancel
2538
+ // is not already queued at an earlier op position.
2539
+ // Re-pricing a buy upward across our own live sell
2540
+ // ladder self-trades during the chunked broadcast
2541
+ // window (production incident: a startup buy was
2542
+ // re-priced upward and filled against our own live
2543
+ // opposite-side sells that this same plan was still
2544
+ // cancelling in later chunks — dozens of self-fills,
2545
+ // fatal fund assert). Skipping is safe: the slot keeps its
2546
+ // old commitment and the next plan re-evaluates once
2547
+ // the crossed order's cancel confirms.
2548
+ const crossedOrder = findCrossedOrder([...crossingCandidates, ...intraBatchCandidates], newPrice, orderType, bot.manager.assets, (o) => {
2549
+ const oid = o?.orderId || o?.chainOrderId;
2550
+ return o
2551
+ && oid
2552
+ && oid !== action.orderId
2553
+ && !cancelOpIndexByOrderId.has(oid);
2554
+ });
2555
+ if (crossedOrder) {
2556
+ skippedUpdateCount++;
2557
+ if (action.id)
2558
+ skippedUpdateSlotIds.add(action.id);
2559
+ if (action.newGridId)
2560
+ skippedUpdateSlotIds.add(action.newGridId);
2561
+ bot.manager.logger.log(`[COW-CROSS-GUARD] Skipping rotation update ${action.id} -> ${action.newGridId}: ` +
2562
+ `new ${orderType} @${Format.formatPrice6(newPrice)} crosses live ` +
2563
+ `${crossedOrderLabel(crossedOrder)}; re-planned after its cancel confirms.`, 'warn');
2564
+ continue;
2565
+ }
2566
+ // LAST-FILL PRICE GUARD (UPDATE rotation): pivot ± halfIncrement — same as CREATE.
2567
+ try {
2568
+ if (cowResult?.origin !== 'spread-correction') {
2569
+ const lastPrice = bot.manager?._lastFilledPrice;
2570
+ const lastType = bot.manager?._lastFilledType;
2571
+ const inc = Number(bot.manager?.config?.incrementPercent ?? bot?.config?.incrementPercent ?? constantsModule?.DEFAULT_CONFIG?.incrementPercent ?? 0.5);
2572
+ const check = isLastFillGuardBlocked(newPrice, newSize, orderType, lastPrice, lastType, inc);
2573
+ if (check.blocked) {
2574
+ skippedUpdateCount++;
2575
+ if (action.id)
2576
+ skippedUpdateSlotIds.add(action.id);
2577
+ if (action.newGridId)
2578
+ skippedUpdateSlotIds.add(action.newGridId);
2579
+ const dir = orderType === ORDER_TYPES.BUY ? 'above' : 'below';
2580
+ 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');
2581
+ continue;
2582
+ }
2583
+ }
2584
+ }
2585
+ catch (_e) { /* best-effort */ }
2336
2586
  const { amountToSell, minToReceive } = buildCreateOrderArgs({ type: orderType, size: newSize, price: newPrice }, assetA, assetB);
2337
2587
  const buildResult = await chainOrders.buildUpdateOrderOp(bot.account, action.orderId, { amountToSell, minToReceive, newPrice, orderType }, masterOrder.rawOnChain || null);
2338
2588
  if (!buildResult) {
@@ -2356,12 +2606,15 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2356
2606
  },
2357
2607
  finalInts: buildResult.finalInts
2358
2608
  });
2609
+ intraBatchCandidates.push({ type: orderType, price: newPrice, orderId: action.orderId, id: action.newGridId || action.id });
2359
2610
  continue;
2360
2611
  }
2361
- const newSize = Number.isFinite(Number(action.newSize))
2362
- ? Number(action.newSize)
2363
- : Number(action.order?.size || 0);
2364
2612
  const masterOrder = bot.manager.orders.get(action.id);
2613
+ const plannedNewSize = plannedUpdateSize(action);
2614
+ const newSize = clampPostFillUpdateSize(bot, masterOrder, plannedNewSize, action);
2615
+ if (newSize !== plannedNewSize) {
2616
+ clampedUpdateSlotIds.add(action.id);
2617
+ }
2365
2618
  const orderType = action.order?.type || masterOrder?.type;
2366
2619
  const cachedRawOnChain = masterOrder?.rawOnChain || action.order?.rawOnChain || null;
2367
2620
  const op = await chainOrders.buildUpdateOrderOp(bot.account, action.orderId, { amountToSell: newSize, orderType }, cachedRawOnChain);
@@ -2375,6 +2628,8 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2375
2628
  continue;
2376
2629
  }
2377
2630
  operations.push(op.op);
2631
+ if (masterOrder?.price != null)
2632
+ intraBatchCandidates.push({ type: orderType, price: masterOrder.price, orderId: action.orderId, id: action.id });
2378
2633
  const partialOrder = masterOrder || {
2379
2634
  id: action.id,
2380
2635
  orderId: action.orderId,
@@ -2406,20 +2661,38 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2406
2661
  bot.manager.logger.log(`[COW] CREATE fallback for ${action.id} rejected by size validation: ${sizeCheck.reason}`, 'warn');
2407
2662
  }
2408
2663
  else if (fbType && fbSize > 0 && fbPrice > 0) {
2409
- const fbCollision = findPriceCollision(bot.manager.orders.values(), targetSlotId, fbPrice, fbSize, fbType, bot.manager.assets, isOrderPlaced);
2664
+ const fbPrecision = fbType === ORDER_TYPES.SELL ? bot.manager.assets.assetA.precision : bot.manager.assets.assetB.precision;
2665
+ const fbCollision = hasSlotPriceCollision([...bot.manager.orders.values()], fbPrice, fbPrecision, targetSlotId, isOrderPlaced);
2410
2666
  if (fbCollision) {
2411
2667
  bot.manager.logger.log(`[COW] Skipping CREATE fallback for ${targetSlotId} at ${Format.formatPrice6(fbPrice)}: ` +
2412
2668
  `existing placed order ${fbCollision.id} (${fbCollision.orderId}) ` +
2413
2669
  `already at price ${Format.formatPrice6(fbCollision.price)}.`, 'warn');
2414
2670
  continue;
2415
2671
  }
2416
- const fbBatchCollision = findPriceCollision(opContexts, targetSlotId, fbPrice, fbSize, fbType, bot.manager.assets, (ctx) => ctx.kind === 'create');
2672
+ const fbBatchCollision = hasSlotPriceCollision(opContexts, fbPrice, fbPrecision, targetSlotId, (ctx) => ctx.kind === 'create');
2417
2673
  if (fbBatchCollision) {
2418
2674
  bot.manager.logger.log(`[COW] Skipping CREATE fallback for ${targetSlotId} at ${Format.formatPrice6(fbPrice)}: ` +
2419
2675
  `same-batch CREATE ${fbBatchCollision.id} already at ` +
2420
2676
  `price ${Format.formatPrice6(fbBatchCollision.order.price)}.`, 'warn');
2421
2677
  continue;
2422
2678
  }
2679
+ // CROSSING-PLACEMENT GUARD (fallback variant):
2680
+ // the not-found conversion re-prices to the
2681
+ // rotation's target price, so it must obey the
2682
+ // same crossing rule as the rotation UPDATE it
2683
+ // replaces — no crossing of an opposite-side
2684
+ // live order whose cancel is not already
2685
+ // queued at an earlier op position.
2686
+ const fbCrossed = findCrossedOrder(crossingCandidates, fbPrice, fbType, bot.manager.assets, (o) => {
2687
+ const oid = o?.orderId || o?.chainOrderId;
2688
+ return o && oid && !cancelOpIndexByOrderId.has(oid);
2689
+ });
2690
+ if (fbCrossed) {
2691
+ bot.manager.logger.log(`[COW-CROSS-GUARD] Skipping CREATE fallback for ${targetSlotId} at ` +
2692
+ `${Format.formatPrice6(fbPrice)}: new ${fbType} crosses live ` +
2693
+ `${crossedOrderLabel(fbCrossed)}; re-planned after its cancel confirms.`, 'warn');
2694
+ continue;
2695
+ }
2423
2696
  const fbArgs = buildCreateOrderArgs({ type: fbType, size: fbSize, price: fbPrice }, assetA, assetB);
2424
2697
  const fbResult = await chainOrders.buildCreateOrderOp(bot.account, fbArgs.amountToSell, fbArgs.sellAssetId, fbArgs.minToReceive, fbArgs.receiveAssetId, null);
2425
2698
  if (fbResult) {
@@ -2453,6 +2726,15 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2453
2726
  if (skippedUpdateCount > 0) {
2454
2727
  restoreSkippedUpdateSlotsInWorkingGrid(bot, workingGrid, skippedUpdateSlotIds, skippedUpdateCount);
2455
2728
  }
2729
+ if (clampedUpdateSlotIds.size > 0) {
2730
+ const masterVersion = Number.isFinite(Number(bot.manager?._gridVersion))
2731
+ ? Number(bot.manager._gridVersion)
2732
+ : undefined;
2733
+ for (const slotId of clampedUpdateSlotIds) {
2734
+ workingGrid.syncFromMaster(bot.manager.orders, slotId, masterVersion);
2735
+ }
2736
+ bot.manager.logger.log(`[COW] Re-synced ${clampedUpdateSlotIds.size} post-fill-clamped slot(s) from master before commit`, 'debug');
2737
+ }
2456
2738
  if (operations.length === 0) {
2457
2739
  // Pop the working grid: in the re-plan recursion the fresh plan's
2458
2740
  // grid was pushed by performSafeRebalance, and nothing downstream
@@ -2495,7 +2777,10 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2495
2777
  // for it to settle, then claim the slot synchronously (no await
2496
2778
  // between the check inside waitForCowBroadcastSingleFlight and the
2497
2779
  // assignment below), so two batches can never broadcast together.
2498
- await waitForCowBroadcastSingleFlight(bot, 'pre-broadcast');
2780
+ if (await waitForCowBroadcastSingleFlight(bot, 'pre-broadcast')) {
2781
+ popPushedWorkingGrid(bot, cowResult);
2782
+ return { executed: false, aborted: true, reason: 'SHUTDOWN_IN_PROGRESS', hadRotation: false };
2783
+ }
2499
2784
  bot._cowBroadcastInFlight = true;
2500
2785
  heldBroadcastSlot = true;
2501
2786
  await bot._ensureCredentialDaemonWritable('COW batch broadcast');
@@ -2514,19 +2799,74 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2514
2799
  const missingSlots = missingCreateResults
2515
2800
  .map((item) => item.ctx?.order?.id || item.ctx?.id || `op-${item.index}`)
2516
2801
  .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
- };
2802
+ bot.manager.logger.log(`[COW] ${missingCreateResults.length} CREATE op(s) returned no chainOrderId ` +
2803
+ `(${missingSlots}). Resolving from chain before commit (normalize, don't reject).`, 'warn');
2804
+ // Layer 2 (normalize, don't reject + uncertain-broadcast routing):
2805
+ // the batch otherwise succeeded, so a missing id is the
2806
+ // "broadcast succeeded but attach lost" ambiguous case. Poll the
2807
+ // chain to separate REAL on-chain orders (adopt their id into the
2808
+ // working grid so they commit normally) from PHANTOMS (normalize the
2809
+ // slot in place to a clean empty). Either way the REST of the batch
2810
+ // commits — we never discard a whole batch over one missing id.
2811
+ const confirmation = await pollChainForConfirmation(bot, missingCreateResults.map((m) => m.ctx));
2812
+ const accountRef = bot.accountId || bot.account?.id || bot.account;
2813
+ let chainSnap = null;
2814
+ try {
2815
+ const cr = await readOpenOrdersWithMetaSafe(chainOrders, accountRef);
2816
+ if (cr && !cr.truncated)
2817
+ chainSnap = cr.orders;
2818
+ }
2819
+ catch { /* best-effort; confirmed set already known from poll */ }
2820
+ let adoptedCount = 0;
2821
+ let normalizedCount = 0;
2822
+ for (const item of missingCreateResults) {
2823
+ const slotId = item.ctx?.order?.id || item.ctx?.id;
2824
+ const slot = workingGrid.get(slotId);
2825
+ if (!slot)
2826
+ continue;
2827
+ const isConfirmed = confirmation.confirmed.some((c) => (c?.order?.id || c?.id) === slotId);
2828
+ if (isConfirmed && chainSnap) {
2829
+ const match = findChainOrderForSlot(bot, chainSnap, slotId, {
2830
+ sell: item.ctx?.finalInts?.sell,
2831
+ receive: item.ctx?.finalInts?.receive,
2832
+ orderType: item.ctx?.order?.type,
2833
+ fingerprint: buildCreateOpFingerprint({
2834
+ side: item.ctx?.order?.type,
2835
+ assetA: bot.manager?.assets?.assetA?.id,
2836
+ assetB: bot.manager?.assets?.assetB?.id,
2837
+ sellInt: item.ctx?.finalInts?.sell,
2838
+ receiveInt: item.ctx?.finalInts?.receive,
2839
+ slotId
2840
+ })
2841
+ });
2842
+ if (match?.id) {
2843
+ workingGrid.set(slotId, { ...slot, orderId: match.id });
2844
+ adoptedCount++;
2845
+ continue;
2846
+ }
2847
+ }
2848
+ // Normalize: drop the size so the slot cannot persist as a
2849
+ // phantom placed order (VIRTUAL + size>0 + no orderId) — the
2850
+ // exact corrupt shape that recurs as a sized orphan. It commits
2851
+ // as a clean empty and is re-placed by the next cycle / spread
2852
+ // correction.
2853
+ workingGrid.set(slotId, {
2854
+ ...slot,
2855
+ size: 0,
2856
+ orderId: null,
2857
+ state: ORDER_STATES.VIRTUAL
2858
+ });
2859
+ normalizedCount++;
2860
+ }
2861
+ bot.manager.logger.log(`[COW] Missing CREATEs resolved: ${adoptedCount} adopted from chain, ` +
2862
+ `${normalizedCount} normalized in place; committing remainder of batch.`, 'warn');
2863
+ // NOTE: we intentionally do NOT mark these as structural blockers.
2864
+ // Normalizing the slot to a clean empty lets the next cycle re-create
2865
+ // it, and the regular open-orders sync adopts any order that really
2866
+ // did land on chain — so a permanent block (which would reject every
2867
+ // subsequent CREATE batch) is avoided.
2868
+ // DO NOT pop/discard the working grid — fall through to commit the
2869
+ // rest of the batch below.
2530
2870
  }
2531
2871
  // Pre-apply rotation state transitions to the working grid so the
2532
2872
  // COW commit is truly atomic for structural changes (source → VIRTUAL,
@@ -2547,7 +2887,7 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2547
2887
  // is on chain; adopt the placed orders from the chain so master
2548
2888
  // converges instead of remaining divergent until a later sync.
2549
2889
  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]');
2890
+ const adopted = await adoptPlacedBatchFromChain(bot, chainOrders, '[COW]', { placedResults: result, placedContexts: executedContexts });
2551
2891
  if (!adopted) {
2552
2892
  // Chain state unknown (empty/lagging read or sync failure):
2553
2893
  // keep the pending-broadcast protection so a later plan
@@ -2560,6 +2900,7 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2560
2900
  // Deduct create fees for the placed orders (mirrors
2561
2901
  // processBatchResults, which the refused path bypasses).
2562
2902
  await applyAdoptionFeeAccounting(bot, executedContexts);
2903
+ await restoreBoundaryAfterAdoption(bot, workingBoundary);
2563
2904
  await persistGridAndClearPendingBroadcasts(bot, '[COW]');
2564
2905
  return { executed: false, hadRotation: false, commitRefused: true };
2565
2906
  }
@@ -2657,6 +2998,7 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2657
2998
  return { executed: false, hadRotation: false, commitRefused: true, chainAdoptionPending: true };
2658
2999
  }
2659
3000
  await applyAdoptionFeeAccounting(bot, opContexts);
3001
+ await restoreBoundaryAfterAdoption(bot, workingBoundary);
2660
3002
  await persistGridAndClearPendingBroadcasts(bot, '[COW][UNCERTAIN]');
2661
3003
  return { executed: false, hadRotation: false, commitRefused: true, uncertainResolved: true };
2662
3004
  }
@@ -2679,6 +3021,7 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2679
3021
  // result to extract); deduct create fees so the optimistic
2680
3022
  // balance reflects the on-chain cost.
2681
3023
  await applyAdoptionFeeAccounting(bot, opContexts);
3024
+ await restoreBoundaryAfterAdoption(bot, workingBoundary);
2682
3025
  await persistGridAndClearPendingBroadcasts(bot, '[COW][UNCERTAIN]');
2683
3026
  return { executed: true, hadRotation: false, uncertainResolved: true };
2684
3027
  }
@@ -2755,16 +3098,193 @@ async function requestStructuralResync(bot, reason, details = {}) {
2755
3098
  * @param {string} logPrefix - Log prefix for sync failure messages
2756
3099
  * @returns {Promise<boolean>}
2757
3100
  */
2758
- async function adoptPlacedBatchFromChain(bot, chainOrders, logPrefix) {
3101
+ /**
3102
+ * Collect every on-chain order id master currently needs to converge against,
3103
+ * so adoption can re-read them by id (immune to the get_full_accounts window
3104
+ * truncation) instead of relying on a partial window read.
3105
+ *
3106
+ * Sources:
3107
+ * - master's own tracked order ids (existing on-chain orders);
3108
+ * - the batch's fresh CREATE ids extracted from the broadcast result
3109
+ * (operation_results[i][1] aligns positionally with placedContexts[i]).
3110
+ *
3111
+ * @param {any} mgr - bot.manager
3112
+ * @param {any} placedResults - broadcast result (has operation_results); null when unavailable
3113
+ * @param {any[]} placedContexts - opContexts (aligned with operation_results); null when unavailable
3114
+ * @returns {string[]} Unique, well-formed 1.7.x order ids
3115
+ */
3116
+ function collectKnownOnChainOrderIds(mgr, placedResults, placedContexts) {
3117
+ const masterIds = new Set();
3118
+ const grid = mgr && mgr.grid;
3119
+ if (Array.isArray(grid)) {
3120
+ for (const slot of grid) {
3121
+ if (slot && slot.orderId && /^1\.7\.\d+$/.test(String(slot.orderId))) {
3122
+ masterIds.add(String(slot.orderId));
3123
+ }
3124
+ }
3125
+ }
3126
+ const createIds = new Set();
3127
+ if (placedResults && Array.isArray(placedContexts)) {
3128
+ const opResults = extractBatchOperationResults(placedResults);
3129
+ if (Array.isArray(opResults)) {
3130
+ for (let i = 0; i < placedContexts.length; i++) {
3131
+ const ctx = placedContexts[i];
3132
+ if (!ctx || ctx.kind !== 'create')
3133
+ continue;
3134
+ const opResult = opResults[i] && opResults[i][1];
3135
+ if (opResult && /^1\.7\.\d+$/.test(String(opResult))) {
3136
+ createIds.add(String(opResult));
3137
+ }
3138
+ }
3139
+ }
3140
+ }
3141
+ const all = new Set([...masterIds, ...createIds]);
3142
+ return { masterIds: [...masterIds], createIds: [...createIds], all: [...all] };
3143
+ }
3144
+ /**
3145
+ * Converge master with the chain after a COW commit was refused (master moved
3146
+ * during broadcast) or an uncertain broadcast was poll-confirmed.
3147
+ *
3148
+ * Preferred path (when the broadcast result is available): re-read the exact
3149
+ * on-chain orders BY ID — every id master already tracks plus the batch's
3150
+ * fresh CREATE ids. get_objects returns the complete, authoritative set
3151
+ * regardless of account size, so syncFromOpenOrders converges master instead
3152
+ * of dropping the freshest creates (which previously left permanent orphans
3153
+ * and tripped the fund invariant). This is fully immune to the
3154
+ * get_full_accounts window truncation that broke the old window-read path.
3155
+ *
3156
+ * Fallback (no broadcast result, e.g. uncertain paths, or by-id read
3157
+ * unavailable): window read. A truncated read is ambiguous — only the
3158
+ * freshest orders are dropped — so it MUST NOT drive adoption; we return false
3159
+ * and let the caller keep pending-broadcast protection + structural resync.
3160
+ *
3161
+ * @param {any} bot
3162
+ * @param {any} chainOrders - chain_orders module (has readOpenOrdersWithMetaSafe + batchReadOrders)
3163
+ * @param {string} logPrefix
3164
+ * @param {Object} [opts]
3165
+ * @param {any} [opts.placedResults] - broadcast result carrying operation_results
3166
+ * @param {any[]} [opts.placedContexts] - opContexts aligned with operation_results
3167
+ * @returns {Promise<boolean>} true if master was adopted from the chain
3168
+ */
3169
+ /**
3170
+ * P1-atomic: after a refused/uncertain COW commit is recovered by adopting the
3171
+ * placed orders from the chain, re-apply the rotational boundary this batch had
3172
+ * already computed (workingBoundary) to master. The refused commit discarded
3173
+ * the working grid, so without this the next rebalance would re-derive the
3174
+ * boundary from a master that still reflects the pre-fill layout and could
3175
+ * re-stamp the just-filled slot x. Applying workingBoundary commits the
3176
+ * post-fill rotation immediately (no re-broadcast), so the next placement lands
3177
+ * at the shifted slot, never at x.
3178
+ *
3179
+ * @param {any} bot
3180
+ * @param {number} workingBoundary - boundary index the refused batch targeted
3181
+ */
3182
+ async function restoreBoundaryAfterAdoption(bot, workingBoundary) {
3183
+ try {
3184
+ if (workingBoundary === undefined || workingBoundary === null)
3185
+ return;
3186
+ if (typeof bot.manager._restoreBoundary === 'function') {
3187
+ bot.manager._restoreBoundary(workingBoundary);
3188
+ }
3189
+ else {
3190
+ bot.manager.boundaryIdx = workingBoundary;
3191
+ }
3192
+ bot.manager.logger.log(`[COW] Restored rotational boundary ${workingBoundary} after refused/uncertain commit + chain adoption (atomic re-plan)`, 'info');
3193
+ }
3194
+ catch (e) {
3195
+ bot.manager.logger.log(`[COW] Post-adoption boundary restore failed: ${getErrorMessage(e)}`, 'warn');
3196
+ }
3197
+ }
3198
+ async function adoptPlacedBatchFromChain(bot, chainOrders, logPrefix, opts = {}) {
3199
+ const { placedResults = null, placedContexts = null } = opts || {};
2759
3200
  try {
3201
+ const mgr = bot.manager;
2760
3202
  const accountRef = bot.accountId || bot.account?.id || bot.account;
3203
+ // PREFERRED: re-read the exact placed/existing orders by id. Only when we
3204
+ // have the broadcast result (so the set includes the freshest CREATE ids);
3205
+ // without it the by-id set would be incomplete and would wrongly sync
3206
+ // master against a partial picture.
3207
+ const { all: knownIds, createIds } = placedResults
3208
+ ? collectKnownOnChainOrderIds(mgr, placedResults, placedContexts)
3209
+ : { all: [], createIds: [] };
3210
+ if (knownIds.length > 0 && typeof chainOrders.batchReadOrders === 'function') {
3211
+ // Retry/backoff (fix #6): a fresh CREATE absent from the first read
3212
+ // is a lagging node, not a missing order; a read error is equally
3213
+ // transient. Retry with backoff before deferring to the structural
3214
+ // resync path — deferral blocks CREATEs for minutes.
3215
+ const maxAttempts = Math.max(1, Number(COW_PERFORMANCE.ADOPTION_READ_MAX_ATTEMPTS) || 3);
3216
+ const baseBackoff = Math.max(250, Number(COW_PERFORMANCE.ADOPTION_READ_BACKOFF_MS) || 2000);
3217
+ let chainMap = null;
3218
+ let laggingCreateIds = [];
3219
+ let lastReadError = null;
3220
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
3221
+ chainMap = null;
3222
+ laggingCreateIds = [];
3223
+ lastReadError = null;
3224
+ try {
3225
+ chainMap = await chainOrders.batchReadOrders(knownIds);
3226
+ }
3227
+ catch (byIdErr) {
3228
+ // A by-id read failure must NOT fall through to the window read
3229
+ // (which would also miss the freshest creates and virtualize them).
3230
+ lastReadError = getErrorMessage(byIdErr);
3231
+ }
3232
+ if (chainMap) {
3233
+ // Lagging-node guard (phantom-virtualization risk): a FRESHLY
3234
+ // BROADCAST create id returning null here almost certainly means the
3235
+ // queried node has not yet indexed the order (it was just placed), not
3236
+ // that it is gone. If we synced a partial set, syncFromOpenOrders'
3237
+ // phantom-cleanup would virtualize that live order and count it as a
3238
+ // fill, re-creating a duplicate on the next cycle — the exact orphan
3239
+ // class this path exists to prevent.
3240
+ for (const id of createIds) {
3241
+ if (chainMap.get(id) == null)
3242
+ laggingCreateIds.push(id);
3243
+ }
3244
+ }
3245
+ if (chainMap && laggingCreateIds.length === 0)
3246
+ break;
3247
+ if (attempt < maxAttempts) {
3248
+ const wait = baseBackoff * Math.pow(2, attempt - 1);
3249
+ bot.manager.logger.log(`${logPrefix} By-id adoption read retry ${attempt}/${maxAttempts - 1} in ${wait}ms ` +
3250
+ `(${chainMap ? `fresh CREATE(s) absent: ${laggingCreateIds.join(', ')}` : `read failed: ${lastReadError}`})`, 'warn');
3251
+ await sleep(wait);
3252
+ }
3253
+ }
3254
+ if (!chainMap) {
3255
+ bot.manager.logger.log(`${logPrefix} By-id adoption read failed after ${maxAttempts} attempt(s): ${lastReadError}; deferring (pending-broadcast protection kept)`, 'warn');
3256
+ return false;
3257
+ }
3258
+ if (laggingCreateIds.length > 0) {
3259
+ bot.manager.logger.log(`${logPrefix} By-id adoption deferred: fresh CREATE(s) ${laggingCreateIds.join(', ')} absent from ` +
3260
+ `${maxAttempts} chain read(s) (likely lagging node). ` +
3261
+ 'Keeping pending-broadcast protection pending a caught-up read.', 'error');
3262
+ return false;
3263
+ }
3264
+ const fullChain = [];
3265
+ if (chainMap && typeof chainMap.forEach === 'function') {
3266
+ chainMap.forEach((order) => { if (order)
3267
+ fullChain.push(order); });
3268
+ }
3269
+ else if (Array.isArray(chainMap)) {
3270
+ for (const o of chainMap)
3271
+ if (o)
3272
+ fullChain.push(o);
3273
+ }
3274
+ // Informational: any other known id (master's pre-existing orders)
3275
+ // absent is expected — those were cancelled/filled in this batch.
3276
+ if (fullChain.length < knownIds.length) {
3277
+ bot.manager.logger.log(`${logPrefix} By-id adoption: ${knownIds.length - fullChain.length}/${knownIds.length} known id(s) absent ` +
3278
+ '(expected cancels/fills); adopting the rest', 'debug');
3279
+ }
3280
+ if (fullChain.length > 0 && typeof mgr.syncFromOpenOrders === 'function') {
3281
+ await mgr.syncFromOpenOrders(fullChain, { skipAccounting: false });
3282
+ bot.manager.logger.log(`${logPrefix} Adopted ${fullChain.length} on-chain order(s) by id after refused/uncertain commit (truncation-immune)`, 'info');
3283
+ return true;
3284
+ }
3285
+ }
3286
+ // FALLBACK: window read (ambiguous when truncated).
2761
3287
  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
3288
  if (freshRead.truncated) {
2769
3289
  bot.manager.logger.log(`${logPrefix} Chain read TRUNCATED after batch broadcast; adoption deferred (pending-broadcast protection kept)`, 'warn');
2770
3290
  return false;
@@ -3051,14 +3571,12 @@ async function processBatchResults(bot, result, opContexts) {
3051
3571
  updateOperationCount
3052
3572
  };
3053
3573
  }
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 };
3574
+ export { isLastFillGuardBlocked, 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
3575
  export default {
3056
3576
  buildOutsideInPairGroupsForOrders,
3057
3577
  buildOutsideInPairGroupsForCreateEntries,
3058
3578
  extractOperationResults,
3059
3579
  findMissingCreateResultContexts,
3060
- recoverAfterMissingCreateResults,
3061
- preserveMissingCreateBlockersAfterRecovery,
3062
3580
  markMissingCreateResultsAsStructuralBlocker,
3063
3581
  formatUnmatchedChainOrderForLog,
3064
3582
  recordPendingBroadcast,