dexbot 1.5.2 → 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 (69) hide show
  1. package/CHANGELOG.md +17 -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/market_adapter/core/asymmetric_bounds.d.ts +33 -1
  27. package/dist/market_adapter/core/asymmetric_bounds.d.ts.map +1 -1
  28. package/dist/market_adapter/core/asymmetric_bounds.js +38 -1
  29. package/dist/market_adapter/core/asymmetric_bounds.js.map +1 -1
  30. package/dist/modules/dexbot_class.d.ts +2 -18
  31. package/dist/modules/dexbot_class.d.ts.map +1 -1
  32. package/dist/modules/dexbot_cow_runtime.d.ts +37 -37
  33. package/dist/modules/dexbot_cow_runtime.d.ts.map +1 -1
  34. package/dist/modules/dexbot_cow_runtime.js +655 -556
  35. package/dist/modules/dexbot_cow_runtime.js.map +1 -1
  36. package/dist/modules/dexbot_maintenance_runtime.d.ts.map +1 -1
  37. package/dist/modules/dexbot_maintenance_runtime.js +31 -51
  38. package/dist/modules/dexbot_maintenance_runtime.js.map +1 -1
  39. package/dist/modules/order/grid.d.ts.map +1 -1
  40. package/dist/modules/order/grid.js +28 -37
  41. package/dist/modules/order/grid.js.map +1 -1
  42. package/dist/modules/order/manager.d.ts +2 -1
  43. package/dist/modules/order/manager.d.ts.map +1 -1
  44. package/dist/modules/order/manager.js +10 -1
  45. package/dist/modules/order/manager.js.map +1 -1
  46. package/dist/modules/order/sync_engine.d.ts.map +1 -1
  47. package/dist/modules/order/sync_engine.js +74 -0
  48. package/dist/modules/order/sync_engine.js.map +1 -1
  49. package/dist/modules/order/utils/math.d.ts +9 -11
  50. package/dist/modules/order/utils/math.d.ts.map +1 -1
  51. package/dist/modules/order/utils/math.js +9 -11
  52. package/dist/modules/order/utils/math.js.map +1 -1
  53. package/dist/modules/order/utils/order.d.ts +51 -16
  54. package/dist/modules/order/utils/order.d.ts.map +1 -1
  55. package/dist/modules/order/utils/order.js +203 -29
  56. package/dist/modules/order/utils/order.js.map +1 -1
  57. package/dist/modules/order/utils/system.d.ts +1 -30
  58. package/dist/modules/order/utils/system.d.ts.map +1 -1
  59. package/dist/modules/order/utils/system.js +31 -98
  60. package/dist/modules/order/utils/system.js.map +1 -1
  61. package/dist/modules/order/utils/validate.d.ts +3 -2
  62. package/dist/modules/order/utils/validate.d.ts.map +1 -1
  63. package/dist/modules/order/utils/validate.js +5 -1
  64. package/dist/modules/order/utils/validate.js.map +1 -1
  65. package/docs/DEXBOT_COMPARISON.md +3 -3
  66. package/docs/EVOLUTION.md +6 -5
  67. package/docs/FUND_MOVEMENT_AND_ACCOUNTING.md +1 -1
  68. package/docs/README.md +2 -2
  69. 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, isEvacuationSizeStillValid, getSellStartIdx, getPrecisionByOrderType, isSlotIndexInGapBand } = math;
21
+ const { validateOrderSize, findCrossedOrder, priceSlotEqual, isEvacuationRotationAllowed, isEvacuationSizeStillValid, getSellStartIdx, getPrecisionByOrderType, isSlotIndexInGapBand, getAssetFeesSafe, blockchainToFloat, floatToBlockchainInt, quantizeFloat } = math;
22
+ import { parseSlotIndex } from './order/utils/slot.js';
22
23
  /**
23
24
  * Re-verify a B-stamped gap-evacuation rotation against the LIVE committed
24
25
  * geometry (boundaryIdx/_gapSlots at execution time). The stamp freezes
@@ -48,7 +49,6 @@ export function isEvacuationStampStillValid(liveBoundary, liveGapSlots, sourceId
48
49
  const dstInRail = orderType === ORDER_TYPES.SELL ? Number(dstIdx) >= sellStartIdx : Number(dstIdx) <= b;
49
50
  return dstInRail;
50
51
  }
51
- import { parseSlotIndex } from './order/utils/slot.js';
52
52
  function hasSlotPriceCollision(items, targetPrice, precision, excludeId, predicate) {
53
53
  for (const it of items) {
54
54
  if (predicate && !predicate(it))
@@ -69,8 +69,6 @@ function hasSlotPriceCollision(items, targetPrice, precision, excludeId, predica
69
69
  }
70
70
  return null;
71
71
  }
72
- // Lazy accessor so test mocks on the math module export take effect at call time.
73
- function getAssetFeesSafe(...args) { return require('./order/utils/math').getAssetFeesSafe(...args); }
74
72
  import * as constantsModule from './constants.js';
75
73
  const { COW_ACTIONS, COW_PERFORMANCE, ORDER_STATES, ORDER_TYPES, REBALANCE_STATES, } = constantsModule;
76
74
  import { acquireIfNotHeld } from './order/async_lock.js';
@@ -79,8 +77,6 @@ const Format = FormatModule;
79
77
  import * as workingGridModule from './order/working_grid.js';
80
78
  const { WorkingGrid } = workingGridModule;
81
79
  import { getErrorMessage } from './utils/errors.js';
82
- import { createRequire } from 'node:module';
83
- const require = createRequire(import.meta.url);
84
80
  // Maximum number of times the pre-broadcast staleness guard may re-plan the
85
81
  // batch from a fresh master before proceeding anyway. Bounded so a master
86
82
  // grid that keeps mutating (fill bursts, sync loops) can never livelock the
@@ -99,11 +95,7 @@ const SINGLE_FLIGHT_MAX_WAIT_MS = 120000;
99
95
  * @returns {Array<Array>}
100
96
  */
101
97
  function buildOutsideInPairGroupsForOrders(orders) {
102
- return buildOutsideInPairGroups(orders, {
103
- isValid: Boolean,
104
- getType: (o) => o.type,
105
- getPrice: (o) => o.price,
106
- });
98
+ return buildOutsideInPairGroupsWithAdapter(orders, (o) => o);
107
99
  }
108
100
  /**
109
101
  * Build outside-in pair groups for create entry contexts.
@@ -111,12 +103,128 @@ function buildOutsideInPairGroupsForOrders(orders) {
111
103
  * @returns {Array<Array>}
112
104
  */
113
105
  function buildOutsideInPairGroupsForCreateEntries(createEntries) {
114
- return buildOutsideInPairGroups(createEntries, {
115
- isValid: (e) => Boolean(e?.context?.order),
116
- getType: (e) => e.context.order.type,
117
- getPrice: (e) => e.context.order.price,
106
+ return buildOutsideInPairGroupsWithAdapter(createEntries, (e) => e?.context?.order);
107
+ }
108
+ /**
109
+ * Shared outside-in delegation: project each item to its order, then group
110
+ * with the single order adapter. The ForOrders/ForCreateEntries wrappers
111
+ * differ only in this projection.
112
+ * @param {Array} items
113
+ * @param {Function} toOrder - Project an item to its order object
114
+ * @returns {Array<Array>}
115
+ */
116
+ function buildOutsideInPairGroupsWithAdapter(items, toOrder) {
117
+ return buildOutsideInPairGroups(items, {
118
+ isValid: (item) => Boolean(toOrder(item)),
119
+ getType: (item) => toOrder(item)?.type,
120
+ getPrice: (item) => toOrder(item)?.price,
118
121
  });
119
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,
189
+ });
190
+ }
191
+ /**
192
+ * Check whether a chain read is authoritative enough to make
193
+ * definitive absence/lPresence decisions. An authoritative read is
194
+ * non-empty, non-truncated, and non-null.
195
+ * @param {Object} readResult - The result of readOpenOrdersWithMeta
196
+ * @returns {boolean}
197
+ */
198
+ function isAuthoritativeChainRead(readResult) {
199
+ if (!readResult || !Array.isArray(readResult.orders))
200
+ return false;
201
+ if (readResult.truncated)
202
+ return false;
203
+ return readResult.orders.length > 0;
204
+ }
205
+ /**
206
+ * Build a rawOnChain metadata object from final integer values.
207
+ * Used in processBatchResults for size-update, create, and rotation
208
+ * contexts to enrich order objects with on-chain identity.
209
+ * Returns null when the order id (or final ints) is missing: downstream
210
+ * chainOrderUnchangedFromCache treats a null cache as "not unchanged" and
211
+ * defers, which is the safer direction versus comparing an id-less object.
212
+ * @param {string} orderId - The on-chain order id
213
+ * @param {Object} finalInts - The final integer tuple
214
+ * @returns {Object|null}
215
+ */
216
+ function rawOnChainFromInts(orderId, finalInts) {
217
+ if (!orderId || !finalInts)
218
+ return null;
219
+ return {
220
+ id: orderId,
221
+ for_sale: String(finalInts.sell),
222
+ sell_price: {
223
+ base: { amount: String(finalInts.sell), asset_id: finalInts.sellAssetId },
224
+ quote: { amount: String(finalInts.receive), asset_id: finalInts.receiveAssetId },
225
+ },
226
+ };
227
+ }
120
228
  /**
121
229
  * Extract operation results from a batch transaction result.
122
230
  * @param {Object|Array|null} result
@@ -222,14 +330,7 @@ function recordPendingBroadcast(bot, entry) {
222
330
  if (!bot.manager._pendingBroadcasts || !(bot.manager._pendingBroadcasts instanceof Map)) {
223
331
  bot.manager._pendingBroadcasts = new Map();
224
332
  }
225
- const fingerprint = buildCreateOpFingerprint({
226
- side: entry.order.type,
227
- assetA: bot.manager?.assets?.assetA?.id,
228
- assetB: bot.manager?.assets?.assetB?.id,
229
- sellInt: entry.finalInts?.sell,
230
- receiveInt: entry.finalInts?.receive,
231
- slotId: entry.order.id
232
- });
333
+ const fingerprint = createOpFingerprintForSlot(bot, entry.order, entry.finalInts, entry.order.id);
233
334
  if (!fingerprint) {
234
335
  bot.manager.logger.log?.(`[COW] Skipped pending-broadcast record: could not build fingerprint for ${entry.order?.id || 'unknown'}`, 'warn');
235
336
  return;
@@ -510,72 +611,24 @@ async function reconcileAfterUncertainBroadcast(bot, err, opContexts, options =
510
611
  return acquireIfNotHeld(bot.manager?._fillProcessingLock, () => reconcileAfterUncertainBroadcastImpl(bot, err, opContexts, options));
511
612
  }
512
613
  /**
513
- * Reconcile a broadcast whose chain state is unknown (implementation).
614
+ * Match pending broadcasts against a chain snapshot (first pass by slot
615
+ * probe, second pass by fingerprint across all pending slots), then
616
+ * re-read the chain for discarded CREATEs to close the TOCTOU window.
617
+ * Returns `{ adopted, discarded }`, or `{ deferred }` when the re-read
618
+ * is ambiguous/failed and the caller must defer (pending protection kept).
514
619
  * @param {import('./dexbot_class.js').DEXBot} bot
515
- * @param {BroadcastUncertainError} err
516
- * @param {Array<Object>} opContexts
517
- * @param {Object} options
518
- * @returns {Promise<Object>}
620
+ * @param {Array} pending
621
+ * @param {Array} chainSnapshot
622
+ * @param {Array} opContexts
623
+ * @param {*} accountRef
624
+ * @param {*} err
519
625
  */
