dexbot 1.5.1 → 1.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/analysis/ama_fitting/package.json +1 -1
  3. package/analysis/resolve_source.ts +3 -0
  4. package/analysis/results/ama_sweep_results_lp_pool_133_1h.json +2455 -0
  5. package/analysis/results/bot_fitting_results_lp_pool_133_1h.json +218 -0
  6. package/analysis/tradingview/README.md +5 -1
  7. package/analysis/tradingview/analyze_tradingview.ts +27 -1
  8. package/analysis/tradingview/h-bts_tradingview.html +1570 -0
  9. package/analysis/tradingview/t-bts_tradingview.html +1570 -0
  10. package/analysis/tradingview/tradingview_uplot_chart_generator.ts +351 -22
  11. package/analysis/trend_detection/package.json +1 -1
  12. package/claw/package.json +1 -1
  13. package/claw/runtimes/openclaw-plugin/openclaw.plugin.json +1 -1
  14. package/claw/runtimes/openclaw-plugin/package.json +1 -1
  15. package/claw/tests/test_claw_mcp_transport.ts +2 -2
  16. package/dist/analysis/resolve_source.d.ts.map +1 -1
  17. package/dist/analysis/resolve_source.js +3 -0
  18. package/dist/analysis/resolve_source.js.map +1 -1
  19. package/dist/analysis/tradingview/analyze_tradingview.d.ts +3 -0
  20. package/dist/analysis/tradingview/analyze_tradingview.d.ts.map +1 -1
  21. package/dist/analysis/tradingview/analyze_tradingview.js +27 -2
  22. package/dist/analysis/tradingview/analyze_tradingview.js.map +1 -1
  23. package/dist/analysis/tradingview/tradingview_uplot_chart_generator.d.ts.map +1 -1
  24. package/dist/analysis/tradingview/tradingview_uplot_chart_generator.js +348 -22
  25. package/dist/analysis/tradingview/tradingview_uplot_chart_generator.js.map +1 -1
  26. package/dist/dexbot.js +5 -5
  27. package/dist/dexbot.js.map +1 -1
  28. package/dist/market_adapter/core/asymmetric_bounds.d.ts +33 -1
  29. package/dist/market_adapter/core/asymmetric_bounds.d.ts.map +1 -1
  30. package/dist/market_adapter/core/asymmetric_bounds.js +38 -1
  31. package/dist/market_adapter/core/asymmetric_bounds.js.map +1 -1
  32. package/dist/modules/dexbot_class.d.ts +2 -18
  33. package/dist/modules/dexbot_class.d.ts.map +1 -1
  34. package/dist/modules/dexbot_cow_runtime.d.ts +38 -37
  35. package/dist/modules/dexbot_cow_runtime.d.ts.map +1 -1
  36. package/dist/modules/dexbot_cow_runtime.js +722 -557
  37. package/dist/modules/dexbot_cow_runtime.js.map +1 -1
  38. package/dist/modules/dexbot_fill_runtime.d.ts.map +1 -1
  39. package/dist/modules/dexbot_fill_runtime.js +7 -1
  40. package/dist/modules/dexbot_fill_runtime.js.map +1 -1
  41. package/dist/modules/dexbot_maintenance_runtime.d.ts.map +1 -1
  42. package/dist/modules/dexbot_maintenance_runtime.js +31 -51
  43. package/dist/modules/dexbot_maintenance_runtime.js.map +1 -1
  44. package/dist/modules/dexbot_startup_runtime.d.ts.map +1 -1
  45. package/dist/modules/dexbot_startup_runtime.js +42 -3
  46. package/dist/modules/dexbot_startup_runtime.js.map +1 -1
  47. package/dist/modules/order/accounting.d.ts.map +1 -1
  48. package/dist/modules/order/accounting.js +19 -2
  49. package/dist/modules/order/accounting.js.map +1 -1
  50. package/dist/modules/order/export.d.ts +122 -15
  51. package/dist/modules/order/export.d.ts.map +1 -1
  52. package/dist/modules/order/export.js +310 -46
  53. package/dist/modules/order/export.js.map +1 -1
  54. package/dist/modules/order/grid.d.ts.map +1 -1
  55. package/dist/modules/order/grid.js +29 -38
  56. package/dist/modules/order/grid.js.map +1 -1
  57. package/dist/modules/order/grid_reconcile.d.ts.map +1 -1
  58. package/dist/modules/order/grid_reconcile.js +7 -3
  59. package/dist/modules/order/grid_reconcile.js.map +1 -1
  60. package/dist/modules/order/grid_reconcile_internal.d.ts.map +1 -1
  61. package/dist/modules/order/grid_reconcile_internal.js +31 -33
  62. package/dist/modules/order/grid_reconcile_internal.js.map +1 -1
  63. package/dist/modules/order/manager.d.ts +13 -6
  64. package/dist/modules/order/manager.d.ts.map +1 -1
  65. package/dist/modules/order/manager.js +22 -7
  66. package/dist/modules/order/manager.js.map +1 -1
  67. package/dist/modules/order/sync_engine.d.ts.map +1 -1
  68. package/dist/modules/order/sync_engine.js +231 -11
  69. package/dist/modules/order/sync_engine.js.map +1 -1
  70. package/dist/modules/order/utils/math.d.ts +38 -15
  71. package/dist/modules/order/utils/math.d.ts.map +1 -1
  72. package/dist/modules/order/utils/math.js +55 -16
  73. package/dist/modules/order/utils/math.js.map +1 -1
  74. package/dist/modules/order/utils/order.d.ts +116 -20
  75. package/dist/modules/order/utils/order.d.ts.map +1 -1
  76. package/dist/modules/order/utils/order.js +340 -35
  77. package/dist/modules/order/utils/order.js.map +1 -1
  78. package/dist/modules/order/utils/system.d.ts +1 -30
  79. package/dist/modules/order/utils/system.d.ts.map +1 -1
  80. package/dist/modules/order/utils/system.js +31 -98
  81. package/dist/modules/order/utils/system.js.map +1 -1
  82. package/dist/modules/order/utils/validate.d.ts +3 -2
  83. package/dist/modules/order/utils/validate.d.ts.map +1 -1
  84. package/dist/modules/order/utils/validate.js +49 -28
  85. package/dist/modules/order/utils/validate.js.map +1 -1
  86. package/dist/scripts/analyze-credit.d.ts.map +1 -1
  87. package/dist/scripts/analyze-credit.js +53 -33
  88. package/dist/scripts/analyze-credit.js.map +1 -1
  89. package/docs/DEXBOT_COMPARISON.md +3 -3
  90. package/docs/EVOLUTION.md +7 -5
  91. package/docs/FUND_MOVEMENT_AND_ACCOUNTING.md +1 -1
  92. package/docs/README.md +2 -2
  93. package/docs/WORKFLOW.md +1 -1
  94. package/package.json +1 -1
@@ -14,11 +14,12 @@ import { BroadcastUncertainError as BroadcastUncertainErrorBinding } from './dex
14
14
  const BroadcastUncertainError = BroadcastUncertainErrorBinding;
15
15
  import * as orderUtils from './order/utils/order.js';
16
16
  import { sleep } from './order/utils/system.js';
17
- const { buildCreateOrderArgs, buildCreateOpFingerprint, extractBatchOperationResults, formatUnmatchedChainOrder, convertToSpreadPlaceholder, toRailHolePlaceholder, buildOutsideInPairGroups, isOrderPlaced, } = orderUtils;
17
+ const { buildCreateOrderArgs, buildCreateOpFingerprint, extractBatchOperationResults, formatUnmatchedChainOrder, convertToSpreadPlaceholder, toRailHolePlaceholder, buildOutsideInPairGroups, isOrderPlaced, chainOrderUnchangedFromCache, detectCrossedBookPlan, collectKnownOnChainOrderIds, } = orderUtils;
18
18
  import * as validate from './order/utils/validate.js';
19
19
  const { validateCreateTargetSlots, evaluateCommit, hasExecutableActions, stampGapEvacuationRotation } = validate;
20
20
  import * as math from './order/utils/math.js';
21
- const { validateOrderSize, findCrossedOrder, priceSlotEqual, isEvacuationRotationAllowed, getSellStartIdx, getPrecisionByOrderType, isSlotIndexInGapBand } = math;
21
+ const { validateOrderSize, findCrossedOrder, priceSlotEqual, isEvacuationRotationAllowed, isEvacuationSizeStillValid, getSellStartIdx, getPrecisionByOrderType, isSlotIndexInGapBand, getAssetFeesSafe, blockchainToFloat, floatToBlockchainInt, quantizeFloat } = math;
22
+ import { parseSlotIndex } from './order/utils/slot.js';
22
23
  /**
23
24
  * Re-verify a B-stamped gap-evacuation rotation against the LIVE committed
24
25
  * geometry (boundaryIdx/_gapSlots at execution time). The stamp freezes
@@ -48,7 +49,6 @@ export function isEvacuationStampStillValid(liveBoundary, liveGapSlots, sourceId
48
49
  const dstInRail = orderType === ORDER_TYPES.SELL ? Number(dstIdx) >= sellStartIdx : Number(dstIdx) <= b;
49
50
  return dstInRail;
50
51
  }
51
- import { parseSlotIndex } from './order/utils/slot.js';
52
52
  function hasSlotPriceCollision(items, targetPrice, precision, excludeId, predicate) {
53
53
  for (const it of items) {
54
54
  if (predicate && !predicate(it))
@@ -69,8 +69,6 @@ function hasSlotPriceCollision(items, targetPrice, precision, excludeId, predica
69
69
  }
70
70
  return null;
71
71
  }
72
- // Lazy accessor so test mocks on the math module export take effect at call time.
73
- function getAssetFeesSafe(...args) { return require('./order/utils/math').getAssetFeesSafe(...args); }
74
72
  import * as constantsModule from './constants.js';
75
73
  const { COW_ACTIONS, COW_PERFORMANCE, ORDER_STATES, ORDER_TYPES, REBALANCE_STATES, } = constantsModule;
76
74
  import { acquireIfNotHeld } from './order/async_lock.js';
@@ -79,8 +77,6 @@ const Format = FormatModule;
79
77
  import * as workingGridModule from './order/working_grid.js';
80
78
  const { WorkingGrid } = workingGridModule;
81
79
  import { getErrorMessage } from './utils/errors.js';
82
- import { createRequire } from 'node:module';
83
- const require = createRequire(import.meta.url);
84
80
  // Maximum number of times the pre-broadcast staleness guard may re-plan the
85
81
  // batch from a fresh master before proceeding anyway. Bounded so a master
86
82
  // grid that keeps mutating (fill bursts, sync loops) can never livelock the
@@ -99,11 +95,7 @@ const SINGLE_FLIGHT_MAX_WAIT_MS = 120000;
99
95
  * @returns {Array<Array>}
100
96
  */
101
97
  function buildOutsideInPairGroupsForOrders(orders) {
102
- return buildOutsideInPairGroups(orders, {
103
- isValid: Boolean,
104
- getType: (o) => o.type,
105
- getPrice: (o) => o.price,
106
- });
98
+ return buildOutsideInPairGroupsWithAdapter(orders, (o) => o);
107
99
  }
108
100
  /**
109
101
  * Build outside-in pair groups for create entry contexts.
@@ -111,12 +103,128 @@ function buildOutsideInPairGroupsForOrders(orders) {
111
103
  * @returns {Array<Array>}
112
104
  */
