dexbot 1.5.0 → 1.5.2

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 (87) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/analysis/ama_fitting/package.json +1 -1
  3. package/analysis/trend_detection/package.json +1 -1
  4. package/claw/package.json +1 -1
  5. package/claw/runtimes/openclaw-plugin/openclaw.plugin.json +1 -1
  6. package/claw/runtimes/openclaw-plugin/package.json +1 -1
  7. package/claw/tests/test_claw_mcp_transport.ts +2 -2
  8. package/dist/dexbot.js +5 -5
  9. package/dist/dexbot.js.map +1 -1
  10. package/dist/modules/account_orders.d.ts +7 -1
  11. package/dist/modules/account_orders.d.ts.map +1 -1
  12. package/dist/modules/account_orders.js +34 -1
  13. package/dist/modules/account_orders.js.map +1 -1
  14. package/dist/modules/constants.d.ts +2 -0
  15. package/dist/modules/constants.d.ts.map +1 -1
  16. package/dist/modules/constants.js +12 -0
  17. package/dist/modules/constants.js.map +1 -1
  18. package/dist/modules/dexbot_class.d.ts +1 -0
  19. package/dist/modules/dexbot_class.d.ts.map +1 -1
  20. package/dist/modules/dexbot_cow_runtime.d.ts +19 -1
  21. package/dist/modules/dexbot_cow_runtime.d.ts.map +1 -1
  22. package/dist/modules/dexbot_cow_runtime.js +335 -78
  23. package/dist/modules/dexbot_cow_runtime.js.map +1 -1
  24. package/dist/modules/dexbot_fill_runtime.d.ts.map +1 -1
  25. package/dist/modules/dexbot_fill_runtime.js +7 -1
  26. package/dist/modules/dexbot_fill_runtime.js.map +1 -1
  27. package/dist/modules/dexbot_startup_runtime.d.ts +1 -0
  28. package/dist/modules/dexbot_startup_runtime.d.ts.map +1 -1
  29. package/dist/modules/dexbot_startup_runtime.js +54 -4
  30. package/dist/modules/dexbot_startup_runtime.js.map +1 -1
  31. package/dist/modules/dexbot_state_recovery.d.ts.map +1 -1
  32. package/dist/modules/dexbot_state_recovery.js +7 -0
  33. package/dist/modules/dexbot_state_recovery.js.map +1 -1
  34. package/dist/modules/order/accounting.d.ts +34 -0
  35. package/dist/modules/order/accounting.d.ts.map +1 -1
  36. package/dist/modules/order/accounting.js +199 -5
  37. package/dist/modules/order/accounting.js.map +1 -1
  38. package/dist/modules/order/export.d.ts +122 -15
  39. package/dist/modules/order/export.d.ts.map +1 -1
  40. package/dist/modules/order/export.js +310 -46
  41. package/dist/modules/order/export.js.map +1 -1
  42. package/dist/modules/order/grid.d.ts.map +1 -1
  43. package/dist/modules/order/grid.js +22 -17
  44. package/dist/modules/order/grid.js.map +1 -1
  45. package/dist/modules/order/grid_reconcile.d.ts.map +1 -1
  46. package/dist/modules/order/grid_reconcile.js +7 -3
  47. package/dist/modules/order/grid_reconcile.js.map +1 -1
  48. package/dist/modules/order/grid_reconcile_internal.d.ts.map +1 -1
  49. package/dist/modules/order/grid_reconcile_internal.js +98 -28
  50. package/dist/modules/order/grid_reconcile_internal.js.map +1 -1
  51. package/dist/modules/order/manager.d.ts +52 -6
  52. package/dist/modules/order/manager.d.ts.map +1 -1
  53. package/dist/modules/order/manager.js +156 -21
  54. package/dist/modules/order/manager.js.map +1 -1
  55. package/dist/modules/order/strategy.d.ts.map +1 -1
  56. package/dist/modules/order/strategy.js +17 -2
  57. package/dist/modules/order/strategy.js.map +1 -1
  58. package/dist/modules/order/sync_engine.d.ts.map +1 -1
  59. package/dist/modules/order/sync_engine.js +213 -17
  60. package/dist/modules/order/sync_engine.js.map +1 -1
  61. package/dist/modules/order/utils/math.d.ts +70 -4
  62. package/dist/modules/order/utils/math.d.ts.map +1 -1
  63. package/dist/modules/order/utils/math.js +136 -5
  64. package/dist/modules/order/utils/math.js.map +1 -1
  65. package/dist/modules/order/utils/order.d.ts +117 -5
  66. package/dist/modules/order/utils/order.d.ts.map +1 -1
  67. package/dist/modules/order/utils/order.js +274 -13
  68. package/dist/modules/order/utils/order.js.map +1 -1
  69. package/dist/modules/order/utils/system.d.ts +14 -0
  70. package/dist/modules/order/utils/system.d.ts.map +1 -1
  71. package/dist/modules/order/utils/system.js +51 -3
  72. package/dist/modules/order/utils/system.js.map +1 -1
  73. package/dist/modules/order/utils/validate.d.ts +29 -2
  74. package/dist/modules/order/utils/validate.d.ts.map +1 -1
  75. package/dist/modules/order/utils/validate.js +207 -35
  76. package/dist/modules/order/utils/validate.js.map +1 -1
  77. package/dist/scripts/analyze-credit.d.ts.map +1 -1
  78. package/dist/scripts/analyze-credit.js +53 -33
  79. package/dist/scripts/analyze-credit.js.map +1 -1
  80. package/docs/DEXBOT_COMPARISON.md +3 -3
  81. package/docs/EVOLUTION.md +7 -5
  82. package/docs/FUND_MOVEMENT_AND_ACCOUNTING.md +3 -1
  83. package/docs/GRID_RECONCILE.md +1 -0
  84. package/docs/LOGGING.md +3 -0
  85. package/docs/README.md +2 -2
  86. package/docs/WORKFLOW.md +1 -1
  87. package/package.json +1 -1