520
- async function reconcileAfterUncertainBroadcastImpl(bot, err, opContexts, _options) {
521
- const startedAt = Date.now();
522
- const pending = (bot.manager && bot.manager._pendingBroadcasts instanceof Map)
523
- ? Array.from(bot.manager._pendingBroadcasts.values())
524
- : [];
525
- const createContextCount = opContexts.filter((c) => c && c.kind === 'create').length;
526
- const nonCreateContextCount = opContexts.length - createContextCount;
527
- bot.manager.logger.log(`[COW][UNCERTAIN] batchId=${err?.batchId || 'n/a'} ops=${opContexts.length} ` +
528
- `creates=${createContextCount} nonCreates=${nonCreateContextCount} ` +
529
- `staleSinceMs=${err?.timeoutMs || 'n/a'}. Entering reconcile-then-decide.`, 'warn');
530
- if (!chainOrders?.readOpenOrdersWithMeta) {
531
- bot.manager.logger.log('[COW][UNCERTAIN] readOpenOrdersWithMeta unavailable; falling back to structural resync only.', 'error');
532
- if (typeof bot.manager.requestStructuralGridResync === 'function') {
533
- await bot.manager.requestStructuralGridResync('broadcast uncertain — readOpenOrders unavailable', { batchId: err?.batchId || null });
534
- }
535
- clearPendingBroadcasts(bot.manager?._pendingBroadcasts);
536
- return { executed: false, hadRotation: false, uncertain: true };
537
- }
538
- // 1. Read the chain
539
- const accountRef = bot.accountId || bot.account?.id || bot.account;
540
- let chainSnapshot = [];
541
- let chainReadTruncated = false;
542
- try {
543
- const chainRead = await chainOrders.readOpenOrdersWithMeta(accountRef);
544
- chainSnapshot = chainRead.orders;
545
- chainReadTruncated = chainRead.truncated;
546
- }
547
- catch (readErr) {
548
- bot.manager.logger.log(`[COW][UNCERTAIN] readOpenOrders failed: ${readErr?.message || readErr}. ` +
549
- `Falling back to structural resync.`, 'error');
550
- if (typeof bot.manager.requestStructuralGridResync === 'function') {
551
- await bot.manager.requestStructuralGridResync('broadcast uncertain — readOpenOrders failed', { batchId: err?.batchId || null, error: readErr?.message || String(readErr) });
552
- }
553
- clearPendingBroadcasts(bot.manager?._pendingBroadcasts);
554
- return { executed: false, hadRotation: false, uncertain: true };
555
- }
556
- // 1.5. Empty/truncated-read guard: an empty snapshot is ambiguous — the
557
- // account is either genuinely empty or the node is lagging behind the
558
- // just-broadcast transaction. A truncated snapshot (get_full_accounts
559
- // capped limit_orders; fresh creates sort last in the by_account index
560
- // and are the first entries omitted) is equally ambiguous: the batch's
561
- // creates may simply be missing from the returned window. Treating every
562
- // pending broadcast as discarded would clear the pending-broadcast
563
- // protection and let the next cycle re-CREATE slots whose orders may
564
- // actually be on chain (duplicate orders). Keep the protection and let
565
- // the structural resync adopt any landed orders.
566
- if (pending.length > 0 && (chainSnapshot.length === 0 || chainReadTruncated)) {
567
- return await deferUncertainBroadcastRead(bot, `${chainSnapshot.length === 0 ? 'Empty' : 'Truncated'} chain read for ${pending.length} pending broadcast(s)`, '(node may be lagging or the result set capped; no discard decisions made)', 'uncertain broadcast — empty/truncated chain read', { batchId: err?.batchId || null, truncated: chainReadTruncated });
568
- }
626
+ async function matchPendingToChain(bot, pending, chainSnapshot, opContexts, accountRef, err) {
569
627
  const adopted = [];
570
628
  let discarded = [];
571
629
  // 2. For each pending broadcast, look for a chain match.
572
630
  for (const entry of pending) {
573
- const match = findChainOrderForSlot(bot, chainSnapshot, entry.slotId, {
574
- sell: entry.finalInts?.sell,
575
- receive: entry.finalInts?.receive,
576
- orderType: entry.orderType,
577
- fingerprint: entry.fingerprint,
578
- });
631
+ const match = findChainOrderForPendingEntry(bot, chainSnapshot, entry);
579
632
  if (match) {
580
633
  adopted.push({ entry, match });
581
634
  }
@@ -624,21 +677,18 @@ async function reconcileAfterUncertainBroadcastImpl(bot, err, opContexts, _optio
624
677
  // protection and let the next cycle re-create (duplicate) an
625
678
  // order that actually landed in the TOCTOU window. Keep the
626
679
  // pending-broadcast protection and defer to a structural resync.
627
- if (!freshRead || freshRead.truncated || !Array.isArray(freshRead.orders) || freshRead.orders.length === 0) {
680
+ if (!isAuthoritativeChainRead(freshRead)) {
628
681
  const ambiguous = !freshRead || !Array.isArray(freshRead.orders) || freshRead.orders.length === 0;
629
- return await deferUncertainBroadcastRead(bot, `${ambiguous ? 'Empty' : 'Truncated'} re-read for ${createDiscarded.length} discarded CREATE(s)`, '(absence is not authoritative on an ambiguous re-read)', 'uncertain broadcast — ambiguous re-read for discarded creates', { batchId: err?.batchId || null, truncated: !ambiguous });
682
+ return {
683
+ deferred: await deferUncertainBroadcastRead(bot, `${ambiguous ? 'Empty' : 'Truncated'} re-read for ${createDiscarded.length} discarded CREATE(s)`, '(absence is not authoritative on an ambiguous re-read)', 'uncertain broadcast — ambiguous re-read for discarded creates', { batchId: err?.batchId || null, truncated: !ambiguous })
684
+ };
630
685
  }
631
686
  const freshChain = freshRead.orders;
632
687
  const remainingDiscarded = [];
633
688
  for (const entry of discarded) {
634
689
  const ctx = opContexts[entry.ctxIndex];
635
690
  if (ctx && ctx.kind === 'create') {
636
- const match = findChainOrderForSlot(bot, freshChain, entry.slotId, {
637
- sell: entry.finalInts?.sell,
638
- receive: entry.finalInts?.receive,
639
- orderType: entry.orderType,
640
- fingerprint: entry.fingerprint,
641
- });
691
+ const match = findChainOrderForPendingEntry(bot, freshChain, entry);
642
692
  if (match) {
643
693
  adopted.push({ entry, match });
644
694
  bot.manager.logger.log(`[COW][UNCERTAIN] Late-adopted discarded CREATE for slot ${entry.slotId} (${match.id}) via fresh chain read`, 'info');
@@ -654,13 +704,24 @@ async function reconcileAfterUncertainBroadcastImpl(bot, err, opContexts, _optio
654
704
  discarded = remainingDiscarded;
655
705
  }
656
706
  catch (reReadErr) {
657
- return await deferUncertainBroadcastRead(bot, `Fresh chain read for late adoption FAILED (${getErrorMessage(reReadErr)})`, '(absence is not authoritative on a failed re-read)', 'uncertain broadcast — failed re-read for discarded creates', { batchId: err?.batchId || null });
707
+ return {
708
+ deferred: await deferUncertainBroadcastRead(bot, `Fresh chain read for late adoption FAILED (${getErrorMessage(reReadErr)})`, '(absence is not authoritative on a failed re-read)', 'uncertain broadcast — failed re-read for discarded creates', { batchId: err?.batchId || null })
709
+ };
658
710
  }
659
711
  }
660
712
  }
661
- // 3b. Apply decisions
713
+ return { adopted, discarded };
714
+ }
715
+ /**
716
+ * Adopt matched pending broadcasts into the master grid (CREATE slots
717
+ * synchronize with the chain) and clear their pending entries.
718
+ * @param {import('./dexbot_class.js').DEXBot} bot
719
+ * @param {Array} adopted
720
+ * @param {Array} opContexts
721
+ * @returns {Promise<number>} adoptedCount
722
+ */
723
+ async function adoptMatchedEntries(bot, adopted, opContexts) {
662
724
  let adoptedCount = 0;
663
- let discardedCount = 0;
664
725
  for (const { entry, match } of adopted) {
665
726
  adoptedCount++;
666
727
  const plannedOpCtx = opContexts[entry.ctxIndex];
@@ -674,6 +735,7 @@ async function reconcileAfterUncertainBroadcastImpl(bot, err, opContexts, _optio
674
735
  chainOrderId,
675
736
  expectedType,
676
737
  fee: btsFeeData?.createFee || 0,
738
+ order: plannedOpCtx.order ?? entry.order ?? null,
677
739
  }, 'createOrder');
678
740
  }
679
741
  catch (syncErr) {
@@ -685,6 +747,19 @@ async function reconcileAfterUncertainBroadcastImpl(bot, err, opContexts, _optio
685
747
  bot.manager._pendingBroadcasts.delete(entry.fingerprint);
686
748
  }
687
749
  }
750
+ return adoptedCount;
751
+ }
752
+ /**
753
+ * Restore discarded CREATE slots to creation-uncertain state and clear
754
+ * their pending entries; residual pending entries that survive the
755
+ * decide phase are cleaned defensively.
756
+ * @param {import('./dexbot_class.js').DEXBot} bot
757
+ * @param {Array} discarded
758
+ * @param {Array} opContexts
759
+ * @returns {Promise<number>} discardedCount
760
+ */
761
+ async function restoreDiscardedCreates(bot, discarded, opContexts) {
762
+ let discardedCount = 0;
688
763
  for (const entry of discarded) {
689
764
  discardedCount++;
690
765
  const plannedOpCtx = opContexts[entry.ctxIndex];
@@ -761,12 +836,48 @@ async function reconcileAfterUncertainBroadcastImpl(bot, err, opContexts, _optio
761
836
  }
762
837
  }
763
838
  }
839
+ else {
840
+ // Slot missing from master (grid reset raced the uncertain
841
+ // broadcast). Deleting the pending entry below without
842
+ // restoring anything would leave a clean hole the next
843
+ // cycle re-CREATEs — duplicating a possibly-landed order.
844
+ // Materialize the creation-uncertain slot from the
845
+ // broadcast-time descriptor so the next sync's orphan
846
+ // adoption reconciles a landed order into it.
847
+ const missingType = entry.order.type;
848
+ if (missingType === ORDER_TYPES.BUY || missingType === ORDER_TYPES.SELL) {
849
+ bot.manager.logger.log(`[COW][UNCERTAIN] Slot ${entry.order.id} missing from master after discard — materializing creation-uncertain state ` +
850
+ `(type=${missingType}, size: ${entry.order.size}); next sync adoption will reconcile any landed order`, 'warn');
851
+ const updates = [{
852
+ id: entry.order.id,
853
+ type: missingType,
854
+ size: entry.order.size,
855
+ price: entry.order.price,
856
+ state: ORDER_STATES.VIRTUAL,
857
+ createUncertain: true,
858
+ orderId: null,
859
+ rawOnChain: null,
860
+ }];
861
+ if (typeof bot.manager.applyGridUpdateBatch === 'function') {
862
+ await bot.manager.applyGridUpdateBatch(updates, 'uncertain-broadcast-discard-restore');
863
+ }
864
+ }
865
+ else {
866
+ bot.manager.logger.log(`[COW][UNCERTAIN] Slot ${entry.order.id} missing from master after discard with unrecognized type ${missingType} — cannot reconstruct creation-uncertain state; next sync must adopt any landed order as an orphan`, 'error');
867
+ }
868
+ }
869
+ }
870
+ else {
871
+ bot.manager.logger.log(`[COW][UNCERTAIN] Discarded CREATE for slot ${entry.slotId} has no usable placement descriptor (id=${entry.order?.id ?? 'none'}, size=${entry.order?.size ?? 'none'}, type=${entry.order?.type ?? 'none'}) — nothing restored; next sync must adopt any landed order as an orphan`, 'error');
764
872
  }
765
873
  }
766
874
  catch (restoreErr) {
767
875
  bot.manager.logger.log(`[COW][UNCERTAIN] Failed to restore slot ${entry.slotId} after discard: ${restoreErr?.message || restoreErr}`, 'error');
768
876
  }
769
877
  }
878
+ else {
879
+ bot.manager.logger.log(`[COW][UNCERTAIN] Discarded pending broadcast for slot ${entry.slotId} is not a recognizable CREATE (no create opContext, no order id/type) — skipping restore; next sync must adopt any landed order as an orphan`, 'error');
880
+ }
770
881
  // Remove from pending broadcasts.
771
882
  if (entry.fingerprint && bot.manager._pendingBroadcasts?.has(entry.fingerprint)) {
772
883
  bot.manager._pendingBroadcasts.delete(entry.fingerprint);
@@ -786,6 +897,101 @@ async function reconcileAfterUncertainBroadcastImpl(bot, err, opContexts, _optio
786
897
  }
787
898
  bot.manager._pendingBroadcasts.clear();
788
899
  }
900
+ return discardedCount;
901
+ }
902
+ /**
903
+ * Request a structural resync when chain orders remain unaccounted for
904
+ * after the uncertain-broadcast reconciliation.
905
+ * @param {import('./dexbot_class.js').DEXBot} bot
906
+ * @param {*} err
907
+ * @param {Array} pending
908
+ * @param {Array} chainSnapshot
909
+ * @param {Array} adopted
910
+ * @param {number} adoptedCount
911
+ * @param {number} discardedCount
912
+ */
913
+ async function resyncIfUnreconciled(bot, err, pending, chainSnapshot, adopted, adoptedCount, discardedCount) {
914
+ // 7. Request structural resync if any chain orders remain unaccounted for
915
+ // after the reconciliation, ensuring the next cycle re-plans from a clean
916
+ // chain snapshot.
917
+ const alreadyScheduled = bot._structuralGridResyncRunning || bot._structuralGridResyncTimer;
918
+ if (!alreadyScheduled && chainSnapshot.length > 0) {
919
+ const reconciledOrderIds = new Set(adopted.map((a) => a.match?.id).filter(Boolean));
920
+ const unreconciledCount = chainSnapshot.filter((o) => !reconciledOrderIds.has(o.id)).length;
921
+ if (unreconciledCount > 0) {
922
+ bot.manager.logger.log(`[COW][UNCERTAIN] ${unreconciledCount} chain order(s) remain unreconciled after uncertain broadcast recovery. ` +
923
+ `These may be legitimate pre-existing orders or leftovers from a prior cycle. Requesting structural resync.`, 'warn');
924
+ await requestStructuralResync(bot, 'unreconciled orders after uncertain broadcast', {
925
+ batchId: err?.batchId || null,
926
+ pendingCount: pending.length,
927
+ adoptedCount,
928
+ discardedCount,
929
+ unreconciledCount
930
+ });
931
+ }
932
+ }
933
+ }
934
+ /**
935
+ * Reconcile a broadcast whose chain state is unknown (implementation).
936
+ * @param {import('./dexbot_class.js').DEXBot} bot
937
+ * @param {BroadcastUncertainError} err
938
+ * @param {Array<Object>} opContexts
939
+ * @param {Object} options
940
+ * @returns {Promise<Object>}
941
+ */
942
+ async function reconcileAfterUncertainBroadcastImpl(bot, err, opContexts, _options) {
943
+ const startedAt = Date.now();
944
+ const pending = getPendingBroadcasts(bot);
945
+ const createContextCount = opContexts.filter((c) => c && c.kind === 'create').length;
946
+ const nonCreateContextCount = opContexts.length - createContextCount;
947
+ bot.manager.logger.log(`[COW][UNCERTAIN] batchId=${err?.batchId || 'n/a'} ops=${opContexts.length} ` +
948
+ `creates=${createContextCount} nonCreates=${nonCreateContextCount} ` +
949
+ `staleSinceMs=${err?.timeoutMs || 'n/a'}. Entering reconcile-then-decide.`, 'warn');
950
+ if (!chainOrders?.readOpenOrdersWithMeta) {
951
+ bot.manager.logger.log('[COW][UNCERTAIN] readOpenOrdersWithMeta unavailable; falling back to structural resync only.', 'error');
952
+ await requestStructuralResync(bot, 'broadcast uncertain — readOpenOrders unavailable', { batchId: err?.batchId || null });
953
+ clearPendingBroadcasts(bot.manager?._pendingBroadcasts);
954
+ return { executed: false, hadRotation: false, uncertain: true };
955
+ }
956
+ // 1. Read the chain
957
+ const accountRef = bot.accountId || bot.account?.id || bot.account;
958
+ let chainSnapshot = [];
959
+ let chainReadTruncated = false;
960
+ try {
961
+ const chainRead = await chainOrders.readOpenOrdersWithMeta(accountRef);
962
+ chainSnapshot = chainRead.orders;
963
+ chainReadTruncated = chainRead.truncated;
964
+ }
965
+ catch (readErr) {
966
+ bot.manager.logger.log(`[COW][UNCERTAIN] readOpenOrders failed: ${readErr?.message || readErr}. ` +
967
+ `Falling back to structural resync.`, 'error');
968
+ await requestStructuralResync(bot, 'broadcast uncertain — readOpenOrders failed', { batchId: err?.batchId || null, error: readErr?.message || String(readErr) });
969
+ clearPendingBroadcasts(bot.manager?._pendingBroadcasts);
970
+ return { executed: false, hadRotation: false, uncertain: true };
971
+ }
972
+ // 1.5. Empty/truncated-read guard: an empty snapshot is ambiguous — the
973
+ // account is either genuinely empty or the node is lagging behind the
974
+ // just-broadcast transaction. A truncated snapshot (get_full_accounts
975
+ // capped limit_orders; fresh creates sort last in the by_account index
976
+ // and are the first entries omitted) is equally ambiguous: the batch's
977
+ // creates may simply be missing from the returned window. Treating every
978
+ // pending broadcast as discarded would clear the pending-broadcast
979
+ // protection and let the next cycle re-CREATE slots whose orders may
980
+ // actually be on chain (duplicate orders). Keep the protection and let
981
+ // the structural resync adopt any landed orders.
982
+ if (pending.length > 0 && (chainSnapshot.length === 0 || chainReadTruncated)) {
983
+ return await deferUncertainBroadcastRead(bot, `${chainSnapshot.length === 0 ? 'Empty' : 'Truncated'} chain read for ${pending.length} pending broadcast(s)`, '(node may be lagging or the result set capped; no discard decisions made)', 'uncertain broadcast — empty/truncated chain read', { batchId: err?.batchId || null, truncated: chainReadTruncated });
984
+ }
985
+ // 2-3a. Match pending broadcasts to the chain: slot probe, fingerprint
986
+ // second pass, and a TOCTOU re-read for discarded CREATEs (see
987
+ // matchPendingToChain). A deferred re-read returns its result directly.
988
+ const matched = await matchPendingToChain(bot, pending, chainSnapshot, opContexts, accountRef, err);
989
+ if (matched.deferred)
990
+ return matched.deferred;
991
+ const { adopted, discarded } = matched;
992
+ // 3b. Apply decisions
993
+ const adoptedCount = await adoptMatchedEntries(bot, adopted, opContexts);
994
+ let discardedCount = await restoreDiscardedCreates(bot, discarded, opContexts);
789
995
  // 5. Log structured summary
790
996
  const elapsed = Date.now() - startedAt;
791
997
  bot.manager.logger.log(`[COW][UNCERTAIN] Reconciled: ${adoptedCount} adopted, ${discardedCount} discarded ` +
@@ -805,27 +1011,7 @@ async function reconcileAfterUncertainBroadcastImpl(bot, err, opContexts, _optio
805
1011
  // 7. Request structural resync if any chain orders remain unaccounted for
806
1012
  // after the reconciliation, ensuring the next cycle re-plans from a clean
807
1013
  // chain snapshot.
808
- const alreadyScheduled = bot._structuralGridResyncRunning || bot._structuralGridResyncTimer;
809
- if (!alreadyScheduled && chainSnapshot.length > 0) {
810
- const reconciledOrderIds = new Set(adopted.map((a) => a.match?.id).filter(Boolean));
811
- const unreconciledCount = chainSnapshot.filter((o) => !reconciledOrderIds.has(o.id)).length;
812
- if (unreconciledCount > 0) {
813
- bot.manager.logger.log(`[COW][UNCERTAIN] ${unreconciledCount} chain order(s) remain unreconciled after uncertain broadcast recovery. ` +
814
- `These may be legitimate pre-existing orders or leftovers from a prior cycle. Requesting structural resync.`, 'warn');
815
- if (typeof bot.manager.requestStructuralGridResync === 'function') {
816
- await bot.manager.requestStructuralGridResync('unreconciled orders after uncertain broadcast', {
817
- batchId: err?.batchId || null,
818
- pendingCount: pending.length,
819
- adoptedCount,
820
- discardedCount,
821
- unreconciledCount
822
- });
823
- }
824
- else {
825
- bot._warn?.('[COW][UNCERTAIN] requestStructuralGridResync unavailable; cannot schedule structural resync.');
826
- }
827
- }
828
- }
1014
+ await resyncIfUnreconciled(bot, err, pending, chainSnapshot, adopted, adoptedCount, discardedCount);
829
1015
  return { executed: false, hadRotation: false, uncertain: true, adoptedCount, discardedCount };
830
1016
  }
831
1017
  /**
@@ -846,9 +1032,7 @@ async function autoCancelOneUnmatchedOrphan(bot) {
846
1032
  bot._autoCancelOrphanCycleMarker = cycleId;
847
1033
  bot._autoCancelOrphanSubCount = 0;
848
1034
  }
849
- const pending = (bot.manager && bot.manager._pendingBroadcasts instanceof Map)
850
- ? bot.manager._pendingBroadcasts.size
851
- : 0;
1035
+ const pending = countPendingBroadcasts(bot);
852
1036
  if (pending > 0) {
853
1037
  return { cancelled: false, reason: 'pending-broadcasts-active' };
854
1038
  }
@@ -945,14 +1129,7 @@ function verifyCreateAbsent(bot, freshChain, ctx) {
945
1129
  sell: ctx.finalInts.sell,
946
1130
  receive: ctx.finalInts.receive,
947
1131
  orderType: ctx.order.type,
948
- fingerprint: buildCreateOpFingerprint({
949
- side: ctx.order.type,
950
- assetA: bot.manager?.assets?.assetA?.id,
951
- assetB: bot.manager?.assets?.assetB?.id,
952
- sellInt: ctx.finalInts.sell,
953
- receiveInt: ctx.finalInts.receive,
954
- slotId: ctx.order.id
955
- })
1132
+ fingerprint: createOpFingerprintForSlot(bot, ctx.order, ctx.finalInts, ctx.order.id)
956
1133
  });
957
1134
  return match ? 'landed' : 'absent';
958
1135
  }
@@ -1033,7 +1210,11 @@ async function executeWithRetryOnUncertain(bot, operations, opContexts) {
1033
1210
  const accountRef = bot.accountId || bot.account?.id || bot.account;
1034
1211
  const freshRead = await chainOrders.readOpenOrdersWithMeta(accountRef);
1035
1212
  const freshChain = freshRead.orders;
1036
- if (Array.isArray(freshChain) && freshChain.length > 0) {
1213
+ // A truncated read (get_full_accounts caps limit_orders, and
1214
+ // fresh creates sort last in the by_account index) omits the
1215
+ // very orders this batch may have landed — 'absent' is not
1216
+ // authoritative here, degrade to 'unknown' and defer.
1217
+ if (isAuthoritativeChainRead(freshRead)) {
1037
1218
  absence = 'absent';
1038
1219
  for (const ctx of opContexts) {
1039
1220
  if (!ctx)
@@ -1045,13 +1226,6 @@ async function executeWithRetryOnUncertain(bot, operations, opContexts) {
1045
1226
  }
1046
1227
  }
1047
1228
  }
1048
- // A truncated read (get_full_accounts caps limit_orders, and
1049
- // fresh creates sort last in the by_account index) omits the
1050
- // very orders this batch may have landed — 'absent' is not
1051
- // authoritative here, degrade to 'unknown' and defer.
1052
- if (freshRead.truncated && absence === 'absent') {
1053
- absence = 'unknown';
1054
- }
1055
1229
  }
1056
1230
  catch (verifyErr) {
1057
1231
  bot.manager.logger.log(`[COW] Pre-retry chain verification failed (non-fatal): ${verifyErr?.message || verifyErr}`, 'warn');
@@ -1085,31 +1259,6 @@ async function executeWithRetryOnUncertain(bot, operations, opContexts) {
1085
1259
  }
1086
1260
  }
1087
1261
  }
1088
- /**
1089
- * Whether a chain order still matches the cached pre-update state the
1090
- * limit_order_update delta was built from. Only a provably-unchanged order
1091
- * makes a re-broadcast of the identical delta safe (it applies to the same
1092
- * base). Any other state (target applied, filled, resized) must defer.
1093
- * @param {Object} chainOrder - Raw chain order object (get_full_accounts)
1094
- * @param {Object|null} cachedRaw - The rawOnChain cache captured at build time
1095
- * @returns {boolean}
1096
- */
1097
- function chainOrderUnchangedFromCache(chainOrder, cachedRaw) {
1098
- if (!chainOrder || !cachedRaw)
1099
- return false;
1100
- const base = chainOrder.sell_price?.base;
1101
- const quote = chainOrder.sell_price?.quote;
1102
- const cachedBase = cachedRaw.sell_price?.base?.amount;
1103
- const cachedQuote = cachedRaw.sell_price?.quote?.amount;
1104
- const cachedForSale = cachedRaw.for_sale;
1105
- if (base === undefined || quote === undefined)
1106
- return false;
1107
- if (cachedForSale === undefined || cachedBase === undefined || cachedQuote === undefined)
1108
- return false;
1109
- return String(base.amount ?? '') === String(cachedBase)
1110
- && String(quote.amount ?? '') === String(cachedQuote)
1111
- && String(chainOrder.for_sale ?? '') === String(cachedForSale);
1112
- }
1113
1262
  /**
1114
1263
  * Summarize a partial (non-atomic) broadcast for the batch-failure catch log.
1115
1264
  * Supports both the pair-mode grouped path (groupsBroadcast/groupsTotal) and
@@ -1347,7 +1496,6 @@ function validateOperationFunds(bot, operations, assetA, assetB) {
1347
1496
  if (!operations || operations.length === 0) {
1348
1497
  return { isValid: true, summary: 'No operations to validate' };
1349
1498
  }
1350
- const { blockchainToFloat, floatToBlockchainInt, quantizeFloat } = require('./order/utils/math');
1351
1499
  let snap = bot.manager?.getChainFundsSnapshot?.();
1352
1500
  if (!snap) {
1353
1501
  snap = { chainFreeSell: 0, chainFreeBuy: 0 };
@@ -1842,12 +1990,21 @@ function buildCowResultFromPlan(bot, plan) {
1842
1990
  });
1843
1991
  }
1844
1992
  }
1993
+ // Refill-slot wire (boundary-hold): producers attach the slot ids of
1994
+ // hole-CREATEs that justify this plan's boundary shift; the executor
1995
+ // holds the committed boundary when a listed refill is guard-skipped.
1996
+ // Absent/non-array => undefined (guarded default at execution, never
1997
+ // fail-open). Spread-correction plans never set this (disjoint bypass).
1998
+ const refillSlotIds = Array.isArray(plan?.refillSlotIds)
1999
+ ? plan.refillSlotIds.filter((id) => typeof id === 'string' && id.length > 0)
2000
+ : undefined;
1845
2001
  return {
1846
2002
  workingGrid,
1847
2003
  workingIndexes: workingGrid.getIndexes(),
1848
2004
  workingBoundary,
1849
2005
  actions,
1850
- origin: plan?.origin
2006
+ origin: plan?.origin,
2007
+ ...(refillSlotIds !== undefined ? { refillSlotIds } : {})
1851
2008
  };
1852
2009
  }
1853
2010
  /**
@@ -1960,14 +2117,7 @@ async function pollChainForConfirmation(bot, opContexts, options = {}) {
1960
2117
  receive: ctx.finalInts.receive,
1961
2118
  orderType: ctx.order.type,
1962
2119
  fingerprint: createContexts.length > 0
1963
- ? buildCreateOpFingerprint({
1964
- side: ctx.order.type,
1965
- assetA: bot.manager?.assets?.assetA?.id,
1966
- assetB: bot.manager?.assets?.assetB?.id,
1967
- sellInt: ctx.finalInts.sell,
1968
- receiveInt: ctx.finalInts.receive,
1969
- slotId: ctx.order.id
1970
- })
2120
+ ? createOpFingerprintForSlot(bot, ctx.order, ctx.finalInts, ctx.order.id)
1971
2121
  : undefined
1972
2122
  });
1973
2123
  if (match) {
@@ -2001,6 +2151,55 @@ async function pollChainForConfirmation(bot, opContexts, options = {}) {
2001
2151
  `${remaining.length} unconfirmed. Falling back to reconciliation.`, 'warn');
2002
2152
  return { allConfirmed: false, confirmed, unconfirmed: remaining, confirmedChainIds: [...matchedChainIds] };
2003
2153
  }
2154
+ /**
2155
+ * Normalize a producer-supplied refillSlotIds wire into a Set.
2156
+ * Absent/empty/non-array => empty (guarded default, never fail-open).
2157
+ */
2158
+ function toRefillSlotIdSet(refillSlotIds) {
2159
+ const set = new Set();
2160
+ if (Array.isArray(refillSlotIds)) {
2161
+ for (const id of refillSlotIds) {
2162
+ if (typeof id === 'string' && id.length > 0)
2163
+ set.add(id);
2164
+ }
2165
+ }
2166
+ return set;
2167
+ }
2168
+ /**
2169
+ * Boundary-hold decision for guard-skipped refills.
2170
+ * When a skipped slot is one of the plan's refill slots — a hole-CREATE that
2171
+ * justified the planned boundary shift — the committed boundary is kept: the
2172
+ * slot was never placed (CREATE skip) or restored to master (UPDATE skip), so
2173
+ * committing the planned boundary would strand empty rail holes past it
2174
+ * (91->94 with 91-94 empty self-legalizes via resolveGapBand).
2175
+ * Unrelated vetoes (skip ids outside the refill set) never pin geometry.
2176
+ * The grid still commits; only the boundary value is held (same discipline
2177
+ * as the overrun-hold in validateBoundaryCommit).
2178
+ */
2179
+ function resolveRefillBoundaryHold(workingBoundary, committedBoundary, skippedUpdateSlotIds, clampedUpdateSlotIds, refillSlotIds, skippedCreateSlotIds = undefined) {
2180
+ const refills = toRefillSlotIdSet(refillSlotIds);
2181
+ const heldRefillSlotIds = [];
2182
+ if (refills.size > 0) {
2183
+ const seen = new Set();
2184
+ const skipCollections = skippedCreateSlotIds !== undefined
2185
+ ? [skippedUpdateSlotIds, clampedUpdateSlotIds, skippedCreateSlotIds]
2186
+ : [skippedUpdateSlotIds, clampedUpdateSlotIds];
2187
+ for (const coll of skipCollections) {
2188
+ if (!coll || typeof coll[Symbol.iterator] !== 'function')
2189
+ continue;
2190
+ for (const id of coll) {
2191
+ if (typeof id === 'string' && refills.has(id) && !seen.has(id)) {
2192
+ seen.add(id);
2193
+ heldRefillSlotIds.push(id);
2194
+ }
2195
+ }
2196
+ }
2197
+ }
2198
+ return {
2199
+ effectiveBoundary: heldRefillSlotIds.length > 0 ? committedBoundary : workingBoundary,
2200
+ heldRefillSlotIds
2201
+ };
2202
+ }
2004
2203
  /**
2005
2204
  * Restore skipped update slots in the working grid to master state.
2006
2205
  * @param {import('./dexbot_class.js').DEXBot} bot
@@ -2182,87 +2381,6 @@ async function waitForCowBroadcastSingleFlight(bot, label) {
2182
2381
  * @param {Object} [options={}] - Internal execution options (replanDepth)
2183
2382
  * @returns {Promise<Object>}
2184
2383
  */
2185
- /**
2186
- * PRE-BROADCAST CROSSED-BOOK ASSERT (defense-in-depth, any-writer detection).
2187
- *
2188
- * Simulates the post-batch book: currently placed master orders plus this
2189
- * batch's action overlay (CREATEs add, CANCELs remove, UPDATEs reprice/move).
2190
- * Returns a detail string when a planned BUY would price at-or-above a planned
2191
- * SELL — a state no honest planner produces — so the caller can refuse the
2192
- * broadcast instead of paying for adverse fills. Placed order prices are
2193
- * independent of grid geometry, so this catches boundary overruns regardless
2194
- * of which writer produced them.
2195
- *
2196
- * Detector only: any internal failure returns null (never blocks a broadcast).
2197
- */
2198
- function detectCrossedBookPlan(manager, actions) {
2199
- try {
2200
- const startPrice = Number(manager?.config?.startPrice);
2201
- const book = new Map();
2202
- for (const o of Array.from(manager?.orders?.values?.() ?? [])) {
2203
- if (!o || !o.orderId || o.price == null)
2204
- continue;
2205
- const price = Number(o.price);
2206
- if (!Number.isFinite(price))
2207
- continue;
2208
- let type = o.type;
2209
- if (type !== ORDER_TYPES.BUY && type !== ORDER_TYPES.SELL) {
2210
- // Legacy SPREAD-typed placed order: derive side from the same
2211
- // price-vs-startPrice convention used across the codebase.
2212
- if (!Number.isFinite(startPrice))
2213
- continue;
2214
- type = price < startPrice ? ORDER_TYPES.BUY : ORDER_TYPES.SELL;
2215
- }
2216
- book.set(String(o.id), { type, price });
2217
- }
2218
- for (const a of actions ?? []) {
2219
- const id = String(a.id ?? a.orderId ?? '');
2220
- if (a.type === COW_ACTIONS.CANCEL) {
2221
- if (id)
2222
- book.delete(id);
2223
- }
2224
- else if (a.type === COW_ACTIONS.UPDATE) {
2225
- const newPrice = Number(a.newPrice ?? a.order?.price);
2226
- const newType = a.order?.type;
2227
- if (id && Number.isFinite(newPrice)) {
2228
- const entry = book.get(id);
2229
- const type = (newType === ORDER_TYPES.BUY || newType === ORDER_TYPES.SELL)
2230
- ? newType
2231
- : entry?.type;
2232
- if (entry)
2233
- book.delete(id);
2234
- const key = String(a.newGridId ?? id);
2235
- if (type === ORDER_TYPES.BUY || type === ORDER_TYPES.SELL) {
2236
- book.set(key, { type, price: newPrice });
2237
- }
2238
- }
2239
- }
2240
- else if (a.type === COW_ACTIONS.CREATE) {
2241
- const price = Number(a.order?.price);
2242
- const type = a.order?.type;
2243
- if (!Number.isFinite(price) || (type !== ORDER_TYPES.BUY && type !== ORDER_TYPES.SELL))
2244
- continue;
2245
- if (id)
2246
- book.set(id, { type, price });
2247
- }
2248
- }
2249
- let maxBuy = -Infinity;
2250
- let minSell = Infinity;
2251
- for (const { type, price } of book.values()) {
2252
- if (type === ORDER_TYPES.BUY && price > maxBuy)
2253
- maxBuy = price;
2254
- else if (type === ORDER_TYPES.SELL && price < minSell)
2255
- minSell = price;
2256
- }
2257
- if (Number.isFinite(maxBuy) && Number.isFinite(minSell) && minSell <= maxBuy) {
2258
- return `bestPlacedBuy=${maxBuy} >= bestPlacedSell=${minSell}`;
2259
- }
2260
- return null;
2261
- }
2262
- catch {
2263
- return null;
2264
- }
2265
- }
2266
2384
  /**
2267
2385
  * Derive an update action's planned target size (shared by the rotation and
2268
2386
  * plain size-update op builders).
@@ -2305,58 +2423,22 @@ function clampPostFillUpdateSize(bot, masterOrder, newSize, action) {
2305
2423
  }
2306
2424
  return target;
2307
2425
  }
2308
- async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2309
- const replanDepth = Number.isFinite(Number(options?.replanDepth)) ? Number(options.replanDepth) : 0;
2310
- bot._currentCycleId = (Number.isFinite(Number(bot._currentCycleId)) ? Number(bot._currentCycleId) : 0) + 1;
2311
- const { workingGrid, workingIndexes, workingBoundary, actions } = cowResult;
2312
- if (bot.config.dryRun) {
2313
- const cancelCount = actions.filter((a) => a.type === COW_ACTIONS.CANCEL).length;
2314
- const createCount = actions.filter((a) => a.type === COW_ACTIONS.CREATE).length;
2315
- const updateCount = actions.filter((a) => a.type === COW_ACTIONS.UPDATE).length;
2316
- if (cancelCount > 0)
2317
- bot.manager.logger.log(`Dry run: would cancel ${cancelCount} orders`, 'info');
2318
- if (createCount > 0)
2319
- bot.manager.logger.log(`Dry run: would place ${createCount} new orders`, 'info');
2320
- if (updateCount > 0)
2321
- bot.manager.logger.log(`Dry run: would update ${updateCount} orders`, 'info');
2322
- popPushedWorkingGrid(bot, cowResult);
2323
- return { executed: true, hadRotation: false };
2324
- }
2325
- // Single-flight COW broadcast guard (entry check): never broadcast two
2326
- // batches concurrently. Overlapping broadcasts plan from the same base
2327
- // grid version; when the first commits it bumps _gridVersion, so the
2328
- // second's commit is refused (base-version mismatch) -> adopt-from-chain
2329
- // -> snapshot reload that can drop the adopted order and produce an
2330
- // orphan fill. This entry wait is an optimization; the authoritative
2331
- // atomic check-and-set happens right before the broadcast below.
2332
- if (await waitForCowBroadcastSingleFlight(bot, 'entry')) {
2333
- popPushedWorkingGrid(bot, cowResult);
2334
- return { executed: false, aborted: true, reason: 'SHUTDOWN_IN_PROGRESS', hadRotation: false };
2335
- }
2336
- // DRAIN PENDING CORRECTIONS before the batch is planned/broadcast.
2337
- // Cancel-only corrections (duplicate-price orphans) queued by an earlier
2338
- // sync must not sit while batches run back-to-back (startup create
2339
- // groups, fill bursts) — with the open-orders sync loop disabled they
2340
- // would otherwise linger indefinitely, keep blocking same-level CREATEs,
2341
- // and risk cancelling the wrong side of a duplicate later. Draining here
2342
- // also keeps this batch's collision checks (chain_orphan_collision)
2343
- // honest: orphaned chain orders already queued for cancellation are
2344
- // resolved before the plan validates its CREATE targets against them.
2345
- const pendingCorrectionCount = Array.isArray(bot.manager?.ordersNeedingPriceCorrection)
2346
- ? bot.manager.ordersNeedingPriceCorrection.length
2347
- : 0;
2348
- if (pendingCorrectionCount > 0 && !bot._shuttingDown) {
2349
- try {
2350
- bot.manager.logger.log(`[COW] Draining ${pendingCorrectionCount} pending correction(s) before batch`, 'info');
2351
- const drainResult = await orderUtils.correctAllPriceMismatches(bot.manager, bot.account, bot.privateKey, chainOrders);
2352
- if (drainResult?.failed > 0) {
2353
- bot.manager.logger.log(`[COW] ${drainResult.failed} correction(s) failed pre-batch; remaining entries retry on next sync/maintenance tick`, 'warn');
2354
- }
2355
- }
2356
- catch (drainErr) {
2357
- bot.manager.logger.log(`[COW] Pre-batch correction drain failed: ${getErrorMessage(drainErr)}`, 'warn');
2358
- }
2359
- }
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;
2360
2442
  const chainOrderCandidates = Array.isArray(bot.manager?._lastUnmatchedChainOrders)
2361
2443
  ? bot.manager._lastUnmatchedChainOrders
2362
2444
  : [];
@@ -2398,11 +2480,14 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2398
2480
  if (hasHardOccupiedViolation) {
2399
2481
  popPushedWorkingGrid(bot, cowResult);
2400
2482
  return {
2401
- executed: false,
2402
- aborted: true,
2403
- reason: 'CREATE_SLOT_OCCUPIED',
2404
- violations: createSlotValidation.violations,
2405
- 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
+ }
2406
2491
  };
2407
2492
  }
2408
2493
  const violatingIds = createSlotValidation.violatingTargetIds;
@@ -2424,7 +2509,7 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2424
2509
  // returns: a pushed working grid must be popped here or the
2425
2510
  // caller would leak the stack entry.
2426
2511
  popPushedWorkingGrid(bot, cowResult);
2427
- return { executed: false, hadRotation: false };
2512
+ return { proceed: false, result: { executed: false, hadRotation: false } };
2428
2513
  }
2429
2514
  }
2430
2515
  }
@@ -2436,27 +2521,24 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2436
2521
  `Waiting for next fill or sync cycle to reset recovery state.`, 'warn');
2437
2522
  popPushedWorkingGrid(bot, cowResult);
2438
2523
  return {
2439
- executed: false,
2440
- aborted: true,
2441
- reason: 'RECOVERY_EXHAUSTED',
2442
- hadRotation: false
2524
+ proceed: false,
2525
+ result: {
2526
+ executed: false,
2527
+ aborted: true,
2528
+ reason: 'RECOVERY_EXHAUSTED',
2529
+ hadRotation: false
2530
+ }
2443
2531
  };
2444
2532
  }
2445
2533
  const unmatchedChainOrders = Array.isArray(bot.manager?._lastUnmatchedChainOrders)
2446
2534
  ? bot.manager._lastUnmatchedChainOrders
2447
2535
  : [];
2448
- const pendingBroadcasts = (bot.manager && bot.manager._pendingBroadcasts instanceof Map)
2449
- ? Array.from(bot.manager._pendingBroadcasts.values())
2450
- : [];
2536
+ const pendingBroadcasts = getPendingBroadcasts(bot);
2451
2537
  if (hasCreateActions && (unmatchedChainOrders.length > 0 || pendingBroadcasts.length > 0)) {
2452
2538
  if (pendingBroadcasts.length > 0) {
2453
2539
  bot.manager.logger.log(`[COW] Rejecting CREATE batch: ${pendingBroadcasts.length} pending broadcast(s) from a prior uncertain ` +
2454
2540
  `broadcast. Running recovery before placing replacement orders.`, 'error');
2455
- if (typeof bot.manager.requestStructuralGridResync === 'function') {
2456
- if (bot.manager._recoveryState)
2457
- bot.manager._recoveryState = { ...bot.manager._recoveryState, structuralResyncRequested: true };
2458
- await bot.manager.requestStructuralGridResync('pending broadcasts before COW create', { pendingBroadcasts: pendingBroadcasts.map((p) => p.slotId) });
2459
- }
2541
+ await requestStructuralResync(bot, 'pending broadcasts before COW create', { pendingBroadcasts: pendingBroadcasts.map((p) => p.slotId) });
2460
2542
  try {
2461
2543
  await reconcileAfterUncertainBroadcast(bot, new BroadcastUncertainError('rejected CREATE batch had pending broadcasts', {
2462
2544
  operations: pendingBroadcasts.map((p) => p.order),
@@ -2471,10 +2553,13 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2471
2553
  }
2472
2554
  popPushedWorkingGrid(bot, cowResult);
2473
2555
  return {
2474
- executed: false,
2475
- aborted: true,
2476
- reason: 'PENDING_BROADCASTS',
2477
- hadRotation: false
2556
+ proceed: false,
2557
+ result: {
2558
+ executed: false,
2559
+ aborted: true,
2560
+ reason: 'PENDING_BROADCASTS',
2561
+ hadRotation: false
2562
+ }
2478
2563
  };
2479
2564
  }
2480
2565
  const unmatchedSample = unmatchedChainOrders
@@ -2491,8 +2576,8 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2491
2576
  // freshest orders; syncing on it would virtualize live slots and
2492
2577
  // re-create duplicates. Defer the adoption to a clean read — the
2493
2578
  // unmatched orders keep blocking CREATEs until then.
2494
- if (freshRead.truncated) {
2495
- bot.manager.logger.log('[COW] Post-guard chain snapshot TRUNCATED; skipping adoption sync (partial snapshot would virtualize live slots) — unmatched chain orders keep blocking CREATEs', 'warn');
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');
2496
2581
  }
2497
2582
  else if (freshRead.orders && freshRead.orders.length > 0) {
2498
2583
  const freshSnapshot = freshRead.orders;
@@ -2532,18 +2617,17 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2532
2617
  catch (syncErr) {
2533
2618
  bot.manager.logger.log(`[COW] Failed to sync/unmatched orders: ${syncErr?.message || syncErr}`, 'warn');
2534
2619
  }
2535
- if (typeof bot.manager.requestStructuralGridResync === 'function') {
2536
- if (bot.manager._recoveryState)
2537
- bot.manager._recoveryState = { ...bot.manager._recoveryState, structuralResyncRequested: true };
2538
- await bot.manager.requestStructuralGridResync('unmatched chain orders before COW create', { unmatchedChainOrders: unmatchedChainOrders });
2539
- }
2620
+ await requestStructuralResync(bot, 'unmatched chain orders before COW create', { unmatchedChainOrders: unmatchedChainOrders });
2540
2621
  bot.manager.logger.log(`[COW] Rejecting CREATE batch after sync: working grid invalidated by master mutation`, 'info');
2541
2622
  popPushedWorkingGrid(bot, cowResult);
2542
2623
  return {
2543
- executed: false,
2544
- aborted: true,
2545
- reason: 'UNMATCHED_CHAIN_ORDERS',
2546
- hadRotation: false
2624
+ proceed: false,
2625
+ result: {
2626
+ executed: false,
2627
+ aborted: true,
2628
+ reason: 'UNMATCHED_CHAIN_ORDERS',
2629
+ hadRotation: false
2630
+ }
2547
2631
  };
2548
2632
  }
2549
2633
  // Crossing-check candidate set (master + pending-broadcast + unmatched
@@ -2551,7 +2635,6 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2551
2635
  // it reflects any sync they triggered.
2552
2636
  const crossingCandidates = buildCrossingCandidates(bot);
2553
2637
  const intraBatchCandidates = [];
2554
- const { assetA, assetB } = bot.manager.assets;
2555
2638
  // CROSSED-BOOK GATE: refuse to broadcast any batch whose simulated result
2556
2639
  // prices a BUY at-or-above a SELL (see detectCrossedBookPlan).
2557
2640
  const crossedBookDetail = detectCrossedBookPlan(bot.manager, actions);
@@ -2559,17 +2642,90 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2559
2642
  bot.manager.logger.log(`[COW] Rejecting batch pre-broadcast: crossed book detected (${crossedBookDetail})`, 'error');
2560
2643
  popPushedWorkingGrid(bot, cowResult);
2561
2644
  return {
2562
- executed: false,
2563
- aborted: true,
2564
- reason: 'CROSSED_BOOK',
2565
- detail: crossedBookDetail,
2566
- hadRotation: false
2645
+ proceed: false,
2646
+ result: {
2647
+ executed: false,
2648
+ aborted: true,
2649
+ reason: 'CROSSED_BOOK',
2650
+ detail: crossedBookDetail,
2651
+ hadRotation: false
2652
+ }
2567
2653
  };
2568
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;
2569
2721
  const operations = [];
2570
2722
  const opContexts = [];
2571
2723
  const skippedUpdateSlotIds = new Set();
2572
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();
2573
2729
  // Per-batch LAST-FILL-GUARD disposition counters. Per-action pass lines
2574
2730
  // would spam big batches, so the guard emits one batch summary instead
2575
2731
  // (see the summary after the action loop below).
@@ -2645,6 +2801,8 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2645
2801
  const sizeValidation = validateOrderSizeForExecution(bot, order.size, order.type, order, order.size);
2646
2802
  if (!sizeValidation.isValid) {
2647
2803
  bot.manager.logger.log(`Skipping create op for ${action.id}: ${sizeValidation.reason}`, 'warn');
2804
+ if (action.id)
2805
+ skippedCreateSlotIds.add(action.id);
2648
2806
  continue;
2649
2807
  }
2650
2808
  const liveSlot = bot.manager.orders.get(order.id);
@@ -2669,6 +2827,8 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2669
2827
  `same-batch CREATE ${batchCollision.id} already at ` +
2670
2828
  `price ${Format.formatPrice6(batchCollision.order.price)}. ` +
2671
2829
  `The next reconcile cycle will resolve the mismatch.`, 'warn');
2830
+ if (order.id)
2831
+ skippedCreateSlotIds.add(order.id);
2672
2832
  continue;
2673
2833
  }
2674
2834
  // CROSSING-PLACEMENT GUARD (create variant): the batch-level
@@ -2687,6 +2847,8 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2687
2847
  bot.manager.logger.log(`[COW-CROSS-GUARD] Skipping CREATE for ${order.id} at ` +
2688
2848
  `${Format.formatPrice6(createPrice)}: crosses live ` +
2689
2849
  `${crossedOrderLabel(effectiveCrossed)}; re-planned after its cancel confirms.`, 'warn');
2850
+ if (order.id)
2851
+ skippedCreateSlotIds.add(order.id);
2690
2852
  continue;
2691
2853
  }
2692
2854
  // LAST-FILL PRICE GUARD: pivot ± halfIncrement (BUY < pivot*(1-half), SELL > pivot*(1+half)).
@@ -2699,20 +2861,15 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2699
2861
  const isCorrectionCreate = actionOrigin === 'spread-correction'
2700
2862
  || (actionOrigin == null && batchOrigin === 'spread-correction');
2701
2863
  if (!isCorrectionCreate) {
2702
- try {
2703
- if (refreshLastFillPivotFromQueue(bot))
2704
- lastFillGuardPivotRefreshed = true;
2705
- }
2706
- catch { /* best-effort */ }
2707
- const lastPrice = bot.manager?._lastFilledPrice;
2708
- const lastType = bot.manager?._lastFilledType;
2709
- const inc = resolveLastFillGuardIncrement(bot);
2710
- const check = isLastFillGuardBlocked(createPrice, order.size, order.type, lastPrice, lastType, inc);
2711
- lastFillGuardStats.checked++;
2864
+ const { check, refreshed } = runLastFillGuardCheck(bot, createPrice, order.size, order.type, lastFillGuardStats);
2865
+ if (refreshed)
2866
+ lastFillGuardPivotRefreshed = true;
2712
2867
  if (check.blocked) {
2713
2868
  lastFillGuardStats.skipped++;
2714
2869
  const dir = order.type === ORDER_TYPES.BUY ? 'above' : 'below';
2715
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);
2716
2873
  continue;
2717
2874
  }