113
105
  function buildOutsideInPairGroupsForCreateEntries(createEntries) {
114
- return buildOutsideInPairGroups(createEntries, {
115
- isValid: (e) => Boolean(e?.context?.order),
116
- getType: (e) => e.context.order.type,
117
- getPrice: (e) => e.context.order.price,
106
+ return buildOutsideInPairGroupsWithAdapter(createEntries, (e) => e?.context?.order);
107
+ }
108
+ /**
109
+ * Shared outside-in delegation: project each item to its order, then group
110
+ * with the single order adapter. The ForOrders/ForCreateEntries wrappers
111
+ * differ only in this projection.
112
+ * @param {Array} items
113
+ * @param {Function} toOrder - Project an item to its order object
114
+ * @returns {Array<Array>}
115
+ */
116
+ function buildOutsideInPairGroupsWithAdapter(items, toOrder) {
117
+ return buildOutsideInPairGroups(items, {
118
+ isValid: (item) => Boolean(toOrder(item)),
119
+ getType: (item) => toOrder(item)?.type,
120
+ getPrice: (item) => toOrder(item)?.price,
121
+ });
122
+ }
123
+ /**
124
+ * Create a create-op fingerprint for a specific slot, used for
125
+ * pending-broadcast matching and chain-order fingerprinting.
126
+ * Consolidates the fingerprint construction pattern found in
127
+ * recordPendingBroadcast, buildChainOrderFingerprint, findChainOrderForSlot,
128
+ * and verifyCreateAbsent.
129
+ * @param {import('./dexbot_class.js').DEXBot} bot
130
+ * @param {Object} order - The order object
131
+ * @param {Object} finalInts - The final integer tuple { sell, receive, sellAssetId, receiveAssetId }
132
+ * @param {string} slotId - The slot identifier
133
+ * @returns {string|null}
134
+ */
135
+ function createOpFingerprintForSlot(bot, order, finalInts, slotId) {
136
+ if (!order || !finalInts || !slotId)
137
+ return null;
138
+ return buildCreateOpFingerprint({
139
+ side: order.type,
140
+ assetA: bot.manager?.assets?.assetA?.id,
141
+ assetB: bot.manager?.assets?.assetB?.id,
142
+ sellInt: finalInts.sell,
143
+ receiveInt: finalInts.receive,
144
+ slotId,
145
+ });
146
+ }
147
+ /**
148
+ * Read the pending-broadcast entries as an array. The manager map is
149
+ * created lazily by recordPendingBroadcast, so every reader must guard
150
+ * on `instanceof Map` — this helper is the single spelling of that guard.
151
+ * @param {import('./dexbot_class.js').DEXBot} bot
152
+ * @returns {Array}
153
+ */
154
+ function getPendingBroadcasts(bot) {
155
+ return (bot.manager && bot.manager._pendingBroadcasts instanceof Map)
156
+ ? Array.from(bot.manager._pendingBroadcasts.values())
157
+ : [];
158
+ }
159
+ /**
160
+ * Count pending-broadcast entries without materializing the array.
161
+ * Same instanceof Map guard as getPendingBroadcasts, for hot paths that
162
+ * only need the count (e.g. the orphan auto-cancel gate).
163
+ * @param {import('./dexbot_class.js').DEXBot} bot
164
+ * @returns {number}
165
+ */
166
+ function countPendingBroadcasts(bot) {
167
+ return (bot.manager && bot.manager._pendingBroadcasts instanceof Map)
168
+ ? bot.manager._pendingBroadcasts.size
169
+ : 0;
170
+ }
171
+ /**
172
+ * Probe the chain snapshot for a pending-broadcast entry's slot.
173
+ * Single spelling of the findChainOrderForSlot probe literal used by
174
+ * reconcile (first pass + re-read), the poll confirmer, and the
175
+ * create-absence verifier.
176
+ * @param {import('./dexbot_class.js').DEXBot} bot
177
+ * @param {Array} chainSnapshot
178
+ * @param {Object} entry - Pending entry ({ slotId, finalInts, orderType, fingerprint })
179
+ * @returns {Object|null}
180
+ */
181
+ function findChainOrderForPendingEntry(bot, chainSnapshot, entry) {
182
+ if (!entry?.slotId)
183
+ return null;
184
+ return findChainOrderForSlot(bot, chainSnapshot, entry.slotId, {
185
+ sell: entry.finalInts?.sell,
186
+ receive: entry.finalInts?.receive,
187
+ orderType: entry.orderType,
188
+ fingerprint: entry.fingerprint,
118
189
  });
119
190
  }
191
+ /**
192
+ * Check whether a chain read is authoritative enough to make
193
+ * definitive absence/lPresence decisions. An authoritative read is
194
+ * non-empty, non-truncated, and non-null.
195
+ * @param {Object} readResult - The result of readOpenOrdersWithMeta
196
+ * @returns {boolean}
197
+ */
198
+ function isAuthoritativeChainRead(readResult) {
199
+ if (!readResult || !Array.isArray(readResult.orders))
200
+ return false;
201
+ if (readResult.truncated)
202
+ return false;
203
+ return readResult.orders.length > 0;
204
+ }
205
+ /**
206
+ * Build a rawOnChain metadata object from final integer values.
207
+ * Used in processBatchResults for size-update, create, and rotation
208
+ * contexts to enrich order objects with on-chain identity.
209
+ * Returns null when the order id (or final ints) is missing: downstream
210
+ * chainOrderUnchangedFromCache treats a null cache as "not unchanged" and
211
+ * defers, which is the safer direction versus comparing an id-less object.
212
+ * @param {string} orderId - The on-chain order id
213
+ * @param {Object} finalInts - The final integer tuple
214
+ * @returns {Object|null}
215
+ */
216
+ function rawOnChainFromInts(orderId, finalInts) {
217
+ if (!orderId || !finalInts)
218
+ return null;
219
+ return {
220
+ id: orderId,
221
+ for_sale: String(finalInts.sell),
222
+ sell_price: {
223
+ base: { amount: String(finalInts.sell), asset_id: finalInts.sellAssetId },
224
+ quote: { amount: String(finalInts.receive), asset_id: finalInts.receiveAssetId },
225
+ },
226
+ };
227
+ }
120
228
  /**
121
229
  * Extract operation results from a batch transaction result.
122
230
  * @param {Object|Array|null} result
@@ -222,14 +330,7 @@ function recordPendingBroadcast(bot, entry) {
222
330
  if (!bot.manager._pendingBroadcasts || !(bot.manager._pendingBroadcasts instanceof Map)) {
223
331
  bot.manager._pendingBroadcasts = new Map();
224
332
  }
225
- const fingerprint = buildCreateOpFingerprint({
226
- side: entry.order.type,
227
- assetA: bot.manager?.assets?.assetA?.id,
228
- assetB: bot.manager?.assets?.assetB?.id,
229
- sellInt: entry.finalInts?.sell,
230
- receiveInt: entry.finalInts?.receive,
231
- slotId: entry.order.id
232
- });
333
+ const fingerprint = createOpFingerprintForSlot(bot, entry.order, entry.finalInts, entry.order.id);
233
334
  if (!fingerprint) {
234
335
  bot.manager.logger.log?.(`[COW] Skipped pending-broadcast record: could not build fingerprint for ${entry.order?.id || 'unknown'}`, 'warn');
235
336
  return;
@@ -280,28 +381,18 @@ function crossedOrderLabel(crossed) {
280
381
  * an UPDATE-only rotation batch can re-price across an un-adopted chain
281
382
  * order that master-grid-only checks cannot see (the pending/unmatched
282
383
  * batch guards fire only for CREATE batches).
384
+ *
385
+ * Delegates to the shared order-utils builder so every crossing guard (COW
386
+ * create/rotation/fallback, startup reconcile placement, price-correction)
387
+ * sees the same set. Pending entries are pushed as wrappers (slotId +
388
+ * order): their inner order has no chain id yet, so orderId-only
389
+ * predicates would blind the guard to them — the shared
390
+ * isCrossingCheckCandidate predicate accepts slot-id-only wrappers.
283
391
  * @param {Object} bot
284
392
  * @returns {any[]}
285
393
  */
286
394
  function buildCrossingCandidates(bot) {
287
- const mgr = bot?.manager;
288
- if (!mgr)
289
- return [];
290
- const candidates = mgr.orders instanceof Map ? [...mgr.orders.values()] : [];
291
- if (mgr._pendingBroadcasts instanceof Map) {
292
- for (const entry of mgr._pendingBroadcasts.values()) {
293
- const o = entry?.order;
294
- if (o && o.type != null && o.price != null)
295
- candidates.push(o);
296
- }
297
- }
298
- if (Array.isArray(mgr._lastUnmatchedChainOrders)) {
299
- for (const o of mgr._lastUnmatchedChainOrders) {
300
- if (o && o.type != null && o.price != null)
301
- candidates.push(o);
302
- }
303
- }
304
- return candidates;
395
+ return orderUtils.buildCrossingCheckCandidates(bot?.manager);
305
396
  }
306
397
  /**
307
398
  * Drop only the pending-broadcast entries for the given CREATE slots.
@@ -520,72 +611,24 @@ async function reconcileAfterUncertainBroadcast(bot, err, opContexts, options =
520
611
  return acquireIfNotHeld(bot.manager?._fillProcessingLock, () => reconcileAfterUncertainBroadcastImpl(bot, err, opContexts, options));
521
612
  }
522
613
  /**
523
- * Reconcile a broadcast whose chain state is unknown (implementation).
614
+ * Match pending broadcasts against a chain snapshot (first pass by slot
615
+ * probe, second pass by fingerprint across all pending slots), then
616
+ * re-read the chain for discarded CREATEs to close the TOCTOU window.
617
+ * Returns `{ adopted, discarded }`, or `{ deferred }` when the re-read
618
+ * is ambiguous/failed and the caller must defer (pending protection kept).
524
619
  * @param {import('./dexbot_class.js').DEXBot} bot
525
- * @param {BroadcastUncertainError} err
526
- * @param {Array<Object>} opContexts
527
- * @param {Object} options
528
- * @returns {Promise<Object>}
620
+ * @param {Array} pending
621
+ * @param {Array} chainSnapshot
622
+ * @param {Array} opContexts
623
+ * @param {*} accountRef
624
+ * @param {*} err
529
625
  */
530
- async function reconcileAfterUncertainBroadcastImpl(bot, err, opContexts, _options) {
531
- const startedAt = Date.now();
532
- const pending = (bot.manager && bot.manager._pendingBroadcasts instanceof Map)
533
- ? Array.from(bot.manager._pendingBroadcasts.values())
534
- : [];
535
- const createContextCount = opContexts.filter((c) => c && c.kind === 'create').length;
536
- const nonCreateContextCount = opContexts.length - createContextCount;
537
- bot.manager.logger.log(`[COW][UNCERTAIN] batchId=${err?.batchId || 'n/a'} ops=${opContexts.length} ` +
538
- `creates=${createContextCount} nonCreates=${nonCreateContextCount} ` +
539
- `staleSinceMs=${err?.timeoutMs || 'n/a'}. Entering reconcile-then-decide.`, 'warn');
540
- if (!chainOrders?.readOpenOrdersWithMeta) {
541
- bot.manager.logger.log('[COW][UNCERTAIN] readOpenOrdersWithMeta unavailable; falling back to structural resync only.', 'error');
542
- if (typeof bot.manager.requestStructuralGridResync === 'function') {
543
- await bot.manager.requestStructuralGridResync('broadcast uncertain — readOpenOrders unavailable', { batchId: err?.batchId || null });
544
- }
545
- clearPendingBroadcasts(bot.manager?._pendingBroadcasts);
546
- return { executed: false, hadRotation: false, uncertain: true };
547
- }
548
- // 1. Read the chain
549
- const accountRef = bot.accountId || bot.account?.id || bot.account;
550
- let chainSnapshot = [];
551
- let chainReadTruncated = false;
552
- try {
553
- const chainRead = await chainOrders.readOpenOrdersWithMeta(accountRef);
554
- chainSnapshot = chainRead.orders;
555
- chainReadTruncated = chainRead.truncated;
556
- }
557
- catch (readErr) {
558
- bot.manager.logger.log(`[COW][UNCERTAIN] readOpenOrders failed: ${readErr?.message || readErr}. ` +
559
- `Falling back to structural resync.`, 'error');
560
- if (typeof bot.manager.requestStructuralGridResync === 'function') {
561
- await bot.manager.requestStructuralGridResync('broadcast uncertain — readOpenOrders failed', { batchId: err?.batchId || null, error: readErr?.message || String(readErr) });
562
- }
563
- clearPendingBroadcasts(bot.manager?._pendingBroadcasts);
564
- return { executed: false, hadRotation: false, uncertain: true };
565
- }
566
- // 1.5. Empty/truncated-read guard: an empty snapshot is ambiguous — the
567
- // account is either genuinely empty or the node is lagging behind the
568
- // just-broadcast transaction. A truncated snapshot (get_full_accounts
569
- // capped limit_orders; fresh creates sort last in the by_account index
570
- // and are the first entries omitted) is equally ambiguous: the batch's
571
- // creates may simply be missing from the returned window. Treating every
572
- // pending broadcast as discarded would clear the pending-broadcast
573
- // protection and let the next cycle re-CREATE slots whose orders may
574
- // actually be on chain (duplicate orders). Keep the protection and let
575
- // the structural resync adopt any landed orders.
576
- if (pending.length > 0 && (chainSnapshot.length === 0 || chainReadTruncated)) {
577
- return await deferUncertainBroadcastRead(bot, `${chainSnapshot.length === 0 ? 'Empty' : 'Truncated'} chain read for ${pending.length} pending broadcast(s)`, '(node may be lagging or the result set capped; no discard decisions made)', 'uncertain broadcast — empty/truncated chain read', { batchId: err?.batchId || null, truncated: chainReadTruncated });
578
- }
626
+ async function matchPendingToChain(bot, pending, chainSnapshot, opContexts, accountRef, err) {
579
627
  const adopted = [];
580
628
  let discarded = [];
581
629
  // 2. For each pending broadcast, look for a chain match.
582
630
  for (const entry of pending) {
583
- const match = findChainOrderForSlot(bot, chainSnapshot, entry.slotId, {
584
- sell: entry.finalInts?.sell,
585
- receive: entry.finalInts?.receive,
586
- orderType: entry.orderType,
587
- fingerprint: entry.fingerprint,
588
- });
631
+ const match = findChainOrderForPendingEntry(bot, chainSnapshot, entry);
589
632
  if (match) {
590
633
  adopted.push({ entry, match });
591
634
  }
@@ -634,21 +677,18 @@ async function reconcileAfterUncertainBroadcastImpl(bot, err, opContexts, _optio
634
677
  // protection and let the next cycle re-create (duplicate) an
635
678
  // order that actually landed in the TOCTOU window. Keep the
636
679
  // pending-broadcast protection and defer to a structural resync.
637
- if (!freshRead || freshRead.truncated || !Array.isArray(freshRead.orders) || freshRead.orders.length === 0) {
680
+ if (!isAuthoritativeChainRead(freshRead)) {
638
681
  const ambiguous = !freshRead || !Array.isArray(freshRead.orders) || freshRead.orders.length === 0;
639
- return await deferUncertainBroadcastRead(bot, `${ambiguous ? 'Empty' : 'Truncated'} re-read for ${createDiscarded.length} discarded CREATE(s)`, '(absence is not authoritative on an ambiguous re-read)', 'uncertain broadcast — ambiguous re-read for discarded creates', { batchId: err?.batchId || null, truncated: !ambiguous });
682
+ return {
683
+ deferred: await deferUncertainBroadcastRead(bot, `${ambiguous ? 'Empty' : 'Truncated'} re-read for ${createDiscarded.length} discarded CREATE(s)`, '(absence is not authoritative on an ambiguous re-read)', 'uncertain broadcast — ambiguous re-read for discarded creates', { batchId: err?.batchId || null, truncated: !ambiguous })
684
+ };
640
685
  }
641
686
  const freshChain = freshRead.orders;
642
687
  const remainingDiscarded = [];
643
688
  for (const entry of discarded) {
644
689
  const ctx = opContexts[entry.ctxIndex];
645
690
  if (ctx && ctx.kind === 'create') {
646
- const match = findChainOrderForSlot(bot, freshChain, entry.slotId, {
647
- sell: entry.finalInts?.sell,
648
- receive: entry.finalInts?.receive,
649
- orderType: entry.orderType,
650
- fingerprint: entry.fingerprint,
651
- });
691
+ const match = findChainOrderForPendingEntry(bot, freshChain, entry);
652
692
  if (match) {
653
693
  adopted.push({ entry, match });
654
694
  bot.manager.logger.log(`[COW][UNCERTAIN] Late-adopted discarded CREATE for slot ${entry.slotId} (${match.id}) via fresh chain read`, 'info');
@@ -664,13 +704,24 @@ async function reconcileAfterUncertainBroadcastImpl(bot, err, opContexts, _optio
664
704
  discarded = remainingDiscarded;
665
705
  }
666
706
  catch (reReadErr) {
667
- return await deferUncertainBroadcastRead(bot, `Fresh chain read for late adoption FAILED (${getErrorMessage(reReadErr)})`, '(absence is not authoritative on a failed re-read)', 'uncertain broadcast — failed re-read for discarded creates', { batchId: err?.batchId || null });
707
+ return {
708
+ deferred: await deferUncertainBroadcastRead(bot, `Fresh chain read for late adoption FAILED (${getErrorMessage(reReadErr)})`, '(absence is not authoritative on a failed re-read)', 'uncertain broadcast — failed re-read for discarded creates', { batchId: err?.batchId || null })
709
+ };
668
710
  }
669
711
  }
670
712
  }
671
- // 3b. Apply decisions
713
+ return { adopted, discarded };
714
+ }
715
+ /**
716
+ * Adopt matched pending broadcasts into the master grid (CREATE slots
717
+ * synchronize with the chain) and clear their pending entries.
718
+ * @param {import('./dexbot_class.js').DEXBot} bot
719
+ * @param {Array} adopted
720
+ * @param {Array} opContexts
721
+ * @returns {Promise<number>} adoptedCount
722
+ */
723
+ async function adoptMatchedEntries(bot, adopted, opContexts) {
672
724
  let adoptedCount = 0;
673
- let discardedCount = 0;
674
725
  for (const { entry, match } of adopted) {
675
726
  adoptedCount++;
676
727
  const plannedOpCtx = opContexts[entry.ctxIndex];
@@ -684,6 +735,7 @@ async function reconcileAfterUncertainBroadcastImpl(bot, err, opContexts, _optio
684
735
  chainOrderId,
685
736
  expectedType,
686
737
  fee: btsFeeData?.createFee || 0,
738
+ order: plannedOpCtx.order ?? entry.order ?? null,
687
739
  }, 'createOrder');
688
740
  }
689
741
  catch (syncErr) {
@@ -695,6 +747,19 @@ async function reconcileAfterUncertainBroadcastImpl(bot, err, opContexts, _optio
695
747
  bot.manager._pendingBroadcasts.delete(entry.fingerprint);
696
748
  }
697
749
  }
750
+ return adoptedCount;
751
+ }
752
+ /**
753
+ * Restore discarded CREATE slots to creation-uncertain state and clear
754
+ * their pending entries; residual pending entries that survive the
755
+ * decide phase are cleaned defensively.
756
+ * @param {import('./dexbot_class.js').DEXBot} bot
757
+ * @param {Array} discarded
758
+ * @param {Array} opContexts
759
+ * @returns {Promise<number>} discardedCount
760
+ */
761
+ async function restoreDiscardedCreates(bot, discarded, opContexts) {
762
+ let discardedCount = 0;
698
763
  for (const entry of discarded) {
699
764
  discardedCount++;
700
765
  const plannedOpCtx = opContexts[entry.ctxIndex];
@@ -771,12 +836,48 @@ async function reconcileAfterUncertainBroadcastImpl(bot, err, opContexts, _optio
771
836
  }
772
837
  }
773
838
  }
839
+ else {
840
+ // Slot missing from master (grid reset raced the uncertain
841
+ // broadcast). Deleting the pending entry below without
842
+ // restoring anything would leave a clean hole the next
843
+ // cycle re-CREATEs — duplicating a possibly-landed order.
844
+ // Materialize the creation-uncertain slot from the
845
+ // broadcast-time descriptor so the next sync's orphan
846
+ // adoption reconciles a landed order into it.
847
+ const missingType = entry.order.type;
848
+ if (missingType === ORDER_TYPES.BUY || missingType === ORDER_TYPES.SELL) {
849
+ bot.manager.logger.log(`[COW][UNCERTAIN] Slot ${entry.order.id} missing from master after discard — materializing creation-uncertain state ` +
850
+ `(type=${missingType}, size: ${entry.order.size}); next sync adoption will reconcile any landed order`, 'warn');
851
+ const updates = [{
852
+ id: entry.order.id,
853
+ type: missingType,
854
+ size: entry.order.size,
855
+ price: entry.order.price,
856
+ state: ORDER_STATES.VIRTUAL,
857
+ createUncertain: true,
858
+ orderId: null,
859
+ rawOnChain: null,
860
+ }];
861
+ if (typeof bot.manager.applyGridUpdateBatch === 'function') {
862
+ await bot.manager.applyGridUpdateBatch(updates, 'uncertain-broadcast-discard-restore');
863
+ }
864
+ }
865
+ else {
866
+ bot.manager.logger.log(`[COW][UNCERTAIN] Slot ${entry.order.id} missing from master after discard with unrecognized type ${missingType} — cannot reconstruct creation-uncertain state; next sync must adopt any landed order as an orphan`, 'error');
867
+ }
868
+ }
869
+ }
870
+ else {
871
+ bot.manager.logger.log(`[COW][UNCERTAIN] Discarded CREATE for slot ${entry.slotId} has no usable placement descriptor (id=${entry.order?.id ?? 'none'}, size=${entry.order?.size ?? 'none'}, type=${entry.order?.type ?? 'none'}) — nothing restored; next sync must adopt any landed order as an orphan`, 'error');
774
872
  }
775
873
  }
776
874
  catch (restoreErr) {
777
875
  bot.manager.logger.log(`[COW][UNCERTAIN] Failed to restore slot ${entry.slotId} after discard: ${restoreErr?.message || restoreErr}`, 'error');
778
876
  }
779
877
  }
878
+ else {
879
+ bot.manager.logger.log(`[COW][UNCERTAIN] Discarded pending broadcast for slot ${entry.slotId} is not a recognizable CREATE (no create opContext, no order id/type) — skipping restore; next sync must adopt any landed order as an orphan`, 'error');
880
+ }
780
881
  // Remove from pending broadcasts.
781
882
  if (entry.fingerprint && bot.manager._pendingBroadcasts?.has(entry.fingerprint)) {
782
883
  bot.manager._pendingBroadcasts.delete(entry.fingerprint);
@@ -796,6 +897,101 @@ async function reconcileAfterUncertainBroadcastImpl(bot, err, opContexts, _optio
796
897
  }
797
898
  bot.manager._pendingBroadcasts.clear();
798
899
  }
900
+ return discardedCount;
901
+ }
902
+ /**
903
+ * Request a structural resync when chain orders remain unaccounted for
904
+ * after the uncertain-broadcast reconciliation.
905
+ * @param {import('./dexbot_class.js').DEXBot} bot
906
+ * @param {*} err
907
+ * @param {Array} pending
908
+ * @param {Array} chainSnapshot
909
+ * @param {Array} adopted
910
+ * @param {number} adoptedCount
911
+ * @param {number} discardedCount
912
+ */
913
+ async function resyncIfUnreconciled(bot, err, pending, chainSnapshot, adopted, adoptedCount, discardedCount) {
914
+ // 7. Request structural resync if any chain orders remain unaccounted for
915
+ // after the reconciliation, ensuring the next cycle re-plans from a clean
916
+ // chain snapshot.
917
+ const alreadyScheduled = bot._structuralGridResyncRunning || bot._structuralGridResyncTimer;
918
+ if (!alreadyScheduled && chainSnapshot.length > 0) {
919
+ const reconciledOrderIds = new Set(adopted.map((a) => a.match?.id).filter(Boolean));
920
+ const unreconciledCount = chainSnapshot.filter((o) => !reconciledOrderIds.has(o.id)).length;
921
+ if (unreconciledCount > 0) {
922
+ bot.manager.logger.log(`[COW][UNCERTAIN] ${unreconciledCount} chain order(s) remain unreconciled after uncertain broadcast recovery. ` +
923
+ `These may be legitimate pre-existing orders or leftovers from a prior cycle. Requesting structural resync.`, 'warn');
924
+ await requestStructuralResync(bot, 'unreconciled orders after uncertain broadcast', {
925
+ batchId: err?.batchId || null,
926
+ pendingCount: pending.length,
927
+ adoptedCount,
928
+ discardedCount,
929
+ unreconciledCount
930
+ });
931
+ }
932
+ }
933
+ }
934
+ /**
935
+ * Reconcile a broadcast whose chain state is unknown (implementation).
936
+ * @param {import('./dexbot_class.js').DEXBot} bot
937
+ * @param {BroadcastUncertainError} err
938
+ * @param {Array<Object>} opContexts
939
+ * @param {Object} options
940
+ * @returns {Promise<Object>}
941
+ */
942
+ async function reconcileAfterUncertainBroadcastImpl(bot, err, opContexts, _options) {
943
+ const startedAt = Date.now();
944
+ const pending = getPendingBroadcasts(bot);
945
+ const createContextCount = opContexts.filter((c) => c && c.kind === 'create').length;
946
+ const nonCreateContextCount = opContexts.length - createContextCount;
947
+ bot.manager.logger.log(`[COW][UNCERTAIN] batchId=${err?.batchId || 'n/a'} ops=${opContexts.length} ` +
948
+ `creates=${createContextCount} nonCreates=${nonCreateContextCount} ` +
949
+ `staleSinceMs=${err?.timeoutMs || 'n/a'}. Entering reconcile-then-decide.`, 'warn');
950
+ if (!chainOrders?.readOpenOrdersWithMeta) {
951
+ bot.manager.logger.log('[COW][UNCERTAIN] readOpenOrdersWithMeta unavailable; falling back to structural resync only.', 'error');
952
+ await requestStructuralResync(bot, 'broadcast uncertain — readOpenOrders unavailable', { batchId: err?.batchId || null });
953
+ clearPendingBroadcasts(bot.manager?._pendingBroadcasts);
954
+ return { executed: false, hadRotation: false, uncertain: true };
955
+ }
956
+ // 1. Read the chain
957
+ const accountRef = bot.accountId || bot.account?.id || bot.account;
958
+ let chainSnapshot = [];
959
+ let chainReadTruncated = false;
960
+ try {
961
+ const chainRead = await chainOrders.readOpenOrdersWithMeta(accountRef);
962
+ chainSnapshot = chainRead.orders;
963
+ chainReadTruncated = chainRead.truncated;
964
+ }
965
+ catch (readErr) {
966
+ bot.manager.logger.log(`[COW][UNCERTAIN] readOpenOrders failed: ${readErr?.message || readErr}. ` +
967
+ `Falling back to structural resync.`, 'error');
968
+ await requestStructuralResync(bot, 'broadcast uncertain — readOpenOrders failed', { batchId: err?.batchId || null, error: readErr?.message || String(readErr) });
969
+ clearPendingBroadcasts(bot.manager?._pendingBroadcasts);
970
+ return { executed: false, hadRotation: false, uncertain: true };
971
+ }
972
+ // 1.5. Empty/truncated-read guard: an empty snapshot is ambiguous — the
973
+ // account is either genuinely empty or the node is lagging behind the
974
+ // just-broadcast transaction. A truncated snapshot (get_full_accounts
975
+ // capped limit_orders; fresh creates sort last in the by_account index
976
+ // and are the first entries omitted) is equally ambiguous: the batch's
977
+ // creates may simply be missing from the returned window. Treating every
978
+ // pending broadcast as discarded would clear the pending-broadcast
979
+ // protection and let the next cycle re-CREATE slots whose orders may
980
+ // actually be on chain (duplicate orders). Keep the protection and let
981
+ // the structural resync adopt any landed orders.
982
+ if (pending.length > 0 && (chainSnapshot.length === 0 || chainReadTruncated)) {
983
+ return await deferUncertainBroadcastRead(bot, `${chainSnapshot.length === 0 ? 'Empty' : 'Truncated'} chain read for ${pending.length} pending broadcast(s)`, '(node may be lagging or the result set capped; no discard decisions made)', 'uncertain broadcast — empty/truncated chain read', { batchId: err?.batchId || null, truncated: chainReadTruncated });
984
+ }
985
+ // 2-3a. Match pending broadcasts to the chain: slot probe, fingerprint
986
+ // second pass, and a TOCTOU re-read for discarded CREATEs (see
987
+ // matchPendingToChain). A deferred re-read returns its result directly.
988
+ const matched = await matchPendingToChain(bot, pending, chainSnapshot, opContexts, accountRef, err);
989
+ if (matched.deferred)
990
+ return matched.deferred;
991
+ const { adopted, discarded } = matched;
992
+ // 3b. Apply decisions
993
+ const adoptedCount = await adoptMatchedEntries(bot, adopted, opContexts);
994
+ let discardedCount = await restoreDiscardedCreates(bot, discarded, opContexts);
799
995
  // 5. Log structured summary
800
996
  const elapsed = Date.now() - startedAt;
801
997
  bot.manager.logger.log(`[COW][UNCERTAIN] Reconciled: ${adoptedCount} adopted, ${discardedCount} discarded ` +
@@ -815,27 +1011,7 @@ async function reconcileAfterUncertainBroadcastImpl(bot, err, opContexts, _optio
815
1011
  // 7. Request structural resync if any chain orders remain unaccounted for
816
1012
  // after the reconciliation, ensuring the next cycle re-plans from a clean
817
1013
  // chain snapshot.
818
- const alreadyScheduled = bot._structuralGridResyncRunning || bot._structuralGridResyncTimer;
819
- if (!alreadyScheduled && chainSnapshot.length > 0) {
820
- const reconciledOrderIds = new Set(adopted.map((a) => a.match?.id).filter(Boolean));
821
- const unreconciledCount = chainSnapshot.filter((o) => !reconciledOrderIds.has(o.id)).length;
822
- if (unreconciledCount > 0) {
823
- bot.manager.logger.log(`[COW][UNCERTAIN] ${unreconciledCount} chain order(s) remain unreconciled after uncertain broadcast recovery. ` +
824
- `These may be legitimate pre-existing orders or leftovers from a prior cycle. Requesting structural resync.`, 'warn');
825
- if (typeof bot.manager.requestStructuralGridResync === 'function') {
826
- await bot.manager.requestStructuralGridResync('unreconciled orders after uncertain broadcast', {
827
- batchId: err?.batchId || null,
828
- pendingCount: pending.length,
829
- adoptedCount,
830
- discardedCount,
831
- unreconciledCount
832
- });
833
- }
834
- else {
835
- bot._warn?.('[COW][UNCERTAIN] requestStructuralGridResync unavailable; cannot schedule structural resync.');
836
- }
837
- }
838
- }
1014
+ await resyncIfUnreconciled(bot, err, pending, chainSnapshot, adopted, adoptedCount, discardedCount);
839
1015
  return { executed: false, hadRotation: false, uncertain: true, adoptedCount, discardedCount };
840
1016
  }
841
1017
  /**
@@ -856,9 +1032,7 @@ async function autoCancelOneUnmatchedOrphan(bot) {
856
1032
  bot._autoCancelOrphanCycleMarker = cycleId;
857
1033
  bot._autoCancelOrphanSubCount = 0;
858
1034
  }
859
- const pending = (bot.manager && bot.manager._pendingBroadcasts instanceof Map)
860
- ? bot.manager._pendingBroadcasts.size
861
- : 0;
1035
+ const pending = countPendingBroadcasts(bot);
862
1036
  if (pending > 0) {
863
1037
  return { cancelled: false, reason: 'pending-broadcasts-active' };
864
1038
  }
@@ -955,14 +1129,7 @@ function verifyCreateAbsent(bot, freshChain, ctx) {
955
1129
  sell: ctx.finalInts.sell,
956
1130
  receive: ctx.finalInts.receive,
957
1131
  orderType: ctx.order.type,
958
- fingerprint: buildCreateOpFingerprint({
959
- side: ctx.order.type,
960
- assetA: bot.manager?.assets?.assetA?.id,
961
- assetB: bot.manager?.assets?.assetB?.id,
962
- sellInt: ctx.finalInts.sell,
963
- receiveInt: ctx.finalInts.receive,
964
- slotId: ctx.order.id
965
- })
1132
+ fingerprint: createOpFingerprintForSlot(bot, ctx.order, ctx.finalInts, ctx.order.id)
966
1133
  });
967
1134
  return match ? 'landed' : 'absent';
968
1135
  }
@@ -1043,7 +1210,11 @@ async function executeWithRetryOnUncertain(bot, operations, opContexts) {
1043
1210
  const accountRef = bot.accountId || bot.account?.id || bot.account;
1044
1211
  const freshRead = await chainOrders.readOpenOrdersWithMeta(accountRef);
1045
1212
  const freshChain = freshRead.orders;
1046
- if (Array.isArray(freshChain) && freshChain.length > 0) {
1213
+ // A truncated read (get_full_accounts caps limit_orders, and
1214
+ // fresh creates sort last in the by_account index) omits the
1215
+ // very orders this batch may have landed — 'absent' is not
1216
+ // authoritative here, degrade to 'unknown' and defer.
1217
+ if (isAuthoritativeChainRead(freshRead)) {
1047
1218
  absence = 'absent';
1048
1219
  for (const ctx of opContexts) {
1049
1220
  if (!ctx)
@@ -1055,13 +1226,6 @@ async function executeWithRetryOnUncertain(bot, operations, opContexts) {
1055
1226
  }
1056
1227
  }
1057
1228
  }
1058
- // A truncated read (get_full_accounts caps limit_orders, and
1059
- // fresh creates sort last in the by_account index) omits the
1060
- // very orders this batch may have landed — 'absent' is not
1061
- // authoritative here, degrade to 'unknown' and defer.
1062
- if (freshRead.truncated && absence === 'absent') {
1063
- absence = 'unknown';
1064
- }
1065
1229
  }
1066
1230
  catch (verifyErr) {
1067
1231
  bot.manager.logger.log(`[COW] Pre-retry chain verification failed (non-fatal): ${verifyErr?.message || verifyErr}`, 'warn');
@@ -1095,31 +1259,6 @@ async function executeWithRetryOnUncertain(bot, operations, opContexts) {
1095
1259
  }
1096
1260
  }
1097
1261
  }
1098
- /**
1099
- * Whether a chain order still matches the cached pre-update state the
1100
- * limit_order_update delta was built from. Only a provably-unchanged order
1101
- * makes a re-broadcast of the identical delta safe (it applies to the same
1102
- * base). Any other state (target applied, filled, resized) must defer.
1103
- * @param {Object} chainOrder - Raw chain order object (get_full_accounts)
1104
- * @param {Object|null} cachedRaw - The rawOnChain cache captured at build time
1105
- * @returns {boolean}
1106
- */
1107
- function chainOrderUnchangedFromCache(chainOrder, cachedRaw) {
1108
- if (!chainOrder || !cachedRaw)
1109
- return false;
1110
- const base = chainOrder.sell_price?.base;
1111
- const quote = chainOrder.sell_price?.quote;
1112
- const cachedBase = cachedRaw.sell_price?.base?.amount;
1113
- const cachedQuote = cachedRaw.sell_price?.quote?.amount;
1114
- const cachedForSale = cachedRaw.for_sale;
1115
- if (base === undefined || quote === undefined)
1116
- return false;
1117
- if (cachedForSale === undefined || cachedBase === undefined || cachedQuote === undefined)
1118
- return false;
1119
- return String(base.amount ?? '') === String(cachedBase)
1120
- && String(quote.amount ?? '') === String(cachedQuote)
1121
- && String(chainOrder.for_sale ?? '') === String(cachedForSale);
1122
- }
1123
1262
  /**
1124
1263
  * Summarize a partial (non-atomic) broadcast for the batch-failure catch log.
1125
1264
  * Supports both the pair-mode grouped path (groupsBroadcast/groupsTotal) and
@@ -1357,7 +1496,6 @@ function validateOperationFunds(bot, operations, assetA, assetB) {
1357
1496
  if (!operations || operations.length === 0) {
1358
1497
  return { isValid: true, summary: 'No operations to validate' };
1359
1498
  }
1360
- const { blockchainToFloat, floatToBlockchainInt, quantizeFloat } = require('./order/utils/math');
1361
1499
  let snap = bot.manager?.getChainFundsSnapshot?.();
1362
1500
  if (!snap) {
1363
1501
  snap = { chainFreeSell: 0, chainFreeBuy: 0 };
@@ -1852,12 +1990,21 @@ function buildCowResultFromPlan(bot, plan) {
1852
1990
  });
1853
1991
  }
1854
1992
  }
1993
+ // Refill-slot wire (boundary-hold): producers attach the slot ids of
1994
+ // hole-CREATEs that justify this plan's boundary shift; the executor
1995
+ // holds the committed boundary when a listed refill is guard-skipped.
1996
+ // Absent/non-array => undefined (guarded default at execution, never
1997
+ // fail-open). Spread-correction plans never set this (disjoint bypass).
1998
+ const refillSlotIds = Array.isArray(plan?.refillSlotIds)
1999
+ ? plan.refillSlotIds.filter((id) => typeof id === 'string' && id.length > 0)
2000
+ : undefined;
1855
2001
  return {
1856
2002
  workingGrid,
1857
2003
  workingIndexes: workingGrid.getIndexes(),
1858
2004
  workingBoundary,
1859
2005
  actions,
1860
- origin: plan?.origin
2006
+ origin: plan?.origin,
2007
+ ...(refillSlotIds !== undefined ? { refillSlotIds } : {})
1861
2008
  };
1862
2009
  }
1863
2010
  /**
@@ -1935,10 +2082,14 @@ async function pollChainForConfirmation(bot, opContexts, options = {}) {
1935
2082
  // Only CREATE operations can be confirmed by polling (they appear as new orders on chain)
1936
2083
  const createContexts = opContexts.filter((ctx) => ctx && ctx.kind === 'create' && ctx.finalInts && ctx.order);
1937
2084
  if (createContexts.length === 0) {
1938
- return { allConfirmed: false, confirmed: [], unconfirmed: [...opContexts] };
2085
+ return { allConfirmed: false, confirmed: [], unconfirmed: [...opContexts], confirmedChainIds: [] };
1939
2086
  }
1940
2087
  const accountRef = bot.accountId || bot.account?.id || bot.account;
1941
2088
  let remaining = [...createContexts];
2089
+ // Chain ids of the poll-matched fresh creates. Retained (not just
2090
+ // logged) so the poll-confirmed adoption path can re-read them BY ID —
2091
+ // the window fallback cannot prove a lagging node has them yet.
2092
+ const matchedChainIds = [];
1942
2093
  for (let attempt = 1; attempt <= maxPollRetries; attempt++) {
1943
2094
  try {
1944
2095
  const chainRead = await readOpenOrdersWithMetaSafe(chainOrders, accountRef);
@@ -1966,18 +2117,13 @@ async function pollChainForConfirmation(bot, opContexts, options = {}) {
1966
2117
  receive: ctx.finalInts.receive,
1967
2118
  orderType: ctx.order.type,
1968
2119
  fingerprint: createContexts.length > 0
1969
- ? buildCreateOpFingerprint({
1970
- side: ctx.order.type,
1971
- assetA: bot.manager?.assets?.assetA?.id,
1972
- assetB: bot.manager?.assets?.assetB?.id,
1973
- sellInt: ctx.finalInts.sell,
1974
- receiveInt: ctx.finalInts.receive,
1975
- slotId: ctx.order.id
1976
- })
2120
+ ? createOpFingerprintForSlot(bot, ctx.order, ctx.finalInts, ctx.order.id)
1977
2121
  : undefined
1978
2122
  });
1979
2123
  if (match) {
1980
2124
  bot.manager.logger.log(`[COW][POLL] Confirmed CREATE for slot ${ctx.order.id} on chain as ${match.id}`, 'debug');
2125
+ if (match.id && /^1\.7\.\d+$/.test(String(match.id)))
2126
+ matchedChainIds.push(String(match.id));
1981
2127
  }
1982
2128
  else {
1983
2129
  stillUnconfirmed.push(ctx);
@@ -1986,7 +2132,7 @@ async function pollChainForConfirmation(bot, opContexts, options = {}) {
1986
2132
  if (stillUnconfirmed.length === 0) {
1987
2133
  const confirmed = createContexts;
1988
2134
  bot.manager.logger.log(`[COW][POLL] All ${confirmed.length} CREATE(s) confirmed on chain after ${attempt} poll(s)`, 'info');
1989
- return { allConfirmed: true, confirmed, unconfirmed: [] };
2135
+ return { allConfirmed: true, confirmed, unconfirmed: [], confirmedChainIds: [...matchedChainIds] };
1990
2136
  }
1991
2137
  remaining = stillUnconfirmed;
1992
2138
  if (attempt < maxPollRetries) {
@@ -2003,7 +2149,56 @@ async function pollChainForConfirmation(bot, opContexts, options = {}) {
2003
2149
  const confirmed = createContexts.filter((ctx) => !remaining.includes(ctx));
2004
2150
  bot.manager.logger.log(`[COW][POLL] ${confirmed.length}/${createContexts.length} CREATE(s) confirmed after ${maxPollRetries} polls; ` +
2005
2151
  `${remaining.length} unconfirmed. Falling back to reconciliation.`, 'warn');
2006
- return { allConfirmed: false, confirmed, unconfirmed: remaining };
2152
+ return { allConfirmed: false, confirmed, unconfirmed: remaining, confirmedChainIds: [...matchedChainIds] };
2153
+ }
2154
+ /**
2155
+ * Normalize a producer-supplied refillSlotIds wire into a Set.
2156
+ * Absent/empty/non-array => empty (guarded default, never fail-open).
2157
+ */
2158
+ function toRefillSlotIdSet(refillSlotIds) {
2159
+ const set = new Set();
2160
+ if (Array.isArray(refillSlotIds)) {
2161
+ for (const id of refillSlotIds) {
2162
+ if (typeof id === 'string' && id.length > 0)
2163
+ set.add(id);
2164
+ }
2165
+ }
2166
+ return set;
2167
+ }
2168
+ /**
2169
+ * Boundary-hold decision for guard-skipped refills.
2170
+ * When a skipped slot is one of the plan's refill slots — a hole-CREATE that
2171
+ * justified the planned boundary shift — the committed boundary is kept: the
2172
+ * slot was never placed (CREATE skip) or restored to master (UPDATE skip), so
2173
+ * committing the planned boundary would strand empty rail holes past it
2174
+ * (91->94 with 91-94 empty self-legalizes via resolveGapBand).
2175
+ * Unrelated vetoes (skip ids outside the refill set) never pin geometry.
2176
+ * The grid still commits; only the boundary value is held (same discipline
2177
+ * as the overrun-hold in validateBoundaryCommit).
2178
+ */
2179
+ function resolveRefillBoundaryHold(workingBoundary, committedBoundary, skippedUpdateSlotIds, clampedUpdateSlotIds, refillSlotIds, skippedCreateSlotIds = undefined) {
2180
+ const refills = toRefillSlotIdSet(refillSlotIds);
2181
+ const heldRefillSlotIds = [];
2182
+ if (refills.size > 0) {
2183
+ const seen = new Set();
2184
+ const skipCollections = skippedCreateSlotIds !== undefined
2185
+ ? [skippedUpdateSlotIds, clampedUpdateSlotIds, skippedCreateSlotIds]
2186
+ : [skippedUpdateSlotIds, clampedUpdateSlotIds];
2187
+ for (const coll of skipCollections) {
2188
+ if (!coll || typeof coll[Symbol.iterator] !== 'function')
2189
+ continue;
2190
+ for (const id of coll) {
2191
+ if (typeof id === 'string' && refills.has(id) && !seen.has(id)) {
2192
+ seen.add(id);
2193
+ heldRefillSlotIds.push(id);
2194
+ }
2195
+ }
2196
+ }
2197
+ }
2198
+ return {
2199
+ effectiveBoundary: heldRefillSlotIds.length > 0 ? committedBoundary : workingBoundary,
2200
+ heldRefillSlotIds
2201
+ };
2007
2202
  }
2008
2203
  /**
2009
2204
  * Restore skipped update slots in the working grid to master state.
@@ -2186,87 +2381,6 @@ async function waitForCowBroadcastSingleFlight(bot, label) {
2186
2381
  * @param {Object} [options={}] - Internal execution options (replanDepth)
2187
2382
  * @returns {Promise<Object>}
2188
2383
  */
2189
- /**
2190
- * PRE-BROADCAST CROSSED-BOOK ASSERT (defense-in-depth, any-writer detection).
2191
- *
2192
- * Simulates the post-batch book: currently placed master orders plus this
2193
- * batch's action overlay (CREATEs add, CANCELs remove, UPDATEs reprice/move).
2194
- * Returns a detail string when a planned BUY would price at-or-above a planned
2195
- * SELL — a state no honest planner produces — so the caller can refuse the
2196
- * broadcast instead of paying for adverse fills. Placed order prices are
2197
- * independent of grid geometry, so this catches boundary overruns regardless
2198
- * of which writer produced them.
2199
- *
2200
- * Detector only: any internal failure returns null (never blocks a broadcast).
2201
- */
2202
- function detectCrossedBookPlan(manager, actions) {
2203
- try {
2204
- const startPrice = Number(manager?.config?.startPrice);
2205
- const book = new Map();
2206
- for (const o of Array.from(manager?.orders?.values?.() ?? [])) {
2207
- if (!o || !o.orderId || o.price == null)
2208
- continue;
2209
- const price = Number(o.price);
2210
- if (!Number.isFinite(price))
2211
- continue;
2212
- let type = o.type;
2213
- if (type !== ORDER_TYPES.BUY && type !== ORDER_TYPES.SELL) {
2214
- // Legacy SPREAD-typed placed order: derive side from the same
2215
- // price-vs-startPrice convention used across the codebase.
2216
- if (!Number.isFinite(startPrice))
2217
- continue;
2218
- type = price < startPrice ? ORDER_TYPES.BUY : ORDER_TYPES.SELL;
2219
- }
2220
- book.set(String(o.id), { type, price });
2221
- }
2222
- for (const a of actions ?? []) {
2223
- const id = String(a.id ?? a.orderId ?? '');
2224
- if (a.type === COW_ACTIONS.CANCEL) {
2225
- if (id)
2226
- book.delete(id);
2227
- }
2228
- else if (a.type === COW_ACTIONS.UPDATE) {
2229
- const newPrice = Number(a.newPrice ?? a.order?.price);
2230
- const newType = a.order?.type;
2231
- if (id && Number.isFinite(newPrice)) {
2232
- const entry = book.get(id);
2233
- const type = (newType === ORDER_TYPES.BUY || newType === ORDER_TYPES.SELL)
2234
- ? newType
2235
- : entry?.type;
2236
- if (entry)
2237
- book.delete(id);
2238
- const key = String(a.newGridId ?? id);
2239
- if (type === ORDER_TYPES.BUY || type === ORDER_TYPES.SELL) {
2240
- book.set(key, { type, price: newPrice });
2241
- }
2242
- }
2243
- }
2244
- else if (a.type === COW_ACTIONS.CREATE) {
2245
- const price = Number(a.order?.price);
2246
- const type = a.order?.type;
2247
- if (!Number.isFinite(price) || (type !== ORDER_TYPES.BUY && type !== ORDER_TYPES.SELL))
2248
- continue;
2249
- if (id)
2250
- book.set(id, { type, price });
2251
- }
2252
- }
2253
- let maxBuy = -Infinity;
2254
- let minSell = Infinity;
2255
- for (const { type, price } of book.values()) {
2256
- if (type === ORDER_TYPES.BUY && price > maxBuy)
2257
- maxBuy = price;
2258
- else if (type === ORDER_TYPES.SELL && price < minSell)
2259
- minSell = price;
2260
- }
2261
- if (Number.isFinite(maxBuy) && Number.isFinite(minSell) && minSell <= maxBuy) {
2262
- return `bestPlacedBuy=${maxBuy} >= bestPlacedSell=${minSell}`;
2263
- }
2264
- return null;
2265
- }
2266
- catch {
2267
- return null;
2268
- }
2269
- }
2270
2384
  /**
2271
2385
  * Derive an update action's planned target size (shared by the rotation and
2272
2386
  * plain size-update op builders).
@@ -2309,58 +2423,22 @@ function clampPostFillUpdateSize(bot, masterOrder, newSize, action) {
2309
2423
  }
2310
2424
  return target;
2311
2425
  }
2312
- async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2313
- const replanDepth = Number.isFinite(Number(options?.replanDepth)) ? Number(options.replanDepth) : 0;
2314
- bot._currentCycleId = (Number.isFinite(Number(bot._currentCycleId)) ? Number(bot._currentCycleId) : 0) + 1;
2315
- const { workingGrid, workingIndexes, workingBoundary, actions } = cowResult;
2316
- if (bot.config.dryRun) {
2317
- const cancelCount = actions.filter((a) => a.type === COW_ACTIONS.CANCEL).length;
2318
- const createCount = actions.filter((a) => a.type === COW_ACTIONS.CREATE).length;
2319
- const updateCount = actions.filter((a) => a.type === COW_ACTIONS.UPDATE).length;
2320
- if (cancelCount > 0)
2321
- bot.manager.logger.log(`Dry run: would cancel ${cancelCount} orders`, 'info');
2322
- if (createCount > 0)
2323
- bot.manager.logger.log(`Dry run: would place ${createCount} new orders`, 'info');
2324
- if (updateCount > 0)
2325
- bot.manager.logger.log(`Dry run: would update ${updateCount} orders`, 'info');
2326
- popPushedWorkingGrid(bot, cowResult);
2327
- return { executed: true, hadRotation: false };
2328
- }
2329
- // Single-flight COW broadcast guard (entry check): never broadcast two
2330
- // batches concurrently. Overlapping broadcasts plan from the same base
2331
- // grid version; when the first commits it bumps _gridVersion, so the
2332
- // second's commit is refused (base-version mismatch) -> adopt-from-chain
2333
- // -> snapshot reload that can drop the adopted order and produce an
2334
- // orphan fill. This entry wait is an optimization; the authoritative
2335
- // atomic check-and-set happens right before the broadcast below.
2336
- if (await waitForCowBroadcastSingleFlight(bot, 'entry')) {
2337
- popPushedWorkingGrid(bot, cowResult);
2338
- return { executed: false, aborted: true, reason: 'SHUTDOWN_IN_PROGRESS', hadRotation: false };
2339
- }
2340
- // DRAIN PENDING CORRECTIONS before the batch is planned/broadcast.
2341
- // Cancel-only corrections (duplicate-price orphans) queued by an earlier
2342
- // sync must not sit while batches run back-to-back (startup create
2343
- // groups, fill bursts) — with the open-orders sync loop disabled they
2344
- // would otherwise linger indefinitely, keep blocking same-level CREATEs,
2345
- // and risk cancelling the wrong side of a duplicate later. Draining here
2346
- // also keeps this batch's collision checks (chain_orphan_collision)
2347
- // honest: orphaned chain orders already queued for cancellation are
2348
- // resolved before the plan validates its CREATE targets against them.
2349
- const pendingCorrectionCount = Array.isArray(bot.manager?.ordersNeedingPriceCorrection)
2350
- ? bot.manager.ordersNeedingPriceCorrection.length
2351
- : 0;
2352
- if (pendingCorrectionCount > 0 && !bot._shuttingDown) {
2353
- try {
2354
- bot.manager.logger.log(`[COW] Draining ${pendingCorrectionCount} pending correction(s) before batch`, 'info');
2355
- const drainResult = await orderUtils.correctAllPriceMismatches(bot.manager, bot.account, bot.privateKey, chainOrders);
2356
- if (drainResult?.failed > 0) {
2357
- bot.manager.logger.log(`[COW] ${drainResult.failed} correction(s) failed pre-batch; remaining entries retry on next sync/maintenance tick`, 'warn');
2358
- }
2359
- }
2360
- catch (drainErr) {
2361
- bot.manager.logger.log(`[COW] Pre-batch correction drain failed: ${getErrorMessage(drainErr)}`, 'warn');
2362
- }
2363
- }
2426
+ /**
2427
+ * Pre-broadcast guard chain for a COW batch: create-slot validation (with
2428
+ * tolerance-violation filtering), recovery-exhausted block, pending-broadcast
2429
+ * and unmatched-chain-order guards (with adoption sync), and the crossed-book
2430
+ * gate. Every refusal pops the pushed working grid before returning.
2431
+ *
2432
+ * Mutates cowResult.actions in place (tolerance-violating CREATEs are
2433
+ * filtered); the caller holds the same array reference.
2434
+ *
2435
+ * @param {import('./dexbot_class.js').DEXBot} bot
2436
+ * @param {Object} cowResult - Rebalance/COW result carrying actions
2437
+ * @returns {Promise<Object>} { proceed:false, result } on refusal, or
2438
+ * { proceed:true, crossingCandidates, intraBatchCandidates } on pass
2439
+ */
2440
+ async function runPreBroadcastGuards(bot, cowResult) {
2441
+ const { actions } = cowResult;
2364
2442
  const chainOrderCandidates = Array.isArray(bot.manager?._lastUnmatchedChainOrders)
2365
2443
  ? bot.manager._lastUnmatchedChainOrders
2366
2444
  : [];
@@ -2378,6 +2456,12 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2378
2456
  case 'chain_orphan_collision':
2379
2457
  reason = `unmatched on-chain order ${violation.currentOrderId} at same price`;
2380
2458
  break;
2459
+ case 'same_batch_price_duplicate':
2460
+ reason = `another CREATE in the same batch at same broadcast price (duplicate of ${violation.duplicateOf})`;
2461
+ break;
2462
+ case 'create_price_invalid':
2463
+ reason = `broadcast price is not a finite number`;
2464
+ break;
2381
2465
  default:
2382
2466
  reason = `existing orderId=${violation.currentOrderId}`;
2383
2467
  }
@@ -2396,11 +2480,14 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2396
2480
  if (hasHardOccupiedViolation) {
2397
2481
  popPushedWorkingGrid(bot, cowResult);
2398
2482
  return {
2399
- executed: false,
2400
- aborted: true,
2401
- reason: 'CREATE_SLOT_OCCUPIED',
2402
- violations: createSlotValidation.violations,
2403
- hadRotation: false
2483
+ proceed: false,
2484
+ result: {
2485
+ executed: false,
2486
+ aborted: true,
2487
+ reason: 'CREATE_SLOT_OCCUPIED',
2488
+ violations: createSlotValidation.violations,
2489
+ hadRotation: false
2490
+ }
2404
2491
  };
2405
2492
  }
2406
2493
  const violatingIds = createSlotValidation.violatingTargetIds;
@@ -2422,7 +2509,7 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2422
2509
  // returns: a pushed working grid must be popped here or the
2423
2510
  // caller would leak the stack entry.
2424
2511
  popPushedWorkingGrid(bot, cowResult);
2425
- return { executed: false, hadRotation: false };
2512
+ return { proceed: false, result: { executed: false, hadRotation: false } };
2426
2513
  }
2427
2514
  }
2428
2515
  }
@@ -2434,27 +2521,24 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2434
2521
  `Waiting for next fill or sync cycle to reset recovery state.`, 'warn');