@@ -95,16 +95,84 @@
95
95
  */
96
96
  import { ORDER_TYPES, ORDER_STATES, TIMING, BTS_PRECISION } from '../constants.js';
97
97
  import * as Format from './format.js';
98
- import { lookupAsset } from './utils/system.js';
98
+ import { lookupAsset, sleep, resolveAccountRef } from './utils/system.js';
99
99
  import * as chainOrders from '../chain_orders.js';
100
100
  import * as client from '../bitshares_client.js';
101
101
  const { BitShares } = client;
102
102
  import { NATIVE_CLIENT } from '../constants.js';
103
103
  const { toFiniteNumber } = Format;
104
- import { blockchainToFloat, floatToBlockchainInt, calculatePriceTolerance, getAssetFees, getBtsSide, slotIndexForPrice, isSlotInRail, priceSlotEqual } from './utils/math.js';
104
+ import { blockchainToFloat, floatToBlockchainInt, calculatePriceTolerance, getAssetFees, getBtsSide, getSellStartIdx, slotIndexForPrice, isSlotInRail, isSlotIndexInGapBand, priceSlotEqual } from './utils/math.js';
105
105
  import { parseChainOrder, findMatchingGridOrderByOpenOrder, applyChainSizeToGridOrder, convertToSpreadPlaceholder, virtualizeOrder, buildFillKey, isOrderPlaced, hasOnChainId, isOrderVirtual, resolveSpreadOrderSide, duplicateOrphanLogInfo } from './utils/order.js';
106
+ import { parseSlotIndex } from './utils/slot.js';
106
107
  import { resolveProcessedFillPersistenceMode } from './processed_fill_store.js';
107
108
  import { getErrorMessage } from '../utils/errors.js';