2718
2875
  lastFillGuardStats.passed++;
@@ -2726,6 +2883,8 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2726
2883
  const buildResult = await chainOrders.buildCreateOrderOp(bot.account, args.amountToSell, args.sellAssetId, args.minToReceive, args.receiveAssetId, null);
2727
2884
  if (!buildResult) {
2728
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);
2729
2888
  continue;
2730
2889
  }
2731
2890
  operations.push(buildResult.op);
@@ -2928,16 +3087,9 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
2928
3087
  lastFillGuardStats.bypassed++;
2929
3088
  }
2930
3089
  else {
2931
- try {
2932
- if (refreshLastFillPivotFromQueue(bot))
2933
- lastFillGuardPivotRefreshed = true;
2934
- }
2935
- catch { /* best-effort */ }
2936
- const lastPrice = bot.manager?._lastFilledPrice;
2937
- const lastType = bot.manager?._lastFilledType;
2938
- const inc = resolveLastFillGuardIncrement(bot);
2939
- const check = isLastFillGuardBlocked(newPrice, newSize, orderType, lastPrice, lastType, inc);
2940
- lastFillGuardStats.checked++;
3090
+ const { check, refreshed } = runLastFillGuardCheck(bot, newPrice, newSize, orderType, lastFillGuardStats);
3091
+ if (refreshed)
3092
+ lastFillGuardPivotRefreshed = true;
2941
3093
  if (check.blocked) {
2942
3094
  lastFillGuardStats.skipped++;
2943
3095
  skippedUpdateCount++;
@@ -3065,14 +3217,9 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
3065
3217
  // so it obeys the same guard with a refreshed
3066
3218
  // pivot — no origin bypass, same as rotations.
3067
3219
  try {
3068
- if (refreshLastFillPivotFromQueue(bot))
3220
+ const { check: fbCheck, refreshed: fbRefreshed } = runLastFillGuardCheck(bot, fbPrice, fbSize, fbType, lastFillGuardStats);
3221
+ if (fbRefreshed)
3069
3222
  lastFillGuardPivotRefreshed = true;
3070
- }
3071
- catch { /* best-effort */ }
3072
- try {
3073
- const fbInc = resolveLastFillGuardIncrement(bot);
3074
- const fbCheck = isLastFillGuardBlocked(fbPrice, fbSize, fbType, bot.manager?._lastFilledPrice, bot.manager?._lastFilledType, fbInc);
3075
- lastFillGuardStats.checked++;
3076
3223
  if (fbCheck.blocked) {
3077
3224
  lastFillGuardStats.skipped++;
3078
3225
  const fbDir = fbType === ORDER_TYPES.BUY ? 'above' : 'below';
@@ -3160,6 +3307,15 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
3160
3307
  }
3161
3308
  bot.manager.logger.log(`[COW] Re-synced ${clampedUpdateSlotIds.size} post-fill-clamped slot(s) from master before commit`, 'debug');
3162
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
+ }
3163
3319
  if (operations.length === 0) {
3164
3320
  // Pop the working grid: in the re-plan recursion the fresh plan's
3165
3321
  // grid was pushed by performSafeRebalance, and nothing downstream
@@ -3255,14 +3411,7 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
3255
3411
  sell: item.ctx?.finalInts?.sell,
3256
3412
  receive: item.ctx?.finalInts?.receive,
3257
3413
  orderType: item.ctx?.order?.type,
3258
- fingerprint: buildCreateOpFingerprint({
3259
- side: item.ctx?.order?.type,
3260
- assetA: bot.manager?.assets?.assetA?.id,
3261
- assetB: bot.manager?.assets?.assetB?.id,
3262
- sellInt: item.ctx?.finalInts?.sell,
3263
- receiveInt: item.ctx?.finalInts?.receive,
3264
- slotId
3265
- })
3414
+ fingerprint: createOpFingerprintForSlot(bot, item.ctx?.order, item.ctx?.finalInts, slotId)
3266
3415
  });
3267
3416
  if (match?.id) {
3268
3417
  workingGrid.set(slotId, { ...slot, orderId: match.id });
@@ -3305,29 +3454,17 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
3305
3454
  // options.result, so a later throw in this frame (e.g.
3306
3455
  // processBatchResults after a successful commit) cannot pop a
3307
3456
  // second time for the same grid in the batch catch below.
3308
- const commitOk = await bot.manager._commitWorkingGrid(workingGrid, workingIndexes, workingBoundary, { skipRecalc: true, result: cowResult });
3457
+ const commitOk = await bot.manager._commitWorkingGrid(workingGrid, workingIndexes, effectiveBoundary, { skipRecalc: true, result: cowResult });
3309
3458
  if (!commitOk) {
3310
3459
  // Master changed during broadcast (e.g. a fill landed and was
3311
3460
  // processed concurrently) so the commit was refused. The batch
3312
3461
  // is on chain; adopt the placed orders from the chain so master
3313
3462
  // converges instead of remaining divergent until a later sync.
3314
- bot.manager.logger.log('[COW] Commit refused after broadcast; adopting placed orders from chain to keep master in sync', 'warn');
3315
- const adopted = await adoptPlacedBatchFromChain(bot, chainOrders, '[COW]', { placedResults: result, placedContexts: executedContexts });
3316
- if (!adopted) {
3317
- // Chain state unknown (empty/lagging read or sync failure):
3318
- // keep the pending-broadcast protection so a later plan
3319
- // cannot duplicate the placed orders, and let the structural
3320
- // resync adopt them from the chain.
3321
- bot.manager.logger.log('[COW] Commit refused and chain adoption unavailable; keeping pending-broadcast protection pending structural resync', 'error');
3322
- await requestStructuralResync(bot, 'commit refused after broadcast (chain adoption unavailable)', { reason: 'chain-adoption-unavailable' });
3323
- return { executed: false, hadRotation: false, commitRefused: true, chainAdoptionPending: true };
3324
- }
3325
- // Deduct create fees for the placed orders (mirrors
3326
- // processBatchResults, which the refused path bypasses).
3327
- await applyAdoptionFeeAccounting(bot, executedContexts);
3328
- await restoreBoundaryAfterAdoption(bot, workingBoundary);
3329
- await persistGridAndClearPendingBroadcasts(bot, '[COW]');
3330
- return { executed: false, hadRotation: false, commitRefused: true };
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
+ });
3331
3468
  }