2435
2522
  popPushedWorkingGrid(bot, cowResult);
2436
2523
  return {
2437
- executed: false,
2438
- aborted: true,
2439
- reason: 'RECOVERY_EXHAUSTED',
2440
- hadRotation: false
2524
+ proceed: false,
2525
+ result: {
2526
+ executed: false,
2527
+ aborted: true,
2528
+ reason: 'RECOVERY_EXHAUSTED',
2529
+ hadRotation: false
2530
+ }
2441
2531
  };
2442
2532
  }
2443
2533
  const unmatchedChainOrders = Array.isArray(bot.manager?._lastUnmatchedChainOrders)
2444
2534
  ? bot.manager._lastUnmatchedChainOrders
2445
2535
  : [];
2446
- const pendingBroadcasts = (bot.manager && bot.manager._pendingBroadcasts instanceof Map)
2447
- ? Array.from(bot.manager._pendingBroadcasts.values())
2448
- : [];
2536
+ const pendingBroadcasts = getPendingBroadcasts(bot);
2449
2537
  if (hasCreateActions && (unmatchedChainOrders.length > 0 || pendingBroadcasts.length > 0)) {
2450
2538
  if (pendingBroadcasts.length > 0) {
2451
2539
  bot.manager.logger.log(`[COW] Rejecting CREATE batch: ${pendingBroadcasts.length} pending broadcast(s) from a prior uncertain ` +
2452
2540
  `broadcast. Running recovery before placing replacement orders.`, 'error');
2453
- if (typeof bot.manager.requestStructuralGridResync === 'function') {
2454
- if (bot.manager._recoveryState)
2455
- bot.manager._recoveryState = { ...bot.manager._recoveryState, structuralResyncRequested: true };
2456
- await bot.manager.requestStructuralGridResync('pending broadcasts before COW create', { pendingBroadcasts: pendingBroadcasts.map((p) => p.slotId) });
2457
- }
2541
+ await requestStructuralResync(bot, 'pending broadcasts before COW create', { pendingBroadcasts: pendingBroadcasts.map((p) => p.slotId) });
2458
2542
  try {
2459
2543
  await reconcileAfterUncertainBroadcast(bot, new BroadcastUncertainError('rejected CREATE batch had pending broadcasts', {
2460
2544
  operations: pendingBroadcasts.map((p) => p.order),
@@ -2469,10 +2553,13 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2469
2553
  }
2470
2554
  popPushedWorkingGrid(bot, cowResult);
2471
2555
  return {
2472
- executed: false,
2473
- aborted: true,
2474
- reason: 'PENDING_BROADCASTS',
2475
- hadRotation: false
2556
+ proceed: false,
2557
+ result: {
2558
+ executed: false,
2559
+ aborted: true,
2560
+ reason: 'PENDING_BROADCASTS',
2561
+ hadRotation: false
2562
+ }
2476
2563
  };
2477
2564
  }
2478
2565
  const unmatchedSample = unmatchedChainOrders
@@ -2489,8 +2576,8 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2489
2576
  // freshest orders; syncing on it would virtualize live slots and
2490
2577
  // re-create duplicates. Defer the adoption to a clean read — the
2491
2578
  // unmatched orders keep blocking CREATEs until then.
2492
- if (freshRead.truncated) {
2493
- bot.manager.logger.log('[COW] Post-guard chain snapshot TRUNCATED; skipping adoption sync (partial snapshot would virtualize live slots) — unmatched chain orders keep blocking CREATEs', 'warn');
2579
+ if (!isAuthoritativeChainRead(freshRead)) {
2580
+ bot.manager.logger.log('[COW] Post-guard chain snapshot not authoritative; skipping adoption sync (partial snapshot would virtualize live slots) — unmatched chain orders keep blocking CREATEs', 'warn');
2494
2581
  }
2495
2582
  else if (freshRead.orders && freshRead.orders.length > 0) {
2496
2583
  const freshSnapshot = freshRead.orders;
@@ -2530,18 +2617,17 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2530
2617
  catch (syncErr) {
2531
2618
  bot.manager.logger.log(`[COW] Failed to sync/unmatched orders: ${syncErr?.message || syncErr}`, 'warn');
2532
2619
  }
2533
- if (typeof bot.manager.requestStructuralGridResync === 'function') {
2534
- if (bot.manager._recoveryState)
2535
- bot.manager._recoveryState = { ...bot.manager._recoveryState, structuralResyncRequested: true };
2536
- await bot.manager.requestStructuralGridResync('unmatched chain orders before COW create', { unmatchedChainOrders: unmatchedChainOrders });
2537
- }
2620
+ await requestStructuralResync(bot, 'unmatched chain orders before COW create', { unmatchedChainOrders: unmatchedChainOrders });
2538
2621
  bot.manager.logger.log(`[COW] Rejecting CREATE batch after sync: working grid invalidated by master mutation`, 'info');
2539
2622
  popPushedWorkingGrid(bot, cowResult);
2540
2623
  return {
2541
- executed: false,
2542
- aborted: true,
2543
- reason: 'UNMATCHED_CHAIN_ORDERS',
2544
- hadRotation: false
2624
+ proceed: false,
2625
+ result: {
2626
+ executed: false,
2627
+ aborted: true,
2628
+ reason: 'UNMATCHED_CHAIN_ORDERS',
2629
+ hadRotation: false
2630
+ }
2545
2631
  };
2546
2632
  }
2547
2633
  // Crossing-check candidate set (master + pending-broadcast + unmatched
@@ -2549,7 +2635,6 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2549
2635
  // it reflects any sync they triggered.
2550
2636
  const crossingCandidates = buildCrossingCandidates(bot);
2551
2637
  const intraBatchCandidates = [];
2552
- const { assetA, assetB } = bot.manager.assets;
2553
2638
  // CROSSED-BOOK GATE: refuse to broadcast any batch whose simulated result
2554
2639
  // prices a BUY at-or-above a SELL (see detectCrossedBookPlan).
2555
2640
  const crossedBookDetail = detectCrossedBookPlan(bot.manager, actions);
@@ -2557,17 +2642,90 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2557
2642
  bot.manager.logger.log(`[COW] Rejecting batch pre-broadcast: crossed book detected (${crossedBookDetail})`, 'error');
2558
2643
  popPushedWorkingGrid(bot, cowResult);
2559
2644
  return {
2560
- executed: false,
2561
- aborted: true,
2562
- reason: 'CROSSED_BOOK',
2563
- detail: crossedBookDetail,
2564
- hadRotation: false
2645
+ proceed: false,
2646
+ result: {
2647
+ executed: false,
2648
+ aborted: true,
2649
+ reason: 'CROSSED_BOOK',
2650
+ detail: crossedBookDetail,
2651
+ hadRotation: false
2652
+ }
2565
2653
  };
2566
2654
  }
2655
+ return { proceed: true, crossingCandidates, intraBatchCandidates };
2656
+ }
2657
+ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2658
+ const replanDepth = Number.isFinite(Number(options?.replanDepth)) ? Number(options.replanDepth) : 0;
2659
+ bot._currentCycleId = (Number.isFinite(Number(bot._currentCycleId)) ? Number(bot._currentCycleId) : 0) + 1;
2660
+ const { workingGrid, workingIndexes, workingBoundary, actions } = cowResult;
2661
+ // Boundary-hold value: computed pre-broadcast after the skip-restore and
2662
+ // frozen for every downstream commit path (success + uncertain-catch).
2663
+ // workingBoundary itself stays untouched (audit trail).
2664
+ let effectiveBoundary = workingBoundary;
2665
+ if (bot.config.dryRun) {
2666
+ const cancelCount = actions.filter((a) => a.type === COW_ACTIONS.CANCEL).length;
2667
+ const createCount = actions.filter((a) => a.type === COW_ACTIONS.CREATE).length;
2668
+ const updateCount = actions.filter((a) => a.type === COW_ACTIONS.UPDATE).length;
2669
+ if (cancelCount > 0)
2670
+ bot.manager.logger.log(`Dry run: would cancel ${cancelCount} orders`, 'info');
2671
+ if (createCount > 0)
2672
+ bot.manager.logger.log(`Dry run: would place ${createCount} new orders`, 'info');
2673
+ if (updateCount > 0)
2674
+ bot.manager.logger.log(`Dry run: would update ${updateCount} orders`, 'info');
2675
+ popPushedWorkingGrid(bot, cowResult);
2676
+ return { executed: true, hadRotation: false };
2677
+ }
2678
+ // Single-flight COW broadcast guard (entry check): never broadcast two
2679
+ // batches concurrently. Overlapping broadcasts plan from the same base
2680
+ // grid version; when the first commits it bumps _gridVersion, so the
2681
+ // second's commit is refused (base-version mismatch) -> adopt-from-chain
2682
+ // -> snapshot reload that can drop the adopted order and produce an
2683
+ // orphan fill. This entry wait is an optimization; the authoritative
2684
+ // atomic check-and-set happens right before the broadcast below.
2685
+ if (await waitForCowBroadcastSingleFlight(bot, 'entry')) {
2686
+ popPushedWorkingGrid(bot, cowResult);
2687
+ return { executed: false, aborted: true, reason: 'SHUTDOWN_IN_PROGRESS', hadRotation: false };
2688
+ }
2689
+ // DRAIN PENDING CORRECTIONS before the batch is planned/broadcast.
2690
+ // Cancel-only corrections (duplicate-price orphans) queued by an earlier
2691
+ // sync must not sit while batches run back-to-back (startup create
2692
+ // groups, fill bursts) — with the open-orders sync loop disabled they
2693
+ // would otherwise linger indefinitely, keep blocking same-level CREATEs,
2694
+ // and risk cancelling the wrong side of a duplicate later. Draining here
2695
+ // also keeps this batch's collision checks (chain_orphan_collision)
2696
+ // honest: orphaned chain orders already queued for cancellation are
2697
+ // resolved before the plan validates its CREATE targets against them.
2698
+ const pendingCorrectionCount = Array.isArray(bot.manager?.ordersNeedingPriceCorrection)
2699
+ ? bot.manager.ordersNeedingPriceCorrection.length
2700
+ : 0;
2701
+ if (pendingCorrectionCount > 0 && !bot._shuttingDown) {
2702
+ try {
2703
+ bot.manager.logger.log(`[COW] Draining ${pendingCorrectionCount} pending correction(s) before batch`, 'info');
2704
+ const drainResult = await orderUtils.correctAllPriceMismatches(bot.manager, bot.account, bot.privateKey, chainOrders);
2705
+ if (drainResult?.failed > 0) {
2706
+ bot.manager.logger.log(`[COW] ${drainResult.failed} correction(s) failed pre-batch; remaining entries retry on next sync/maintenance tick`, 'warn');
2707
+ }
2708
+ }
2709
+ catch (drainErr) {
2710
+ bot.manager.logger.log(`[COW] Pre-batch correction drain failed: ${getErrorMessage(drainErr)}`, 'warn');
2711
+ }
2712
+ }
2713
+ // Pre-broadcast guard chain (create-slot validation, recovery-exhausted
2714
+ // block, pending/unmatched guards, crossed-book gate) — see
2715
+ // runPreBroadcastGuards. Refusals already popped the working grid.
2716
+ const guards = await runPreBroadcastGuards(bot, cowResult);
2717
+ if (!guards.proceed)
2718
+ return guards.result;
2719
+ const { crossingCandidates, intraBatchCandidates } = guards;
2720
+ const { assetA, assetB } = bot.manager.assets;
2567
2721
  const operations = [];
2568
2722
  const opContexts = [];
2569
2723
  const skippedUpdateSlotIds = new Set();
2570
2724
  let skippedUpdateCount = 0;
2725
+ // Guard-skipped CREATE slot ids (hole-refills never placed). Fed to the
2726
+ // boundary-hold intersect alongside the UPDATE sets above — a skipped
2727
+ // refill CREATE strands its rail hole exactly like a restored UPDATE.
2728
+ const skippedCreateSlotIds = new Set();
2571
2729
  // Per-batch LAST-FILL-GUARD disposition counters. Per-action pass lines
2572
2730
  // would spam big batches, so the guard emits one batch summary instead
2573
2731
  // (see the summary after the action loop below).
@@ -2643,6 +2801,8 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2643
2801
  const sizeValidation = validateOrderSizeForExecution(bot, order.size, order.type, order, order.size);
2644
2802
  if (!sizeValidation.isValid) {
2645
2803
  bot.manager.logger.log(`Skipping create op for ${action.id}: ${sizeValidation.reason}`, 'warn');
2804
+ if (action.id)
2805
+ skippedCreateSlotIds.add(action.id);
2646
2806
  continue;
2647
2807
  }
2648
2808
  const liveSlot = bot.manager.orders.get(order.id);
@@ -2667,6 +2827,8 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2667
2827
  `same-batch CREATE ${batchCollision.id} already at ` +
2668
2828
  `price ${Format.formatPrice6(batchCollision.order.price)}. ` +
2669
2829
  `The next reconcile cycle will resolve the mismatch.`, 'warn');
2830
+ if (order.id)
2831
+ skippedCreateSlotIds.add(order.id);
2670
2832
  continue;
2671
2833
  }
2672
2834
  // CROSSING-PLACEMENT GUARD (create variant): the batch-level
@@ -2678,16 +2840,15 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2678
2840
  // an opposite-side order cancelled in a later chunk would
2679
2841
  // otherwise coexist with this create mid-broadcast and
2680
2842
  // self-trade (production incident class).
2681
- const createCrossed = findCrossedOrder(crossingCandidates, createPrice, order.type, bot.manager.assets, (o) => {
2682
- const oid = o?.orderId || o?.chainOrderId;
2683
- return o && oid && !cancelOpIndexByOrderId.has(oid);
2684
- });
2843
+ const createCrossed = findCrossedOrder(crossingCandidates, createPrice, order.type, bot.manager.assets, (o) => orderUtils.isCrossingCheckCandidate(o, null, cancelOpIndexByOrderId));
2685
2844
  const intraBatchCrossed = createCrossed ? null : findCrossedOrder(intraBatchCandidates, createPrice, order.type, bot.manager.assets);
2686
2845
  const effectiveCrossed = createCrossed || intraBatchCrossed;
2687
2846
  if (effectiveCrossed) {
2688
2847
  bot.manager.logger.log(`[COW-CROSS-GUARD] Skipping CREATE for ${order.id} at ` +
2689
2848
  `${Format.formatPrice6(createPrice)}: crosses live ` +
2690
2849
  `${crossedOrderLabel(effectiveCrossed)}; re-planned after its cancel confirms.`, 'warn');
2850
+ if (order.id)
2851
+ skippedCreateSlotIds.add(order.id);
2691
2852
  continue;
2692
2853
  }
2693
2854
  // LAST-FILL PRICE GUARD: pivot ± halfIncrement (BUY < pivot*(1-half), SELL > pivot*(1+half)).
@@ -2700,20 +2861,15 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2700
2861
  const isCorrectionCreate = actionOrigin === 'spread-correction'
2701
2862
  || (actionOrigin == null && batchOrigin === 'spread-correction');
2702
2863
  if (!isCorrectionCreate) {
2703
- try {
2704
- if (refreshLastFillPivotFromQueue(bot))
2705
- lastFillGuardPivotRefreshed = true;
2706
- }
2707
- catch { /* best-effort */ }
2708
- const lastPrice = bot.manager?._lastFilledPrice;
2709
- const lastType = bot.manager?._lastFilledType;
2710
- const inc = resolveLastFillGuardIncrement(bot);
2711
- const check = isLastFillGuardBlocked(createPrice, order.size, order.type, lastPrice, lastType, inc);
2712
- lastFillGuardStats.checked++;
2864
+ const { check, refreshed } = runLastFillGuardCheck(bot, createPrice, order.size, order.type, lastFillGuardStats);
2865
+ if (refreshed)
2866
+ lastFillGuardPivotRefreshed = true;
2713
2867
  if (check.blocked) {
2714
2868
  lastFillGuardStats.skipped++;
2715
2869
  const dir = order.type === ORDER_TYPES.BUY ? 'above' : 'below';
2716
2870
  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');
2871
+ if (order.id)
2872
+ skippedCreateSlotIds.add(order.id);
2717
2873
  continue;
2718
2874
  }
2719
2875
  lastFillGuardStats.passed++;
@@ -2727,6 +2883,8 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2727
2883
  const buildResult = await chainOrders.buildCreateOrderOp(bot.account, args.amountToSell, args.sellAssetId, args.minToReceive, args.receiveAssetId, null);
2728
2884
  if (!buildResult) {
2729
2885
  bot.manager.logger.log(`Skipping create op for ${action.id}: amounts would round to 0 on blockchain`, 'warn');
2886
+ if (action.id)
2887
+ skippedCreateSlotIds.add(action.id);
2730
2888
  continue;
2731
2889
  }
2732
2890
  operations.push(buildResult.op);
@@ -2795,13 +2953,7 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2795
2953
  // fatal fund assert). Skipping is safe: the slot keeps its
2796
2954
  // old commitment and the next plan re-evaluates once
2797
2955
  // the crossed order's cancel confirms.
2798
- const crossedOrder = findCrossedOrder([...crossingCandidates, ...intraBatchCandidates], newPrice, orderType, bot.manager.assets, (o) => {
2799
- const oid = o?.orderId || o?.chainOrderId;
2800
- return o
2801
- && oid
2802
- && oid !== action.orderId
2803
- && !cancelOpIndexByOrderId.has(oid);
2804
- });
2956
+ const crossedOrder = findCrossedOrder([...crossingCandidates, ...intraBatchCandidates], newPrice, orderType, bot.manager.assets, (o) => orderUtils.isCrossingCheckCandidate(o, action.orderId, cancelOpIndexByOrderId));
2805
2957
  if (crossedOrder) {
2806
2958
  skippedUpdateCount++;
2807
2959
  if (action.id)
@@ -2855,6 +3007,32 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2855
3007
  `boundary ${bot.manager?.boundaryIdx}/gap ${bot.manager?._gapSlots} — re-proving live`, 'warn');
2856
3008
  }
2857
3009
  }