109
+ /**
110
+ * Confirming re-read for the suspect-empty-read guard: after
111
+ * SYNC_SUSPECT_EMPTY_READ_LIMIT consecutive 0-order snapshots with placed
112
+ * grid orders still present, one more read after
113
+ * SYNC_EMPTY_READ_CONFIRM_DELAY_MS decides whether the account is genuinely
114
+ * empty. A lagging node serves consecutive empties, so the count alone is
115
+ * not authoritative.
116
+ *
117
+ * Test seam: when manager._confirmEmptyReadFn is a function it is used
118
+ * instead of the chain read (must resolve to a raw order array, or null
119
+ * when the re-read itself is ambiguous). A throwing seam is 'ambiguous'.
120
+ *
121
+ * @param {Object} mgr - OrderManager instance (accountId, config, logger).
122
+ * @returns {Promise<string>} 'confirmed' (still empty — accept),
123
+ * 'contradicted' (non-empty — reset counter, refuse this round),
124
+ * 'ambiguous' (truncated/read-error — refuse, re-confirm next round), or
125
+ * 'unavailable' (no chain identity or dry-run — caller falls back to the
126
+ * legacy count-based acceptance, preserving unit-test semantics).
127
+ */
128
+ async function confirmSuspectEmptyRead(mgr) {
129
+ try {
130
+ if (mgr?.config?.dryRun)
131
+ return 'unavailable';
132
+ const seam = mgr?._confirmEmptyReadFn;
133
+ const delay = Math.max(0, Number(TIMING.SYNC_EMPTY_READ_CONFIRM_DELAY_MS) || 0);
134
+ if (typeof seam === 'function') {
135
+ if (delay > 0)
136
+ await sleep(delay);
137
+ let fresh = null;
138
+ try {
139
+ fresh = await seam();
140
+ }
141
+ catch {
142
+ return 'ambiguous';
143
+ }
144
+ if (fresh === null || fresh === undefined)
145
+ return 'ambiguous';
146
+ if (Array.isArray(fresh) && fresh.length > 0)
147
+ return 'contradicted';
148
+ return 'confirmed';
149
+ }
150
+ let accountRef = null;
151
+ try {
152
+ accountRef = resolveAccountRef(mgr, null);
153
+ }
154
+ catch {
155
+ return 'unavailable';
156
+ }
157
+ if (!accountRef)
158
+ return 'unavailable';
159
+ if (delay > 0)
160
+ await sleep(delay);
161
+ const fresh = await chainOrders.readOpenOrdersGuarded(chainOrders, accountRef, {
162
+ log: (message, level) => mgr?.logger?.log?.(message, level),
163
+ label: 'SYNC-CONFIRM',
164
+ detail: 'suspect-empty confirm re-read',
165
+ });
166
+ if (fresh === null)
167
+ return 'ambiguous';
168
+ if (Array.isArray(fresh) && fresh.length > 0)
169
+ return 'contradicted';
170
+ return 'confirmed';
171
+ }
172
+ catch {
173
+ return 'ambiguous';
174
+ }
175
+ }
108
176
  function describeNearestAdoptionCandidates(mgr, chainOrder, precision, calcTolerance, matchedGridOrderIds = null) {
109
177
  if (!mgr?.orders || !chainOrder || typeof precision !== 'number')
110
178
  return 'candidate diagnostics unavailable';
@@ -200,6 +268,15 @@ async function adoptChainOrderIntoSlot(mgr, slot, chainOrder, chainOrderId, rawC
200
268
  let bestMatch = { ...slot };
201
269
  const wasVirtual = slot.state === ORDER_STATES.VIRTUAL;
202
270
  const wasPartial = slot.state === ORDER_STATES.PARTIAL;
271
+ // SPREAD slots are legitimate adoption targets (typeCompat allows them),
272
+ // but the slot must be re-typed to the real on-chain side BEFORE activation:
273
+ // validateOrder treats SPREAD + orderId/on-chain state as fatal
274
+ // ILLEGAL_SPREAD_STATE, and the size precision below depends on the
275
+ // resolved side. Every other activation path (COW rotation destinations,
276
+ // legacy fallback adoption, grid load) performs the same re-type.
277
+ if (bestMatch.type === ORDER_TYPES.SPREAD && (chainOrder.type === ORDER_TYPES.BUY || chainOrder.type === ORDER_TYPES.SELL)) {
278
+ bestMatch.type = chainOrder.type;
279
+ }
203
280
  bestMatch.orderId = chainOrderId;
204
281
  bestMatch.state = wasVirtual ? ORDER_STATES.ACTIVE : slot.state;
205
282
  const bestMatchRaw = rawChainOrders.get(chainOrderId);
@@ -233,8 +310,17 @@ async function adoptChainOrderIntoSlot(mgr, slot, chainOrder, chainOrderId, rawC
233
310
  }
234
311
  else {
235
312
  const spreadOrder = convertToSpreadPlaceholder(bestMatch);
313
+ const applied = await mgr._applyOrderUpdate(spreadOrder, 'sync-pass2-filled', { skipAccounting: skipAccounting, fee: 0 });
314
+ if (applied === false) {
315
+ // Rejected adoption must not poison the slot for the next
316
+ // chain order: the slot was marked matched above, so release
317
+ // it — B's adoption re-fails the same validation and gets
318
+ // the existing cancelOnly handling if the rejection was
319
+ // slot-specific.
320
+ matchedGridOrderIds.delete(bestMatch.id);
321
+ return false;
322
+ }
236
323
  filledOrders.push({ ...bestMatch });
237
- await mgr._applyOrderUpdate(spreadOrder, 'sync-pass2-filled', { skipAccounting: skipAccounting, fee: 0 });
238
324
  updatedOrders.push(spreadOrder);
239
325
  chainOrderIdsOnGrid.add(chainOrderId);
240
326
  return true; // filled path
@@ -243,10 +329,16 @@ async function adoptChainOrderIntoSlot(mgr, slot, chainOrder, chainOrderId, rawC
243
329
  else if (wasPartial) {
244
330
  bestMatch.state = ORDER_STATES.PARTIAL;
245
331
  }
246
- await mgr._applyOrderUpdate(bestMatch, 'sync-pass2-orphan', { skipAccounting: skipAccounting, fee: 0 });
332
+ const applied = await mgr._applyOrderUpdate(bestMatch, 'sync-pass2-orphan', { skipAccounting: skipAccounting, fee: 0 });
333
+ if (applied === false) {
334
+ // Same un-poisoning as the filled path above: the slot counts as
335
+ // matched only once the update lands.
336
+ matchedGridOrderIds.delete(bestMatch.id);
337
+ return false;
338
+ }
247
339
  updatedOrders.push(bestMatch);
248
340
  chainOrderIdsOnGrid.add(chainOrderId);
249
- return false;
341
+ return true;
250
342
  }
251
343
  function computeOutOfToleranceDriftTag(mgr, chainOrder, calcToleranceFn) {
252
344
  if (!mgr?.orders || !chainOrder)
@@ -567,7 +659,8 @@ class SyncEngine {
567
659
  // ("phantom" resets). Refuse to reconcile on a
568
660
  // suspect empty; the guard is self-expiring: after
569
661
  // TIMING.SYNC_SUSPECT_EMPTY_READ_LIMIT consecutive empty reads the
570
- // account really is empty and the sync accepts it. Any non-empty read
662
+ // account really is empty and the sync accepts it — but only after one
663
+ // confirming re-read (see below). Any non-empty read
571
664
  // resets the counter.
572
665
  if (parsedChainOrders.size === 0) {
573
666
  const gridOrderIds = Array.from(mgr.orders.values()).filter((o) => o?.orderId).length;
@@ -583,7 +676,33 @@ class SyncEngine {
583
676
  `refusing reconciliation (phantom protection); accepting after ${limit} consecutive empties or next non-empty read`, 'warn');
584
677
  return { filledOrders: [], updatedOrders: [], ordersNeedingCorrection: [], unmatchedChainOrders: [] };
585
678
  }
586
- mgr.logger?.log?.(`[SYNC] Empty read confirmed after ${suspect.count} consecutive attempts — reconciling to empty account`, 'warn');
679
+ // Limit reached: a lagging node serves CONSECUTIVE 0-order
680
+ // snapshots, so the count alone is not authoritative. Require
681
+ // one confirming re-read after SYNC_EMPTY_READ_CONFIRM_DELAY_MS
682
+ // before reconciling to empty; a contradicted re-read
683
+ // (non-empty) resets the counter and refuses this round (the
684
+ // next sync reconciles against the fresh snapshot), while an
685
+ // ambiguous re-read (truncated/read-error) refuses and retries
686
+ // the confirm on the next empty round.
687
+ const confirm = await confirmSuspectEmptyRead(mgr);
688
+ if (confirm === 'contradicted') {
689
+ mgr.logger?.log?.(`[SYNC] Suspect empty read contradicted by confirm re-read with ${gridOrderIds} grid orderIds — ` +
690
+ `resetting empty-read counter; reconciling on the next non-empty sync`, 'warn');
691
+ mgr._suspectEmptyReads = { count: 0, firstAt: 0 };
692
+ return { filledOrders: [], updatedOrders: [], ordersNeedingCorrection: [], unmatchedChainOrders: [] };
693
+ }
694
+ if (confirm === 'ambiguous') {
695
+ mgr.logger?.log?.(`[SYNC] Suspect empty read confirm re-read ambiguous with ${gridOrderIds} grid orderIds — ` +
696
+ `refusing reconciliation; re-confirming on the next empty read`, 'warn');
697
+ mgr._suspectEmptyReads = { count: limit, firstAt: suspect.firstAt || Date.now() };
698
+ return { filledOrders: [], updatedOrders: [], ordersNeedingCorrection: [], unmatchedChainOrders: [] };
699
+ }
700
+ // 'confirmed' (still empty on re-read) or 'unavailable' (no
701
+ // chain identity / dry-run — legacy count-based acceptance,
702
+ // preserving unit-test semantics): reconcile to empty.
703
+ mgr.logger?.log?.(`[SYNC] Empty read confirmed after ${suspect.count} consecutive attempts` +
704
+ (confirm === 'unavailable' ? ' (no chain identity for confirm re-read)' : ' (+ confirming re-read)') +
705
+ ` — reconciling to empty account`, 'warn');
587
706
  mgr._suspectEmptyReads = { count: 0, firstAt: 0 };
588
707
  }
589
708
  else {
@@ -829,9 +948,17 @@ class SyncEngine {
829
948
  && toFiniteNumber(reboundOrder.rawOnChain?.for_sale, 0) > 0) {
830
949
  reboundOrder.state = ORDER_STATES.PARTIAL;
831
950
  }
951
+ const applied = await mgr._applyOrderUpdate(reboundOrder, 'sync-pass1-duplicate-swap', { skipAccounting: skipAccounting, fee: 0 });
952
+ if (applied === false) {
953
+ // Rejected rebind: the slot keeps its old binding
954
+ // (set ops must run only after the apply lands), so
955
+ // the old orderId stays matched on-grid and the swap
956
+ // candidate stays unmatched → pass 2 cancels it.
957
+ mgr.logger?.log?.(`[SYNC] Size tiebreak for ${gridOrder.id}: rebind to ${swapMatch.id} rejected — slot keeps ${gridOrder.orderId}; duplicate falls to pass 2`, 'warn');
958
+ continue;
959
+ }
832
960
  chainOrderIdsOnGrid.delete(gridOrder.orderId);
833
961
  chainOrderIdsOnGrid.add(swapMatch.id);
834
- await mgr._applyOrderUpdate(reboundOrder, 'sync-pass1-duplicate-swap', { skipAccounting: skipAccounting, fee: 0 });
835
962
  updatedOrders.push(reboundOrder);
836
963
  mgr.logger?.log?.(`[SYNC] Size tiebreak for ${gridOrder.id}: tracked order ${gridOrder.orderId} ` +
837
964
  `(chain size ${chainOrder.size}) disagrees with booked size ${gridOrder.size}, but ` +
@@ -855,11 +982,23 @@ class SyncEngine {
855
982
  updatedOrder.state = (chainSizeInt < currentSizeInt)
856
983
  ? ORDER_STATES.PARTIAL
857
984
  : gridOrder.state;
858
- await mgr._applyOrderUpdate(updatedOrder, 'sync-pass1-partial', { skipAccounting: skipAccounting, fee: 0 });
985
+ const partialApplied = await mgr._applyOrderUpdate(updatedOrder, 'sync-pass1-partial', { skipAccounting: skipAccounting, fee: 0 });
986
+ if (partialApplied === false) {
987
+ // Rejected resize: the slot keeps its booked size;
988
+ // divergence re-detects on the next sync.
989
+ mgr.logger?.log?.(`[SYNC] Partial-size update for ${gridOrder.id} rejected — slot keeps booked size ${gridOrder.size}`, 'warn');
990
+ }
859
991
  }
860
992
  else {
861
993
  const spreadOrder = convertToSpreadPlaceholder(gridOrder);
862
- await mgr._applyOrderUpdate(spreadOrder, 'sync-pass1-filled', { skipAccounting: skipAccounting, fee: 0 });
994
+ const filledApplied = await mgr._applyOrderUpdate(spreadOrder, 'sync-pass1-filled', { skipAccounting: skipAccounting, fee: 0 });
995
+ if (filledApplied === false) {
996
+ // Rejected virtualization must not book a fill:
997
+ // neither the filled order nor the placeholder
998
+ // update leaves this path.
999
+ mgr.logger?.log?.(`[SYNC] Filled virtualization for ${gridOrder.id} rejected — fill NOT booked`, 'error');
1000
+ continue;
1001
+ }
863
1002
  // Push the filled order with its REAL side (chain order
864
1003
  // type), not the SPREAD placeholder: downstream fill
865
1004
  // processing (deriveTargetBoundary) derives boundary
@@ -913,7 +1052,14 @@ class SyncEngine {
913
1052
  const currentGridOrder = mgr.orders.get(gridOrder.id) || gridOrder;
914
1053
  const hadOrderId = Boolean(currentGridOrder?.orderId);
915
1054
  const spreadOrder = convertToSpreadPlaceholder(currentGridOrder);
916
- await mgr._applyOrderUpdate(spreadOrder, 'sync-cleanup-phantom', { skipAccounting: skipAccounting, fee: 0 });
1055
+ const phantomApplied = await mgr._applyOrderUpdate(spreadOrder, 'sync-cleanup-phantom', { skipAccounting: skipAccounting, fee: 0 });
1056
+ if (phantomApplied === false) {
1057
+ // Rejected virtualization books no phantom fill: the slot
1058
+ // keeps its order and the disappearance re-detects next
1059
+ // sync.
1060
+ mgr.logger?.log?.(`[SYNC] Phantom cleanup for ${gridOrder.id} rejected — fill NOT booked`, 'warn');
1061
+ continue;
1062
+ }
917
1063
  // Only genuine disappearances (had orderId) count as fills.
918
1064
  if (hadOrderId) {
919
1065
  // A SPREAD slot can carry an on-chain order (spread-correction
@@ -961,6 +1107,19 @@ class SyncEngine {
961
1107
  const slotId = `slot-${idx}`;
962
1108
  const gapSlots = genesis.gapSlots ?? mgr._gapSlots ?? 0;
963
1109
  const boundaryIdx = mgr.boundaryIdx;
1110
+ // Pre-boundary sync: gap geometry is unknown, so adoption is
1111
+ // deferred entirely — touch nothing (no adopt, no cancelOnly).
1112
+ // The orphan stays visible to the crossing guards and the
1113
+ // validate orphan layer via _lastUnmatchedChainOrders and is
1114
+ // re-evaluated once the boundary commits. Accepted cost: a
1115
+ // legitimate in-rail orphan waits one sync cycle
1116
+ // post-boundary-commit before adoption. Strictly better than
1117
+ // adopting a gap stray into the wrong slot.
1118
+ if (boundaryIdx == null || !Number.isFinite(Number(boundaryIdx))) {
1119
+ unmatchedChainOrders.push({ chainOrderId, type: chainOrder.type, price: chainOrder.price, size: chainOrder.size, raw: rawChainOrders.get(chainOrderId), reason: 'boundary-unknown-deferred', candidateSlotId: slotId });
1120
+ mgr.logger?.log?.(`[SYNC] Orphaned chain order ${chainOrderId} (${chainOrder.type}, price=${chainOrder.price}, size=${chainOrder.size}) — NOT adopted: boundary unknown, deferred until boundary commits (nearest slot ${slotId})`, 'warn');
1121
+ continue;
1122
+ }
964
1123
  // Duplicate-price guard becomes slotId equality: if placed order already occupies this slot
965
1124
  const occupying = mgr.orders.get(slotId);
966
1125
  if (occupying && isOrderPlaced(occupying) && occupying.type === chainOrder.type) {
@@ -970,8 +1129,9 @@ class SyncEngine {
970
1129
  queueCorrection({ gridOrder: occupying, chainOrderId, expectedPrice: chainOrder.price, size: chainOrder.size, type: chainOrder.type, isSurplus: true, cancelOnly: true });
971
1130
  continue;
972
1131
  }
973
- // Gap exclusion: nearest slot in SPREAD gap → no adopt. When boundaryIdx == null (pre-boundary sync) the gap is unknown so we allow adoption — the next reconcile will relocate gap stray per §10 table's cancelOnly intent (documented hole).
974
- if (boundaryIdx != null && Number.isFinite(Number(boundaryIdx))) {
1132
+ // Gap exclusion: nearest slot in SPREAD gap → no adopt (boundary
1133
+ // is known here — the pre-boundary case continued above).
1134
+ {
975
1135
  const inRail = isSlotInRail(boundaryIdx, gapSlots, chainOrder.type, { id: slotId });
976
1136
  if (!inRail) {
977
1137
  unmatchedChainOrders.push({ chainOrderId, type: chainOrder.type, price: chainOrder.price, size: chainOrder.size, raw: rawChainOrders.get(chainOrderId), reason: 'no-available-nearest-slot', candidateSlotId: slotId });
@@ -991,7 +1151,14 @@ class SyncEngine {
991
1151
  unmatchedChainOrders.push({ chainOrderId, type: chainOrder.type, price: chainOrder.price, size: chainOrder.size, raw: rawChainOrders.get(chainOrderId), reason: 'no-available-nearest-slot', candidateSlotId: slotId });
992
1152
  continue;
993
1153
  }
994
- await adoptChainOrderIntoSlot(mgr, slot, chainOrder, chainOrderId, rawChainOrders, matchedGridOrderIds, chainOrderIdsOnGrid, filledOrders, updatedOrders, skipAccounting);
1154
+ const adopted = await adoptChainOrderIntoSlot(mgr, slot, chainOrder, chainOrderId, rawChainOrders, matchedGridOrderIds, chainOrderIdsOnGrid, filledOrders, updatedOrders, skipAccounting);
1155
+ if (!adopted) {
1156
+ // Order update was rejected (fatal validation) — the chain
1157
+ // order stays untracked and must not dangle on the book.
1158
+ unmatchedChainOrders.push({ chainOrderId, type: chainOrder.type, price: chainOrder.price, size: chainOrder.size, raw: rawChainOrders.get(chainOrderId), reason: 'adoption-rejected', candidateSlotId: slotId });
1159
+ queueCorrection({ gridOrder: slot, chainOrderId, expectedPrice: chainOrder.price, size: chainOrder.size, type: chainOrder.type, isSurplus: true, cancelOnly: true });
1160
+ mgr.logger?.log?.(`[SYNC] Chain order ${chainOrderId} (${chainOrder.type}, price=${chainOrder.price}) NOT adopted into slot ${slotId}: order update rejected — queued for cancellation`, 'error');
1161
+ }
995
1162
  continue;
996
1163
  }
997
1164
  // Legacy fallback when genesis unavailable (migration)
@@ -1008,7 +1175,12 @@ class SyncEngine {
1008
1175
  }
1009
1176
  const match = findMatchingGridOrderByOpenOrder({ orderId: chainOrderId, type: chainOrder.type, price: chainOrder.price, size: chainOrder.size }, { orders: mgr.orders, assets: mgr.assets, calcToleranceFn: (p, s, t) => calculatePriceTolerance(p, s, t, mgr.assets), logger: mgr.logger, allowSmallerChainSize: true, requireAvailableSlot: true, excludeGridOrderIds: matchedGridOrderIds });
1010
1177
  if (match && !matchedGridOrderIds.has(match.id)) {
1011
- await adoptChainOrderIntoSlot(mgr, match, chainOrder, chainOrderId, rawChainOrders, matchedGridOrderIds, chainOrderIdsOnGrid, filledOrders, updatedOrders, skipAccounting);
1178
+ const adopted = await adoptChainOrderIntoSlot(mgr, match, chainOrder, chainOrderId, rawChainOrders, matchedGridOrderIds, chainOrderIdsOnGrid, filledOrders, updatedOrders, skipAccounting);
1179
+ if (!adopted) {
1180
+ unmatchedChainOrders.push({ chainOrderId, type: chainOrder.type, price: chainOrder.price, size: chainOrder.size, raw: rawChainOrders.get(chainOrderId), reason: 'adoption-rejected', candidateSlotId: match.id });
1181
+ queueCorrection({ gridOrder: match, chainOrderId, expectedPrice: chainOrder.price, size: chainOrder.size, type: chainOrder.type, isSurplus: true, cancelOnly: true });
1182
+ mgr.logger?.log?.(`[SYNC] Chain order ${chainOrderId} (${chainOrder.type}, price=${chainOrder.price}) NOT adopted into slot ${match.id}: order update rejected — queued for cancellation`, 'error');
1183
+ }
1012
1184
  }
1013
1185
  else if (match) {
1014
1186
  mgr.logger?.log?.(`Warning: Orphan chain order ${chainOrderId} matched grid order ${match.id}, but grid order was already matched to another chain order. Queuing orphan for cancellation.`, 'warn');
@@ -1049,11 +1221,35 @@ class SyncEngine {
1049
1221
  rawOnChain: adoptedRaw ? { ...adoptedRaw, fetchedAt: Date.now() } : adoptedRaw,
1050
1222
  ...(adoptedBtsFeeState ? { btsFeeState: adoptedBtsFeeState } : {}),
1051
1223
  };
1224
+ const applied = await mgr._applyOrderUpdate(adoptedOrder, 'sync-pass2-adopt-orphan', { skipAccounting: skipAccounting, fee: 0 });
1225
+ if (applied === false) {
1226
+ // Fatal rejection: parity with the genesis adoption path —
1227
+ // the slot must not be marked matched and the chain order
1228
+ // must not dangle untracked on the book.
1229
+ unmatchedChainOrders.push({ chainOrderId, type: chainOrder.type, price: chainOrder.price, size: chainOrder.size, raw: rawChainOrders.get(chainOrderId), reason: 'adoption-rejected', candidateSlotId: adoptedSlot.id });
1230
+ queueCorrection({ gridOrder: adoptedSlot, chainOrderId, expectedPrice: chainOrder.price, size: chainOrder.size, type: chainOrder.type, isSurplus: true, cancelOnly: true });
1231
+ mgr.logger?.log?.(`[SYNC] Chain order ${chainOrderId} (${chainOrder.type}, price=${chainOrder.price}) NOT adopted into slot ${adoptedSlot.id}: order update rejected — queued for cancellation`, 'error');
1232
+ continue;
1233
+ }
1052
1234
  matchedGridOrderIds.add(adoptedSlot.id);
1053
1235
  chainOrderIdsOnGrid.add(chainOrderId);
1054
- await mgr._applyOrderUpdate(adoptedOrder, 'sync-pass2-adopt-orphan', { skipAccounting: skipAccounting, fee: 0 });
1055
1236
  updatedOrders.push(adoptedOrder);
1056
- mgr.logger?.log?.(`[SYNC] Orphaned chain order ${chainOrderId} (${chainOrder.type}, price=${chainOrder.price}, size=${chainOrder.size}) adopted into slot ${adoptedSlot.id} (was ${adoptedSlot.type})`, 'warn');
1237
+ // Phase 4 attribution: log the adoption slot's geometry
1238
+ // (idx vs frozen boundary/gap) so the next re-map incident
1239
+ // can tell an in-rail adoption from a gap-band re-map
1240
+ // without on-chain archaeology.
1241
+ let adoptGeo = '';
1242
+ try {
1243
+ const adoptIdx = parseSlotIndex(adoptedSlot.id);
1244
+ const adoptB = Number(mgr?.boundaryIdx);
1245
+ const adoptG = Number(mgr?._gapSlots);
1246
+ if (adoptIdx !== null && adoptIdx !== undefined && Number.isFinite(adoptB) && Number.isFinite(adoptG)) {
1247
+ const inBand = isSlotIndexInGapBand(adoptIdx, adoptB, adoptG);
1248
+ adoptGeo = ` geo(idx=${adoptIdx},boundary=${adoptB},gap=${adoptG},sellStart=${getSellStartIdx(adoptB, adoptG)},band=${inBand ? 'gap' : 'rail'})`;
1249
+ }
1250
+ }
1251
+ catch { /* geometry is diagnostic-only */ }
1252
+ mgr.logger?.log?.(`[SYNC] Orphaned chain order ${chainOrderId} (${chainOrder.type}, price=${chainOrder.price}, size=${chainOrder.size}) adopted into slot ${adoptedSlot.id} (was ${adoptedSlot.type})${adoptGeo}`, 'warn');
1057
1253
  }
1058
1254
  else {
1059
1255
  const precision = (chainOrder.type === ORDER_TYPES.SELL) ? assetAPrecision : assetBPrecision;