3332
3469
  const batchResult = await processBatchResults(bot, result, executedContexts);
3333
3470
  const persistResult = await bot.manager.persistGrid();
@@ -3342,10 +3479,7 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
3342
3479
  bot.manager.logger.log(`[COW][PERSIST-GUARD] Retry also skipped/invalid ` +
3343
3480
  `(${retryResult.reason || 'no reason'}). Master grid in memory ` +
3344
3481
  `is ahead of disk snapshot; structural resync requested.`, 'error');
3345
- if (typeof bot.manager.requestStructuralGridResync === 'function') {
3346
- bot.manager._recoveryState = { ...bot.manager._recoveryState, structuralResyncRequested: true };
3347
- await bot.manager.requestStructuralGridResync('persistence guard triggered after COW batch', { persistReason: retryResult.reason || 'unknown' });
3348
- }
3482
+ await requestStructuralResync(bot, 'persistence guard triggered after COW batch', { persistReason: retryResult.reason || 'unknown' });
3349
3483
  }
3350
3484
  else {
3351
3485
  delete bot.manager._persistenceWarning;
@@ -3410,22 +3544,17 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
3410
3544
  // _commitWorkingGrid pops on every settle path and clears
3411
3545
  // the push marker via options.result, so a later throw here
3412
3546
  // must not pop again in the batch catch below.
3413
- 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 });
3414
3548
  if (!pollCommitOk) {
3415
3549
  // Master moved while polling — same recovery as the
3416
3550
  // refused-commit path: adopt from chain, keep pending
3417
3551
  // protection if adoption is unavailable.
3418
- bot.manager.logger.log(`[COW][UNCERTAIN] Poll-confirmed commit refused; adopting placed orders from chain`, 'warn');
3419
- const pollAdopted = await adoptPlacedBatchFromChain(bot, chainOrders, '[COW][UNCERTAIN]', { placedContexts: opContexts, polledCreateIds: confirmation.confirmedChainIds });
3420
- if (!pollAdopted) {
3421
- bot.manager.logger.log('[COW][UNCERTAIN] Poll-refused commit with unavailable chain adoption; keeping pending protection pending structural resync', 'error');
3422
- await requestStructuralResync(bot, 'poll-confirmed commit refused (chain adoption unavailable)', { reason: 'chain-adoption-unavailable' });
3423
- return { executed: false, hadRotation: false, commitRefused: true, chainAdoptionPending: true };
3424
- }
3425
- await applyAdoptionFeeAccounting(bot, opContexts);
3426
- await restoreBoundaryAfterAdoption(bot, workingBoundary);
3427
- await persistGridAndClearPendingBroadcasts(bot, '[COW][UNCERTAIN]');
3428
- return { executed: false, hadRotation: false, commitRefused: true, uncertainResolved: true };
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
+ });
3429
3558
  }