3010
+ if (stampUsable) {
3011
+ // Stamped-size re-proof: the stamp proved the
3012
+ // plan-time size, but an unprocessed fill can
3013
+ // land between plan-build and execution and
3014
+ // shrink the booked remaining below the
3015
+ // planned size (the PARTIAL-only growth guard
3016
+ // above misses it when the slot is not yet
3017
+ // PARTIAL). Re-prove against the LIVE master
3018
+ // size; invalidating the stamp routes into
3019
+ // the unstamped probe below, whose
3020
+ // isEvacuationRotationAllowed also rejects
3021
+ // growth and then falls through to the
3022
+ // normal last-fill guard.
3023
+ let stampPrecision = null;
3024
+ try {
3025
+ stampPrecision = getPrecisionByOrderType(bot.manager.assets, orderType);
3026
+ }
3027
+ catch {
3028
+ stampPrecision = null;
3029
+ }
3030
+ if (!isEvacuationSizeStillValid(newSize, Number(masterOrder?.size), stampPrecision)) {
3031
+ stampUsable = false;
3032
+ bot.manager.logger.log(`[LAST-FILL-GUARD] Stamped evacuation for ${action.id} -> ${action.newGridId} lost its size cover: ` +
3033
+ `planned ${Format.formatAmount(newSize)} exceeds booked remaining ${Format.formatAmount(Number(masterOrder?.size))} — re-proving live`, 'warn');
3034
+ }
3035
+ }
2858
3036
  if (stampUsable) {
2859
3037
  bypassedEvacuation = true;
2860
3038
  bot.manager.logger.log(`[LAST-FILL-GUARD] Allowing ${orderType} evacuation UPDATE for ${action.id} -> ${action.newGridId} at ${Format.formatPrice6(newPrice)}: ` +
@@ -2909,16 +3087,9 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2909
3087
  lastFillGuardStats.bypassed++;
2910
3088
  }
2911
3089
  else {
2912
- try {
2913
- if (refreshLastFillPivotFromQueue(bot))
2914
- lastFillGuardPivotRefreshed = true;
2915
- }
2916
- catch { /* best-effort */ }
2917
- const lastPrice = bot.manager?._lastFilledPrice;
2918
- const lastType = bot.manager?._lastFilledType;
2919
- const inc = resolveLastFillGuardIncrement(bot);
2920
- const check = isLastFillGuardBlocked(newPrice, newSize, orderType, lastPrice, lastType, inc);
2921
- lastFillGuardStats.checked++;
3090
+ const { check, refreshed } = runLastFillGuardCheck(bot, newPrice, newSize, orderType, lastFillGuardStats);
3091
+ if (refreshed)
3092
+ lastFillGuardPivotRefreshed = true;
2922
3093
  if (check.blocked) {
2923
3094
  lastFillGuardStats.skipped++;
2924
3095
  skippedUpdateCount++;
@@ -3034,10 +3205,7 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
3034
3205
  // replaces — no crossing of an opposite-side
3035
3206
  // live order whose cancel is not already
3036
3207
  // queued at an earlier op position.
3037
- const fbCrossed = findCrossedOrder(crossingCandidates, fbPrice, fbType, bot.manager.assets, (o) => {
3038
- const oid = o?.orderId || o?.chainOrderId;
3039
- return o && oid && !cancelOpIndexByOrderId.has(oid);
3040
- });
3208
+ const fbCrossed = findCrossedOrder(crossingCandidates, fbPrice, fbType, bot.manager.assets, (o) => orderUtils.isCrossingCheckCandidate(o, null, cancelOpIndexByOrderId));
3041
3209
  if (fbCrossed) {
3042
3210
  bot.manager.logger.log(`[COW-CROSS-GUARD] Skipping CREATE fallback for ${targetSlotId} at ` +
3043
3211
  `${Format.formatPrice6(fbPrice)}: new ${fbType} crosses live ` +
@@ -3049,14 +3217,9 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
3049
3217
  // so it obeys the same guard with a refreshed
3050
3218
  // pivot — no origin bypass, same as rotations.
3051
3219
  try {
3052
- if (refreshLastFillPivotFromQueue(bot))
3220
+ const { check: fbCheck, refreshed: fbRefreshed } = runLastFillGuardCheck(bot, fbPrice, fbSize, fbType, lastFillGuardStats);
3221
+ if (fbRefreshed)
3053
3222
  lastFillGuardPivotRefreshed = true;
3054
- }
3055
- catch { /* best-effort */ }
3056
- try {
3057
- const fbInc = resolveLastFillGuardIncrement(bot);
3058
- const fbCheck = isLastFillGuardBlocked(fbPrice, fbSize, fbType, bot.manager?._lastFilledPrice, bot.manager?._lastFilledType, fbInc);
3059
- lastFillGuardStats.checked++;
3060
3223
  if (fbCheck.blocked) {
3061
3224
  lastFillGuardStats.skipped++;
3062
3225
  const fbDir = fbType === ORDER_TYPES.BUY ? 'above' : 'below';
@@ -3144,6 +3307,15 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
3144
3307
  }
3145
3308
  bot.manager.logger.log(`[COW] Re-synced ${clampedUpdateSlotIds.size} post-fill-clamped slot(s) from master before commit`, 'debug');
3146
3309
  }
3310
+ // BOUNDARY HOLD: skipped refill slots strand empty rail holes past the
3311
+ // planned boundary (commit gate skips empties, self-legalizing) — hold
3312
+ // the committed boundary; intersect-only, grid still commits.
3313
+ const refillHold = resolveRefillBoundaryHold(workingBoundary, bot.manager.boundaryIdx, skippedUpdateSlotIds, clampedUpdateSlotIds, cowResult?.refillSlotIds, skippedCreateSlotIds);
3314
+ effectiveBoundary = refillHold.effectiveBoundary;
3315
+ if (refillHold.heldRefillSlotIds.length > 0) {
3316
+ bot.manager.logger.log(`[COW] Boundary hold: ${refillHold.heldRefillSlotIds.length} refill slot(s) skipped ` +
3317
+ `(${refillHold.heldRefillSlotIds.join(', ')}) — keeping ${bot.manager.boundaryIdx} over planned ${workingBoundary}`, 'warn');
3318
+ }
3147
3319
  if (operations.length === 0) {
3148
3320
  // Pop the working grid: in the re-plan recursion the fresh plan's
3149
3321
  // grid was pushed by performSafeRebalance, and nothing downstream
@@ -3239,14 +3411,7 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
3239
3411
  sell: item.ctx?.finalInts?.sell,
3240
3412
  receive: item.ctx?.finalInts?.receive,
3241
3413
  orderType: item.ctx?.order?.type,
3242
- fingerprint: buildCreateOpFingerprint({
3243
- side: item.ctx?.order?.type,
3244
- assetA: bot.manager?.assets?.assetA?.id,
3245
- assetB: bot.manager?.assets?.assetB?.id,
3246
- sellInt: item.ctx?.finalInts?.sell,
3247
- receiveInt: item.ctx?.finalInts?.receive,
3248
- slotId
3249
- })
3414
+ fingerprint: createOpFingerprintForSlot(bot, item.ctx?.order, item.ctx?.finalInts, slotId)
3250
3415
  });
3251
3416
  if (match?.id) {
3252
3417
  workingGrid.set(slotId, { ...slot, orderId: match.id });
@@ -3289,29 +3454,17 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
3289
3454
  // options.result, so a later throw in this frame (e.g.
3290
3455
  // processBatchResults after a successful commit) cannot pop a
3291
3456
  // second time for the same grid in the batch catch below.
3292
- const commitOk = await bot.manager._commitWorkingGrid(workingGrid, workingIndexes, workingBoundary, { skipRecalc: true, result: cowResult });
3457
+ const commitOk = await bot.manager._commitWorkingGrid(workingGrid, workingIndexes, effectiveBoundary, { skipRecalc: true, result: cowResult });
3293
3458
  if (!commitOk) {
3294
3459
  // Master changed during broadcast (e.g. a fill landed and was
3295
3460
  // processed concurrently) so the commit was refused. The batch
3296
3461
  // is on chain; adopt the placed orders from the chain so master
3297
3462
  // converges instead of remaining divergent until a later sync.
3298
- bot.manager.logger.log('[COW] Commit refused after broadcast; adopting placed orders from chain to keep master in sync', 'warn');
3299
- const adopted = await adoptPlacedBatchFromChain(bot, chainOrders, '[COW]', { placedResults: result, placedContexts: executedContexts });
3300
- if (!adopted) {
3301
- // Chain state unknown (empty/lagging read or sync failure):
3302
- // keep the pending-broadcast protection so a later plan
3303
- // cannot duplicate the placed orders, and let the structural
3304
- // resync adopt them from the chain.
3305
- bot.manager.logger.log('[COW] Commit refused and chain adoption unavailable; keeping pending-broadcast protection pending structural resync', 'error');
3306
- await requestStructuralResync(bot, 'commit refused after broadcast (chain adoption unavailable)', { reason: 'chain-adoption-unavailable' });
3307
- return { executed: false, hadRotation: false, commitRefused: true, chainAdoptionPending: true };
3308
- }
3309
- // Deduct create fees for the placed orders (mirrors
3310
- // processBatchResults, which the refused path bypasses).
3311
- await applyAdoptionFeeAccounting(bot, executedContexts);
3312
- await restoreBoundaryAfterAdoption(bot, workingBoundary);
3313
- await persistGridAndClearPendingBroadcasts(bot, '[COW]');
3314
- return { executed: false, hadRotation: false, commitRefused: true };
3463
+ return await recoverRefusedCommit(bot, chainOrders, '[COW]', { placedResults: result, placedContexts: executedContexts }, executedContexts, effectiveBoundary, {
3464
+ failureResyncReason: 'commit refused after broadcast (chain adoption unavailable)',
3465
+ failureLogMessage: 'Commit refused and chain adoption unavailable; keeping pending-broadcast protection pending structural resync',
3466
+ preAdoptLogMessage: 'Commit refused after broadcast; adopting placed orders from chain to keep master in sync',
3467
+ });
3315
3468
  }
3316
3469
  const batchResult = await processBatchResults(bot, result, executedContexts);
3317
3470
  const persistResult = await bot.manager.persistGrid();
@@ -3326,10 +3479,7 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
3326
3479
  bot.manager.logger.log(`[COW][PERSIST-GUARD] Retry also skipped/invalid ` +
3327
3480
  `(${retryResult.reason || 'no reason'}). Master grid in memory ` +
3328
3481
  `is ahead of disk snapshot; structural resync requested.`, 'error');
3329
- if (typeof bot.manager.requestStructuralGridResync === 'function') {
3330
- bot.manager._recoveryState = { ...bot.manager._recoveryState, structuralResyncRequested: true };
3331
- await bot.manager.requestStructuralGridResync('persistence guard triggered after COW batch', { persistReason: retryResult.reason || 'unknown' });
3332
- }
3482
+ await requestStructuralResync(bot, 'persistence guard triggered after COW batch', { persistReason: retryResult.reason || 'unknown' });
3333
3483
  }
3334
3484
  else {
3335
3485
  delete bot.manager._persistenceWarning;
@@ -3394,22 +3544,17 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
3394
3544
  // _commitWorkingGrid pops on every settle path and clears
3395
3545
  // the push marker via options.result, so a later throw here
3396
3546
  // must not pop again in the batch catch below.
3397
- const pollCommitOk = await bot.manager._commitWorkingGrid(workingGrid, workingIndexes, workingBoundary, { skipRecalc: true, result: cowResult });
3547
+ const pollCommitOk = await bot.manager._commitWorkingGrid(workingGrid, workingIndexes, effectiveBoundary, { skipRecalc: true, result: cowResult });
3398
3548
  if (!pollCommitOk) {
3399
3549
  // Master moved while polling — same recovery as the
3400
3550
  // refused-commit path: adopt from chain, keep pending
3401
3551
  // protection if adoption is unavailable.
3402
- bot.manager.logger.log(`[COW][UNCERTAIN] Poll-confirmed commit refused; adopting placed orders from chain`, 'warn');
3403
- const pollAdopted = await adoptPlacedBatchFromChain(bot, chainOrders, '[COW][UNCERTAIN]');
3404
- if (!pollAdopted) {
3405
- bot.manager.logger.log('[COW][UNCERTAIN] Poll-refused commit with unavailable chain adoption; keeping pending protection pending structural resync', 'error');
3406
- await requestStructuralResync(bot, 'poll-confirmed commit refused (chain adoption unavailable)', { reason: 'chain-adoption-unavailable' });
3407
- return { executed: false, hadRotation: false, commitRefused: true, chainAdoptionPending: true };
3408
- }
3409
- await applyAdoptionFeeAccounting(bot, opContexts);
3410
- await restoreBoundaryAfterAdoption(bot, workingBoundary);
3411
- await persistGridAndClearPendingBroadcasts(bot, '[COW][UNCERTAIN]');
3412
- return { executed: false, hadRotation: false, commitRefused: true, uncertainResolved: true };
3552
+ return await recoverRefusedCommit(bot, chainOrders, '[COW][UNCERTAIN]', { placedContexts: opContexts, polledCreateIds: confirmation.confirmedChainIds }, opContexts, effectiveBoundary, {
3553
+ successReturn: { executed: false, hadRotation: false, commitRefused: true, uncertainResolved: true },
3554
+ failureResyncReason: 'poll-confirmed commit refused (chain adoption unavailable)',
3555
+ failureLogMessage: 'Poll-refused commit with unavailable chain adoption; keeping pending protection pending structural resync',
3556
+ preAdoptLogMessage: 'Poll-confirmed commit refused; adopting placed orders from chain',
3557
+ });
3413
3558
  }
3414
3559
  // Enrich master grid with chain-assigned order IDs and amounts;
3415
3560
  // accounting enabled so the adopted orders' capital is locked
@@ -3420,19 +3565,16 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
3420
3565
  // on a failed adoption would let the next cycle re-create the
3421
3566
  // VIRTUAL slots as duplicate on-chain orders. Keep the
3422
3567
  // protection and defer to a structural resync instead.
3423
- const pollAdoptedOk = await adoptPlacedBatchFromChain(bot, chainOrders, '[COW][UNCERTAIN]');
3424
- if (!pollAdoptedOk) {
3425
- bot.manager.logger.log('[COW][UNCERTAIN] Poll-confirmed commit with unavailable chain adoption; keeping pending protection pending structural resync', 'error');
3426
- await requestStructuralResync(bot, 'poll-confirmed commit (chain adoption unavailable)', { reason: 'chain-adoption-unavailable' });
3427
- return { executed: false, hadRotation: false, commitRefused: false, chainAdoptionPending: true };
3428
- }
3429
3568
  // The commit happened without processBatchResults (no success
3430
3569
  // result to extract); deduct create fees so the optimistic
3431
- // balance reflects the on-chain cost.
3432
- await applyAdoptionFeeAccounting(bot, opContexts);
3433
- await restoreBoundaryAfterAdoption(bot, workingBoundary);
3434
- await persistGridAndClearPendingBroadcasts(bot, '[COW][UNCERTAIN]');
3435
- return { executed: true, hadRotation: false, uncertainResolved: true };
3570
+ // balance reflects the on-chain cost (shared with the
3571
+ // refused-commit paths via recoverRefusedCommit).
3572
+ return await recoverRefusedCommit(bot, chainOrders, '[COW][UNCERTAIN]', { placedContexts: opContexts, polledCreateIds: confirmation.confirmedChainIds }, opContexts, effectiveBoundary, {
3573
+ extraReturn: { commitRefused: false },
3574
+ successReturn: { executed: true, hadRotation: false, uncertainResolved: true },
3575
+ failureResyncReason: 'poll-confirmed commit (chain adoption unavailable)',
3576
+ failureLogMessage: 'Poll-confirmed commit with unavailable chain adoption; keeping pending protection pending structural resync',
3577
+ });
3436
3578
  }
3437
3579
  }
3438
3580
  catch (pollErr) {
@@ -3486,8 +3628,10 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
3486
3628
  * @param {Object} [details={}] - Details passed to the resync handler
3487
3629
  */
3488
3630
  async function requestStructuralResync(bot, reason, details = {}) {
3489
- if (typeof bot.manager?.requestStructuralGridResync !== 'function')
3631
+ if (typeof bot.manager?.requestStructuralGridResync !== 'function') {
3632
+ bot._warn?.(`[COW] requestStructuralGridResync unavailable; cannot schedule structural resync (reason: ${reason}).`);
3490
3633
  return;
3634
+ }
3491
3635
  if (bot.manager._recoveryState) {
3492
3636
  bot.manager._recoveryState = { ...bot.manager._recoveryState, structuralResyncRequested: true };
3493
3637
  }
@@ -3507,49 +3651,6 @@ async function requestStructuralResync(bot, reason, details = {}) {
3507
3651
  * @param {string} logPrefix - Log prefix for sync failure messages
3508
3652
  * @returns {Promise<boolean>}
3509
3653
  */
3510
- /**
3511
- * Collect every on-chain order id master currently needs to converge against,
3512
- * so adoption can re-read them by id (immune to the get_full_accounts window
3513
- * truncation) instead of relying on a partial window read.
3514
- *
3515
- * Sources:
3516
- * - master's own tracked order ids (existing on-chain orders);
3517
- * - the batch's fresh CREATE ids extracted from the broadcast result
3518
- * (operation_results[i][1] aligns positionally with placedContexts[i]).
3519
- *
3520
- * @param {any} mgr - bot.manager
3521
- * @param {any} placedResults - broadcast result (has operation_results); null when unavailable
3522
- * @param {any[]} placedContexts - opContexts (aligned with operation_results); null when unavailable
3523
- * @returns {string[]} Unique, well-formed 1.7.x order ids
3524
- */
3525
- function collectKnownOnChainOrderIds(mgr, placedResults, placedContexts) {
3526
- const masterIds = new Set();
3527
- const grid = mgr && mgr.grid;
3528
- if (Array.isArray(grid)) {
3529
- for (const slot of grid) {
3530
- if (slot && slot.orderId && /^1\.7\.\d+$/.test(String(slot.orderId))) {
3531
- masterIds.add(String(slot.orderId));
3532
- }
3533
- }
3534
- }
3535
- const createIds = new Set();
3536
- if (placedResults && Array.isArray(placedContexts)) {
3537
- const opResults = extractBatchOperationResults(placedResults);
3538
- if (Array.isArray(opResults)) {
3539
- for (let i = 0; i < placedContexts.length; i++) {
3540
- const ctx = placedContexts[i];
3541
- if (!ctx || ctx.kind !== 'create')
3542
- continue;
3543
- const opResult = opResults[i] && opResults[i][1];
3544
- if (opResult && /^1\.7\.\d+$/.test(String(opResult))) {
3545
- createIds.add(String(opResult));
3546
- }
3547
- }
3548
- }
3549
- }
3550
- const all = new Set([...masterIds, ...createIds]);
3551
- return { masterIds: [...masterIds], createIds: [...createIds], all: [...all] };
3552
- }
3553
3654
  /**
3554
3655
  * Converge master with the chain after a COW commit was refused (master moved
3555
3656
  * during broadcast) or an uncertain broadcast was poll-confirmed.
@@ -3573,6 +3674,10 @@ function collectKnownOnChainOrderIds(mgr, placedResults, placedContexts) {
3573
3674
  * @param {Object} [opts]
3574
3675
  * @param {any} [opts.placedResults] - broadcast result carrying operation_results
3575
3676
  * @param {any[]} [opts.placedContexts] - opContexts aligned with operation_results
3677
+ * @param {string[]} [opts.polledCreateIds] - fresh CREATE chain ids confirmed by
3678
+ * the uncertain-broadcast poll (no broadcast result exists on that path);
3679
+ * routes the poll call sites through the by-id path with its lagging-create
3680
+ * retry instead of the unguarded window fallback
3576
3681
  * @returns {Promise<boolean>} true if master was adopted from the chain
3577
3682
  */
3578
3683
  /**
@@ -3605,16 +3710,20 @@ async function restoreBoundaryAfterAdoption(bot, workingBoundary) {
3605
3710
  }
3606
3711
  }
3607
3712
  async function adoptPlacedBatchFromChain(bot, chainOrders, logPrefix, opts = {}) {
3608
- const { placedResults = null, placedContexts = null } = opts || {};
3713
+ const { placedResults = null, placedContexts = null, polledCreateIds = null } = opts || {};
3609
3714
  try {
3610
3715
  const mgr = bot.manager;
3611
3716
  const accountRef = bot.accountId || bot.account?.id || bot.account;
3612
- // PREFERRED: re-read the exact placed/existing orders by id. Only when we
3613
- // have the broadcast result (so the set includes the freshest CREATE ids);
3614
- // without it the by-id set would be incomplete and would wrongly sync
3615
- // master against a partial picture.
3616
- const { all: knownIds, createIds } = placedResults
3617
- ? collectKnownOnChainOrderIds(mgr, placedResults, placedContexts)
3717
+ // PREFERRED: re-read the exact placed/existing orders by id. The by-id
3718
+ // set needs the broadcast result (freshest CREATE ids), the
3719
+ // poll-confirmed CREATE ids, or at minimum the contexts referencing
3720
+ // existing chain orders — without any id hints the set would be
3721
+ // incomplete and would wrongly sync master against a partial picture.
3722
+ const haveIdHints = Boolean(placedResults)
3723
+ || (Array.isArray(placedContexts) && placedContexts.length > 0)
3724
+ || (Array.isArray(polledCreateIds) && polledCreateIds.length > 0);
3725
+ const { all: knownIds, createIds } = haveIdHints
3726
+ ? collectKnownOnChainOrderIds(mgr, placedResults, placedContexts, polledCreateIds)
3618
3727
  : { all: [], createIds: [] };
3619
3728
  if (knownIds.length > 0 && typeof chainOrders.batchReadOrders === 'function') {
3620
3729
  // Retry/backoff (fix #6): a fresh CREATE absent from the first read
@@ -3694,8 +3803,8 @@ async function adoptPlacedBatchFromChain(bot, chainOrders, logPrefix, opts = {})
3694
3803
  }
3695
3804
  // FALLBACK: window read (ambiguous when truncated).
3696
3805
  const freshRead = await readOpenOrdersWithMetaSafe(chainOrders, accountRef);
3697
- if (freshRead.truncated) {
3698
- bot.manager.logger.log(`${logPrefix} Chain read TRUNCATED after batch broadcast; adoption deferred (pending-broadcast protection kept)`, 'warn');
3806
+ if (!isAuthoritativeChainRead(freshRead)) {
3807
+ bot.manager.logger.log(`${logPrefix} Chain read ${freshRead?.truncated ? 'TRUNCATED' : 'EMPTY'} after batch broadcast; adoption deferred (pending-broadcast protection kept)`, 'warn');
3699
3808
  return false;
3700
3809
  }
3701
3810
  const freshChain = freshRead.orders;
@@ -3725,6 +3834,75 @@ async function persistGridAndClearPendingBroadcasts(bot, logPrefix) {
3725
3834
  }
3726
3835
  clearPendingBroadcasts(bot.manager?._pendingBroadcasts);
3727
3836
  }
3837
+ /**
3838
+ * Recover from a refused commit after a COW batch broadcast.
3839
+ * Adopts placed orders from the chain, applies fee accounting, restores
3840
+ * the boundary, and persists. All three commit-refused paths
3841
+ * (success-path refused, poll-refused, and poll-confirmed uncertain)
3842
+ * share this exact sequence.
3843
+ * @param {import('./dexbot_class.js').DEXBot} bot
3844
+ * @param {Object} chainOrders - Chain orders module
3845
+ * @param {string} logPrefix - Log prefix for messages
3846
+ * @param {Object} adoptOpts - Options passed to adoptPlacedBatchFromChain
3847
+ * @param {Array} contexts - Op contexts for fee accounting
3848
+ * @param {Object} workingBoundary - The working boundary
3849
+ * @param {Object} [opts={}] - Named options (object form: adjacent string
3850
+ * options were positional before, a transposition would compile silently).
3851
+ * @param {Object} [opts.extraReturn={}] - Extra fields merged into the failure return object
3852
+ * @param {Object|null} [opts.successReturn=null] - Return object on successful adoption
3853
+ * (defaults to the commit-refused shape; the poll-confirmed path passes its
3854
+ * executed:true shape)
3855
+ * @param {string} [opts.failureResyncReason='commit refused after broadcast (chain adoption unavailable)']
3856
+ * @param {string} [opts.failureLogMessage='Commit refused and chain adoption unavailable; keeping pending-broadcast protection pending structural resync']
3857
+ * - Failure-path log body (prefixed with logPrefix); each call site passes
3858
+ * its original wording so log greps keep matching.
3859
+ * @param {string|null} [opts.preAdoptLogMessage=null] - Optional warn logged before
3860
+ * the adoption attempt (the success-path and poll-refused sites log one;
3861
+ * the poll-confirmed site never did)
3862
+ * @returns {Promise<Object>} The commit-refused return object
3863
+ */
3864
+ async function recoverRefusedCommit(bot, chainOrders, logPrefix, adoptOpts, contexts, workingBoundary, opts = {}) {
3865
+ const { extraReturn = {}, successReturn = null, failureResyncReason = 'commit refused after broadcast (chain adoption unavailable)', failureLogMessage = 'Commit refused and chain adoption unavailable; keeping pending-broadcast protection pending structural resync', preAdoptLogMessage = null, } = opts;
3866
+ if (preAdoptLogMessage) {
3867
+ bot.manager.logger.log(`${logPrefix} ${preAdoptLogMessage}`, 'warn');
3868
+ }
3869
+ const adopted = await adoptPlacedBatchFromChain(bot, chainOrders, logPrefix, adoptOpts);
3870
+ if (!adopted) {
3871
+ bot.manager.logger.log(`${logPrefix} ${failureLogMessage}`, 'error');
3872
+ await requestStructuralResync(bot, failureResyncReason, { reason: 'chain-adoption-unavailable' });
3873
+ return { executed: false, hadRotation: false, commitRefused: true, chainAdoptionPending: true, ...extraReturn };
3874
+ }
3875
+ await applyAdoptionFeeAccounting(bot, contexts);
3876
+ await restoreBoundaryAfterAdoption(bot, workingBoundary);
3877
+ await persistGridAndClearPendingBroadcasts(bot, logPrefix);
3878
+ return successReturn ?? { executed: false, hadRotation: false, commitRefused: true, ...extraReturn };
3879
+ }
3880
+ /**
3881
+ * Run the last-fill guard probe: refresh the pivot from still-queued fills,
3882
+ * read the durable pivot, and evaluate isLastFillGuardBlocked. Consolidates
3883
+ * the identical probe pattern in the CREATE, UPDATE-rotation, and
3884
+ * CREATE-fallback guards (origin bypasses and skip logging stay at the
3885
+ * call sites, which differ per action kind).
3886
+ * @param {Object} bot
3887
+ * @param {number} price - Target order price
3888
+ * @param {number} size - Order size
3889
+ * @param {string} type - ORDER_TYPES.BUY/SELL
3890
+ * @param {Object} stats - lastFillGuardStats tracker (checked++ here)
3891
+ * @returns {{check: Object, refreshed: boolean}}
3892
+ */
3893
+ function runLastFillGuardCheck(bot, price, size, type, stats) {
3894
+ let refreshed = false;
3895
+ try {
3896
+ refreshed = !!refreshLastFillPivotFromQueue(bot);
3897
+ }
3898
+ catch { /* best-effort */ }
3899
+ const lastPrice = bot.manager?._lastFilledPrice;
3900
+ const lastType = bot.manager?._lastFilledType;
3901
+ const inc = resolveLastFillGuardIncrement(bot);
3902
+ const check = isLastFillGuardBlocked(price, size, type, lastPrice, lastType, inc);
3903
+ stats.checked++;
3904
+ return { check, refreshed };
3905
+ }
3728
3906
  /**
3729
3907
  * Apply BTS create-fee accounting for a batch that bypassed the normal
3730
3908
  * processBatchResults pipeline (commit refused after broadcast, or
@@ -3761,6 +3939,7 @@ async function applyAdoptionFeeAccounting(bot, contexts) {
3761
3939
  isPartialPlacement: false,
3762
3940
  expectedType: ctx.order.type,
3763
3941
  fee: btsFeeData?.createFee || 0,
3942
+ order: ctx.order ?? null,
3764
3943
  }, 'createOrder');
3765
3944
  }
3766
3945
  catch (feeErr) {
@@ -3800,6 +3979,22 @@ async function applyAdoptionFeeAccounting(bot, contexts) {
3800
3979
  }
3801
3980
  }
3802
3981
  }
3982
+ /**
3983
+ * Table-driven optimistic fee call for processBatchResults: the cancel,
3984
+ * size-update, and both rotation branches all invoke
3985
+ * updateOptimisticFreeBalance(oldOrder, newOrder, context, fee, false)
3986
+ * under the same accountant guard — one spelling instead of four.
3987
+ * @param {import('./dexbot_class.js').DEXBot} bot
3988
+ * @param {Object} oldOrder
3989
+ * @param {Object} newOrder
3990
+ * @param {string} context - Fee context ('fill-cancel' | 'order-update')
3991
+ * @param {number} fee - BTS fee amount
3992
+ */
3993
+ async function applyOptimisticFeeBalance(bot, oldOrder, newOrder, context, fee) {
3994
+ if (oldOrder && newOrder && bot.manager.accountant) {
3995
+ await bot.manager.accountant.updateOptimisticFreeBalance(oldOrder, newOrder, context, fee || 0, false);
3996
+ }
3997
+ }
3803
3998
  /**
3804
3999
  * Process results from batch transaction execution.
3805
4000
  * Updates order state, synchronizes with chain, and deducts BTS fees.
@@ -3827,27 +4022,16 @@ async function processBatchResults(bot, result, opContexts) {
3827
4022
  bot.manager.logger.log(`Cancelled surplus order ${ctx.order.id} (${ctx.order.orderId})`, 'info');
3828
4023
  const oldOrder = ctx.order;
3829
4024
  const committedOrder = oldOrder?.id ? bot.manager.orders.get(oldOrder.id) : null;
3830
- if (oldOrder && committedOrder && bot.manager.accountant) {
3831
- await bot.manager.accountant.updateOptimisticFreeBalance(oldOrder, committedOrder, 'fill-cancel', btsFeeData?.cancelFee || 0, false);
3832
- }
4025
+ await applyOptimisticFeeBalance(bot, oldOrder, committedOrder, 'fill-cancel', btsFeeData?.cancelFee || 0);
3833
4026
  }
3834
4027
  else if (ctx.kind === 'size-update') {
3835
4028
  const oldOrder = ctx.updateInfo.partialOrder;
3836
4029
  const ord = bot.manager.orders.get(oldOrder.id);
3837
- if (oldOrder && ord && bot.manager.accountant) {
3838
- await bot.manager.accountant.updateOptimisticFreeBalance(oldOrder, ord, 'order-update', btsFeeData?.updateFee || 0, false);
3839
- }
4030
+ await applyOptimisticFeeBalance(bot, oldOrder, ord, 'order-update', btsFeeData?.updateFee || 0);
3840
4031
  if (ord) {
3841
4032
  const updatedSlot = { ...ord, size: ctx.updateInfo.newSize };
3842
4033
  if (ctx.finalInts) {
3843
- updatedSlot.rawOnChain = {
3844
- id: ord.orderId,
3845
- for_sale: String(ctx.finalInts.sell),
3846
- sell_price: {
3847
- base: { amount: String(ctx.finalInts.sell), asset_id: ctx.finalInts.sellAssetId },
3848
- quote: { amount: String(ctx.finalInts.receive), asset_id: ctx.finalInts.receiveAssetId }
3849
- }
3850
- };
4034
+ updatedSlot.rawOnChain = rawOnChainFromInts(ord.orderId, ctx.finalInts);
3851
4035
  }
3852
4036
  updatesToApply.push({ order: updatedSlot, context: 'post-update-metadata' });
3853
4037
  }
@@ -3858,7 +4042,8 @@ async function processBatchResults(bot, result, opContexts) {
3858
4042
  const chainOrderId = res && res[1];
3859
4043
  if (chainOrderId) {
3860
4044
  await bot.manager.synchronizeWithChain({
3861
- gridOrderId: ctx.order.id, chainOrderId, expectedType: ctx.order.type, fee: btsFeeData?.createFee || 0
4045
+ gridOrderId: ctx.order.id, chainOrderId, expectedType: ctx.order.type, fee: btsFeeData?.createFee || 0,
4046
+ order: ctx.order ?? null,
3862
4047
  }, 'createOrder');
3863
4048
  if (ctx.finalInts) {
3864
4049
  const syncedOrder = bot.manager.orders.get(ctx.order.id);
@@ -3866,14 +4051,7 @@ async function processBatchResults(bot, result, opContexts) {
3866
4051
  updatesToApply.push({
3867
4052
  order: {
3868
4053
  ...syncedOrder,
3869
- rawOnChain: {
3870
- id: chainOrderId,
3871
- for_sale: String(ctx.finalInts.sell),
3872
- sell_price: {
3873
- base: { amount: String(ctx.finalInts.sell), asset_id: ctx.finalInts.sellAssetId },
3874
- quote: { amount: String(ctx.finalInts.receive), asset_id: ctx.finalInts.receiveAssetId }
3875
- }
3876
- }
4054
+ rawOnChain: rawOnChainFromInts(chainOrderId, ctx.finalInts)
3877
4055
  },
3878
4056
  context: 'post-placement-metadata'
3879
4057
  });
@@ -3903,20 +4081,11 @@ async function processBatchResults(bot, result, opContexts) {
3903
4081
  const { oldOrder, newPrice, newGridId, newSize, type } = rotation;
3904
4082
  if (!newGridId) {
3905
4083
  const ord = bot.manager.orders.get(oldOrder.id || rotation.id);
3906
- if (oldOrder && ord && bot.manager.accountant) {
3907
- await bot.manager.accountant.updateOptimisticFreeBalance(oldOrder, ord, 'order-update', btsFeeData?.updateFee || 0, false);
3908
- }
4084
+ await applyOptimisticFeeBalance(bot, oldOrder, ord, 'order-update', btsFeeData?.updateFee || 0);
3909
4085
  if (ord) {
3910
4086
  const updatedSlot = { ...ord, size: newSize };
3911
4087
  if (ctx.finalInts) {
3912
- updatedSlot.rawOnChain = {
3913
- id: ord.orderId,
3914
- for_sale: String(ctx.finalInts.sell),
3915
- sell_price: {
3916
- base: { amount: String(ctx.finalInts.sell), asset_id: ctx.finalInts.sellAssetId },
3917
- quote: { amount: String(ctx.finalInts.receive), asset_id: ctx.finalInts.receiveAssetId }
3918
- }
3919
- };
4088
+ updatedSlot.rawOnChain = rawOnChainFromInts(ord.orderId, ctx.finalInts);
3920
4089
  }
3921
4090
  updatesToApply.push({ order: updatedSlot, context: 'post-update-metadata' });
3922
4091
  }
@@ -3947,18 +4116,9 @@ async function processBatchResults(bot, result, opContexts) {
3947
4116
  orderId: oldOrder?.orderId || slot.orderId || null
3948
4117
  };
3949
4118
  if (ctx.finalInts) {
3950
- updatedSlot.rawOnChain = {
3951
- id: updatedSlot.orderId,
3952
- for_sale: String(ctx.finalInts.sell),
3953
- sell_price: {
3954
- base: { amount: String(ctx.finalInts.sell), asset_id: ctx.finalInts.sellAssetId },
3955
- quote: { amount: String(ctx.finalInts.receive), asset_id: ctx.finalInts.receiveAssetId }
3956
- }
3957
- };
3958
- }
3959
- if (oldOrder && updatedSlot && bot.manager.accountant) {
3960
- await bot.manager.accountant.updateOptimisticFreeBalance(oldOrder, updatedSlot, 'order-update', btsFeeData?.updateFee || 0, false);
4119
+ updatedSlot.rawOnChain = rawOnChainFromInts(updatedSlot.orderId, ctx.finalInts);
3961
4120
  }
4121
+ await applyOptimisticFeeBalance(bot, oldOrder, updatedSlot, 'order-update', btsFeeData?.updateFee || 0);
3962
4122
  if (oldOrder?.id && oldOrder.id !== newGridId) {
3963
4123
  const currentSource = bot.manager.orders.get(oldOrder.id);
3964
4124
  if (currentSource && currentSource.orderId) {
@@ -3985,7 +4145,10 @@ async function processBatchResults(bot, result, opContexts) {
3985
4145
  updateOperationCount
3986
4146
  };
3987
4147
  }
3988
- 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 };
4148
+ export { isLastFillGuardBlocked, refreshLastFillPivotFromQueue, buildOutsideInPairGroupsForOrders, buildOutsideInPairGroupsForCreateEntries, extractOperationResults, findMissingCreateResultContexts, markMissingCreateResultsAsStructuralBlocker, formatUnmatchedChainOrderForLog, recordPendingBroadcast, clearPendingBroadcasts, clearPendingBroadcastsForSlots, popPushedWorkingGrid, buildChainOrderFingerprint, normalizeChainOrderForPendingMatch, findChainOrderForSlot, reconcileAfterUncertainBroadcast, reconcileAfterUncertainBroadcastImpl, autoCancelOneUnmatchedOrphan, shouldExecuteCreatePairMode, executeWithRetryOnUncertain, executeChunkedWithRetryOnUncertain, formatPartialBroadcastSummary, executeOperationsWithStrategy, validateOperationFunds, resolveIdealSizeForValidation, validateOrderSizeForExecution, buildActionsFromPlan, buildCowResultFromPlan, restoreSkippedUpdateSlotsInWorkingGrid, applyRotationTransitionsToWorkingGrid, pollChainForConfirmation, updateOrdersOnChainBatchCOW, processBatchResults, adoptPlacedBatchFromChain, resolveRefillBoundaryHold, toRefillSlotIdSet };
4149
+ // Exported for regression tests (issue #23 sibling): the uncertain-broadcast
4150
+ // discard path must never drop a placement silently when master lost the slot.
4151
+ export { restoreDiscardedCreates };
3989
4152
  export default {
3990
4153
  buildOutsideInPairGroupsForOrders,
3991
4154
  buildOutsideInPairGroupsForCreateEntries,
@@ -4014,6 +4177,8 @@ export default {
4014
4177
  buildActionsFromPlan,
4015
4178
  buildCowResultFromPlan,
4016
4179
  restoreSkippedUpdateSlotsInWorkingGrid,
4180
+ resolveRefillBoundaryHold,
4181
+ toRefillSlotIdSet,
4017
4182
  applyRotationTransitionsToWorkingGrid,
4018
4183
  pollChainForConfirmation,
4019
4184
  updateOrdersOnChainBatchCOW,