3430
3559
  // Enrich master grid with chain-assigned order IDs and amounts;
3431
3560
  // accounting enabled so the adopted orders' capital is locked
@@ -3436,19 +3565,16 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
3436
3565
  // on a failed adoption would let the next cycle re-create the
3437
3566
  // VIRTUAL slots as duplicate on-chain orders. Keep the
3438
3567
  // protection and defer to a structural resync instead.
3439
- const pollAdoptedOk = await adoptPlacedBatchFromChain(bot, chainOrders, '[COW][UNCERTAIN]', { placedContexts: opContexts, polledCreateIds: confirmation.confirmedChainIds });
3440
- if (!pollAdoptedOk) {
3441
- bot.manager.logger.log('[COW][UNCERTAIN] Poll-confirmed commit with unavailable chain adoption; keeping pending protection pending structural resync', 'error');
3442
- await requestStructuralResync(bot, 'poll-confirmed commit (chain adoption unavailable)', { reason: 'chain-adoption-unavailable' });
3443
- return { executed: false, hadRotation: false, commitRefused: false, chainAdoptionPending: true };
3444
- }
3445
3568
  // The commit happened without processBatchResults (no success
3446
3569
  // result to extract); deduct create fees so the optimistic
3447
- // balance reflects the on-chain cost.
3448
- await applyAdoptionFeeAccounting(bot, opContexts);
3449
- await restoreBoundaryAfterAdoption(bot, workingBoundary);
3450
- await persistGridAndClearPendingBroadcasts(bot, '[COW][UNCERTAIN]');
3451
- return { executed: true, hadRotation: false, uncertainResolved: true };
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
+ });
3452
3578
  }
3453
3579
  }
3454
3580
  catch (pollErr) {
@@ -3502,8 +3628,10 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
3502
3628
  * @param {Object} [details={}] - Details passed to the resync handler
3503
3629
  */
3504
3630
  async function requestStructuralResync(bot, reason, details = {}) {
3505
- 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}).`);
3506
3633
  return;
3634
+ }
3507
3635
  if (bot.manager._recoveryState) {
3508
3636
  bot.manager._recoveryState = { ...bot.manager._recoveryState, structuralResyncRequested: true };
3509
3637
  }
@@ -3523,91 +3651,6 @@ async function requestStructuralResync(bot, reason, details = {}) {
3523
3651
  * @param {string} logPrefix - Log prefix for sync failure messages
3524
3652
  * @returns {Promise<boolean>}
3525
3653
  */
3526
- /**
3527
- * Collect every on-chain order id master currently needs to converge against,
3528
- * so adoption can re-read them by id (immune to the get_full_accounts window
3529
- * truncation) instead of relying on a partial window read.
3530
- *
3531
- * Sources:
3532
- * - master's own tracked order ids (existing on-chain orders);
3533
- * - the batch's fresh CREATE ids extracted from the broadcast result
3534
- * (operation_results[i][1] aligns positionally with placedContexts[i]).
3535
- *
3536
- * @param {any} mgr - bot.manager
3537
- * @param {any} placedResults - broadcast result (has operation_results); null when unavailable
3538
- * @param {any[]} placedContexts - opContexts (aligned with operation_results); null when unavailable
3539
- * @param {string[]|null} [extraCreateIds=null] - fresh CREATE chain ids from another
3540
- * authoritative source (e.g. the uncertain-broadcast poll confirmation) when
3541
- * no broadcast result exists; merged into createIds so the lagging-create
3542
- * retry guards them
3543
- * @returns {string[]} Unique, well-formed 1.7.x order ids
3544
- */
3545
- function collectKnownOnChainOrderIds(mgr, placedResults, placedContexts, extraCreateIds = null) {
3546
- const masterIds = new Set();
3547
- const grid = mgr && mgr.grid;
3548
- if (Array.isArray(grid)) {
3549
- for (const slot of grid) {
3550
- if (slot && slot.orderId && /^1\.7\.\d+$/.test(String(slot.orderId))) {
3551
- masterIds.add(String(slot.orderId));
3552
- }
3553
- }
3554
- }
3555
- // Master tracked ids live in the orders Map (mgr.grid is legacy and
3556
- // unset on OrderManager — without this the by-id set omits every
3557
- // pre-existing ACTIVE order and pass-1 phantom cleanup would virtualize
3558
- // them as fills on a partial snapshot).
3559
- if (mgr && mgr.orders instanceof Map) {
3560
- for (const slot of mgr.orders.values()) {
3561
- if (slot && slot.orderId && /^1\.7\.\d+$/.test(String(slot.orderId))) {
3562
- masterIds.add(String(slot.orderId));
3563
- }
3564
- }
3565
- }
3566
- const createIds = new Set();
3567
- if (placedResults && Array.isArray(placedContexts)) {
3568
- const opResults = extractBatchOperationResults(placedResults);
3569
- if (Array.isArray(opResults)) {
3570
- for (let i = 0; i < placedContexts.length; i++) {
3571
- const ctx = placedContexts[i];
3572
- if (!ctx || ctx.kind !== 'create')
3573
- continue;
3574
- const opResult = opResults[i] && opResults[i][1];
3575
- if (opResult && /^1\.7\.\d+$/.test(String(opResult))) {
3576
- createIds.add(String(opResult));
3577
- }
3578
- }
3579
- }
3580
- }
3581
- if (Array.isArray(extraCreateIds)) {
3582
- for (const id of extraCreateIds) {
3583
- if (id && /^1\.7\.\d+$/.test(String(id)))
3584
- createIds.add(String(id));
3585
- }
3586
- }
3587
- // Existing chain ids referenced by non-create op contexts (cancel /
3588
- // rotation / size-update). Pre-existing orders whose absence is expected
3589
- // (cancels/fills in this batch), so they join the by-id set but never
3590
- // the lagging-create guard.
3591
- if (Array.isArray(placedContexts)) {
3592
- for (const ctx of placedContexts) {
3593
- if (!ctx || ctx.kind === 'create')
3594
- continue;
3595
- const refs = [];
3596
- if (ctx.kind === 'cancel' && ctx.order)
3597
- refs.push(ctx.order.orderId);
3598
- else if (ctx.kind === 'rotation' && ctx.rotation?.oldOrder)
3599
- refs.push(ctx.rotation.oldOrder.orderId);
3600
- else if (ctx.kind === 'size-update' && ctx.updateInfo?.partialOrder)
3601
- refs.push(ctx.updateInfo.partialOrder.orderId);
3602
- for (const id of refs) {
3603
- if (id && /^1\.7\.\d+$/.test(String(id)))
3604
- masterIds.add(String(id));
3605
- }
3606
- }
3607
- }
3608
- const all = new Set([...masterIds, ...createIds]);
3609
- return { masterIds: [...masterIds], createIds: [...createIds], all: [...all] };
3610
- }
3611
3654
  /**
3612
3655
  * Converge master with the chain after a COW commit was refused (master moved
3613
3656
  * during broadcast) or an uncertain broadcast was poll-confirmed.
@@ -3760,8 +3803,8 @@ async function adoptPlacedBatchFromChain(bot, chainOrders, logPrefix, opts = {})
3760
3803
  }
3761
3804
  // FALLBACK: window read (ambiguous when truncated).
3762
3805
  const freshRead = await readOpenOrdersWithMetaSafe(chainOrders, accountRef);
3763
- if (freshRead.truncated) {
3764
- bot.manager.logger.log(`${logPrefix} Chain read TRUNCATED after batch broadcast; adoption deferred (pending-broadcast protection kept)`, 'warn');
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');
3765
3808
  return false;
3766
3809
  }
3767
3810
  const freshChain = freshRead.orders;
@@ -3791,6 +3834,75 @@ async function persistGridAndClearPendingBroadcasts(bot, logPrefix) {
3791
3834
  }
3792
3835
  clearPendingBroadcasts(bot.manager?._pendingBroadcasts);
3793
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
+ }
3794
3906
  /**
3795
3907
  * Apply BTS create-fee accounting for a batch that bypassed the normal
3796
3908
  * processBatchResults pipeline (commit refused after broadcast, or
@@ -3827,6 +3939,7 @@ async function applyAdoptionFeeAccounting(bot, contexts) {
3827
3939
  isPartialPlacement: false,
3828
3940
  expectedType: ctx.order.type,
3829
3941
  fee: btsFeeData?.createFee || 0,
3942
+ order: ctx.order ?? null,
3830
3943
  }, 'createOrder');
3831
3944
  }
3832
3945
  catch (feeErr) {
@@ -3866,6 +3979,22 @@ async function applyAdoptionFeeAccounting(bot, contexts) {
3866
3979
  }
3867
3980
  }
3868
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
+ }
3869
3998
  /**
3870
3999
  * Process results from batch transaction execution.
3871
4000
  * Updates order state, synchronizes with chain, and deducts BTS fees.
@@ -3893,27 +4022,16 @@ async function processBatchResults(bot, result, opContexts) {
3893
4022
  bot.manager.logger.log(`Cancelled surplus order ${ctx.order.id} (${ctx.order.orderId})`, 'info');
3894
4023
  const oldOrder = ctx.order;
3895
4024
  const committedOrder = oldOrder?.id ? bot.manager.orders.get(oldOrder.id) : null;
3896
- if (oldOrder && committedOrder && bot.manager.accountant) {
3897
- await bot.manager.accountant.updateOptimisticFreeBalance(oldOrder, committedOrder, 'fill-cancel', btsFeeData?.cancelFee || 0, false);
3898
- }
4025
+ await applyOptimisticFeeBalance(bot, oldOrder, committedOrder, 'fill-cancel', btsFeeData?.cancelFee || 0);
3899
4026
  }
3900
4027
  else if (ctx.kind === 'size-update') {
3901
4028
  const oldOrder = ctx.updateInfo.partialOrder;
3902
4029
  const ord = bot.manager.orders.get(oldOrder.id);
3903
- if (oldOrder && ord && bot.manager.accountant) {
3904
- await bot.manager.accountant.updateOptimisticFreeBalance(oldOrder, ord, 'order-update', btsFeeData?.updateFee || 0, false);
3905
- }
4030
+ await applyOptimisticFeeBalance(bot, oldOrder, ord, 'order-update', btsFeeData?.updateFee || 0);
3906
4031
  if (ord) {
3907
4032
  const updatedSlot = { ...ord, size: ctx.updateInfo.newSize };
3908
4033
  if (ctx.finalInts) {
3909
- updatedSlot.rawOnChain = {
3910
- id: ord.orderId,
3911
- for_sale: String(ctx.finalInts.sell),
3912
- sell_price: {
3913
- base: { amount: String(ctx.finalInts.sell), asset_id: ctx.finalInts.sellAssetId },
3914
- quote: { amount: String(ctx.finalInts.receive), asset_id: ctx.finalInts.receiveAssetId }
3915
- }
3916
- };
4034
+ updatedSlot.rawOnChain = rawOnChainFromInts(ord.orderId, ctx.finalInts);
3917
4035
  }
3918
4036
  updatesToApply.push({ order: updatedSlot, context: 'post-update-metadata' });
3919
4037
  }
@@ -3924,7 +4042,8 @@ async function processBatchResults(bot, result, opContexts) {
3924
4042
  const chainOrderId = res && res[1];
3925
4043
  if (chainOrderId) {
3926
4044
  await bot.manager.synchronizeWithChain({
3927
- 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,
3928
4047
  }, 'createOrder');
3929
4048
  if (ctx.finalInts) {
3930
4049
  const syncedOrder = bot.manager.orders.get(ctx.order.id);
@@ -3932,14 +4051,7 @@ async function processBatchResults(bot, result, opContexts) {
3932
4051
  updatesToApply.push({
3933
4052
  order: {
3934
4053
  ...syncedOrder,
3935
- rawOnChain: {
3936
- id: chainOrderId,
3937
- for_sale: String(ctx.finalInts.sell),
3938
- sell_price: {
3939
- base: { amount: String(ctx.finalInts.sell), asset_id: ctx.finalInts.sellAssetId },
3940
- quote: { amount: String(ctx.finalInts.receive), asset_id: ctx.finalInts.receiveAssetId }
3941
- }
3942
- }
4054
+ rawOnChain: rawOnChainFromInts(chainOrderId, ctx.finalInts)
3943
4055
  },
3944
4056
  context: 'post-placement-metadata'
3945
4057
  });
@@ -3969,20 +4081,11 @@ async function processBatchResults(bot, result, opContexts) {
3969
4081
  const { oldOrder, newPrice, newGridId, newSize, type } = rotation;
3970
4082
  if (!newGridId) {
3971
4083
  const ord = bot.manager.orders.get(oldOrder.id || rotation.id);
3972
- if (oldOrder && ord && bot.manager.accountant) {
3973
- await bot.manager.accountant.updateOptimisticFreeBalance(oldOrder, ord, 'order-update', btsFeeData?.updateFee || 0, false);
3974
- }
4084
+ await applyOptimisticFeeBalance(bot, oldOrder, ord, 'order-update', btsFeeData?.updateFee || 0);
3975
4085
  if (ord) {
3976
4086
  const updatedSlot = { ...ord, size: newSize };
3977
4087
  if (ctx.finalInts) {
3978
- updatedSlot.rawOnChain = {
3979
- id: ord.orderId,
3980
- for_sale: String(ctx.finalInts.sell),
3981
- sell_price: {
3982
- base: { amount: String(ctx.finalInts.sell), asset_id: ctx.finalInts.sellAssetId },
3983
- quote: { amount: String(ctx.finalInts.receive), asset_id: ctx.finalInts.receiveAssetId }
3984
- }
3985
- };
4088
+ updatedSlot.rawOnChain = rawOnChainFromInts(ord.orderId, ctx.finalInts);
3986
4089
  }
3987
4090
  updatesToApply.push({ order: updatedSlot, context: 'post-update-metadata' });
3988
4091
  }
@@ -4013,18 +4116,9 @@ async function processBatchResults(bot, result, opContexts) {
4013
4116
  orderId: oldOrder?.orderId || slot.orderId || null
4014
4117
  };
4015
4118
  if (ctx.finalInts) {
4016
- updatedSlot.rawOnChain = {
4017
- id: updatedSlot.orderId,
4018
- for_sale: String(ctx.finalInts.sell),
4019
- sell_price: {
4020
- base: { amount: String(ctx.finalInts.sell), asset_id: ctx.finalInts.sellAssetId },
4021
- quote: { amount: String(ctx.finalInts.receive), asset_id: ctx.finalInts.receiveAssetId }
4022
- }
4023
- };
4024
- }
4025
- if (oldOrder && updatedSlot && bot.manager.accountant) {
4026
- await bot.manager.accountant.updateOptimisticFreeBalance(oldOrder, updatedSlot, 'order-update', btsFeeData?.updateFee || 0, false);
4119
+ updatedSlot.rawOnChain = rawOnChainFromInts(updatedSlot.orderId, ctx.finalInts);
4027
4120
  }
4121
+ await applyOptimisticFeeBalance(bot, oldOrder, updatedSlot, 'order-update', btsFeeData?.updateFee || 0);
4028
4122
  if (oldOrder?.id && oldOrder.id !== newGridId) {
4029
4123
  const currentSource = bot.manager.orders.get(oldOrder.id);
4030
4124
  if (currentSource && currentSource.orderId) {
@@ -4051,7 +4145,10 @@ async function processBatchResults(bot, result, opContexts) {
4051
4145
  updateOperationCount
4052
4146
  };
4053
4147
  }
4054
- export { isLastFillGuardBlocked, refreshLastFillPivotFromQueue, buildOutsideInPairGroupsForOrders, buildOutsideInPairGroupsForCreateEntries, extractOperationResults, findMissingCreateResultContexts, markMissingCreateResultsAsStructuralBlocker, formatUnmatchedChainOrderForLog, recordPendingBroadcast, clearPendingBroadcasts, clearPendingBroadcastsForSlots, popPushedWorkingGrid, buildChainOrderFingerprint, normalizeChainOrderForPendingMatch, findChainOrderForSlot, reconcileAfterUncertainBroadcast, reconcileAfterUncertainBroadcastImpl, autoCancelOneUnmatchedOrphan, shouldExecuteCreatePairMode, executeWithRetryOnUncertain, executeChunkedWithRetryOnUncertain, formatPartialBroadcastSummary, executeOperationsWithStrategy, validateOperationFunds, resolveIdealSizeForValidation, validateOrderSizeForExecution, buildActionsFromPlan, buildCowResultFromPlan, restoreSkippedUpdateSlotsInWorkingGrid, applyRotationTransitionsToWorkingGrid, pollChainForConfirmation, updateOrdersOnChainBatchCOW, processBatchResults, adoptPlacedBatchFromChain };
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 };
4055
4152
  export default {
4056
4153
  buildOutsideInPairGroupsForOrders,
4057
4154
  buildOutsideInPairGroupsForCreateEntries,
@@ -4080,6 +4177,8 @@ export default {
4080
4177
  buildActionsFromPlan,
4081
4178
  buildCowResultFromPlan,
4082
4179
  restoreSkippedUpdateSlotsInWorkingGrid,
4180
+ resolveRefillBoundaryHold,
4181
+ toRefillSlotIdSet,
4083
4182
  applyRotationTransitionsToWorkingGrid,
4084
4183
  pollChainForConfirmation,
4085
4184
  updateOrdersOnChainBatchCOW,